252 lines · 9.9 KB · TypeScript
RawDownload
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 = () => (  <div className="flex items-center gap-2 py-8">    <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" />    <p className="text-sm text-muted-foreground">Loading artifacts…</p>  </div>); /** * 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 = () => (  <div    data-slot="artifact-empty"    className="rounded-lg border border-dashed border-border px-6 py-16 text-center"  >    <p className="text-sm text-muted-foreground">      No artifacts yet. Ask an agent to render a UI and it appears here, ready to reopen.    </p>  </div>); /** * 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 (    <div      data-slot="artifact-card"      className="group/artifact-card relative isolate flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] focus-within:border-input hover:border-input"    >      {/* 16:10 preview, on its own plane, separated by the same hairline the          rest of the console uses. */}      <div className="relative aspect-[16/10] overflow-hidden border-b border-border bg-muted/40">        <ArtifactPreview artifactId={artifact.id} preview={artifact.preview} />      </div>       <div className="flex min-w-0 flex-col gap-1 px-4 py-3">        <h3 className="truncate text-sm font-medium text-foreground">          <Link            to="/{-$orgSlug}/artifacts/$artifactId"            params={{ artifactId: artifact.id }}            aria-label={`Open artifact ${artifact.title}`}            className="outline-none after:absolute after:inset-0 after:content-[''] focus-visible:underline"            onClick={() => trackEvent("artifact_opened", { surface: "list" })}          >            {artifact.title}          </Link>        </h3>        {artifact.description ? (          <p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">            {artifact.description}          </p>        ) : null}        <span className="mt-1 font-mono text-[11px] text-muted-foreground">          {formatRelativeTime(artifact.updatedAt)}        </span>      </div>       {/* Above the link overlay, so these stay clickable. Revealed on hover or          focus, and pinned open while their own dialog is. */}      <div className="absolute right-2 top-2 z-10 flex items-center gap-1 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/artifact-card:opacity-100 has-[[data-state=open]]:opacity-100">        <RenameArtifactDialog          currentTitle={artifact.title}          onRename={props.onRename}          trigger={            <Button              type="button"              variant="secondary"              size="xs"              className="bg-card text-muted-foreground hover:text-foreground"            >              Rename            </Button>          }        />        <AlertDialog>          <AlertDialogTrigger asChild>            <Button              type="button"              variant="secondary"              size="xs"              className="bg-card text-muted-foreground hover:text-destructive"            >              Delete            </Button>          </AlertDialogTrigger>          <AlertDialogContent size="sm">            <AlertDialogHeader>              <AlertDialogTitle>Delete {artifact.title}?</AlertDialogTitle>              <AlertDialogDescription>                This removes the artifact for good. Agents will no longer find it by name.              </AlertDialogDescription>            </AlertDialogHeader>            <AlertDialogFooter>              <AlertDialogCancel>Cancel</AlertDialogCancel>              <AlertDialogAction variant="destructive" onClick={props.onRemove}>                Delete Artifact              </AlertDialogAction>            </AlertDialogFooter>          </AlertDialogContent>        </AlertDialog>      </div>    </div>  );} 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.    <PageContainer className="max-w-6xl">      <PageHeader        title="Artifacts"        description="Interactive components your agents generated. Ask an agent to render a UI and it is saved here — reopen it any time, or have the agent bring it back by name."      />       {isAsyncResultLoading(artifacts) ? (        <LoadingState />      ) : (        AsyncResult.match(artifacts, {          onInitial: () => <LoadingState />,          onFailure: () => (            <ErrorState message="Failed to load artifacts" onRetry={refreshArtifacts} />          ),          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 (              <section>                <h2 className="mb-3 flex items-center font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">                  Saved artifacts                  {rows.length > 0 ? (                    <span className="ml-2 font-normal tabular-nums">{rows.length}</span>                  ) : null}                </h2>                {rows.length === 0 ? (                  <EmptyState />                ) : (                  <div                    data-slot="artifact-grid"                    className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"                  >                    {rows.map((artifact) => (                      <ArtifactCard                        key={artifact.id}                        artifact={artifact}                        onRename={(title) => void handleRename(artifact.id, title)}                        onRemove={() => void handleRemove(artifact.id)}                      />                    ))}                  </div>                )}              </section>            );          },        })      )}    </PageContainer>  );} export type { ArtifactSummary };