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>
192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
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>
|
||
);
|
||
}
|