Files
boocode/apps/web/src/components/ToolCallGroup.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

74 lines
2.6 KiB
TypeScript

import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { ToolCallLine, runStatus, type ToolRun } from './ToolCallLine';
interface Props {
// All runs must share the same tool name. Caller (MessageList grouping
// pass) enforces that invariant.
runs: ToolRun[];
}
export function ToolCallGroup({ runs }: Props) {
const [open, setOpen] = useState(false);
if (runs.length === 0) return null;
const toolName = runs[0]!.call.name;
const count = runs.length;
// Group-level status: pending if any are still running, error if any
// finished with an error, otherwise success. Matches the visual the user
// gets when scanning a long run of greps / view_files.
let pending = 0;
let errored = 0;
for (const r of runs) {
const s = runStatus(r);
if (s === 'pending') pending += 1;
else if (s === 'error') errored += 1;
}
const summaryParts: string[] = [];
if (pending > 0) summaryParts.push(`${pending} running`);
if (errored > 0) summaryParts.push(`${errored} failed`);
const summary = summaryParts.length > 0 ? ` (${summaryParts.join(', ')})` : '';
return (
<div className="rounded border border-border/60 bg-muted/20 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="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
>
<ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 motion-reduce:transition-none transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="text-muted-foreground/60 select-none shrink-0"></span>
<span className="font-mono text-foreground/90">
{count} {toolName} call{count === 1 ? '' : 's'}
</span>
{summary && (
<span className="text-muted-foreground truncate">{summary}</span>
)}
<span className="ml-auto text-muted-foreground/60 shrink-0">tap</span>
</button>
{open && (
<div className="border-t border-border/40 px-2 py-1 space-y-0.5">
{runs.map((run, i) => (
<ToolCallLine
key={`${run.call.id}-${i}`}
run={run}
insideGroup
/>
))}
</div>
)}
</div>
);
}