feat: make AI chat tool use blocks expandable with input/output details
Tool call blocks in AI Chat are now clickable and expandable to show tool name, input parameters (pretty-printed JSON), and output/result. Collapsed by default, keyboard accessible (Tab/Enter/Space/Esc). Backend: Rust stream events now carry tool input (accumulated from input_json_delta chunks) and tool output (from tool_result events). Frontend: AiActionCard is a disclosure widget with aria-expanded, error output shown in red, long content truncated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,61 +2,166 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { AiActionCard } from './AiActionCard'
|
||||
|
||||
const defaults = {
|
||||
tool: 'create_note',
|
||||
label: 'Creating note... (abc123)',
|
||||
status: 'done' as const,
|
||||
expanded: false,
|
||||
onToggle: vi.fn(),
|
||||
}
|
||||
|
||||
describe('AiActionCard', () => {
|
||||
it('renders label text', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created test.md" status="done" />)
|
||||
render(<AiActionCard {...defaults} label="Created test.md" />)
|
||||
expect(screen.getByText('Created test.md')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows pending spinner', () => {
|
||||
render(<AiActionCard tool="search_notes" label="Searching..." status="pending" />)
|
||||
render(<AiActionCard {...defaults} tool="search_notes" label="Searching..." status="pending" />)
|
||||
expect(screen.getByTestId('status-pending')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows done check', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" status="done" />)
|
||||
render(<AiActionCard {...defaults} status="done" />)
|
||||
expect(screen.getByTestId('status-done')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows error icon', () => {
|
||||
render(<AiActionCard tool="delete_note" label="Failed" status="error" />)
|
||||
render(<AiActionCard {...defaults} tool="delete_note" label="Failed" status="error" />)
|
||||
expect(screen.getByTestId('status-error')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('is clickable when path and onOpenNote provided', () => {
|
||||
it('navigates to note when no details and path+onOpenNote provided', () => {
|
||||
const onOpenNote = vi.fn()
|
||||
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={onOpenNote} />)
|
||||
fireEvent.click(screen.getByTestId('ai-action-card'))
|
||||
const toggle = vi.fn()
|
||||
render(
|
||||
<AiActionCard {...defaults} path="/vault/test.md" onOpenNote={onOpenNote} onToggle={toggle} />,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('action-card-header'))
|
||||
expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md')
|
||||
expect(toggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has button role when clickable', () => {
|
||||
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={vi.fn()} />)
|
||||
expect(screen.getByRole('button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('is not clickable without path', () => {
|
||||
it('toggles expand instead of navigating when details exist', () => {
|
||||
const onOpenNote = vi.fn()
|
||||
render(<AiActionCard tool="create_note" label="Created" status="done" onOpenNote={onOpenNote} />)
|
||||
fireEvent.click(screen.getByTestId('ai-action-card'))
|
||||
const toggle = vi.fn()
|
||||
render(
|
||||
<AiActionCard
|
||||
{...defaults}
|
||||
path="/vault/test.md"
|
||||
onOpenNote={onOpenNote}
|
||||
onToggle={toggle}
|
||||
input='{"title":"test"}'
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('action-card-header'))
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
expect(onOpenNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is not clickable without onOpenNote', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" path="/vault/test.md" status="done" />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.getAttribute('role')).toBeNull()
|
||||
it('header has button role and is focusable', () => {
|
||||
render(<AiActionCard {...defaults} />)
|
||||
const header = screen.getByTestId('action-card-header')
|
||||
expect(header.getAttribute('role')).toBe('button')
|
||||
expect(header.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
|
||||
it('uses lighter background for ui_ tools', () => {
|
||||
render(<AiActionCard tool="ui_open_tab" label="Opened tab" status="done" />)
|
||||
render(<AiActionCard {...defaults} tool="ui_open_tab" label="Opened tab" />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.style.background).toContain('0.06')
|
||||
})
|
||||
|
||||
it('uses standard background for vault tools', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" status="done" />)
|
||||
render(<AiActionCard {...defaults} />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.style.background).toContain('0.1')
|
||||
})
|
||||
|
||||
// --- Expand / collapse ---
|
||||
|
||||
it('does not show details when collapsed', () => {
|
||||
render(<AiActionCard {...defaults} input='{"q":"test"}' output="found 3" expanded={false} />)
|
||||
expect(screen.queryByTestId('action-card-details')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows details when expanded with input and output', () => {
|
||||
render(<AiActionCard {...defaults} input='{"q":"test"}' output="found 3" expanded />)
|
||||
expect(screen.getByTestId('action-card-details')).toBeTruthy()
|
||||
expect(screen.getByTestId('detail-input')).toBeTruthy()
|
||||
expect(screen.getByTestId('detail-output')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows only input when no output', () => {
|
||||
render(<AiActionCard {...defaults} input='{"q":"test"}' expanded />)
|
||||
expect(screen.getByTestId('detail-input')).toBeTruthy()
|
||||
expect(screen.queryByTestId('detail-output')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows only output when no input', () => {
|
||||
render(<AiActionCard {...defaults} output="result text" expanded />)
|
||||
expect(screen.queryByTestId('detail-input')).toBeNull()
|
||||
expect(screen.getByTestId('detail-output')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show details when expanded but no input or output', () => {
|
||||
render(<AiActionCard {...defaults} expanded />)
|
||||
expect(screen.queryByTestId('action-card-details')).toBeNull()
|
||||
})
|
||||
|
||||
it('formats JSON input prettily', () => {
|
||||
render(<AiActionCard {...defaults} input='{"title":"Hello","content":"world"}' expanded />)
|
||||
const inputBlock = screen.getByTestId('detail-input')
|
||||
expect(inputBlock.textContent).toContain('"title": "Hello"')
|
||||
})
|
||||
|
||||
it('truncates very long output', () => {
|
||||
const longOutput = 'x'.repeat(1000)
|
||||
render(<AiActionCard {...defaults} output={longOutput} expanded />)
|
||||
const outputBlock = screen.getByTestId('detail-output')
|
||||
expect(outputBlock.textContent!.length).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
// --- Keyboard accessibility ---
|
||||
|
||||
it('expands on Enter key', () => {
|
||||
const toggle = vi.fn()
|
||||
render(<AiActionCard {...defaults} onToggle={toggle} input='{"a":1}' />)
|
||||
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Enter' })
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('expands on Space key', () => {
|
||||
const toggle = vi.fn()
|
||||
render(<AiActionCard {...defaults} onToggle={toggle} input='{"a":1}' />)
|
||||
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: ' ' })
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses on Escape key when expanded', () => {
|
||||
const toggle = vi.fn()
|
||||
render(<AiActionCard {...defaults} onToggle={toggle} expanded input='{"a":1}' />)
|
||||
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' })
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not collapse on Escape when already collapsed', () => {
|
||||
const toggle = vi.fn()
|
||||
render(<AiActionCard {...defaults} onToggle={toggle} expanded={false} input='{"a":1}' />)
|
||||
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' })
|
||||
expect(toggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets aria-expanded attribute', () => {
|
||||
const { rerender } = render(<AiActionCard {...defaults} expanded={false} />)
|
||||
expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('false')
|
||||
rerender(<AiActionCard {...defaults} expanded />)
|
||||
expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows error output in red for error status', () => {
|
||||
render(<AiActionCard {...defaults} status="error" output="Permission denied" expanded />)
|
||||
const outputBlock = screen.getByTestId('detail-output')
|
||||
expect(outputBlock.style.color).toContain('destructive')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { type KeyboardEvent, type ReactNode, useCallback } from 'react'
|
||||
import {
|
||||
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
|
||||
CircleNotch, CheckCircle, XCircle,
|
||||
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
export type AiActionStatus = 'pending' | 'done' | 'error'
|
||||
@@ -11,9 +11,15 @@ export interface AiActionCardProps {
|
||||
label: string
|
||||
path?: string
|
||||
status: AiActionStatus
|
||||
input?: string
|
||||
output?: string
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
}
|
||||
|
||||
const MAX_DETAIL_LENGTH = 800
|
||||
|
||||
type IconRenderer = (size: number) => ReactNode
|
||||
|
||||
const TOOL_ICON_MAP: Record<string, IconRenderer> = {
|
||||
@@ -43,27 +49,118 @@ function StatusIndicator({ status }: { status: AiActionStatus }) {
|
||||
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
|
||||
}
|
||||
|
||||
export function AiActionCard({ tool, label, path, status, onOpenNote }: AiActionCardProps) {
|
||||
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
|
||||
const isClickable = !!path && !!onOpenNote
|
||||
const isUiTool = tool.startsWith('ui_')
|
||||
function truncateText(text: string): { text: string; truncated: boolean } {
|
||||
if (text.length <= MAX_DETAIL_LENGTH) return { text, truncated: false }
|
||||
return { text: text.slice(0, MAX_DETAIL_LENGTH), truncated: true }
|
||||
}
|
||||
|
||||
function formatInputForDisplay(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function DetailBlock({ label, content, isError }: {
|
||||
label: string; content: string; isError?: boolean
|
||||
}) {
|
||||
const { text, truncated } = truncateText(content)
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={isClickable ? () => onOpenNote(path) : undefined}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
data-testid="ai-action-card"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">{renderIcon(14)}</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<StatusIndicator status={status} />
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div
|
||||
className="text-muted-foreground"
|
||||
style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', marginBottom: 2 }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<pre
|
||||
data-testid={`detail-${label.toLowerCase()}`}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
lineHeight: 1.4,
|
||||
margin: 0,
|
||||
padding: '4px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--muted)',
|
||||
color: isError ? 'var(--destructive)' : 'var(--foreground)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{text}{truncated && <span className="text-muted-foreground">{'…'}</span>}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiActionCard({
|
||||
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
|
||||
}: AiActionCardProps) {
|
||||
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
|
||||
const isUiTool = tool.startsWith('ui_')
|
||||
const hasDetails = !!(input || output)
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
} else if (e.key === 'Escape' && expanded) {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}, [onToggle, expanded])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (path && onOpenNote && !hasDetails) {
|
||||
onOpenNote(path)
|
||||
} else {
|
||||
onToggle()
|
||||
}
|
||||
}, [path, onOpenNote, hasDetails, onToggle])
|
||||
|
||||
const formattedInput = input ? formatInputForDisplay(input) : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="ai-action-card"
|
||||
className="rounded"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{ padding: '6px 10px', cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="action-card-header"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
|
||||
{hasDetails
|
||||
? (expanded ? <CaretDown size={12} /> : <CaretRight size={12} />)
|
||||
: renderIcon(14)}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<StatusIndicator status={status} />
|
||||
</div>
|
||||
{expanded && hasDetails && (
|
||||
<div
|
||||
data-testid="action-card-details"
|
||||
style={{ padding: '0 10px 8px 10px' }}
|
||||
>
|
||||
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
|
||||
{output && (
|
||||
<DetailBlock label="Output" content={output} isError={status === 'error'} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ describe('AiMessage', () => {
|
||||
<AiMessage
|
||||
userMessage="Do something"
|
||||
actions={[
|
||||
{ tool: 'create_note', label: 'Created test.md', status: 'done' },
|
||||
{ tool: 'search_notes', label: 'Searched', status: 'pending' },
|
||||
{ tool: 'create_note', toolId: 't1', label: 'Created test.md', status: 'done' },
|
||||
{ tool: 'search_notes', toolId: 't2', label: 'Searched', status: 'pending' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
@@ -57,11 +57,11 @@ describe('AiMessage', () => {
|
||||
render(
|
||||
<AiMessage
|
||||
userMessage="Do"
|
||||
actions={[{ tool: 'create_note', label: 'Open', path: '/vault/note.md', status: 'done' }]}
|
||||
actions={[{ tool: 'create_note', toolId: 't1', label: 'Open', path: '/vault/note.md', status: 'done' }]}
|
||||
onOpenNote={onOpenNote}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('ai-action-card'))
|
||||
fireEvent.click(screen.getByTestId('action-card-header'))
|
||||
expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md')
|
||||
})
|
||||
|
||||
@@ -88,4 +88,28 @@ describe('AiMessage', () => {
|
||||
render(<AiMessage userMessage="Ask" actions={[]} />)
|
||||
expect(screen.queryByTestId('ai-action-card')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands and collapses action cards independently', () => {
|
||||
render(
|
||||
<AiMessage
|
||||
userMessage="Do"
|
||||
actions={[
|
||||
{ tool: 'search_notes', toolId: 't1', label: 'Searched', status: 'done', input: '{"q":"test"}', output: 'Found 3' },
|
||||
{ tool: 'create_note', toolId: 't2', label: 'Created', status: 'done', input: '{"title":"x"}' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
const headers = screen.getAllByTestId('action-card-header')
|
||||
// Both collapsed initially
|
||||
expect(screen.queryByTestId('action-card-details')).toBeNull()
|
||||
// Expand first card
|
||||
fireEvent.click(headers[0])
|
||||
expect(screen.getAllByTestId('action-card-details')).toHaveLength(1)
|
||||
// Expand second card too
|
||||
fireEvent.click(headers[1])
|
||||
expect(screen.getAllByTestId('action-card-details')).toHaveLength(2)
|
||||
// Collapse first card
|
||||
fireEvent.click(headers[0])
|
||||
expect(screen.getAllByTestId('action-card-details')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
|
||||
import { AiActionCard, type AiActionStatus } from './AiActionCard'
|
||||
|
||||
export interface AiAction {
|
||||
tool: string
|
||||
toolId: string
|
||||
label: string
|
||||
path?: string
|
||||
status: AiActionStatus
|
||||
input?: string
|
||||
output?: string
|
||||
}
|
||||
|
||||
export interface AiMessageProps {
|
||||
@@ -66,18 +69,25 @@ function ReasoningBlock({ text, expanded, onToggle }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ActionCardsList({ actions, onOpenNote }: {
|
||||
actions: AiAction[]; onOpenNote?: (path: string) => void
|
||||
function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
|
||||
actions: AiAction[]
|
||||
onOpenNote?: (path: string) => void
|
||||
expandedIds: Set<string>
|
||||
onToggleExpand: (toolId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1" style={{ marginBottom: 8 }}>
|
||||
{actions.map((action, i) => (
|
||||
{actions.map((action) => (
|
||||
<AiActionCard
|
||||
key={`${action.tool}-${i}`}
|
||||
key={action.toolId}
|
||||
tool={action.tool}
|
||||
label={action.label}
|
||||
path={action.path}
|
||||
status={action.status}
|
||||
input={action.input}
|
||||
output={action.output}
|
||||
expanded={expandedIds.has(action.toolId)}
|
||||
onToggle={() => onToggleExpand(action.toolId)}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
@@ -115,6 +125,16 @@ function StreamingIndicator() {
|
||||
|
||||
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
|
||||
const [reasoningExpanded, setReasoningExpanded] = useState(false)
|
||||
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleAction = useCallback((toolId: string) => {
|
||||
setExpandedActions(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(toolId)) next.delete(toolId)
|
||||
else next.add(toolId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
|
||||
@@ -126,7 +146,14 @@ export function AiMessage({ userMessage, reasoning, actions, response, isStreami
|
||||
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
|
||||
/>
|
||||
)}
|
||||
{actions.length > 0 && <ActionCardsList actions={actions} onOpenNote={onOpenNote} />}
|
||||
{actions.length > 0 && (
|
||||
<ActionCardsList
|
||||
actions={actions}
|
||||
onOpenNote={onOpenNote}
|
||||
expandedIds={expandedActions}
|
||||
onToggleExpand={toggleAction}
|
||||
/>
|
||||
)}
|
||||
{response && <ResponseBlock text={response} />}
|
||||
{isStreaming && !response && <StreamingIndicator />}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user