Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
public/chat/
data/
.env.*
.env.local
Expand Down
67 changes: 67 additions & 0 deletions chat-ui/src/components/artifact-tray.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-3 space-y-2">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Artifacts</div>
<div className="grid gap-2">
{artifacts.map((artifact) => (
<ArtifactCard key={artifact.id} artifact={artifact} />
))}
</div>
</div>
);
}

function ArtifactCard({ artifact }: { artifact: ChatArtifactView }) {
const size = formatArtifactSize(artifact.sizeBytes);
return (
<div className="flex flex-col gap-3 rounded border border-border bg-card px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded bg-primary/10 text-primary">
<FileText className="h-4 w-4" />
</div>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{artifact.title}</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
Page
</span>
{size && <span className="text-xs text-muted-foreground">{size}</span>}
</div>
<div className="break-all font-mono text-xs text-muted-foreground">{artifact.path ?? artifact.url}</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<a
href={artifact.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex min-h-8 items-center gap-1.5 rounded border border-border px-2.5 text-xs font-medium text-foreground",
"hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<ExternalLink className="h-3.5 w-3.5" />
Open
</a>
<button
type="button"
onClick={() => void navigator.clipboard.writeText(artifact.url)}
className={cn(
"inline-flex min-h-8 items-center gap-1.5 rounded border border-border px-2.5 text-xs font-medium text-foreground",
"hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Copy className="h-3.5 w-3.5" />
Copy URL
</button>
</div>
</div>
);
}
119 changes: 66 additions & 53 deletions chat-ui/src/components/assistant-message.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex justify-start">
<div className="max-w-[92%]">
{thinkingBlocks.map((block, i) => (
<ThinkingBlock key={`thinking-${i}`} block={block} />
))}

{toolCalls.map((tool) => (
<ToolCallCard key={tool.id} tool={tool} />
))}

{textBlocks.map((textContent, index) => (
<Markdown key={`text-${index}`} content={textContent} />
))}

{isStreaming && !hasText && toolCalls.length === 0 && (
<div className="flex items-center gap-1.5 py-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<div className="h-2 w-2 animate-pulse rounded-full bg-primary [animation-delay:150ms]" />
<div className="h-2 w-2 animate-pulse rounded-full bg-primary [animation-delay:300ms]" />
</div>
)}

{message.costUsd != null && message.status === "committed" && (
<div className="mt-1 text-xs text-muted-foreground">
{message.inputTokens != null && message.outputTokens != null && (
<span>
{message.inputTokens.toLocaleString()} in /{" "}
{message.outputTokens.toLocaleString()} out
</span>
)}
{message.costUsd > 0 && (
<span className="ml-2">
${message.costUsd.toFixed(4)}
</span>
)}
</div>
)}
</div>
</div>
);
const textBlocks = getAssistantTextBlocks(message);
const artifacts = extractToolArtifacts(toolCalls);
const hasText = textBlocks.length > 0;

const isStreaming = message.status === "streaming";

return (
<div className="flex justify-start">
<div className="max-w-[92%]">
{thinkingBlocks.map((item) => (
<ThinkingBlock key={item.id} block={item.block} />
))}

{toolCalls.map((tool) => (
<ToolCallCard key={tool.id} tool={tool} />
))}

{textBlocks.map((textContent) => (
<Markdown key={`text-${hashText(textContent)}`} content={textContent} />
))}

<ArtifactTray artifacts={artifacts} />

{isStreaming && !hasText && toolCalls.length === 0 && (
<div className="flex items-center gap-1.5 py-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<div className="h-2 w-2 animate-pulse rounded-full bg-primary [animation-delay:150ms]" />
<div className="h-2 w-2 animate-pulse rounded-full bg-primary [animation-delay:300ms]" />
</div>
)}

{message.costUsd != null && message.status === "committed" && (
<div className="mt-1 text-xs text-muted-foreground">
{message.inputTokens != null && message.outputTokens != null && (
<span>
{message.inputTokens.toLocaleString()} in / {message.outputTokens.toLocaleString()} out
</span>
)}
{message.costUsd > 0 && <span className="ml-2">${message.costUsd.toFixed(4)}</span>}
</div>
)}
</div>
</div>
);
}

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);
}
13 changes: 9 additions & 4 deletions chat-ui/src/components/message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -36,10 +37,10 @@ export function MessageList({
}, [activeToolCalls]);

const thinkingByMessage = useMemo(() => {
const map = new Map<string, ThinkingBlockState[]>();
for (const [, tb] of thinkingBlocks) {
const map = new Map<string, ThinkingBlockItem[]>();
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;
Expand Down Expand Up @@ -85,7 +86,11 @@ export function MessageList({
/>
{message.role === "assistant" && <MessageActions message={message} />}
{message.runTimeline && (
<RunActivityRow activity={message.runTimeline.activity} toolCalls={message.runTimeline.toolCalls} />
<RunActivityRow
activity={message.runTimeline.activity}
toolCalls={message.runTimeline.toolCalls}
artifacts={message.runTimeline.artifacts}
/>
)}
</div>
))}
Expand Down
30 changes: 12 additions & 18 deletions chat-ui/src/components/message.tsx
Original file line number Diff line number Diff line change
@@ -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 <UserMessage message={message} />;
}
if (message.role === "user") {
return <UserMessage message={message} />;
}

return (
<AssistantMessage
message={message}
toolCalls={toolCalls}
thinkingBlocks={thinkingBlocks}
/>
);
return <AssistantMessage message={message} toolCalls={toolCalls} thinkingBlocks={thinkingBlocks} />;
}
16 changes: 14 additions & 2 deletions chat-ui/src/components/run-activity-row.tsx
Original file line number Diff line number Diff line change
@@ -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): {
Expand Down Expand Up @@ -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(", ")}`);
Expand Down Expand Up @@ -159,16 +163,22 @@ 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);
const elapsedAt = activity.isActive ? now : Date.parse(activity.updatedAt);
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 (
<div className="flex justify-start">
Expand Down Expand Up @@ -237,6 +247,8 @@ export function RunActivityRow({
))}
</div>
)}

<ArtifactTray artifacts={artifacts} />
</div>
</div>

Expand Down
13 changes: 6 additions & 7 deletions chat-ui/src/components/tool-call-card.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<div className={cn("my-2 overflow-hidden rounded border bg-card transition-colors", style.border)}>
<button
type="button"
onClick={() => hasBody && setIsOpen(!isOpen)}
onClick={() => hasBody && setDisclosure((current) => toggleToolDisclosure(current))}
className="flex min-h-11 w-full items-center gap-2 px-3 py-2 text-sm"
disabled={!hasBody}
aria-expanded={hasBody ? isOpen : undefined}
Expand Down
Loading
Loading