diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4858e553..8b799e3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,18 @@ jobs: - name: Test run: bun test + + - name: Install Chat UI Dependencies + working-directory: chat-ui + run: bun install --frozen-lockfile + + - name: Chat UI Typecheck + working-directory: chat-ui + run: bun run typecheck + + - name: Chat UI Test + working-directory: chat-ui + run: bun test + + - name: Chat UI Build + run: bun run build:chat-ui diff --git a/.gitignore b/.gitignore index 1fca3d65..f36b8462 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +public/chat/ data/ .env.* .env.local diff --git a/chat-ui/src/components/artifact-tray.tsx b/chat-ui/src/components/artifact-tray.tsx new file mode 100644 index 00000000..82af8b62 --- /dev/null +++ b/chat-ui/src/components/artifact-tray.tsx @@ -0,0 +1,67 @@ +import { formatArtifactSize } from "@/lib/chat-artifacts"; +import type { ChatArtifactView } from "@/lib/chat-types"; +import { cn } from "@/lib/utils"; +import { Copy, ExternalLink, FileText } from "lucide-react"; + +export function ArtifactTray({ artifacts }: { artifacts: ChatArtifactView[] }) { + if (artifacts.length === 0) return null; + + return ( +
+
Artifacts
+
+ {artifacts.map((artifact) => ( + + ))} +
+
+ ); +} + +function ArtifactCard({ artifact }: { artifact: ChatArtifactView }) { + const size = formatArtifactSize(artifact.sizeBytes); + return ( +
+
+
+ +
+
+
+ {artifact.title} + + Page + + {size && {size}} +
+
{artifact.path ?? artifact.url}
+
+
+
+ + + Open + + +
+
+ ); +} diff --git a/chat-ui/src/components/assistant-message.tsx b/chat-ui/src/components/assistant-message.tsx index 0b5be3bb..7d912139 100644 --- a/chat-ui/src/components/assistant-message.tsx +++ b/chat-ui/src/components/assistant-message.tsx @@ -1,62 +1,75 @@ -import type { ChatMessage, ThinkingBlockState, ToolCallState } from "@/lib/chat-types"; +import { extractToolArtifacts } from "@/lib/chat-artifacts"; import { getAssistantTextBlocks } from "@/lib/chat-message-content"; +import type { ChatMessage, ThinkingBlockState, ToolCallState } from "@/lib/chat-types"; +import { ArtifactTray } from "./artifact-tray"; import { Markdown } from "./markdown"; import { ThinkingBlock } from "./thinking-block"; import { ToolCallCard } from "./tool-call-card"; +export type ThinkingBlockItem = { + id: string; + block: ThinkingBlockState; +}; + export function AssistantMessage({ - message, - toolCalls, - thinkingBlocks, + message, + toolCalls, + thinkingBlocks, }: { - message: ChatMessage; - toolCalls: ToolCallState[]; - thinkingBlocks: ThinkingBlockState[]; + message: ChatMessage; + toolCalls: ToolCallState[]; + thinkingBlocks: ThinkingBlockItem[]; }) { - const textBlocks = getAssistantTextBlocks(message); - const hasText = textBlocks.length > 0; - - const isStreaming = message.status === "streaming"; - - return ( -
-
- {thinkingBlocks.map((block, i) => ( - - ))} - - {toolCalls.map((tool) => ( - - ))} - - {textBlocks.map((textContent, index) => ( - - ))} - - {isStreaming && !hasText && toolCalls.length === 0 && ( -
-
-
-
-
- )} - - {message.costUsd != null && message.status === "committed" && ( -
- {message.inputTokens != null && message.outputTokens != null && ( - - {message.inputTokens.toLocaleString()} in /{" "} - {message.outputTokens.toLocaleString()} out - - )} - {message.costUsd > 0 && ( - - ${message.costUsd.toFixed(4)} - - )} -
- )} -
-
- ); + const textBlocks = getAssistantTextBlocks(message); + const artifacts = extractToolArtifacts(toolCalls); + const hasText = textBlocks.length > 0; + + const isStreaming = message.status === "streaming"; + + return ( +
+
+ {thinkingBlocks.map((item) => ( + + ))} + + {toolCalls.map((tool) => ( + + ))} + + {textBlocks.map((textContent) => ( + + ))} + + + + {isStreaming && !hasText && toolCalls.length === 0 && ( +
+
+
+
+
+ )} + + {message.costUsd != null && message.status === "committed" && ( +
+ {message.inputTokens != null && message.outputTokens != null && ( + + {message.inputTokens.toLocaleString()} in / {message.outputTokens.toLocaleString()} out + + )} + {message.costUsd > 0 && ${message.costUsd.toFixed(4)}} +
+ )} +
+
+ ); +} + +function hashText(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash).toString(36); } diff --git a/chat-ui/src/components/message-list.tsx b/chat-ui/src/components/message-list.tsx index b8f01006..d1baf706 100644 --- a/chat-ui/src/components/message-list.tsx +++ b/chat-ui/src/components/message-list.tsx @@ -4,6 +4,7 @@ import type { ChatMessage, RunActivityState, ThinkingBlockState, ToolCallState } import { Button } from "@/ui/button"; import { ArrowDown } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import type { ThinkingBlockItem } from "./assistant-message"; import { Message } from "./message"; import { MessageActions } from "./message-actions"; import { RunActivityRow } from "./run-activity-row"; @@ -36,10 +37,10 @@ export function MessageList({ }, [activeToolCalls]); const thinkingByMessage = useMemo(() => { - const map = new Map(); - for (const [, tb] of thinkingBlocks) { + const map = new Map(); + for (const [id, tb] of thinkingBlocks) { const existing = map.get(tb.messageId) ?? []; - existing.push(tb); + existing.push({ id, block: tb }); map.set(tb.messageId, existing); } return map; @@ -85,7 +86,11 @@ export function MessageList({ /> {message.role === "assistant" && } {message.runTimeline && ( - + )}
))} diff --git a/chat-ui/src/components/message.tsx b/chat-ui/src/components/message.tsx index 88c6a91f..d26faf27 100644 --- a/chat-ui/src/components/message.tsx +++ b/chat-ui/src/components/message.tsx @@ -1,25 +1,19 @@ -import type { ChatMessage, ThinkingBlockState, ToolCallState } from "@/lib/chat-types"; -import { AssistantMessage } from "./assistant-message"; +import type { ChatMessage, ToolCallState } from "@/lib/chat-types"; +import { AssistantMessage, type ThinkingBlockItem } from "./assistant-message"; import { UserMessage } from "./user-message"; export function Message({ - message, - toolCalls, - thinkingBlocks, + message, + toolCalls, + thinkingBlocks, }: { - message: ChatMessage; - toolCalls: ToolCallState[]; - thinkingBlocks: ThinkingBlockState[]; + message: ChatMessage; + toolCalls: ToolCallState[]; + thinkingBlocks: ThinkingBlockItem[]; }) { - if (message.role === "user") { - return ; - } + if (message.role === "user") { + return ; + } - return ( - - ); + return ; } diff --git a/chat-ui/src/components/run-activity-row.tsx b/chat-ui/src/components/run-activity-row.tsx index 7c5f8b7b..63d355de 100644 --- a/chat-ui/src/components/run-activity-row.tsx +++ b/chat-ui/src/components/run-activity-row.tsx @@ -1,8 +1,10 @@ -import type { RunActivityState, SubagentActivity, ToolCallState } from "@/lib/chat-types"; +import { extractToolArtifacts, mergeArtifactViews } from "@/lib/chat-artifacts"; +import type { ChatArtifactView, RunActivityState, SubagentActivity, ToolCallState } from "@/lib/chat-types"; import { cn } from "@/lib/utils"; import { Activity, AlertCircle, CheckCircle2, Clock3, Loader2, Radio, ShieldAlert } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { ArtifactTray } from "./artifact-tray"; import { ToolCallCard } from "./tool-call-card"; function statusIcon(activity: RunActivityState): { @@ -106,7 +108,9 @@ function plural(count: number, singular: string): string { function toolFacts(toolCalls: ToolCallState[]): string[] { const running = toolCalls.filter((tool) => tool.state === "running"); const completed = toolCalls.filter((tool) => tool.state === "result"); - const issues = toolCalls.filter((tool) => tool.state === "error" || tool.state === "blocked" || tool.state === "aborted"); + const issues = toolCalls.filter( + (tool) => tool.state === "error" || tool.state === "blocked" || tool.state === "aborted", + ); const facts: string[] = []; if (running.length > 0) { facts.push(`Using ${running.map((tool) => tool.toolName).join(", ")}`); @@ -159,9 +163,11 @@ function subagentMeta(subagent: SubagentActivity): string { export function RunActivityRow({ activity, toolCalls, + artifacts: durableArtifacts = [], }: { activity: RunActivityState; toolCalls: ToolCallState[]; + artifacts?: ChatArtifactView[]; }) { const { Icon, className } = statusIcon(activity); const now = useLiveNow(activity.isActive); @@ -169,6 +175,10 @@ export function RunActivityRow({ const elapsed = formatElapsed(elapsedAt - Date.parse(activity.startedAt)); const facts = useMemo(() => activityFacts(activity, toolCalls), [activity, toolCalls]); const subagents = useMemo(() => sortedSubagents(activity), [activity]); + const artifacts = useMemo( + () => mergeArtifactViews(durableArtifacts, extractToolArtifacts(toolCalls)), + [durableArtifacts, toolCalls], + ); return (
@@ -237,6 +247,8 @@ export function RunActivityRow({ ))}
)} + +
diff --git a/chat-ui/src/components/tool-call-card.tsx b/chat-ui/src/components/tool-call-card.tsx index e65f8322..daab030a 100644 --- a/chat-ui/src/components/tool-call-card.tsx +++ b/chat-ui/src/components/tool-call-card.tsx @@ -1,4 +1,5 @@ import type { ToolCallState } from "@/lib/chat-types"; +import { initialToolDisclosureState, reconcileToolDisclosureState, toggleToolDisclosure } from "@/lib/tool-disclosure"; import { cn } from "@/lib/utils"; import { AlertCircle, Check, ChevronDown, FileText, Loader2, Shield, Terminal, XCircle } from "lucide-react"; import { useEffect, useId, useState } from "react"; @@ -161,22 +162,20 @@ export function ToolCallCard({ tool }: { tool: ToolCallState }) { const inputDetails = toolInputDetails(tool); const output = tool.output ? redactSensitiveText(truncate(tool.output, TOOL_OUTPUT_DISPLAY_LIMIT)) : ""; - const autoExpand = tool.state === "error" || tool.state === "blocked"; - const [isOpen, setIsOpen] = useState(autoExpand); + const [disclosure, setDisclosure] = useState(() => initialToolDisclosureState(tool.state)); useEffect(() => { - if (tool.state === "error" || tool.state === "blocked") { - setIsOpen(true); - } + setDisclosure((current) => reconcileToolDisclosureState(current, tool.state)); }, [tool.state]); - const hasBody = Boolean(output || tool.error || tool.blockReason || inputDetails); + const isOpen = disclosure.isOpen; + const hasBody = Boolean(output || tool.error || tool.blockReason || inputDetails || tool.fullRef); return (