Files
codecraft/web/src/components/ToolCard.tsx
T
Richard ZinkandClaude Opus 5.5 b99f71526a Generated images survive moves and renames
Agents tidy up folders after generating images (sort into subfolders, rename
revisions); the image records kept the old path, so /api/images/<id>/file
answered 404 and only the requesting device still showed them from its cache.

- Generated images get a copy in the image store (images.blob, kept by the
  blob GC); existing ones are copied once on start-up.
- Records follow: after bash commands with mv/rename (missing images of the
  owner from the last two weeks, found by name or by content hash), when moved
  with the file manager (file or folder), and on access (served from the copy,
  relocated in the background; without a copy found by file name).
- Chat gallery falls back to the preview the model saw when the file is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:37:13 +01:00

192 lines
7.5 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 { 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();
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"));
return (
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
<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>
<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>
);
}