Files
boocode/apps/web/src/components/ToolCallLine.tsx
indifferentketchup aec209310e feat(web): workspace components — ComparePane, Memory page, McpDialog, error boundaries, message-parts
- Add ComparePane.tsx: side-by-side AI response comparison
- Add Memory.tsx: memory management page with CRUD UI
- Add McpPermissionDialog.tsx: MCP tool permission approval dialog
- Add McpResponseDisplay.tsx: MCP response visualization
- Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience
- Add EmptyState.tsx: contextual empty state component
- Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference
- Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard
- Add useDraftPersistence.ts: draft message persistence hook
- Add useTerminals.ts: terminal session management hook
- Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities
- Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes
- Extend hooks: useTerminalSocket, useSessionStream test suite
- Update pages: Home, Project — workspace layout and session flow
2026-06-08 03:49:22 +00:00

225 lines
8.5 KiB
TypeScript

import { useState } from 'react';
import { Check, ChevronRight, Loader2, ShieldAlert, X } from 'lucide-react';
import type { ToolCall, ToolResult } from '@/api/types';
import { linkifyPaths } from '@/lib/linkify-paths';
import { DiffSnippet } from './DiffSnippet';
import { McpPermissionDialog } from './McpPermissionDialog';
// v1.8.2: cap on the inline arg-summary length. Expanded view shows full
// args + full result, so this is purely a single-line render budget.
const ARG_SUMMARY_MAX = 60;
export interface ToolRun {
call: ToolCall;
// null while the call is in flight or the matching tool result hasn't
// arrived yet on the WS stream.
result: ToolResult | null;
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
// Per-tool argument summary mapping from the v1.8.2 spec. Goal is a single
// scannable line that surfaces the *what* (path / pattern) without
// overwhelming the chat with full JSON.
function formatToolArgs(name: string, args: Record<string, unknown>): string {
if (name === 'view_file') {
const path = String(args.path ?? '');
const start = args.start_line;
const end = args.end_line;
if (typeof start === 'number' && typeof end === 'number') {
return truncate(`${path}:${start}-${end}`, ARG_SUMMARY_MAX);
}
if (typeof start === 'number') {
return truncate(`${path}:${start}`, ARG_SUMMARY_MAX);
}
return truncate(path, ARG_SUMMARY_MAX);
}
if (name === 'list_dir') {
return truncate(String(args.path ?? '.'), ARG_SUMMARY_MAX);
}
if (name === 'grep') {
const pattern = String(args.pattern ?? '');
const path = args.path ? ` ${String(args.path)}` : '';
return truncate(`"${pattern}"${path}`, ARG_SUMMARY_MAX);
}
if (name === 'find_files') {
return truncate(String(args.pattern ?? ''), ARG_SUMMARY_MAX);
}
if (name === 'git_status') {
return '';
}
if (name === 'skill_use') {
// Schema (apps/server/src/services/tools.ts SkillUseInput) uses `name`;
// fall back to `skill_name` defensively in case a model emits that key.
return truncate(
String(args.name ?? (args as { skill_name?: unknown }).skill_name ?? '<unknown>'),
ARG_SUMMARY_MAX,
);
}
// v1.12 Track B.2: codecontext tool pills. Format is "most-identifying-arg",
// matching view_file/grep precedent — surface the path/symbol/query that
// makes the call meaningful at a glance.
if (name === 'get_codebase_overview') {
return '';
}
if (name === 'get_file_analysis') {
return truncate(String(args.file_path ?? ''), ARG_SUMMARY_MAX);
}
if (name === 'get_symbol_info') {
return truncate(String(args.symbol_name ?? ''), ARG_SUMMARY_MAX);
}
if (name === 'search_symbols') {
return truncate(`"${String(args.query ?? '')}"`, ARG_SUMMARY_MAX);
}
if (name === 'get_dependencies') {
return truncate(String(args.file_path ?? '(project-wide)'), ARG_SUMMARY_MAX);
}
if (name === 'watch_changes') {
return args.enable ? 'enable' : 'disable';
}
if (name === 'get_semantic_neighborhoods') {
return truncate(String(args.file_path ?? '(project-wide)'), ARG_SUMMARY_MAX);
}
if (name === 'get_framework_analysis') {
return truncate(String(args.framework ?? '(auto-detect)'), ARG_SUMMARY_MAX);
}
// Unknown tool — surface first arg value or the literal {} so the user can
// see something happened. Forward-compatible with future tools.
const keys = Object.keys(args);
if (keys.length === 0) return '{}';
const first = keys[0]!;
return truncate(`${first}: ${String(args[first])}`, ARG_SUMMARY_MAX);
}
export function runStatus(run: ToolRun): 'pending' | 'success' | 'error' {
if (run.result === null) return 'pending';
if (run.result.error) return 'error';
return 'success';
}
// Path-shaped substrings in tool output get a click handler via the shared
// linkifyPaths util (lib/linkify-paths.tsx).
interface Props {
run: ToolRun;
// When rendered inside a ToolCallGroup the line is already nested under a
// shared header, so the leading arrow is dropped to avoid double indent.
insideGroup?: boolean;
chatId?: string;
}
export function ToolCallLine({ run, insideGroup, chatId }: Props) {
const [open, setOpen] = useState(false);
const [approveOpen, setApproveOpen] = useState(false);
const status = runStatus(run);
const args = run.call.args ?? {};
const summary = formatToolArgs(run.call.name, args);
const needsApproval = run.result?.error?.startsWith('requires approval:') === true;
return (
<div className="text-xs">
<button
type="button"
tabIndex={0}
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === 'Escape') {
setOpen(false);
}
}}
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
>
{/* BooCode 2.0: glowing activity indicator (was ↳ / >_) */}
{!insideGroup && (
<span
className="size-1.5 rounded-full bg-primary shrink-0"
style={{ boxShadow: '0 0 6px var(--primary)' }}
aria-hidden
/>
)}
<ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 motion-reduce:transition-none transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="font-mono text-foreground/90 shrink-0">{run.call.name}</span>
{summary && (
<span className="font-mono text-muted-foreground truncate min-w-0 flex-1">
{summary}
</span>
)}
{!summary && <span className="flex-1" />}
<span className="shrink-0 ml-1">
{status === 'pending' && (
<Loader2 className="size-3 text-muted-foreground animate-spin" aria-label="running" />
)}
{status === 'success' && (
<Check className="size-3 text-emerald-500" aria-label="success" />
)}
{status === 'error' && (
<X className="size-3 text-destructive" aria-label="error" />
)}
</span>
</button>
{open && (
<div className="ml-5 mt-1 mb-1 space-y-1">
<pre className="text-[10px] text-muted-foreground font-mono whitespace-pre-wrap break-all bg-muted/30 rounded px-2 py-1">
{JSON.stringify(args, null, 2)}
</pre>
{run.result && (
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
{run.result.error ? (
needsApproval ? (
<span className="flex flex-col gap-2">
<span className="text-amber-600 dark:text-amber-400">
This tool requires your approval
</span>
{chatId && (
<span>
<button
type="button"
onClick={() => setApproveOpen(true)}
className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600 hover:bg-amber-500/20 dark:text-amber-400"
>
<ShieldAlert className="size-3" />
Approve
</button>
</span>
)}
</span>
) : (
<span className="text-destructive">{run.result.error}</span>
)
) : (
linkifyPaths(
typeof run.result.output === 'string'
? run.result.output
: JSON.stringify(run.result.output, null, 2)
)
)}
{run.result.truncated && (
<div className="text-muted-foreground/60 mt-1"> output truncated </div>
)}
</pre>
)}
{needsApproval && chatId && (
<McpPermissionDialog
toolCallId={run.call.id}
toolName={run.call.name}
toolArgs={run.call.args ?? {}}
chatId={chatId}
open={approveOpen}
onClose={() => setApproveOpen(false)}
/>
)}
{run.result?.diff && <DiffSnippet diff={run.result.diff} />}
</div>
)}
</div>
);
}