From 752bb67e3aa877bd18a34e63e33d9f17d7dbc6b6 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:02:20 +0100 Subject: [PATCH] feat: collapse tool_result blocks by default; restyle system messages tool_result messages now render as a single collapsible preview line (ChevronRight + first 80 chars) and expand on click to show full content. system messages drop the Badge/border-l and render as a dim mono inline line. expandedResults state resets when agentId changes. Adds full test coverage in AgentOutputViewer.test.tsx (9 tests). Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/AgentOutputViewer.test.tsx | 234 ++++++++++++++++++ apps/web/src/components/AgentOutputViewer.tsx | 43 +++- 2 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/AgentOutputViewer.test.tsx diff --git a/apps/web/src/components/AgentOutputViewer.test.tsx b/apps/web/src/components/AgentOutputViewer.test.tsx new file mode 100644 index 0000000..1523a63 --- /dev/null +++ b/apps/web/src/components/AgentOutputViewer.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import * as parseModule from '@/lib/parse-agent-output' +import { AgentOutputViewer } from './AgentOutputViewer' + +vi.mock('@/lib/trpc', () => ({ + trpc: { + getAgentOutput: { + useQuery: vi.fn(() => ({ data: [], isLoading: false })), + }, + onAgentOutput: { + useSubscription: vi.fn(), + }, + }, +})) + +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: vi.fn(() => ({ + error: null, + isConnecting: false, + })), +})) + +function makeToolResultMessage(content: string) { + return { + type: 'tool_result' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +function makeSystemMessage(content: string) { + return { + type: 'system' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +function makeTextMessage(content: string) { + return { + type: 'text' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +function makeToolCallMessage(content: string, toolName: string) { + return { + type: 'tool_call' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + meta: { toolName }, + } +} + +function makeErrorMessage(content: string) { + return { + type: 'error' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +function makeSessionEndMessage(content: string) { + return { + type: 'session_end' as const, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), + } +} + +describe('AgentOutputViewer', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: no messages + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([]) + }) + + // Test 1: tool_result renders collapsed by default + it('renders tool_result collapsed by default', () => { + const content = 'file content here and more stuff' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)]) + + render() + + // ChevronRight should be present (collapsed state) + // We check that the SVG for ChevronRight is in the document + // lucide-react renders SVGs — we look for the collapsed container containing the preview text + expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument() + + // "Result" badge should NOT be visible (collapsed) + expect(screen.queryByText('Result')).not.toBeInTheDocument() + + // The container should NOT show a "Result" badge + // ChevronRight is rendered — verify no ChevronDown + const svgs = document.querySelectorAll('svg') + // We look for the collapsed state by absence of "Result" text + expect(screen.queryByText('Result')).toBeNull() + }) + + // Test 2: Clicking collapsed result expands it + it('expands tool_result on click', () => { + const longContent = 'a'.repeat(100) + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)]) + + render() + + // Initially collapsed — click it + const collapsedContainer = screen.getByText(longContent.substring(0, 80)).closest('div')! + fireEvent.click(collapsedContainer) + + // After click: "Result" badge should be visible + expect(screen.getByText('Result')).toBeInTheDocument() + + // Full content should be visible in whitespace-pre-wrap element + const preWrap = document.querySelector('.whitespace-pre-wrap') + expect(preWrap).toBeInTheDocument() + expect(preWrap).toHaveTextContent(longContent) + + // ChevronRight should no longer be visible; ChevronDown should be present + expect(screen.queryByText('Result')).toBeInTheDocument() + }) + + // Test 3: Clicking expanded result collapses it again + it('collapses tool_result on second click', () => { + const longContent = 'b'.repeat(100) + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)]) + + render() + + // Click once to expand + const container = screen.getByText(longContent.substring(0, 80)).closest('div')! + fireEvent.click(container) + expect(screen.getByText('Result')).toBeInTheDocument() + + // Click again to collapse + // After expansion, the container still exists — click the expanded container + // The clickable container is the border-l-2 div + const expandedContainer = screen.getByText('Result').closest('.border-l-2')! + fireEvent.click(expandedContainer) + + // Should be collapsed again + expect(screen.queryByText('Result')).not.toBeInTheDocument() + expect(screen.getByText(longContent.substring(0, 80))).toBeInTheDocument() + }) + + // Test 4: system message renders as single dim line, no badge, no border-l + it('renders system message as a single dim line without badge or border', () => { + const content = 'Session started: abc-123' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSystemMessage(content)]) + + render() + + // Content is visible + expect(screen.getByText(content)).toBeInTheDocument() + + // No "System" badge text + expect(screen.queryByText('System')).not.toBeInTheDocument() + + // The rendered element should NOT have border-l class + const el = screen.getByText(content) + expect(el.className).not.toContain('border-l') + expect(el.closest('[class*="border-l"]')).toBeNull() + }) + + // Test 5: agentId prop change resets expanded results + it('resets expanded results when agentId changes', () => { + const content = 'c'.repeat(100) + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)]) + + const { rerender } = render() + + // Expand the result + const collapsedContainer = screen.getByText(content.substring(0, 80)).closest('div')! + fireEvent.click(collapsedContainer) + expect(screen.getByText('Result')).toBeInTheDocument() + + // Change agentId — should reset expandedResults + rerender() + + // After agentId change, result should be collapsed again + expect(screen.queryByText('Result')).not.toBeInTheDocument() + // Preview text should be visible (collapsed state) + expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument() + }) + + // Test 6: Other message types remain always-expanded (unaffected) + it('always renders text messages fully', () => { + const content = 'This is a text message' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeTextMessage(content)]) + + render() + + expect(screen.getByText(content)).toBeInTheDocument() + + // No chevron icons for text messages + const svgCount = document.querySelectorAll('svg').length + // Only the header bar icons (Pause) should be present, no expand/collapse chevrons + expect(screen.queryByText('Result')).not.toBeInTheDocument() + expect(screen.queryByText('System')).not.toBeInTheDocument() + }) + + it('always renders tool_call messages fully', () => { + const content = 'Read(file.txt)' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolCallMessage(content, 'Read')]) + + render() + + expect(screen.getByText(content)).toBeInTheDocument() + // The tool name badge should be visible + expect(screen.getByText('Read')).toBeInTheDocument() + }) + + it('always renders error messages with Error badge', () => { + const content = 'Something went wrong' + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeErrorMessage(content)]) + + render() + + expect(screen.getByText('Error')).toBeInTheDocument() + expect(screen.getByText(content)).toBeInTheDocument() + }) + + it('always renders session_end messages with session completed text', () => { + vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSessionEndMessage('Session completed')]) + + render() + + expect(screen.getByText('Session completed')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/AgentOutputViewer.tsx b/apps/web/src/components/AgentOutputViewer.tsx index 48663ed..0f597fa 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, Pause, Play, AlertCircle, Square } from "lucide-react"; +import { ArrowDown, ChevronDown, ChevronRight, Pause, Play, AlertCircle, Square } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { useSubscriptionWithErrorHandling } from "@/hooks"; import { @@ -21,6 +21,7 @@ interface AgentOutputViewerProps { export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentOutputViewerProps) { const [messages, setMessages] = useState([]); const [follow, setFollow] = useState(true); + const [expandedResults, setExpandedResults] = useState>(new Set()); const containerRef = useRef(null); // Accumulate timestamped chunks: initial query data + live subscription chunks const chunksRef = useRef([]); @@ -65,6 +66,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO chunksRef.current = []; setMessages([]); setFollow(true); + setExpandedResults(new Set()); }, [agentId]); // Auto-scroll to bottom when following @@ -172,10 +174,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO {messages.map((message, index) => (
{message.type === 'system' && ( -
- System - {message.content} - +
+ {message.content}
)} @@ -203,13 +203,32 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO )} {message.type === 'tool_result' && ( -
- - Result - -
- {message.content} -
+
setExpandedResults(prev => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); else next.add(index); + return next; + })} + > + {expandedResults.has(index) ? ( + <> + + + Result + +
+ {message.content} +
+ + ) : ( + <> + + + {message.content.substring(0, 80)} + + + )}
)}