diff --git a/src/components/ai-elements/tool.tsx b/src/components/ai-elements/tool.tsx index 61bf7c3bc..99c5d57e8 100644 --- a/src/components/ai-elements/tool.tsx +++ b/src/components/ai-elements/tool.tsx @@ -17,9 +17,28 @@ import { XCircleIcon, } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; -import { isValidElement } from "react"; +import { isValidElement, lazy, Suspense } from "react"; -import { CodeBlock } from "./code-block"; +const LazyCodeBlock = lazy(() => + import("./code-block").then(({ CodeBlock }) => ({ default: CodeBlock })) +); + +const DeferredCodeBlock = ({ code }: { code: string }) => ( + +
+          {code}
+        
+ + } + > + +
+); export type ToolProps = ComponentProps; @@ -122,7 +141,7 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => ( Parameters
- +
); @@ -146,10 +165,10 @@ export const ToolOutput = ({ if (typeof output === "object" && !isValidElement(output)) { Output = ( - + ); } else if (typeof output === "string") { - Output = ; + Output = ; } return ( diff --git a/src/features/agents/components/FastAgentPanel/ToolCallTransparency.test.tsx b/src/features/agents/components/FastAgentPanel/ToolCallTransparency.test.tsx new file mode 100644 index 000000000..77f22b57e --- /dev/null +++ b/src/features/agents/components/FastAgentPanel/ToolCallTransparency.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { ToolCallTransparency } from './ToolCallTransparency'; + +const stateCases = [ + { + status: 'running' as const, + primitiveState: 'input-available', + primitiveLabel: 'Running', + }, + { + status: 'success' as const, + primitiveState: 'output-available', + primitiveLabel: 'Completed', + }, + { + status: 'error' as const, + primitiveState: 'output-error', + primitiveLabel: 'Error', + }, +]; + +describe('ToolCallTransparency', () => { + it.each(stateCases)( + 'maps $status to the AI Elements $primitiveState state', + ({ primitiveLabel, primitiveState, status }) => { + const { container } = render( + + ); + + expect(container.querySelector(`[data-tool-state="${primitiveState}"]`)).toBeInTheDocument(); + expect(screen.getByText(primitiveLabel)).toBeInTheDocument(); + } + ); + + it('preserves disclosure, input/output summaries, and complete QuickRef content', () => { + render( + + ); + + const trigger = screen.getByRole('button', { name: /audit schema/i }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('projectId=nodebench')).not.toBeInTheDocument(); + + fireEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('projectId=nodebench')).toBeInTheDocument(); + expect(screen.getByText('12 schema findings')).toBeInTheDocument(); + expect(screen.getByText('QuickRef')).toBeInTheDocument(); + expect(screen.getByText('high')).toBeInTheDocument(); + expect(screen.getByText('Review the missing indexes.')).toBeInTheDocument(); + expect(screen.getByText('convex_suggest_indexes')).toBeInTheDocument(); + expect(screen.getByText('wide-scan')).toBeInTheDocument(); + expect(screen.getByText('missing-index')).toBeInTheDocument(); + expect(screen.getByText('1.3s')).toBeInTheDocument(); + + fireEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('projectId=nodebench')).not.toBeInTheDocument(); + }); + + it('keeps compact mode non-disclosing while retaining tool names, timing, and mapped states', () => { + const { container } = render( + ({ + durationMs: (index + 1) * 100, + status, + toolName: `convex_tool_${index + 1}`, + }))} + /> + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByText('tool 1')).toBeInTheDocument(); + expect(screen.getByText('tool 2')).toBeInTheDocument(); + expect(screen.getByText('tool 3')).toBeInTheDocument(); + expect(screen.getByText('100ms')).toBeInTheDocument(); + expect(screen.getByText('200ms')).toBeInTheDocument(); + expect(screen.getByText('300ms')).toBeInTheDocument(); + for (const { primitiveState } of stateCases) { + expect(container.querySelector(`[data-tool-state="${primitiveState}"]`)).toBeInTheDocument(); + } + }); + + it('renders nothing when no tool calls are present', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/features/agents/components/FastAgentPanel/ToolCallTransparency.tsx b/src/features/agents/components/FastAgentPanel/ToolCallTransparency.tsx index 44a5ab7cd..c9f47372f 100644 --- a/src/features/agents/components/FastAgentPanel/ToolCallTransparency.tsx +++ b/src/features/agents/components/FastAgentPanel/ToolCallTransparency.tsx @@ -6,23 +6,22 @@ * Integrates with the existing ToolStep rendering in UIMessageBubble. */ -import React, { useState, useMemo } from 'react'; +import { useMemo } from 'react'; import { - Wrench, + ArrowRight, + BookOpen, CheckCircle2, - XCircle, Clock, - ChevronDown, - ChevronRight, + Wrench, + XCircle, Zap, - ArrowRight, - Search, - Shield, - Database, - FileCode, - Settings, - BookOpen, } from 'lucide-react'; +import { + Tool, + ToolContent, + ToolHeader, + type ToolPart, +} from '@/components/ai-elements/tool'; interface ToolCallData { toolName: string; @@ -45,26 +44,13 @@ interface ToolCallTransparencyProps { compact?: boolean; } -const TOOL_ICONS: Record = { - convex_audit_schema: , - convex_suggest_indexes: , - convex_check_validator_coverage: , - convex_audit_functions: , - convex_check_function_refs: , - convex_pre_deploy_gate: , - convex_check_env_vars: , - convex_record_gotcha: , - convex_search_gotchas: , - convex_get_methodology: , - convex_discover_tools: , - convex_generate_rules_md: , - convex_snapshot_schema: , - convex_bootstrap_project: , -}; +type PrimitiveToolState = ToolPart['state']; -function getToolIcon(toolName: string): React.ReactNode { - return TOOL_ICONS[toolName] || ; -} +const TOOL_STATE_BY_STATUS: Record = { + running: 'input-available', + success: 'output-available', + error: 'output-error', +}; function getToolCategory(toolName: string): string { if (toolName.includes('schema') || toolName.includes('index') || toolName.includes('validator') || toolName.includes('snapshot')) return 'Schema'; @@ -94,124 +80,127 @@ function ConfidenceBadge({ confidence }: { confidence: 'high' | 'medium' | 'low' ); } +function CompactStatusIcon({ state }: { state: PrimitiveToolState }) { + if (state === 'output-available') { + return ; + } + if (state === 'output-error') { + return ; + } + return ; +} + function SingleToolCall({ call, compact }: { call: ToolCallData; compact?: boolean }) { - const [expanded, setExpanded] = useState(false); const category = getToolCategory(call.toolName); - const icon = getToolIcon(call.toolName); - - const statusIcon = call.status === 'success' - ? - : call.status === 'error' - ? - : ; - + const primitiveState = TOOL_STATE_BY_STATUS[call.status]; const displayName = call.toolName.replace(/^convex_/, '').replace(/_/g, ' '); if (compact) { return ( -
- {statusIcon} + + {displayName} {call.durationMs !== undefined && ( {formatDuration(call.durationMs)} )} -
+ ); } return ( -
- - - {expanded && ( -
- {call.inputSummary && ( -
- Input: - {call.inputSummary} -
- )} - {call.outputSummary && ( -
- Output: - {call.outputSummary} +
+ + + {call.inputSummary && ( +
+ Input: + {call.inputSummary} +
+ )} + {call.outputSummary && ( +
+ Output: + {call.outputSummary} +
+ )} + + {call.quickRef && ( +
+
+ + QuickRef +
- )} - - {call.quickRef && ( -
-
- - QuickRef - +

+ {call.quickRef.nextAction} +

+ {call.quickRef.nextTools.length > 0 && ( +
+ + {call.quickRef.nextTools.map((tool) => ( + + {tool} + + ))}
-

- {call.quickRef.nextAction} -

- {call.quickRef.nextTools.length > 0 && ( -
- - {call.quickRef.nextTools.map((tool) => ( - - {tool} - - ))} -
- )} - {call.quickRef.relatedGotchas.length > 0 && ( -
- - {call.quickRef.relatedGotchas.slice(0, 3).map((g) => ( - - {g} - - ))} -
- )} -
- )} -
- )} -
+ )} + {call.quickRef.relatedGotchas.length > 0 && ( +
+ + {call.quickRef.relatedGotchas.slice(0, 3).map((gotcha) => ( + + {gotcha} + + ))} +
+ )} +
+ )} + + ); } -export function ToolCallTransparency({ toolCalls, isStreaming, compact }: ToolCallTransparencyProps) { +export function ToolCallTransparency({ toolCalls, compact }: ToolCallTransparencyProps) { const stats = useMemo(() => { const total = toolCalls.length; - const success = toolCalls.filter((t) => t.status === 'success').length; - const errors = toolCalls.filter((t) => t.status === 'error').length; - const running = toolCalls.filter((t) => t.status === 'running').length; - const totalDuration = toolCalls.reduce((sum, t) => sum + (t.durationMs || 0), 0); - return { total, success, errors, running, totalDuration }; + const errors = toolCalls.filter((toolCall) => toolCall.status === 'error').length; + const running = toolCalls.filter((toolCall) => toolCall.status === 'running').length; + const totalDuration = toolCalls.reduce((sum, toolCall) => sum + (toolCall.durationMs || 0), 0); + return { total, errors, running, totalDuration }; }, [toolCalls]); if (toolCalls.length === 0) return null; if (compact) { return ( -
- {toolCalls.map((call, idx) => ( - +
+ {toolCalls.map((call, index) => ( + ))}
); @@ -220,7 +209,7 @@ export function ToolCallTransparency({ toolCalls, isStreaming, compact }: ToolCa return (
- + {stats.total} tool call{stats.total !== 1 ? 's' : ''} @@ -228,8 +217,8 @@ export function ToolCallTransparency({ toolCalls, isStreaming, compact }: ToolCa ({formatDuration(stats.totalDuration)}) )} {stats.running > 0 && ( - - + + {stats.running} running )} @@ -239,8 +228,8 @@ export function ToolCallTransparency({ toolCalls, isStreaming, compact }: ToolCa
- {toolCalls.map((call, idx) => ( - + {toolCalls.map((call, index) => ( + ))}