From 752bb67e3aa877bd18a34e63e33d9f17d7dbc6b6 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:02:20 +0100 Subject: [PATCH 1/3] feat: collapse tool_result blocks by default; restyle system messages tool_result messages now render as a single collapsible preview line (ChevronRight + first 80 chars) and expand on click to show full content. system messages drop the Badge/border-l and render as a dim mono inline line. expandedResults state resets when agentId changes. Adds full test coverage in AgentOutputViewer.test.tsx (9 tests). Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/AgentOutputViewer.test.tsx | 234 ++++++++++++++++++ apps/web/src/components/AgentOutputViewer.tsx | 43 +++- 2 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/AgentOutputViewer.test.tsx diff --git a/apps/web/src/components/AgentOutputViewer.test.tsx b/apps/web/src/components/AgentOutputViewer.test.tsx new file mode 100644 index 0000000..1523a63 --- /dev/null +++ b/apps/web/src/components/AgentOutputViewer.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import * as parseModule from '@/lib/parse-agent-output' +import { AgentOutputViewer } from './AgentOutputViewer' + +vi.mock('@/lib/trpc', () => ({ + trpc: { + getAgentOutput: { + useQuery: vi.fn(() => ({ data: [], isLoading: false })), + }, + onAgentOutput: { + useSubscription: vi.fn(), + }, + }, +})) + +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: vi.fn(() => ({ + error: null, + isConnecting: false, + })), +})) + +function makeToolResultMessage(content: string) { + return { + type: 'tool_result' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +function makeSystemMessage(content: string) { + return { + type: 'system' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +function makeTextMessage(content: string) { + return { + type: 'text' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +function makeToolCallMessage(content: string, toolName: string) { + return { + type: 'tool_call' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + meta: { toolName }, + } +} + +function makeErrorMessage(content: string) { + return { + type: 'error' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +function makeSessionEndMessage(content: string) { + return { + type: 'session_end' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +describe('AgentOutputViewer', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: no messages + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([]) + }) + + // Test 1: tool_result renders collapsed by default + it('renders tool_result collapsed by default', () => { + const content = 'file content here and more stuff' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)]) + + render() + + // ChevronRight should be present (collapsed state) + // We check that the SVG for ChevronRight is in the document + // lucide-react renders SVGs — we look for the collapsed container containing the preview text + expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument() + + // "Result" badge should NOT be visible (collapsed) + expect(screen.queryByText('Result')).not.toBeInTheDocument() + + // The container should NOT show a "Result" badge + // ChevronRight is rendered — verify no ChevronDown + const svgs = document.querySelectorAll('svg') + // We look for the collapsed state by absence of "Result" text + expect(screen.queryByText('Result')).toBeNull() + }) + + // Test 2: Clicking collapsed result expands it + it('expands tool_result on click', () => { + const longContent = 'a'.repeat(100) + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)]) + + render() + + // Initially collapsed — click it + const collapsedContainer = screen.getByText(longContent.substring(0, 80)).closest('div')! + fireEvent.click(collapsedContainer) + + // After click: "Result" badge should be visible + expect(screen.getByText('Result')).toBeInTheDocument() + + // Full content should be visible in whitespace-pre-wrap element + const preWrap = document.querySelector('.whitespace-pre-wrap') + expect(preWrap).toBeInTheDocument() + expect(preWrap).toHaveTextContent(longContent) + + // ChevronRight should no longer be visible; ChevronDown should be present + expect(screen.queryByText('Result')).toBeInTheDocument() + }) + + // Test 3: Clicking expanded result collapses it again + it('collapses tool_result on second click', () => { + const longContent = 'b'.repeat(100) + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)]) + + render() + + // Click once to expand + const container = screen.getByText(longContent.substring(0, 80)).closest('div')! + fireEvent.click(container) + expect(screen.getByText('Result')).toBeInTheDocument() + + // Click again to collapse + // After expansion, the container still exists — click the expanded container + // The clickable container is the border-l-2 div + const expandedContainer = screen.getByText('Result').closest('.border-l-2')! + fireEvent.click(expandedContainer) + + // Should be collapsed again + expect(screen.queryByText('Result')).not.toBeInTheDocument() + expect(screen.getByText(longContent.substring(0, 80))).toBeInTheDocument() + }) + + // Test 4: system message renders as single dim line, no badge, no border-l + it('renders system message as a single dim line without badge or border', () => { + const content = 'Session started: abc-123' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSystemMessage(content)]) + + render() + + // Content is visible + expect(screen.getByText(content)).toBeInTheDocument() + + // No "System" badge text + expect(screen.queryByText('System')).not.toBeInTheDocument() + + // The rendered element should NOT have border-l class + const el = screen.getByText(content) + expect(el.className).not.toContain('border-l') + expect(el.closest('[class*="border-l"]')).toBeNull() + }) + + // Test 5: agentId prop change resets expanded results + it('resets expanded results when agentId changes', () => { + const content = 'c'.repeat(100) + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)]) + + const { rerender } = render() + + // Expand the result + const collapsedContainer = screen.getByText(content.substring(0, 80)).closest('div')! + fireEvent.click(collapsedContainer) + expect(screen.getByText('Result')).toBeInTheDocument() + + // Change agentId — should reset expandedResults + rerender() + + // After agentId change, result should be collapsed again + expect(screen.queryByText('Result')).not.toBeInTheDocument() + // Preview text should be visible (collapsed state) + expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument() + }) + + // Test 6: Other message types remain always-expanded (unaffected) + it('always renders text messages fully', () => { + const content = 'This is a text message' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeTextMessage(content)]) + + render() + + expect(screen.getByText(content)).toBeInTheDocument() + + // No chevron icons for text messages + const svgCount = document.querySelectorAll('svg').length + // Only the header bar icons (Pause) should be present, no expand/collapse chevrons + expect(screen.queryByText('Result')).not.toBeInTheDocument() + expect(screen.queryByText('System')).not.toBeInTheDocument() + }) + + it('always renders tool_call messages fully', () => { + const content = 'Read(file.txt)' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolCallMessage(content, 'Read')]) + + render() + + expect(screen.getByText(content)).toBeInTheDocument() + // The tool name badge should be visible + expect(screen.getByText('Read')).toBeInTheDocument() + }) + + it('always renders error messages with Error badge', () => { + const content = 'Something went wrong' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeErrorMessage(content)]) + + render() + + expect(screen.getByText('Error')).toBeInTheDocument() + expect(screen.getByText(content)).toBeInTheDocument() + }) + + it('always renders session_end messages with session completed text', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSessionEndMessage('Session completed')]) + + render() + + expect(screen.getByText('Session completed')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/AgentOutputViewer.tsx b/apps/web/src/components/AgentOutputViewer.tsx index 48663ed..0f597fa 100644 --- a/apps/web/src/components/AgentOutputViewer.tsx +++ b/apps/web/src/components/AgentOutputViewer.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { ArrowDown, Pause, Play, AlertCircle, Square } from "lucide-react"; +import { ArrowDown, ChevronDown, ChevronRight, Pause, Play, AlertCircle, Square } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { useSubscriptionWithErrorHandling } from "@/hooks"; import { @@ -21,6 +21,7 @@ interface AgentOutputViewerProps { export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentOutputViewerProps) { const [messages, setMessages] = useState([]); const [follow, setFollow] = useState(true); + const [expandedResults, setExpandedResults] = useState>(new Set()); const containerRef = useRef(null); // Accumulate timestamped chunks: initial query data + live subscription chunks const chunksRef = useRef([]); @@ -65,6 +66,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO chunksRef.current = []; setMessages([]); setFollow(true); + setExpandedResults(new Set()); }, [agentId]); // Auto-scroll to bottom when following @@ -172,10 +174,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO {messages.map((message, index) => (
{message.type === 'system' && ( -
- System - {message.content} - +
+ {message.content}
)} @@ -203,13 +203,32 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO )} {message.type === 'tool_result' && ( -
- - Result - -
- {message.content} -
+
setExpandedResults(prev => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); else next.add(index); + return next; + })} + > + {expandedResults.has(index) ? ( + <> + + + Result + +
+ {message.content} +
+ + ) : ( + <> + + + {message.content.substring(0, 80)} + + + )}
)} From ee6b0da976e9d8fc4f86104947601e6829ee1cbe Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:02:50 +0100 Subject: [PATCH 2/3] feat: Add tool correlation, toolInput metadata, and unknown-type fallbacks to parser - Extend ParsedMessage.meta with toolInput to expose raw tool arguments - Add toolUseRegistry to correlate tool_result blocks back to originating tool_use - Set toolInput on tool_call messages and populate meta.toolName/toolInput on tool_result - Fix tool_result with is_error:true now correctly produces type "error" - Add catch-all for unknown top-level event types (emits system message) - Add catch-all for unknown assistant content block types (emits system message) - Add unit tests covering all 8 scenarios including regression cases Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/lib/parse-agent-output.test.ts | 264 ++++++++++++++++++++ apps/web/src/lib/parse-agent-output.ts | 29 ++- 2 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/parse-agent-output.test.ts diff --git a/apps/web/src/lib/parse-agent-output.test.ts b/apps/web/src/lib/parse-agent-output.test.ts new file mode 100644 index 0000000..52c7fae --- /dev/null +++ b/apps/web/src/lib/parse-agent-output.test.ts @@ -0,0 +1,264 @@ +import { parseAgentOutput } from "./parse-agent-output"; + +function chunk(events: object[]): string { + return events.map((e) => JSON.stringify(e)).join("\n"); +} + +describe("parseAgentOutput", () => { + // 1. toolInput is set on tool_call messages + it("sets meta.toolInput on tool_call messages", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tu1", + name: "Read", + input: { file_path: "/foo.ts" }, + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolCall = messages.find((m) => m.type === "tool_call"); + expect(toolCall).toBeDefined(); + expect(toolCall!.meta?.toolInput).toEqual({ file_path: "/foo.ts" }); + }); + + // 2. tool_result with tool_use_id gets meta.toolName and meta.toolInput from registry + it("correlates tool_result to its tool_use via registry", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tu1", + name: "Read", + input: { file_path: "/foo.ts" }, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu1", + content: "file contents", + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolResult = messages.find((m) => m.type === "tool_result"); + expect(toolResult).toBeDefined(); + expect(toolResult!.meta?.toolName).toBe("Read"); + expect(toolResult!.meta?.toolInput).toEqual({ file_path: "/foo.ts" }); + }); + + // 3. tool_result with no matching registry entry has no meta.toolName + it("tool_result with unknown tool_use_id has no meta.toolName", () => { + const input = chunk([ + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "unknown-id", + content: "output", + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolResult = messages.find((m) => m.type === "tool_result"); + expect(toolResult).toBeDefined(); + expect(toolResult!.meta?.toolName).toBeUndefined(); + }); + + // 4. tool_result with is_error: true produces type: "error" and meta.isError: true + it("tool_result with is_error: true produces error message", () => { + const input = chunk([ + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu1", + is_error: true, + content: "something went wrong", + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const errorMsg = messages.find((m) => m.content === "something went wrong"); + expect(errorMsg).toBeDefined(); + expect(errorMsg!.type).toBe("error"); + expect(errorMsg!.meta?.isError).toBe(true); + }); + + // 5. tool_result from a Task tool_use gets correct meta.toolName and meta.toolInput + it("tool_result from Task tool_use has correct meta", () => { + const taskInput = { + subagent_type: "Explore", + description: "find files", + prompt: "search for *.ts", + }; + const input = chunk([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tu2", + name: "Task", + input: taskInput, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu2", + content: "found 10 files", + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolResult = messages.find((m) => m.type === "tool_result"); + expect(toolResult).toBeDefined(); + expect(toolResult!.meta?.toolName).toBe("Task"); + expect(toolResult!.meta?.toolInput).toEqual(taskInput); + }); + + // 6. Unknown top-level event type produces a system message + it("unknown top-level event type produces system message", () => { + const input = chunk([{ type: "future_event_type", data: {} }]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("system"); + expect(messages[0].content).toBe("[unknown event: future_event_type]"); + }); + + // 7. Unknown assistant content block type produces a system message + it("unknown assistant content block type produces system message", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [{ type: "image", data: "base64..." }], + }, + }, + ]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("system"); + expect(messages[0].content).toBe("[unsupported content block: image]"); + }); + + // 8. Previously passing behavior unchanged + describe("previously passing behavior", () => { + it("system event with session_id produces system message", () => { + const input = chunk([{ type: "system", session_id: "sess-123" }]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("system"); + expect(messages[0].content).toBe("Session started: sess-123"); + }); + + it("assistant text block produces text message", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [{ type: "text", text: "Hello, world!" }], + }, + }, + ]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("text"); + expect(messages[0].content).toBe("Hello, world!"); + }); + + it("assistant tool_use block produces tool_call message with meta.toolName", () => { + const input = chunk([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tu1", + name: "Bash", + input: { command: "ls -la", description: "list files" }, + }, + ], + }, + }, + ]); + const messages = parseAgentOutput(input); + const toolCall = messages.find((m) => m.type === "tool_call"); + expect(toolCall).toBeDefined(); + expect(toolCall!.meta?.toolName).toBe("Bash"); + }); + + it("result event with is_error: false produces session_end", () => { + const input = chunk([ + { + type: "result", + is_error: false, + total_cost_usd: 0.01, + duration_ms: 5000, + }, + ]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("session_end"); + expect(messages[0].content).toBe("Session completed"); + }); + + it("result event with is_error: true produces session_end with meta.isError", () => { + const input = chunk([ + { + type: "result", + is_error: true, + total_cost_usd: 0.01, + duration_ms: 5000, + }, + ]); + const messages = parseAgentOutput(input); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("session_end"); + expect(messages[0].meta?.isError).toBe(true); + }); + + it("non-JSON line produces error message with raw line as content", () => { + const rawLine = "This is not JSON at all"; + const messages = parseAgentOutput(rawLine); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("error"); + expect(messages[0].content).toBe(rawLine); + }); + }); +}); diff --git a/apps/web/src/lib/parse-agent-output.ts b/apps/web/src/lib/parse-agent-output.ts index ca6065c..7f950bb 100644 --- a/apps/web/src/lib/parse-agent-output.ts +++ b/apps/web/src/lib/parse-agent-output.ts @@ -10,6 +10,7 @@ export interface ParsedMessage { timestamp?: Date; meta?: { toolName?: string; + toolInput?: unknown; isError?: boolean; cost?: number; duration?: number; @@ -80,6 +81,7 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag : raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) })); const parsedMessages: ParsedMessage[] = []; + const toolUseRegistry = new Map(); for (const chunk of chunks) { const lines = chunk.content.split("\n").filter(Boolean); @@ -113,7 +115,14 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag type: "tool_call", content: formatToolCall(block), timestamp: chunk.timestamp, - meta: { toolName: block.name }, + meta: { toolName: block.name, toolInput: block.input }, + }); + toolUseRegistry.set(block.id, { name: block.name, input: block.input }); + } else { + parsedMessages.push({ + type: "system", + content: `[unsupported content block: ${block.type}]`, + timestamp: chunk.timestamp, }); } } @@ -149,10 +158,20 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag output.length > 1000 ? output.substring(0, 1000) + "\n... (truncated)" : output; + const isError = block.is_error === true; + const originatingCall = block.tool_use_id + ? toolUseRegistry.get(block.tool_use_id) + : undefined; parsedMessages.push({ - type: "tool_result", + type: isError ? "error" : "tool_result", content: displayOutput, timestamp: chunk.timestamp, + meta: { + ...(isError ? { isError: true } : {}), + ...(originatingCall + ? { toolName: originatingCall.name, toolInput: originatingCall.input } + : {}), + }, }); } } @@ -180,6 +199,12 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag duration: event.duration_ms, }, }); + } else { + parsedMessages.push({ + type: "system", + content: `[unknown event: ${event.type ?? "(no type)"}]`, + timestamp: chunk.timestamp, + }); } } catch { // Not JSON, display as-is From f6938ae7e16b3722af0192685a97eb78e3631d33 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:05:48 +0100 Subject: [PATCH 3/3] feat: Add live todo strip and Task result preview to AgentOutputViewer - Derives currentTodos from the most recent TodoWrite tool_call on each render - Renders a TASKS strip between the header and scroll area with status icons (CheckCircle2 for completed, Loader2/animate-spin for in_progress, Circle for pending) - Caps the strip at 5 rows and shows "+ N more" for overflow - Updates collapsed tool_result preview to show "{subagent_type} result" for Task tool results instead of the raw first-80-chars substring - Adds 10 new tests covering all todo strip states and Task preview variants Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/AgentOutputViewer.test.tsx | 173 ++++++++++++++++++ apps/web/src/components/AgentOutputViewer.tsx | 38 +++- 2 files changed, 209 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/AgentOutputViewer.test.tsx b/apps/web/src/components/AgentOutputViewer.test.tsx index 1523a63..c06cd40 100644 --- a/apps/web/src/components/AgentOutputViewer.test.tsx +++ b/apps/web/src/components/AgentOutputViewer.test.tsx @@ -56,6 +56,30 @@ function makeToolCallMessage(content: string, toolName: string) { } } +function makeTodoWriteMessage(todos: Array<{ content: string; status: string; activeForm: string }>) { + return { + type: 'tool_call' as const, + content: 'TodoWrite(...)', + timestamp: new Date('2024-01-01T00:00:00Z'), + meta: { + toolName: 'TodoWrite', + toolInput: { todos }, + }, + } +} + +function makeToolResultMessageWithMeta( + content: string, + meta: { toolName?: string; toolInput?: unknown } +) { + return { + type: 'tool_result' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + meta, + } +} + function makeErrorMessage(content: string) { return { type: 'error' as const, @@ -231,4 +255,153 @@ describe('AgentOutputViewer', () => { expect(screen.getByText('Session completed')).toBeInTheDocument() }) + + describe('Todo strip', () => { + it('is absent when no TodoWrite tool_call exists', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeTextMessage('some output'), + makeToolCallMessage('Read(file.txt)', 'Read'), + ]) + + render() + + expect(screen.queryByText('TASKS')).not.toBeInTheDocument() + }) + + it('is present with TASKS label and all todos when a TodoWrite tool_call exists', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeTodoWriteMessage([ + { content: 'Fix bug', status: 'completed', activeForm: 'Fixing bug' }, + { content: 'Add tests', status: 'in_progress', activeForm: 'Adding tests' }, + { content: 'Update docs', status: 'pending', activeForm: 'Updating docs' }, + ]), + ]) + + render() + + expect(screen.getByText('TASKS')).toBeInTheDocument() + expect(screen.getByText('Fix bug')).toBeInTheDocument() + expect(screen.getByText('Add tests')).toBeInTheDocument() + expect(screen.getByText('Update docs')).toBeInTheDocument() + }) + + it('shows only the most recent TodoWrite todos', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeTodoWriteMessage([ + { content: 'Old task', status: 'pending', activeForm: 'Old task' }, + ]), + makeTodoWriteMessage([ + { content: 'New task', status: 'in_progress', activeForm: 'New task' }, + ]), + ]) + + render() + + expect(screen.getByText('New task')).toBeInTheDocument() + expect(screen.queryByText('Old task')).not.toBeInTheDocument() + }) + + it('renders Loader2 with animate-spin for in_progress todo', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeTodoWriteMessage([ + { content: 'Work', status: 'in_progress', activeForm: 'Working' }, + ]), + ]) + + render() + + // Find SVG with animate-spin class (Loader2) + const spinningIcon = document.querySelector('svg.animate-spin') + expect(spinningIcon).toBeInTheDocument() + }) + + it('renders CheckCircle2 and strikethrough text for completed todo', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeTodoWriteMessage([ + { content: 'Done', status: 'completed', activeForm: 'Done' }, + ]), + ]) + + render() + + const doneText = screen.getByText('Done') + expect(doneText.className).toContain('line-through') + }) + + it('renders Circle and muted text without strikethrough for pending todo', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeTodoWriteMessage([ + { content: 'Later', status: 'pending', activeForm: 'Later' }, + ]), + ]) + + render() + + const laterText = screen.getByText('Later') + expect(laterText.className).not.toContain('line-through') + }) + + it('renders at most 5 todo rows and shows overflow count when there are 7 todos', () => { + const todos = Array.from({ length: 7 }, (_, i) => ({ + content: `Task ${i + 1}`, + status: 'pending', + activeForm: `Task ${i + 1}`, + })) + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeTodoWriteMessage(todos), + ]) + + render() + + // Only first 5 are rendered + expect(screen.getByText('Task 1')).toBeInTheDocument() + expect(screen.getByText('Task 5')).toBeInTheDocument() + expect(screen.queryByText('Task 6')).not.toBeInTheDocument() + expect(screen.queryByText('Task 7')).not.toBeInTheDocument() + + // Overflow indicator + expect(screen.getByText('+ 2 more')).toBeInTheDocument() + }) + }) + + describe('Task result collapsed preview', () => { + it('shows subagent_type result label when meta.toolName is Task with subagent_type', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeToolResultMessageWithMeta('raw subagent output content '.repeat(5), { + toolName: 'Task', + toolInput: { subagent_type: 'Explore', description: 'desc', prompt: 'prompt' }, + }), + ]) + + render() + + expect(screen.getByText('Explore result')).toBeInTheDocument() + }) + + it('shows Subagent result when meta.toolName is Task but no subagent_type', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeToolResultMessageWithMeta('raw subagent output content '.repeat(5), { + toolName: 'Task', + toolInput: { description: 'desc', prompt: 'prompt' }, + }), + ]) + + render() + + expect(screen.getByText('Subagent result')).toBeInTheDocument() + }) + + it('shows first 80 chars of content for non-Task tool results', () => { + const content = 'some file content that is longer than 80 characters '.repeat(3) + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ + makeToolResultMessageWithMeta(content, { + toolName: 'Read', + }), + ]) + + render() + + expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument() + }) + }) }) diff --git a/apps/web/src/components/AgentOutputViewer.tsx b/apps/web/src/components/AgentOutputViewer.tsx index 0f597fa..447073a 100644 --- a/apps/web/src/components/AgentOutputViewer.tsx +++ b/apps/web/src/components/AgentOutputViewer.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { ArrowDown, ChevronDown, ChevronRight, Pause, Play, AlertCircle, Square } from "lucide-react"; +import { ArrowDown, Pause, Play, AlertCircle, Square, ChevronRight, ChevronDown, CheckCircle2, Loader2, Circle } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { useSubscriptionWithErrorHandling } from "@/hooks"; import { @@ -97,6 +97,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO const isLoading = outputQuery.isLoading; const hasOutput = messages.length > 0; + const lastTodoWrite = [...messages] + .reverse() + .find(m => m.type === "tool_call" && m.meta?.toolName === "TodoWrite"); + + const currentTodos = lastTodoWrite + ? (lastTodoWrite.meta?.toolInput as { todos: Array<{ content: string; status: string; activeForm: string }> } | undefined)?.todos ?? [] + : []; + return (
{/* Header */} @@ -159,6 +167,30 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
+ {/* Todo strip */} + {currentTodos.length > 0 && ( +
+
TASKS
+ {currentTodos.slice(0, 5).map((todo, i) => ( +
+ {todo.status === "completed" && } + {todo.status === "in_progress" && } + {todo.status === "pending" && } + {todo.content} +
+ ))} + {currentTodos.length > 5 && ( +
+ {currentTodos.length - 5} more
+ )} +
+ )} + {/* Output content */}
- {message.content.substring(0, 80)} + {message.meta?.toolName === "Task" + ? `${(message.meta.toolInput as any)?.subagent_type ?? "Subagent"} result` + : message.content.substring(0, 80)} )}