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 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 16:02:20 +01:00
parent 2bef0fa682
commit 752bb67e3a
2 changed files with 265 additions and 12 deletions

View File

@@ -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(<AgentOutputViewer agentId="agent-1" />)
// 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(<AgentOutputViewer agentId="agent-1" />)
// 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(<AgentOutputViewer agentId="agent-1" />)
// 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(<AgentOutputViewer agentId="agent-1" />)
// 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(<AgentOutputViewer agentId="agent-1" />)
// 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(<AgentOutputViewer agentId="agent-2" />)
// 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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
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(<AgentOutputViewer agentId="agent-1" />)
expect(screen.getByText('Session completed')).toBeInTheDocument()
})
})