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 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 16:05:48 +01:00
parent 32f58ddb43
commit f6938ae7e1
2 changed files with 209 additions and 2 deletions

View File

@@ -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) { function makeErrorMessage(content: string) {
return { return {
type: 'error' as const, type: 'error' as const,
@@ -231,4 +255,153 @@ describe('AgentOutputViewer', () => {
expect(screen.getByText('Session completed')).toBeInTheDocument() 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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
// 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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
// 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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument()
})
})
}) })

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; 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 { trpc } from "@/lib/trpc";
import { useSubscriptionWithErrorHandling } from "@/hooks"; import { useSubscriptionWithErrorHandling } from "@/hooks";
import { import {
@@ -97,6 +97,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
const isLoading = outputQuery.isLoading; const isLoading = outputQuery.isLoading;
const hasOutput = messages.length > 0; 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 ( return (
<div className="flex flex-col h-full rounded-lg border border-terminal-border overflow-hidden"> <div className="flex flex-col h-full rounded-lg border border-terminal-border overflow-hidden">
{/* Header */} {/* Header */}
@@ -159,6 +167,30 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
</div> </div>
</div> </div>
{/* Todo strip */}
{currentTodos.length > 0 && (
<div className="bg-terminal border-b border-terminal-border px-4 py-2">
<div className="text-[10px] text-terminal-muted/60 font-mono uppercase tracking-widest mb-1">TASKS</div>
{currentTodos.slice(0, 5).map((todo, i) => (
<div key={i} className="flex items-center gap-2 font-mono text-xs">
{todo.status === "completed" && <CheckCircle2 className="h-3 w-3 text-terminal-muted/60" />}
{todo.status === "in_progress" && <Loader2 className="h-3 w-3 text-terminal-fg animate-spin" />}
{todo.status === "pending" && <Circle className="h-3 w-3 text-terminal-muted/40" />}
<span className={
todo.status === "completed"
? "text-terminal-muted/60 line-through"
: todo.status === "in_progress"
? "text-terminal-fg"
: "text-terminal-muted"
}>{todo.content}</span>
</div>
))}
{currentTodos.length > 5 && (
<div className="font-mono text-xs text-terminal-muted/60">+ {currentTodos.length - 5} more</div>
)}
</div>
)}
{/* Output content */} {/* Output content */}
<div <div
ref={containerRef} ref={containerRef}
@@ -225,7 +257,9 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
<> <>
<ChevronRight className="h-4 w-4 text-terminal-muted inline-block shrink-0 mr-1" /> <ChevronRight className="h-4 w-4 text-terminal-muted inline-block shrink-0 mr-1" />
<span className="text-terminal-muted/60 text-xs font-mono truncate"> <span className="text-terminal-muted/60 text-xs font-mono truncate">
{message.content.substring(0, 80)} {message.meta?.toolName === "Task"
? `${(message.meta.toolInput as any)?.subagent_type ?? "Subagent"} result`
: message.content.substring(0, 80)}
</span> </span>
</> </>
)} )}