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