93c11e5557
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
428 lines
21 KiB
TypeScript
428 lines
21 KiB
TypeScript
/**
|
|
* Tool call card: collapses to a single line by
|
|
* default — status icon + tool name + duration (a live-ticking timer while running) + a plain
|
|
* `[stop reason]` marker when the step did not finish cleanly; clicking expands full arguments
|
|
* and output; a bound subagent renders as a full-width shortcut row below them regardless of
|
|
* collapsed state (the child conversation itself lives in the subagents side panel; the row
|
|
* carries its own pending-approval dot, so the card no longer needs to auto-expand for nested
|
|
* approvals).
|
|
*
|
|
* Duration accounting = **argument-generation segment + execution segment** (excludes time
|
|
* spent waiting on human approval): the model streaming out arguments token by token is often
|
|
* slower than the tool call itself, so reporting only the execution segment would badly
|
|
* understate this step's cost. While waiting on approval, the already-settled generation
|
|
* segment is shown; the wait itself is marked by the amber hourglass icon alone, since the
|
|
* approval block below the row is always on screen and names the tool and its arguments.
|
|
*/
|
|
import { useRef, useState } from "react";
|
|
import { S } from "../../lib/strings";
|
|
import { humanizeDuration } from "../../lib/format";
|
|
import type { StopReason } from "@prismshadow/penguin-core/omnimessage";
|
|
import { approvalKey } from "../../lib/omni/stream-model";
|
|
import type { ToolCallItem } from "../../lib/omni/stream-model";
|
|
import { Chevron } from "../../components/ui/chevron";
|
|
import { ZoomableImage } from "../../components/ui/image-zoom";
|
|
import { StatusIcon } from "../../components/ui/status-icon";
|
|
import type { RunState } from "../../components/ui/status-icon";
|
|
import { ApprovalButtons } from "./approval-buttons";
|
|
import { LiveDuration } from "./live-duration";
|
|
import { agentIdFromRunSubagentArgs } from "./agent-topology";
|
|
import { SubagentChip } from "./subagent-chip";
|
|
import type { StreamRenderContext } from "./message-stream";
|
|
|
|
/** Tools that accept the optional model-written `description` argument. */
|
|
const DESCRIBED_TOOLS = new Set([
|
|
"exec_command",
|
|
"input_command",
|
|
"run_subagent",
|
|
"input_subagent",
|
|
]);
|
|
|
|
/** The three file tools: previewed by their `file_path` argument. */
|
|
const FILE_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
|
|
|
|
/**
|
|
* Colour for the row's `[stop reason]` marker: amber for a user interruption, red for a real
|
|
* failure. StatusIcon has a single failure tone, so the icon alone cannot carry this — without
|
|
* the marker an aborted call reads exactly like a failed one. Mirrors the warning-vs-error
|
|
* split `stopReasonTone` gives the Badge used elsewhere (Trace viewer, composer).
|
|
*/
|
|
function stopReasonToneClass(stopReason: string): string {
|
|
return stopReason === "aborted"
|
|
? "text-amber-600 dark:text-amber-400"
|
|
: "text-red-600 dark:text-red-400";
|
|
}
|
|
|
|
/**
|
|
* Shortens a path for one-line display: at most one parent directory plus the filename
|
|
* (`…/parent/file.ts`); paths already within that shape are shown as-is (same rule as the
|
|
* CLI's tool-render). The full path stays in the expanded arguments block.
|
|
*/
|
|
export function shortenPath(p: string): string {
|
|
const segments = p.split("/").filter((s) => s.length > 0);
|
|
if (segments.length <= 2) return p;
|
|
return `…/${segments[segments.length - 2]}/${segments[segments.length - 1]}`;
|
|
}
|
|
|
|
/**
|
|
* Argument preview (same approach as the CLI's tool-render): exec_command shows `$ <cmd>`,
|
|
* the file tools show their shortened file path, other tools show a single-line
|
|
* `name(args)` prefix. Arguments may be incomplete JSON (mid-stream), so extraction is done
|
|
* leniently. The preview deliberately keeps the real arguments (not the model-written
|
|
* description): it heads the approval row, and the user must approve the actual command,
|
|
* not the model's summary of it.
|
|
*/
|
|
export function previewArguments(name: string, argsJson: string): string {
|
|
if (name === "exec_command") {
|
|
const cmd = extractStringField(argsJson, "cmd");
|
|
if (cmd !== null) return `$ ${cmd.value.replace(/\s+/g, " ").trim()}`;
|
|
}
|
|
if (FILE_TOOLS.has(name)) {
|
|
const filePath = extractStringField(argsJson, "file_path");
|
|
if (filePath !== null) return shortenPath(filePath.value.replace(/\s+/g, " ").trim());
|
|
}
|
|
return argsJson.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
/**
|
|
* Collapsed-header subtitle: the human-readable line next to the tool name — the
|
|
* model-written `description` argument when the call carries one (declared in the tool's
|
|
* config schema; per-tool `call_description: false` removes it, in which case the model
|
|
* never sends it), or the shortened file path for the file tools. Null when there is
|
|
* nothing beyond the raw arguments.
|
|
*
|
|
* The subtitle appears once, fully formed, never mid-stream (#137): a growing description
|
|
* re-solves the header's flex line every frame, and `shortenPath` on a still-growing path
|
|
* rewrites non-monotonically (`/ho` → `…/cc/dev` → `…/dev/x`) — so a field renders only
|
|
* after its closing quote. `settled` (arguments finished streaming) lifts that gate: the
|
|
* text cannot change anymore, which also covers a call that never closed the string
|
|
* (aborted / malformed). Same rule as the CLI's tool-render. The wait is short by
|
|
* construction — `description` is required first in schema order, and `file_path` is the
|
|
* first file-tool argument — while `write_file`'s `content` may stream long after.
|
|
*/
|
|
export function headerSubtitle(name: string, argsJson: string, settled = true): string | null {
|
|
// The description wins whenever the call carries one: schemas are user-editable, so a
|
|
// file tool may have `description` enabled even though the default schema leaves it out
|
|
// (the CLI derives the same rule from the session's schemas).
|
|
if (DESCRIBED_TOOLS.has(name) || FILE_TOOLS.has(name)) {
|
|
const desc = extractStringField(argsJson, "description");
|
|
if (desc !== null) {
|
|
if (!desc.complete && !settled) return null;
|
|
const line = desc.value.replace(/\s+/g, " ").trim();
|
|
if (line) return line;
|
|
}
|
|
if (DESCRIBED_TOOLS.has(name)) return null;
|
|
}
|
|
if (FILE_TOOLS.has(name)) {
|
|
const filePath = extractStringField(argsJson, "file_path");
|
|
if (filePath !== null) {
|
|
if (!filePath.complete && !settled) return null;
|
|
const line = filePath.value.replace(/\s+/g, " ").trim();
|
|
if (line) return shortenPath(line);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Decoded file-tool payload for the pending-approval block: the user is approving a
|
|
* concrete rewrite (old_string/new_string/content), so the bare path is not enough — the
|
|
* actual arguments are rendered in the scrollable expanded style while the call is PENDING.
|
|
* Null for other tools or unparseable arguments (arguments are complete by approval time).
|
|
*/
|
|
export function pendingFilePayload(name: string, argsJson: string): string | null {
|
|
if (!FILE_TOOLS.has(name)) return null;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(argsJson);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (parsed === null || typeof parsed !== "object") return null;
|
|
const args = parsed as Record<string, unknown>;
|
|
const sections: string[] = [];
|
|
const push = (label: string, value: unknown): void => {
|
|
if (value === undefined) return;
|
|
if (typeof value === "string" && value.includes("\n")) {
|
|
sections.push(`${label}:\n${value}`);
|
|
} else {
|
|
sections.push(`${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
|
|
}
|
|
};
|
|
push("file_path", args["file_path"]);
|
|
if (name === "read_file") {
|
|
push("offset", args["offset"]);
|
|
push("limit", args["limit"]);
|
|
} else if (name === "edit_file") {
|
|
push("old_string", args["old_string"]);
|
|
push("new_string", args["new_string"]);
|
|
if (args["replace_all"] === true) push("replace_all", true);
|
|
} else if (name === "write_file") {
|
|
push("content", args["content"]);
|
|
}
|
|
return sections.join("\n");
|
|
}
|
|
|
|
/** A string field read from possibly-incomplete JSON: the value seen so far, and whether its closing quote has arrived (mirrors the CLI's PartialField). */
|
|
interface PartialField {
|
|
value: string;
|
|
complete: boolean;
|
|
}
|
|
|
|
/** Extracts the current value of a string field from a possibly-incomplete JSON object string (a simplified version, good enough for preview purposes). */
|
|
function extractStringField(argsJson: string, field: string): PartialField | null {
|
|
const key = `"${field}"`;
|
|
const keyIndex = argsJson.indexOf(key);
|
|
if (keyIndex === -1) return null;
|
|
let i = keyIndex + key.length;
|
|
while (/\s/.test(argsJson[i] ?? "")) i += 1;
|
|
if (argsJson[i] !== ":") return null;
|
|
i += 1;
|
|
while (/\s/.test(argsJson[i] ?? "")) i += 1;
|
|
if (argsJson[i] !== '"') return null;
|
|
i += 1;
|
|
let out = "";
|
|
let escaped = false;
|
|
for (; i < argsJson.length; i += 1) {
|
|
const ch = argsJson[i]!;
|
|
if (escaped) {
|
|
out += ch === "n" ? "\n" : ch === "t" ? "\t" : ch;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (ch === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (ch === '"') return { value: out, complete: true };
|
|
out += ch;
|
|
}
|
|
return { value: out, complete: false };
|
|
}
|
|
|
|
export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRenderContext }) {
|
|
const [open, setOpen] = useState(false);
|
|
const userToggled = useRef(false);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
// Matched by the current origin chain + toolCallId: prevents parent/child session tool_call_id collisions from lighting each other up.
|
|
const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId));
|
|
|
|
const preview = previewArguments(item.name, item.argumentsText);
|
|
// Settled once argument streaming stopped (or the complete call arrived): the subtitle's
|
|
// completeness gate is lifted — whatever is there is final.
|
|
const subtitle = headerSubtitle(item.name, item.argumentsText, !item.callStreaming);
|
|
// Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution).
|
|
const executing = item.callComplete && !item.outputComplete && !pending;
|
|
// Argument-generation segment (settled): the live execution timer accumulates on top of this as a baseline, so the displayed duration doesn't shrink back once output arrives.
|
|
const genMs =
|
|
item.argStartedAtMs !== undefined && item.callStartedAtMs !== undefined
|
|
? Math.max(0, item.callStartedAtMs - item.argStartedAtMs)
|
|
: 0;
|
|
const failed =
|
|
(item.callStopReason !== undefined && item.callStopReason !== "completed") ||
|
|
(item.outputStopReason !== undefined && item.outputStopReason !== "completed");
|
|
const state: RunState = pending
|
|
? "waiting"
|
|
: executing || item.callStreaming
|
|
? "running"
|
|
: failed
|
|
? "failed"
|
|
: "done";
|
|
// Decision wording ("Approved · manual", "Denied · manual", …): carried ONLY by the left status
|
|
// icon's title/aria-label — per review the row shows no visible decision text at any
|
|
// breakpoint; the icon is the single source of truth for how the call was decided.
|
|
const decisionText = item.decision
|
|
? `${item.decision === "allow" ? S.chat.decisionAllow : S.chat.decisionDeny} · ${
|
|
item.decisionSource === "manual" ? S.chat.decisionManual : S.chat.decisionAuto
|
|
}`
|
|
: null;
|
|
// A user denial reports stop_reason "aborted" on the output it feeds back; that abort IS the
|
|
// decision, not an independent outcome — the icon reads "Denied", and no separate `[aborted]`
|
|
// marker repeats it. A user-abort of a RUNNING tool carries no deny decision, so the label
|
|
// falls through to its stop reason below.
|
|
const deniedByUser = item.decision === "deny" && item.outputStopReason === "aborted";
|
|
const stateLabel = pending
|
|
? S.chat.approvalWaiting
|
|
: state === "running"
|
|
? S.chat.workRunning
|
|
: state === "done"
|
|
? (decisionText ?? S.chat.workDone)
|
|
: deniedByUser
|
|
? (decisionText ?? undefined)
|
|
: (item.outputStopReason ?? item.callStopReason);
|
|
// Stop reasons to spell out on the row. The two segments frequently carry the SAME value —
|
|
// a call that closed undispatched copies its own reason onto the output it will never
|
|
// produce (settleUndispatchedCall) — so equal values collapse to one marker instead of the
|
|
// `[malformed][malformed]` the two old pills rendered side by side.
|
|
const outcomes = [
|
|
...new Set(
|
|
[item.callStopReason, deniedByUser ? undefined : item.outputStopReason].filter(
|
|
(r): r is StopReason => r !== undefined && r !== "completed",
|
|
),
|
|
),
|
|
];
|
|
|
|
return (
|
|
<div ref={rootRef}>
|
|
{/* Collapsed row: status icon + tool name + total duration (generation + execution,
|
|
excluding approval wait) + the stop reason when the step did not finish cleanly.
|
|
Expand chevron on the right.
|
|
|
|
Stacked sticky, second level (same as the thinking row): while this card's expanded
|
|
output scrolls, the row pins right BELOW the stuck group header (top-4 = the
|
|
header's -top-4 offset + its 2rem height) — the bar directly above the content is
|
|
always the section the reader is in, never a skipped level. Opaque background for
|
|
the stuck state; collapsing from stuck lands the view back on the row.
|
|
|
|
The stop reason used to render as a padded Badge pill, which competed for width on a
|
|
phone. It is now the same plain `[reason]` marker the thinking row directly above it
|
|
already uses (thinking-block.tsx) — cheap enough for 390px, and the two rows in a
|
|
work group finally read alike. Dropping it entirely and leaving the icon to carry the
|
|
outcome does not work: the icon has one failure tone, so an interrupted call would
|
|
look identical to a hard failure, and a call that never ran — malformed, or closed
|
|
undispatched — has no output block to expand into, so its title/aria-label would be
|
|
the only explanation anywhere, out of reach on touch. The Trace viewer still shows
|
|
the raw stop reason per event, which is where the literal value belongs.
|
|
|
|
A pending call gets no "awaiting approval" text either: the approval block below is
|
|
always on screen while one is pending — it names the tool, shows the arguments and
|
|
carries the Allow/Deny buttons — so the row would only repeat it. The amber hourglass
|
|
StatusIcon (labeled) marks the wait, at every breakpoint. */}
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
onClick={() => {
|
|
userToggled.current = true;
|
|
const willClose = open;
|
|
setOpen((v) => !v);
|
|
if (willClose) {
|
|
requestAnimationFrame(() => rootRef.current?.scrollIntoView({ block: "nearest" }));
|
|
}
|
|
}}
|
|
className="sticky top-4 z-[4] flex w-full items-center gap-2 bg-white px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800"
|
|
>
|
|
<StatusIcon state={state} label={stateLabel} />
|
|
<span className="shrink-0 truncate font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
|
|
{item.name || S.chat.unknownTool}
|
|
</span>
|
|
{/* Human-readable subtitle: the model-written call description (command/subagent tools) or the file path (file tools). */}
|
|
{subtitle && (
|
|
<span className="min-w-0 shrink truncate text-xs text-gray-500 dark:text-gray-400">
|
|
{subtitle}
|
|
</span>
|
|
)}
|
|
<span className="shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">
|
|
{item.durationMs !== undefined ? (
|
|
humanizeDuration(item.durationMs)
|
|
) : executing ? (
|
|
// Execution timer: argument-generation baseline + a live segment starting from approval grant (or from call completion if no approval was needed).
|
|
<LiveDuration sinceMs={item.approvalAtMs ?? item.callStartedAtMs} offsetMs={genMs} />
|
|
) : pending ? (
|
|
// No ticking while waiting on approval: frozen at the settled argument-generation segment.
|
|
genMs > 0 ? (
|
|
humanizeDuration(genMs)
|
|
) : null
|
|
) : item.callStreaming ? (
|
|
// Generating arguments: live-ticking timer (falls back to a pulsing ellipsis when no start time is known).
|
|
item.argStartedAtMs !== undefined ? (
|
|
<LiveDuration sinceMs={item.argStartedAtMs} />
|
|
) : (
|
|
<span className="animate-pulse">…</span>
|
|
)
|
|
) : null}
|
|
</span>
|
|
{outcomes.map((reason) => (
|
|
<span
|
|
key={reason}
|
|
className={`shrink-0 font-mono text-xs ${stopReasonToneClass(reason)}`}
|
|
>
|
|
[{reason}]
|
|
</span>
|
|
))}
|
|
<span className="min-w-0 flex-1" />
|
|
{/* Expand indicator on the right */}
|
|
<Chevron open={open} className="text-gray-400" />
|
|
</button>
|
|
|
|
{/* Pending approval: always visible regardless of collapsed state — shows the tool name and arguments so the user knows what they're approving. */}
|
|
{pending && (
|
|
<div className="border-t border-gray-100 bg-amber-50 px-3 py-2 dark:border-gray-800 dark:bg-amber-950/30">
|
|
{/* The user must be able to read the FULL command before deciding: below sm the
|
|
preview wraps in whole (expanded-args style: pre-wrap + break-all, no inner
|
|
scroll, the block may grow) — the one-line treatment resumes once decided, since
|
|
this pending block unmounts and only the truncating header subtitle remains. At
|
|
≥sm the row stays one line (the desktop column is wide enough in practice). */}
|
|
<div className="mb-2 flex items-start gap-2 sm:items-center">
|
|
<span className="shrink-0 rounded-md bg-white px-1.5 py-0.5 font-mono text-xs font-semibold text-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
|
{item.name || S.chat.unknownTool}
|
|
</span>
|
|
<span className="min-w-0 flex-1 whitespace-pre-wrap break-all font-mono text-xs text-gray-600 sm:truncate dark:text-gray-400">
|
|
{preview}
|
|
</span>
|
|
</div>
|
|
{/* File tools: the one-line preview shows only the (shortened) path, but the user is
|
|
approving a concrete rewrite — render the decoded payload (old_string/new_string/
|
|
content) in the scrollable expanded style while pending. */}
|
|
{(() => {
|
|
const payload = pendingFilePayload(item.name, item.argumentsText);
|
|
return payload !== null ? (
|
|
<pre className="mb-2 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md bg-white/70 px-2 py-1.5 text-xs leading-5 text-gray-700 dark:bg-gray-950/40 dark:text-gray-300">
|
|
{payload}
|
|
</pre>
|
|
) : null;
|
|
})()}
|
|
<ApprovalButtons
|
|
onDecide={(decision) => ctx.onApprove(item.toolCallId, decision, ctx.origin)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Expanded details: full arguments / output */}
|
|
{open && (
|
|
<div className="anim-fade">
|
|
{item.argumentsText && (
|
|
// Arguments are shown as a fully wrapped block (no height cap, no scrollbar): the
|
|
// arguments are key to understanding this call, and tucking them into an inner
|
|
// scroll area would make them hard to read and fight with the message stream's own scroll.
|
|
<pre className="whitespace-pre-wrap break-all border-t border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-400">
|
|
{item.argumentsText}
|
|
</pre>
|
|
)}
|
|
{(item.output || item.outputStreaming) && (
|
|
<pre className="max-h-72 overflow-auto whitespace-pre-wrap border-t border-gray-100 px-3 py-2 text-xs leading-5 text-gray-600 dark:border-gray-800 dark:text-gray-300">
|
|
{item.output}
|
|
{item.outputStreaming && <span className="animate-pulse">▌</span>}
|
|
</pre>
|
|
)}
|
|
{/* Tool output images (e.g. read_image): shown as thumbnails, click to zoom (ZoomableImage). */}
|
|
{item.images && item.images.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 border-t border-gray-100 px-3 py-2 dark:border-gray-800">
|
|
{item.images.map((src, i) => (
|
|
<ZoomableImage
|
|
key={i}
|
|
src={src}
|
|
alt={S.chat.toolImageAlt}
|
|
className="max-h-40 max-w-full rounded-md border border-gray-200 dark:border-gray-700"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Subagent row: always visible (unaffected by the tool card's collapsed state) below the expanded arguments/output — a full-width shortcut bar into the subagents panel; the nested conversation no longer renders inline. */}
|
|
{item.subagent && (
|
|
<div className="px-3 pb-2 pt-2">
|
|
<SubagentChip
|
|
sessionId={item.subagentSessionId ?? ""}
|
|
model={item.subagent}
|
|
running={!item.outputComplete}
|
|
agentId={agentIdFromRunSubagentArgs(item.argumentsText)}
|
|
ctx={ctx}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|