Files
codecraft/web/src/components/ToolCard.tsx
T
Richard ZinkandClaude Opus 5.5 3b0764450a Long chats stay fast; search in a chat; media overview
Performance (measured with 5,900 messages: open 9 s -> 0.7 s, typing 440 ms -> 25 ms)
- GET /sessions/{id}?limit=N loads the end of a chat (from the last summary
  on, at most N); /sessions/{id}/messages?before|after|around loads more
  (scrolling up stops at a summary unless asked). Without parameters the
  full history as before (terminal client).
- Chat view: memoised message list (typing, streaming and status changes no
  longer re-render the history), finished turns fold into "N steps · M files"
  with the answer visible, tool card bodies and summaries are built only when
  opened, scroll-to-bottom only when content changes.

Search: /sessions/{id}/search over the whole chat; a hit loads its part
(around) and marks the message, "back to the latest messages" returns.

Media: /sessions/{id}/media lists images, videos and audio of a chat and its
subagents (generated images, tool output, files written, media in answers,
attachments); ⋯ -> Media shows them with filters, a large view and a jump to
the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:32:18 +01:00

201 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Icon } from "./Icon";
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
import { useState } from "react";
import { useT } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import type { Part, Todo } from "../types";
import { Spinner } from "./Activity";
import { Diff } from "./Diff";
import { IMAGE_EXT, projectFileUrl } from "./Markdown";
type ToolCall = Extract<Part, { type: "tool_call" }>;
type ToolResult = Extract<Part, { type: "tool_result" }>;
function short(s: unknown, n = 90): string {
const str = typeof s === "string" ? s : JSON.stringify(s);
return str.length > n ? `${str.slice(0, n)}…` : str;
}
/** One-line summary of a tool call. */
export function toolTitle(name: string, input: any): string {
switch (name) {
case "read":
case "write":
case "edit":
return input?.path ?? "";
case "list":
return input?.path ?? ".";
case "bash":
return input?.description || input?.command || "";
case "craft_phase":
return `${input?.phase ?? ""}${input?.note ? ` – ${input.note}` : ""}`;
case "web_search":
return input?.query ?? "";
case "diagnostics":
return input?.path ?? "";
case "glob":
case "grep":
return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`;
case "web_fetch":
case "browser_snapshot":
return input?.url ?? "";
case "task":
return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`;
case "image_compare":
return `${input?.original ?? ""} vs. ${input?.candidate ?? ""}`;
case "skill":
return input?.name ?? "";
case "ask_user":
return input?.questions?.[0]?.question ?? "";
default:
return short(input);
}
}
export function TodoList({ todos }: { todos: Todo[] }) {
const icon = { completed: "box-on", in_progress: "play", pending: "box-off" } as const;
return (
<ul>
{todos.map((td, i) => (
<li key={i} className={td.status}>
<Icon name={icon[td.status as keyof typeof icon] ?? "box-off"} />
<span>{td.content}</span>
</li>
))}
</ul>
);
}
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
const t = useT();
// The body (diff, output) is only built while the card is open – long chats have thousands.
const [toggled, setToggled] = useState<boolean | null>(null);
const progress = useStore((s) => s.progress[call.id]);
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
const meta = result?.meta;
// The live task list has its own panel; updates are a single line here.
if (call.name === "todo_write" && !result?.is_error) {
const todos: Todo[] = call.input?.todos ?? [];
const done = todos.filter((x) => x.status === "completed").length;
const current = todos.find((x) => x.status === "in_progress");
return (
<div className="todo-line">
<Icon name="box-on" /> {t("tool.todosUpdated", { done, total: todos.length })}
{current && <span> · <Icon name="play" className="tiny" /> {current.content}</span>}
</div>
);
}
if (call.name === "remember") {
return (
<div className="todo-line memory-card">
<Icon name="brain" /> {t("memory.remembered")}: {call.input?.note}
</div>
);
}
// Image files written by the agent are shown as images (SVG too, rendered via <img>).
const writtenImage =
(call.name === "write" || call.name === "edit") && typeof call.input?.path === "string" && IMAGE_EXT.test(call.input.path) && result && !result.is_error
? projectFileUrl(call.input.path, baseDir)
: undefined;
if (writtenImage) {
return (
<div className="tool image-result">
<div className="tool-head">
<span className="tool-name">{call.name}</span>
<span className="tool-title">{call.input.path}</span>
<span className="status ok"><Icon name="check" /></span>
</div>
<FileFigure url={writtenImage} name={call.input.path}>
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
</FileFigure>
</div>
);
}
// Short diffs are shown right away, like in a terminal agent.
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
const openByDefault =
(status === "err" && !!result) ||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
const open = toggled ?? openByDefault;
return (
<details className={`tool ${status === "run" ? "running" : ""}`} open={open} onToggle={(e) => {
const now = (e.currentTarget as HTMLDetailsElement).open;
if (now !== open) setToggled(now);
}}>
<summary>
<span className="tool-name">{call.name}</span>
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
{meta?.similarity !== undefined && <span className="chip">{meta.similarity} %</span>}
{call.name === "task" && meta?.model && <span className="chip">{meta.model}</span>}
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
</summary>
{open && (
<div className="tool-body">
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
{call.name === "task" && meta?.session_id && (
<button className="small" onClick={() => navigate(`/s/${meta.session_id}`)}>
{t("tool.openSubagent")}
</button>
)}
{!["bash", "edit", "write", "task"].includes(call.name) && (
<>
<div className="label">{t("tool.input")}</div>
<pre className="mono">{JSON.stringify(call.input, null, 2)}</pre>
</>
)}
{!result && progress && (
<>
<div className="label">{t("tool.output")}</div>
<pre className="mono">{progress}</pre>
</>
)}
{result && !((call.name === "edit" || call.name === "write") && meta?.diff && !result.is_error) && (
<>
<div className="label">{t("tool.output")}</div>
<pre className="mono">{result.content}</pre>
</>
)}
{Array.isArray(meta?.image_ids) ? (
<div className="gallery small">
{meta.image_ids.map((id: string, i: number) => {
// The file may be gone (moved or deleted later): fall back to the preview the model saw.
const preview = result?.images?.[i];
return (
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
<img
src={`/api/images/${id}/file`}
alt=""
onError={(e) => {
const img = e.currentTarget;
if (preview && !img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = imageSrc(preview);
}
}}
/>
</FileFigure>
);
})}
</div>
) : (
result?.images?.map((img, i) => (
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
<img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
</FileFigure>
))
)}
</div>
)}
</details>
);
}