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:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user