v1.8.2: tool loop cap-hit summary + tool call UI compaction
Old hardcoded MAX_TOOL_LOOP_DEPTH=15 replaced by per-agent max_tool_calls (1-100, AGENTS.md frontmatter) with defaults: 30 for read-only-only agents, 10 for agents that include any non-read-only tool, 15 for raw chat. When the loop hits cap, fire one final summary call with tools disabled, stream the wrap-up into the in-flight assistant message, then insert a system sentinel with metadata.kind='cap_hit'. The sentinel renders an amber bubble with a Continue button (latest sentinel only) that POSTs to a new /api/chats/:id/continue route to extend. Hard ceiling: 3 cap-hits per chat (2 continues max) — third sentinel reports can_continue=false. Error frames carry a machine-readable reason code alongside human error text. Failed messages persist the reason via metadata.kind='error' so the bubble renders specifics on reload (WS error frame is one-shot). Tool call UI rewired: ToolCallLine renders inline (↳ name args spinner/check/✗, expand-on-tap for args+result); ToolCallGroup collapses 3+ consecutive same-tool runs into a compact card. MessageList owns a three-pass pre-render (flatten + fold tool results onto matching runs by id + group same-tool runs + number sentinels). MessageBubble drops tool rendering and adds the sentinel / error-reason branches. ToolCallCard deleted. Roadmap follow-up logged: add explicit max_tool_calls: 30 to the 6 agents in /data/AGENTS.md and /opt/boocode/AGENTS.md post-ship for discoverability (defaults handle behavior identically). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
90
apps/web/src/components/CapHitSentinel.tsx
Normal file
90
apps/web/src/components/CapHitSentinel.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Message } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
// 1-indexed position among cap-hit sentinels in this chat. The first
|
||||
// cap-hit is 1, second is 2, third is 3 (hard ceiling).
|
||||
capHitPosition: number;
|
||||
// Only the most recent sentinel shows the Continue button. Older ones
|
||||
// render text-only — they've already been continued past.
|
||||
isLatest: boolean;
|
||||
}
|
||||
|
||||
// Hard ceiling = 3 cap-hits per chat ⇒ 2 continues max. Lives here in sync
|
||||
// with insertCapHitSentinel's `canContinue = priorCount < 2` rule in
|
||||
// services/inference.ts.
|
||||
const MAX_CONTINUES = 2;
|
||||
|
||||
export function CapHitSentinel({ message, capHitPosition, isLatest }: Props) {
|
||||
const meta = message.metadata;
|
||||
// Defensive parse — if the row is somehow missing metadata we still render
|
||||
// the bare text rather than crashing the chat.
|
||||
const isCapHit =
|
||||
meta !== null && typeof meta === 'object' && meta.kind === 'cap_hit';
|
||||
|
||||
const limit = isCapHit ? meta.limit : null;
|
||||
const canContinue = isCapHit ? meta.can_continue : false;
|
||||
const agentName = isCapHit ? meta.agent_name : null;
|
||||
// `capHitPosition` is 1-indexed; `MAX_CONTINUES - (position - 1)` is the
|
||||
// number of continues remaining including this one. Clamped to ≥0.
|
||||
const remaining = Math.max(0, MAX_CONTINUES - (capHitPosition - 1));
|
||||
|
||||
const [continuing, setContinuing] = useState(false);
|
||||
|
||||
async function handleContinue() {
|
||||
if (continuing || !canContinue || !isLatest) return;
|
||||
setContinuing(true);
|
||||
try {
|
||||
await api.chats.continue(message.chat_id, message.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'continue failed');
|
||||
} finally {
|
||||
setContinuing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip wording from the v1.8.2 spec. Disabled state takes precedence —
|
||||
// the spec text "Hard limit reached — start a new chat" matches what the
|
||||
// server returns when canContinue is false.
|
||||
const enabledTooltip = limit
|
||||
? `Resumes with a fresh budget of ${limit} tool calls. ${remaining} continue${remaining === 1 ? '' : 's'} remaining on this chat.`
|
||||
: undefined;
|
||||
const disabledTooltip = 'Hard limit reached — start a new chat';
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
|
||||
<div className="px-3 py-2 flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
{isCapHit && limit !== null
|
||||
? `Reached tool budget (${limit}/${limit})${agentName ? ` — ${agentName}` : ''}.`
|
||||
: 'Reached tool budget.'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{message.content}
|
||||
</div>
|
||||
{isLatest && (
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleContinue()}
|
||||
disabled={!canContinue || continuing}
|
||||
title={canContinue ? enabledTooltip : disabledTooltip}
|
||||
>
|
||||
{continuing ? 'Continuing…' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Chat, Message } from '@/api/types';
|
||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { CapHitSentinel } from './CapHitSentinel';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -19,6 +19,15 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// v1.8.2: human labels for the machine-readable error reasons that ride on
|
||||
// failed assistant messages via metadata.kind === 'error'. Kept short so the
|
||||
// inline render under "message failed" stays a single muted line.
|
||||
const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
|
||||
llm_provider_error: 'LLM provider error',
|
||||
tool_execution_failed: 'Tool execution failed',
|
||||
summary_after_cap_failed: 'Summary after tool budget hit failed',
|
||||
};
|
||||
|
||||
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
||||
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
||||
// match, but `src/foo.ts` will). False positives at the edges are accepted
|
||||
@@ -94,6 +103,9 @@ function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
||||
interface Props {
|
||||
message: Message;
|
||||
sessionChats?: Chat[];
|
||||
// v1.8.2: passed by MessageList's render-item pass for cap-hit sentinels.
|
||||
// Only the most recent sentinel shows the Continue button.
|
||||
capHitInfo?: { position: number; isLatest: boolean };
|
||||
}
|
||||
|
||||
function MarkdownBody({ content }: { content: string }) {
|
||||
@@ -464,15 +476,34 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, sessionChats }: Props) {
|
||||
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
if (message.kind === 'compact') {
|
||||
return <CompactCard message={message} sessionChats={sessionChats} />;
|
||||
}
|
||||
|
||||
if (message.role === 'tool') {
|
||||
return <ToolCallCard message={message} />;
|
||||
// v1.8.2: cap-hit sentinels render as a distinct system bubble with a
|
||||
// Continue button. MessageList's pre-render pass tags each sentinel with
|
||||
// its position; only the latest gets the actionable button.
|
||||
if (
|
||||
message.role === 'system' &&
|
||||
message.metadata?.kind === 'cap_hit' &&
|
||||
capHitInfo
|
||||
) {
|
||||
return (
|
||||
<CapHitSentinel
|
||||
message={message}
|
||||
capHitPosition={capHitInfo.position}
|
||||
isLatest={capHitInfo.isLatest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// v1.8.2: tool messages and assistant tool_calls are now rendered by
|
||||
// MessageList via ToolCallLine / ToolCallGroup. Tool-role messages reach
|
||||
// this point only if MessageList didn't consume them (shouldn't happen,
|
||||
// but guard against it by rendering nothing rather than a stale card).
|
||||
if (message.role === 'tool') return null;
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="group flex flex-col items-end gap-1">
|
||||
@@ -487,14 +518,17 @@ export function MessageBubble({ message, sessionChats }: Props) {
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const failed = message.status === 'failed';
|
||||
const hasContent = message.content.length > 0;
|
||||
const hasToolCalls = (message.tool_calls?.length ?? 0) > 0;
|
||||
// v1.8.2: if metadata stamps an error reason, surface it inline under the
|
||||
// generic "message failed" line. Keeps the user's eye where it already is
|
||||
// rather than introducing a separate banner.
|
||||
const errorMeta =
|
||||
message.metadata !== null && message.metadata.kind === 'error'
|
||||
? message.metadata
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col gap-2">
|
||||
{message.tool_calls?.map((tc) => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
{(hasContent || (!hasToolCalls && isStreaming)) && (
|
||||
{(hasContent || isStreaming) && (
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||
{isStreaming && (
|
||||
@@ -503,12 +537,18 @@ export function MessageBubble({ message, sessionChats }: Props) {
|
||||
</div>
|
||||
)}
|
||||
{failed && (
|
||||
<div className="text-xs text-destructive">message failed</div>
|
||||
<div className="text-xs text-destructive">
|
||||
message failed
|
||||
{errorMeta && (
|
||||
<span className="block text-muted-foreground mt-0.5">
|
||||
{ERROR_REASON_LABELS[errorMeta.error_reason]}
|
||||
{errorMeta.error_text ? ` — ${errorMeta.error_text}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isStreaming && <StatsLine message={message} />}
|
||||
{!isStreaming && (hasContent || hasToolCalls) && (
|
||||
<ActionRow message={message} />
|
||||
)}
|
||||
{!isStreaming && hasContent && <ActionRow message={message} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,128 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import type { Chat, Message } from '@/api/types';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
sessionChats?: Chat[];
|
||||
}
|
||||
|
||||
// v1.8.2: pre-render units. The single linear `messages` array gets walked
|
||||
// into a render-time list where each tool_call is a first-class item and
|
||||
// tool_result messages are folded onto their matching tool_run by id.
|
||||
type RenderItem =
|
||||
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
|
||||
| { kind: 'tool_run'; run: ToolRun; key: string }
|
||||
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
||||
|
||||
const GROUP_THRESHOLD = 3;
|
||||
|
||||
function isCapHitSentinel(m: Message): boolean {
|
||||
return m.role === 'system' && m.metadata?.kind === 'cap_hit';
|
||||
}
|
||||
|
||||
// First pass: walk messages chronologically, expanding assistant tool_calls
|
||||
// into per-call run items and folding tool_result messages onto their
|
||||
// matching runs. Tool messages themselves never produce a render item.
|
||||
// Assistant messages produce a text render item only when they have text;
|
||||
// pure tool-call messages are "transparent" so consecutive tool runs can
|
||||
// still group across them.
|
||||
function flatten(messages: Message[]): RenderItem[] {
|
||||
const items: RenderItem[] = [];
|
||||
const runsByCallId = new Map<string, ToolRun>();
|
||||
|
||||
for (const m of messages) {
|
||||
if (m.role === 'tool') {
|
||||
if (m.tool_results) {
|
||||
const run = runsByCallId.get(m.tool_results.tool_call_id);
|
||||
if (run) run.result = m.tool_results;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const hasToolCalls = m.tool_calls != null && m.tool_calls.length > 0;
|
||||
const hasText = m.content.length > 0;
|
||||
if (m.role === 'assistant' && hasToolCalls) {
|
||||
if (hasText || m.status === 'streaming') {
|
||||
items.push({ kind: 'message', message: m });
|
||||
}
|
||||
for (const tc of m.tool_calls!) {
|
||||
const run: ToolRun = { call: tc, result: null };
|
||||
runsByCallId.set(tc.id, run);
|
||||
items.push({ kind: 'tool_run', run, key: tc.id });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
items.push({ kind: 'message', message: m });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
|
||||
// of the same tool name into a single tool_group. Any other render item
|
||||
// (text bubble, sentinel, user message) breaks the chain.
|
||||
function group(items: RenderItem[]): RenderItem[] {
|
||||
const out: RenderItem[] = [];
|
||||
let i = 0;
|
||||
while (i < items.length) {
|
||||
const item = items[i]!;
|
||||
if (item.kind !== 'tool_run') {
|
||||
out.push(item);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const name = item.run.call.name;
|
||||
let j = i + 1;
|
||||
while (
|
||||
j < items.length &&
|
||||
items[j]!.kind === 'tool_run' &&
|
||||
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
|
||||
) {
|
||||
j += 1;
|
||||
}
|
||||
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
|
||||
if (run.length >= GROUP_THRESHOLD) {
|
||||
out.push({
|
||||
kind: 'tool_group',
|
||||
runs: run.map((r) => r.run),
|
||||
key: `group-${run[0]!.key}`,
|
||||
});
|
||||
} else {
|
||||
for (const r of run) out.push(r);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Third pass: number cap-hit sentinels (1-indexed) and mark the latest.
|
||||
// CapHitSentinel uses position to compute the "N continues remaining"
|
||||
// tooltip, and isLatest to gate the Continue button (only the most recent
|
||||
// sentinel is actionable).
|
||||
function stampCapHits(items: RenderItem[]): RenderItem[] {
|
||||
const totalCapHits = items.reduce(
|
||||
(n, it) => n + (it.kind === 'message' && isCapHitSentinel(it.message) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
if (totalCapHits === 0) return items;
|
||||
let index = 0;
|
||||
return items.map((it) => {
|
||||
if (it.kind !== 'message' || !isCapHitSentinel(it.message)) return it;
|
||||
index += 1;
|
||||
return {
|
||||
...it,
|
||||
capHitInfo: { position: index, isLatest: index === totalCapHits },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageList({ messages, sessionChats }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: 'end' });
|
||||
}, [messages]);
|
||||
@@ -25,9 +138,22 @@ export function MessageList({ messages, sessionChats }: Props) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||
{messages.map((m) => (
|
||||
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
|
||||
))}
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'message') {
|
||||
return (
|
||||
<MessageBubble
|
||||
key={item.message.id}
|
||||
message={item.message}
|
||||
sessionChats={sessionChats}
|
||||
capHitInfo={item.capHitInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'tool_run') {
|
||||
return <ToolCallLine key={item.key} run={item.run} />;
|
||||
}
|
||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||
})}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChevronRight, Wrench } from 'lucide-react';
|
||||
import type { Message, ToolCall } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
interface Props {
|
||||
message?: Message;
|
||||
toolCall?: ToolCall;
|
||||
}
|
||||
|
||||
// Same regex/heuristic as MessageBubble: paths ending in `.ext` with at
|
||||
// least one `/`. Linkifies file paths emitted by tools like grep / find_files
|
||||
// so they're clickable.
|
||||
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
||||
|
||||
function linkifyOutput(text: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let idx = 0;
|
||||
for (const match of text.matchAll(PATH_REGEX)) {
|
||||
const matchedText = match[0];
|
||||
const start = match.index ?? 0;
|
||||
if (!matchedText.includes('/')) continue;
|
||||
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
||||
out.push(
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
sessionEvents.emit({
|
||||
type: 'open_file_in_browser',
|
||||
path: matchedText,
|
||||
})
|
||||
}
|
||||
className="text-primary underline cursor-pointer hover:text-primary/80"
|
||||
>
|
||||
{matchedText}
|
||||
</button>
|
||||
);
|
||||
lastIdx = start + matchedText.length;
|
||||
idx += 1;
|
||||
}
|
||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||
return out.length > 0 ? out : [text];
|
||||
}
|
||||
|
||||
export function ToolCallCard({ message, toolCall }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tc = toolCall ?? message?.tool_calls?.[0];
|
||||
const result = message?.tool_results;
|
||||
|
||||
const name = tc?.name ?? 'tool';
|
||||
const args = tc?.args ?? {};
|
||||
const error = result?.error;
|
||||
const output = result?.output;
|
||||
const truncated = result?.truncated;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 text-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/60 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3.5 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Wrench className="size-3.5 opacity-70" />
|
||||
<span className="font-mono font-medium">{name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground truncate min-w-0 flex-1">
|
||||
{JSON.stringify(args)}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="text-xs text-destructive font-medium ml-2">error</span>
|
||||
)}
|
||||
{truncated && (
|
||||
<span className="text-xs text-muted-foreground ml-2">truncated</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-2.5 py-2 border-t bg-background/40">
|
||||
{error ? (
|
||||
<pre className="text-xs text-destructive font-mono whitespace-pre-wrap">
|
||||
{error}
|
||||
</pre>
|
||||
) : output !== undefined ? (
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-72 overflow-y-auto">
|
||||
{linkifyOutput(
|
||||
typeof output === 'string'
|
||||
? output
|
||||
: JSON.stringify(output, null, 2)
|
||||
)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">no result yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/ToolCallGroup.tsx
Normal file
64
apps/web/src/components/ToolCallGroup.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 text-muted-foreground/60 shrink-0 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>
|
||||
);
|
||||
}
|
||||
167
apps/web/src/components/ToolCallLine.tsx
Normal file
167
apps/web/src/components/ToolCallLine.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, ChevronRight, Loader2, X } from 'lucide-react';
|
||||
import type { ToolCall, ToolResult } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
// 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.
|
||||
export 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 '';
|
||||
}
|
||||
// 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 paths in tool output text get a click handler so users can
|
||||
// jump to the file. Same heuristic as MessageBubble.linkifyPaths.
|
||||
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
||||
function linkifyOutput(text: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let idx = 0;
|
||||
for (const match of text.matchAll(PATH_REGEX)) {
|
||||
const matchedText = match[0];
|
||||
const start = match.index ?? 0;
|
||||
if (!matchedText.includes('/')) continue;
|
||||
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
||||
out.push(
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
sessionEvents.emit({ type: 'open_file_in_browser', path: matchedText })
|
||||
}
|
||||
className="text-primary underline cursor-pointer hover:text-primary/80"
|
||||
>
|
||||
{matchedText}
|
||||
</button>
|
||||
);
|
||||
lastIdx = start + matchedText.length;
|
||||
idx += 1;
|
||||
}
|
||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||
return out.length > 0 ? out : [text];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function ToolCallLine({ run, insideGroup }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const status = runStatus(run);
|
||||
const args = run.call.args ?? {};
|
||||
const summary = formatToolArgs(run.call.name, args);
|
||||
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1"
|
||||
>
|
||||
{!insideGroup && (
|
||||
<span className="text-muted-foreground/60 select-none shrink-0">↳</span>
|
||||
)}
|
||||
<ChevronRight
|
||||
className={`size-3 text-muted-foreground/60 shrink-0 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 ? (
|
||||
<span className="text-destructive">{run.result.error}</span>
|
||||
) : (
|
||||
linkifyOutput(
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user