From f6938ae7e16b3722af0192685a97eb78e3631d33 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:05:48 +0100 Subject: [PATCH] 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)} )}