From 69039a2c66f210b4d8a49ac44db948b96b35d249 Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Tue, 3 Nov 2026 16:16:09 -0500 Subject: [PATCH] Show agent chat artifacts in a sandboxed viewer --- components/AgentChat/ActivityFeed.tsx | 71 +++++- components/AgentChat/ChatTranscript.tsx | 111 ++++----- .../AgentChat/artifacts/ArtifactCard.tsx | 51 ++++ .../AgentChat/artifacts/ArtifactFrame.tsx | 61 +++++ .../AgentChat/artifacts/ArtifactViewer.tsx | 149 ++++++++++++ .../AgentChat/artifacts/artifactDocument.ts | 196 +++++++++++++++ .../AgentChat/artifacts/artifactKinds.ts | 64 +++++ components/modals/ArtifactModal.tsx | 226 ++++++++++++++++++ hooks/useAgentChat.ts | 24 +- hooks/useArtifact.ts | 43 ++++ services/artifact.service.ts | 13 + types/agentChat.ts | 25 ++ types/artifact.ts | 29 +++ 13 files changed, 1007 insertions(+), 56 deletions(-) create mode 100644 components/AgentChat/artifacts/ArtifactCard.tsx create mode 100644 components/AgentChat/artifacts/ArtifactFrame.tsx create mode 100644 components/AgentChat/artifacts/ArtifactViewer.tsx create mode 100644 components/AgentChat/artifacts/artifactDocument.ts create mode 100644 components/AgentChat/artifacts/artifactKinds.ts create mode 100644 components/modals/ArtifactModal.tsx create mode 100644 hooks/useArtifact.ts create mode 100644 services/artifact.service.ts create mode 100644 types/artifact.ts diff --git a/components/AgentChat/ActivityFeed.tsx b/components/AgentChat/ActivityFeed.tsx index cdd33c4be..1a1416781 100644 --- a/components/AgentChat/ActivityFeed.tsx +++ b/components/AgentChat/ActivityFeed.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, type ComponentType } from 'react'; import { + AppWindow, Ban, BookOpen, Brain, @@ -23,12 +24,15 @@ import { Loader } from '@/components/ui/Loader'; import { cn } from '@/utils/styles'; import { MarkdownMessage } from './MarkdownMessage'; import { CodeExecutionPanel } from './CodeExecutionPanel'; +import { ArtifactCard } from './artifacts/ArtifactCard'; +import { useArtifactViewer } from './artifacts/ArtifactViewer'; import { answerRevealKey, isRevealable, narrationRevealKey } from '@/hooks/useTextReveal'; import type { ActivityCallStatus, ChatFeedItem, ChatActivitySource, ChatToolCallActivity, + ChatToolDraftActivity, } from '@/types/agentChat'; /** @@ -47,6 +51,9 @@ const TOOL_ICONS: Record> = { get_work_fulltext: FileSearch, code_execution: SquareTerminal, bash_code_execution: SquareTerminal, + create_artifact: AppWindow, + update_artifact: AppWindow, + read_artifact: AppWindow, }; /** @@ -151,6 +158,8 @@ const TEXT_SHINE = cn( */ function ToolCallRow({ call }: { readonly call: ChatToolCallActivity }) { const ToolIcon = TOOL_ICONS[call.tool] ?? Wrench; + const viewer = useArtifactViewer(); + const artifact = call.artifact; return (
@@ -169,9 +178,18 @@ function ToolCallRow({ call }: { readonly call: ChatToolCallActivity }) { {humanizeLabel(call.label)}
- {call.detail && ( + {/* An artifact's card names it, so its title isn't repeated as detail. */} + {call.detail && !artifact && (

{call.detail}

)} + {artifact && ( + viewer.openArtifact(artifact) : undefined} + /> + )} {call.code_execution && ( )} @@ -181,6 +199,54 @@ function ToolCallRow({ call }: { readonly call: ChatToolCallActivity }) { ); } +/** + * An artifact the agent is writing: its label with the sweep, and a card + * that opens the viewer on the content as it streams. The content is a page + * or a program, not prose, so it never unfolds into the feed. + */ +function ArtifactDraftRow({ + draft, + streaming, + executionId, +}: { + readonly draft: ChatToolDraftActivity & { id?: string }; + readonly streaming: boolean; + readonly executionId?: number; +}) { + const viewer = useArtifactViewer(); + const lines = draft.text ? draft.text.split('\n').length : 0; + const itemId = draft.id; + return ( +
+ +
+
+
+ viewer.openDraft(executionId, itemId) + : undefined + } + /> +
+
+ ); +} + /** * Markdown markers dropped for the collapsed one-line preview, where markup * can't render and would read as clutter. Underscores stay: intraword `_` @@ -399,6 +465,9 @@ function ActivityItemBody({ /> ); } + if (item.type === 'tool_draft' && item.tool === 'create_artifact') { + return ; + } if (item.type === 'tool_draft') { // Coloured and iconed like the tool row it becomes, so the draft and the // call it turns into read as one step rather than two. diff --git a/components/AgentChat/ChatTranscript.tsx b/components/AgentChat/ChatTranscript.tsx index 39ae761ff..7210a85d5 100644 --- a/components/AgentChat/ChatTranscript.tsx +++ b/components/AgentChat/ChatTranscript.tsx @@ -10,6 +10,7 @@ import { answerRevealKey, isRevealable } from '@/hooks/useTextReveal'; import { ExecutionProgress } from './ExecutionProgress'; import { PendingThinkingRow } from './ActivityFeed'; import { RoutedModelNote } from './RoutedModelNote'; +import { ArtifactViewerProvider } from './artifacts/ArtifactViewer'; type TranscriptEntry = | { key: string; kind: 'user'; message: ChatMessage } @@ -207,61 +208,63 @@ export function ChatTranscript({ // Turns are separated by whitespace alone now that neither the assistant // answer nor its tool activity carries a card, so the gap between them has // to be larger than the gap within one turn. -
- {entries.map((entry) => { - switch (entry.kind) { - case 'user': - return ( - - ); - case 'pending-user': - // Two siblings, not one wrapper: the echo stands in for a user - // entry and the execution entry about to follow it, so it has to - // take the same between-entry gap that pair will. Boxed together - // they took the within-turn 12px where the real pair takes 20px, - // and the placeholder dropped 8px the moment the refetch landed. - return ( - - - - - ); - case 'execution': - return ( -
- +
+ {entries.map((entry) => { + switch (entry.kind) { + case 'user': + return ( + - {entry.execution.routed_model && ( - + + + + ); + case 'execution': + return ( +
+ - )} - {entry.answer && ( - - )} - {renderExecutionExtra?.(entry.execution)} -
- ); - case 'assistant': - return ; - default: - return null; - } - })} -
+ {entry.execution.routed_model && ( + + )} + {entry.answer && ( + + )} + {renderExecutionExtra?.(entry.execution)} +
+ ); + case 'assistant': + return ; + default: + return null; + } + })} +
+ ); } diff --git a/components/AgentChat/artifacts/ArtifactCard.tsx b/components/AgentChat/artifacts/ArtifactCard.tsx new file mode 100644 index 000000000..0b0715eb9 --- /dev/null +++ b/components/AgentChat/artifacts/ArtifactCard.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/utils/styles'; +import { artifactKindInfo } from './artifactKinds'; + +interface ArtifactCardProps { + readonly title: string; + readonly kind: string | undefined; + /** The second line: the version, or how far a draft has got. */ + readonly caption: string; + readonly live?: boolean; + /** Absent where no viewer is mounted; the card is then a plain label. */ + readonly onOpen?: () => void; +} + +/** An artifact in the activity feed, opening it in the viewer. */ +export function ArtifactCard({ title, kind, caption, live = false, onOpen }: ArtifactCardProps) { + const { icon: Icon, label } = artifactKindInfo(kind); + const body = ( + <> + + + + {title} + + {label} · {caption} + + + {onOpen &&