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)}
>
)}