import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import { Link } from "@tanstack/react-router";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import * as Exit from "effect/Exit";
import { toast } from "sonner";
import type { ArtifactId, ArtifactPreview as ArtifactPreviewValue } from "@executor-js/sdk/shared";
import { trackEvent } from "../api/analytics";
import { usePreloadArtifactRenderer } from "../api/artifact-renderer";
import {
artifactsOptimisticAtom,
removeArtifactOptimistic,
renameArtifactOptimistic,
} from "../api/atoms";
import { artifactWriteKeys } from "../api/reactivity-keys";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../components/alert-dialog";
import { Button } from "../components/button";
import { ArtifactPreview } from "../components/artifact-preview";
import { ErrorState } from "../components/error-state";
import { PageContainer, PageHeader } from "../components/page";
import { isAsyncResultLoading } from "../lib/async-result";
import { useExecutorDocumentTitle } from "../lib/document-title";
import { formatRelativeTime } from "../lib/relative-time";
import { RenameArtifactDialog } from "./artifact-rename-dialog";
/** The wire row `artifacts.list` returns — no `code`, so the list stays cheap.
* The preview is the exception: the gallery is what draws it. */
interface ArtifactSummary {
readonly id: ArtifactId;
readonly title: string;
readonly description: string | null;
readonly preview: ArtifactPreviewValue | null;
readonly updatedAt: number;
}
const LoadingState = () => (
Loading artifacts…
);
/**
* Artifacts are never created here — a model makes them by calling `create-artifact`
* over MCP — so the empty state teaches that path rather than offering a button
* that cannot exist.
*/
const EmptyState = () => (
No artifacts yet. Ask an agent to render a UI and it appears here, ready to reopen.
);
/**
* One artifact, as a gallery card.
*
* The whole card is the link — the preview is the biggest, most obvious target,
* and a title-only hit area would waste it. The row actions therefore cannot be
* nested inside it (a button inside an anchor is invalid, and the click would
* navigate through them), so the link is a full-bleed overlay and the actions
* sit above it on the z-axis. That keeps one accessible name for the card and
* one focus stop, with the actions as their own stops after it.
*/
function ArtifactCard(props: {
readonly artifact: ArtifactSummary;
readonly onRename: (title: string) => void;
readonly onRemove: () => void;
}) {
const { artifact } = props;
return (
{/* 16:10 preview, on its own plane, separated by the same hairline the
rest of the console uses. */}
{/* Above the link overlay, so these stay clickable. Revealed on hover or
focus, and pinned open while their own dialog is. */}
Rename
}
/>
Delete {artifact.title}?
This removes the artifact for good. Agents will no longer find it by name.
Cancel
Delete Artifact
);
}
export function ArtifactsPage() {
useExecutorDocumentTitle("Artifacts");
// Opening a card is the only thing this page is for, and the renderer chunk is
// the slowest part of doing so. Fetching it while the user is still choosing
// means the detail page never has to wait for it — the renderer's own
// placeholder simply stops existing for anyone arriving from here.
usePreloadArtifactRenderer();
const artifacts = useAtomValue(artifactsOptimisticAtom);
const refreshArtifacts = useAtomRefresh(artifactsOptimisticAtom);
const doRename = useAtomSet(renameArtifactOptimistic, { mode: "promiseExit" });
const doRemove = useAtomSet(removeArtifactOptimistic, { mode: "promiseExit" });
const handleRename = async (artifactId: ArtifactId, title: string) => {
const exit = await doRename({
params: { artifactId },
payload: { title },
reactivityKeys: artifactWriteKeys,
});
trackEvent("artifact_renamed", { success: Exit.isSuccess(exit) });
if (Exit.isFailure(exit)) toast.error("Couldn't rename the artifact. Try again.");
};
const handleRemove = async (artifactId: ArtifactId) => {
const exit = await doRemove({
params: { artifactId },
reactivityKeys: artifactWriteKeys,
});
trackEvent("artifact_removed", { success: Exit.isSuccess(exit) });
if (Exit.isFailure(exit)) toast.error("Couldn't delete the artifact. Try again.");
};
return (
// Wider than the settings column: three preview cards need the room, and
// this page is a gallery rather than a form.
{isAsyncResultLoading(artifacts) ? (
) : (
AsyncResult.match(artifacts, {
onInitial: () => ,
onFailure: () => (
),
onSuccess: ({ value }) => {
// Most-recently-updated first: an artifact just generated by an
// agent is the one the user is most likely coming here to open.
const rows = [...value].sort((a, b) => b.updatedAt - a.updatedAt);
return (