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>
201 lines
7.8 KiB
TypeScript
201 lines
7.8 KiB
TypeScript
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>
|
||
);
|
||
}
|