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:
234
apps/web/src/components/AgentOutputViewer.test.tsx
Normal file
234
apps/web/src/components/AgentOutputViewer.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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, Pause, Play, AlertCircle, Square } from "lucide-react";
|
import { ArrowDown, ChevronDown, ChevronRight, Pause, Play, AlertCircle, Square } from "lucide-react";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ interface AgentOutputViewerProps {
|
|||||||
export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentOutputViewerProps) {
|
export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentOutputViewerProps) {
|
||||||
const [messages, setMessages] = useState<ParsedMessage[]>([]);
|
const [messages, setMessages] = useState<ParsedMessage[]>([]);
|
||||||
const [follow, setFollow] = useState(true);
|
const [follow, setFollow] = useState(true);
|
||||||
|
const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set());
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// Accumulate timestamped chunks: initial query data + live subscription chunks
|
// Accumulate timestamped chunks: initial query data + live subscription chunks
|
||||||
const chunksRef = useRef<TimestampedChunk[]>([]);
|
const chunksRef = useRef<TimestampedChunk[]>([]);
|
||||||
@@ -65,6 +66,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
|||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setFollow(true);
|
setFollow(true);
|
||||||
|
setExpandedResults(new Set());
|
||||||
}, [agentId]);
|
}, [agentId]);
|
||||||
|
|
||||||
// Auto-scroll to bottom when following
|
// Auto-scroll to bottom when following
|
||||||
@@ -172,10 +174,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
|||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<div key={index} className={getMessageStyling(message.type)}>
|
<div key={index} className={getMessageStyling(message.type)}>
|
||||||
{message.type === 'system' && (
|
{message.type === 'system' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-terminal-muted/60 text-xs font-mono">
|
||||||
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
|
{message.content}
|
||||||
<span className="text-xs text-terminal-muted">{message.content}</span>
|
|
||||||
<Timestamp date={message.timestamp} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -203,13 +203,32 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{message.type === 'tool_result' && (
|
{message.type === 'tool_result' && (
|
||||||
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0">
|
<div
|
||||||
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
|
className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0 cursor-pointer"
|
||||||
Result
|
onClick={() => setExpandedResults(prev => {
|
||||||
</Badge>
|
const next = new Set(prev);
|
||||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
if (next.has(index)) next.delete(index); else next.add(index);
|
||||||
{message.content}
|
return next;
|
||||||
</div>
|
})}
|
||||||
|
>
|
||||||
|
{expandedResults.has(index) ? (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-4 w-4 text-terminal-muted inline-block shrink-0 mr-1" />
|
||||||
|
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
|
||||||
|
Result
|
||||||
|
</Badge>
|
||||||
|
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
{message.content.substring(0, 80)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user