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>
167 lines
5.1 KiB
TypeScript
167 lines
5.1 KiB
TypeScript
import { type KeyboardEvent, type ReactNode, useCallback } from 'react'
|
|
import {
|
|
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
|
|
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
|
|
} from '@phosphor-icons/react'
|
|
|
|
export type AiActionStatus = 'pending' | 'done' | 'error'
|
|
|
|
export interface AiActionCardProps {
|
|
tool: string
|
|
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> = {
|
|
create_note: (s) => <PencilSimple size={s} />,
|
|
edit_note_frontmatter: (s) => <PencilSimple size={s} />,
|
|
append_to_note: (s) => <PencilSimple size={s} />,
|
|
search_notes: (s) => <MagnifyingGlass size={s} />,
|
|
list_notes: (s) => <MagnifyingGlass size={s} />,
|
|
link_notes: (s) => <Link size={s} />,
|
|
delete_note: (s) => <Trash size={s} />,
|
|
vault_context: (s) => <ChartBar size={s} />,
|
|
ui_open_note: (s) => <Eye size={s} />,
|
|
ui_open_tab: (s) => <Eye size={s} />,
|
|
ui_highlight: (s) => <Sparkle size={s} />,
|
|
ui_set_filter: (s) => <Sparkle size={s} />,
|
|
}
|
|
|
|
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={s} />
|
|
|
|
function StatusIndicator({ status }: { status: AiActionStatus }) {
|
|
if (status === 'pending') {
|
|
return <CircleNotch size={14} className="ai-spin text-muted-foreground" data-testid="status-pending" />
|
|
}
|
|
if (status === 'done') {
|
|
return <CheckCircle size={14} weight="fill" style={{ color: 'var(--accent-green)' }} data-testid="status-done" />
|
|
}
|
|
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
|
|
}
|
|
|
|
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 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>
|
|
)
|
|
}
|