summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-15 08:44:04 +0900
committerAdam Malczewski <[email protected]>2026-10-15 08:44:04 +0900
commitc38aba84b29f13c4d6ac940b47ce231e55b6558a (patch)
tree5470958aab18ba956305f12d2fdfd5dcf25c2686 /src/features
parent8676bd29452f7539622901cb5ae703a3fdaebd07 (diff)
downloaddispatch-web-c38aba84b29f13c4d6ac940b47ce231e55b6558a.tar.gz
dispatch-web-c38aba84b29f13c4d6ac940b47ce231e55b6558a.zip
feat(tool-call-cards): polish responsive headers and expanded details
Diffstat (limited to 'src/features')
-rw-r--r--src/features/chat/logic/tool-summary.test.ts72
-rw-r--r--src/features/chat/logic/tool-summary.ts68
-rw-r--r--src/features/chat/ui/ChatView.test.ts55
-rw-r--r--src/features/chat/ui/ToolCard.svelte316
4 files changed, 383 insertions, 128 deletions
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts
index 5caa1f1..381842a 100644
--- a/src/features/chat/logic/tool-summary.test.ts
+++ b/src/features/chat/logic/tool-summary.test.ts
@@ -69,7 +69,10 @@ describe("tool summaries", () => {
it("formats edit call badges and old/new details without hiding multiline text", () => {
const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" };
const card = view("edit_file", input);
- expect(card.callBadges).toEqual(["+3 −2"]);
+ expect(card.callBadges).toEqual([
+ { label: "+3", tone: "success" },
+ { label: "−2", tone: "error" },
+ ]);
expect(card.callDetail).toEqual({
kind: "edit",
path: "a.ts",
@@ -77,7 +80,8 @@ describe("tool summaries", () => {
newText: input.newString,
});
expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([
- "+1 −0",
+ { label: "+1", tone: "success" },
+ { label: "−0", tone: "error" },
]);
expect(view("edit_file", { path: "a" }).callBadges).toEqual([]);
});
@@ -97,6 +101,33 @@ describe("tool summaries", () => {
],
});
});
+ it("formats short scalar arrays inline, retaining quotes, escapes, nulls and empty arrays", () => {
+ expect(
+ view("mcp", {
+ labels: ["ui", "chat"],
+ scalars: [1, true, null],
+ empty: [],
+ escaped: ["a\nb"],
+ }).callDetail,
+ ).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "labels", value: '["ui", "chat"]', multiline: false },
+ { key: "scalars", value: "[1, true, null]", multiline: false },
+ { key: "empty", value: "[]", multiline: false },
+ { key: "escaped", value: '["a\\nb"]', multiline: false },
+ ],
+ });
+ });
+ it("keeps long arrays and nested structures pretty-printed and complete", () => {
+ const input = { long: ["x".repeat(80)], nested: [{ a: 1 }], mixed: [1, { a: 1 }] };
+ const card = view("mcp", input);
+ if (card.callDetail.kind !== "fields") throw new Error("expected fields");
+ expect(card.callDetail.fields.map((field) => field.multiline)).toEqual([true, true, true]);
+ expect(card.callDetail.fields.map((field) => JSON.parse(field.value))).toEqual(
+ Object.values(input),
+ );
+ });
it.each([
"command",
"path",
@@ -192,14 +223,12 @@ describe("tool summaries", () => {
expect(viewToolEntry(e)).toMatchObject({
name: "read_file",
status: "ok",
- callSummary: "call unavailable",
+ callSummary: "Call in earlier history",
resultSummary: "1 line",
});
expect(viewToolEntry(e).callDetail).toEqual({
- kind: "fields",
- fields: [
- { key: "call", value: "Call unavailable (earlier history unloaded)", multiline: false },
- ],
+ kind: "unavailable",
+ text: "The call is in earlier, unloaded history. Its response is shown below.",
});
});
});
@@ -213,14 +242,15 @@ describe("tool response facts", () => {
["mcp", "", false, "no output"],
["mcp", " \n\t", false, "no output"],
["mcp", "a short response", false, "a short response"],
+ ["mcp", "A successful response with more than forty characters of text", false, "1 line"],
["mcp", "x".repeat(200), false, "1 line"],
["web_search", "a\nb\nc", false, "3 lines"],
["run_shell", "", true, "failed"],
- ["mcp", "Permission denied", true, "failed · Permission denied"],
- ["run_shell", "error\ndetail", true, "failed · 2 lines"],
+ ["mcp", "Permission denied", true, "failed"],
+ ["run_shell", "error\ndetail", true, "failed"],
["run_shell", "partial\n\n[Command timed out]", true, "timed out"],
["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"],
- ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · 2 lines · truncated"],
+ ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · truncated"],
["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"],
[
"edit_file",
@@ -237,6 +267,28 @@ describe("tool response facts", () => {
expect(card.status).toBe(isError ? "error" : "ok");
expect(card.output?.isError).toBe(isError);
});
+ it.each([
+ ["run_shell", "oops\ndetails", true, "failed"],
+ ["run_shell", "partial\n[Command timed out]", true, "timed out"],
+ ["write_file", 'Created "a.ts" (4200 bytes).', false, "created"],
+ ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote"],
+ ["edit_file", 'Replaced 3 occurrences in "a.ts".\nDiagnostics: warning', false, "replaced"],
+ ["run_shell", "a\nb\nc\nd", false, "4 lines"],
+ ["mcp", "Created issue #42", false, "1 line"],
+ ["mcp", "", false, "no output"],
+ ["read_file", "(empty file: a.ts)", false, "empty file"],
+ ])("%s supplies a complete compact response fact", (name, content, isError, expected) => {
+ expect(view(name, {}, content, isError).compactResultSummary).toBe(expected);
+ });
+ it("never puts error content in header summaries, but retains it verbatim in output", () => {
+ const content = 'Error: oldString not found in content of "src/missing.ts".';
+ const card = view("edit_file", { path: "src/missing.ts" }, content, true);
+ expect(card.resultSummary).toBe("failed");
+ expect(card.compactResultSummary).toBe("failed");
+ expect(card.output).toEqual({ kind: "full", lines: [content], isError: true });
+ expect(card.callSummary).toBe("src/missing.ts");
+ expect(view("mcp", {}, null).compactResultSummary).toBeNull();
+ });
it("does not infer success on an error that resembles a write result", () => {
expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch(
/^failed/,
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
index 3b75a6b..2e7680c 100644
--- a/src/features/chat/logic/tool-summary.ts
+++ b/src/features/chat/logic/tool-summary.ts
@@ -4,9 +4,14 @@ export interface ToolCardView {
readonly name: string;
readonly status: "pending" | "ok" | "error";
readonly callSummary: string;
- readonly callBadges: readonly string[];
+ readonly callBadges: readonly {
+ readonly label: string;
+ readonly tone: "success" | "error";
+ }[];
readonly resultSummary: string | null;
+ readonly compactResultSummary: string | null;
readonly callDetail:
+ | { readonly kind: "unavailable"; readonly text: string }
| { readonly kind: "command"; readonly text: string }
| {
readonly kind: "edit";
@@ -68,6 +73,22 @@ function serialize(input: unknown, pretty = false): string {
}
}
+/** Small scalar arrays are easier to scan inline; nested/long values stay pretty JSON. */
+function fieldValue(value: unknown): string {
+ if (
+ Array.isArray(value) &&
+ value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item))
+ ) {
+ try {
+ const inline = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`;
+ if (inline.length <= 80) return inline;
+ } catch {
+ // Non-wire values still use the safe serializer below.
+ }
+ }
+ return serialize(value, true);
+}
+
function arg(args: Args, ...keys: string[]): string {
for (const key of keys) {
const value = Object.hasOwn(args, key) ? args[key] : undefined;
@@ -146,7 +167,7 @@ function detail(name: string, input: unknown, args: Args): ToolCardView["callDet
return {
kind: "fields",
fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => {
- const text = serialize(value, true);
+ const text = fieldValue(value);
return { key: String(key), value: text, multiline: text.includes("\n") };
}),
};
@@ -166,8 +187,7 @@ function responseSummary(name: string, content: string, isError: boolean): strin
const truncated = /\[Output truncated[^\]]*\]/i.test(content);
let summary: string;
if (timeout) summary = "timed out";
- else if (isError)
- summary = `failed${lines.length > 1 ? ` · ${count}` : first ? ` · ${first}` : ""}`;
+ else if (isError) summary = "failed";
else if (!content.trim()) summary = "no output";
else if (/^\(empty file\b/i.test(content)) summary = "empty file";
else {
@@ -189,7 +209,19 @@ function responseSummary(name: string, content: string, isError: boolean): strin
summary = count;
} else summary = first;
}
- return oneLine(`${summary}${truncated ? " · truncated" : ""}`, 96);
+ const label = `${summary}${truncated ? " · truncated" : ""}`;
+ // Even the wide header must leave room for the call; prefer a whole short
+ // fact over chopping an arbitrary one-line response halfway through a word.
+ return label.length <= 40 ? label : compactResponseSummary(label, lines.length);
+}
+
+/** Never CSS-clip response facts: swap to a whole short fact when space is scarce. */
+function compactResponseSummary(summary: string, count: number): string {
+ const fact = summary.split(" · ")[0] ?? summary;
+ if (/^(failed|timed out|no output|empty file|created|overwrote|\d+ lines?)$/.test(fact))
+ return fact;
+ if (/^\d+ replacements?$/.test(fact)) return "replaced";
+ return `${count} ${count === 1 ? "line" : "lines"}`;
}
export function viewToolEntry(entry: ToolEntry): ToolCardView {
@@ -216,16 +248,21 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
const builtInSummary = summarize?.(args).trim();
const callSummary =
entry.call === null
- ? "call unavailable"
+ ? "Call in earlier history"
: oneLine(builtInSummary || generic || serialize(input));
- const callBadges: string[] = [];
+ const callBadges: ToolCardView["callBadges"][number][] = [];
if (entry.toolName === "edit_file" && entry.call !== null) {
if (typeof args.oldString === "string" && typeof args.newString === "string") {
- callBadges.push(`+${lineCount(args.newString)} −${lineCount(args.oldString)}`);
+ callBadges.push(
+ { label: `+${lineCount(args.newString)}`, tone: "success" },
+ { label: `−${lineCount(args.oldString)}`, tone: "error" },
+ );
}
}
const result = entry.result;
const lines = result === null ? [] : outputLines(result.content);
+ const resultSummary =
+ result === null ? null : responseSummary(entry.toolName, result.content, result.isError);
return {
name: entry.toolName,
status: result === null ? "pending" : result.isError ? "error" : "ok",
@@ -234,18 +271,13 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
callDetail:
entry.call === null
? {
- kind: "fields",
- fields: [
- {
- key: "call",
- value: "Call unavailable (earlier history unloaded)",
- multiline: false,
- },
- ],
+ kind: "unavailable",
+ text: "The call is in earlier, unloaded history. Its response is shown below.",
}
: detail(entry.toolName, input, args),
- resultSummary:
- result === null ? null : responseSummary(entry.toolName, result.content, result.isError),
+ resultSummary,
+ compactResultSummary:
+ resultSummary === null ? null : compactResponseSummary(resultSummary, lines.length),
output:
result === null
? null
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
index d12088b..412bf2d 100644
--- a/src/features/chat/ui/ChatView.test.ts
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -244,7 +244,8 @@ describe("ChatView — tool cards", () => {
expect(box).not.toBeChecked();
expect(screen.getByLabelText("Failed")).toHaveClass("text-error");
expect(screen.getByLabelText("Failed")).toHaveTextContent("✗");
- expect(screen.getByText("→ failed · Permission denied")).toHaveClass("text-error");
+ expect(screen.getByText("→ failed").closest(".tool-result")).toHaveClass("text-error");
+ expect(container.querySelector(".tool-header")).not.toHaveTextContent("Permission denied");
expect(container.querySelector("pre")).toBeNull();
await userEvent.click(box);
expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error");
@@ -253,10 +254,11 @@ describe("ChatView — tool cards", () => {
const chunks = shellChunks("contents", false, true);
const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } });
expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
- expect(screen.getByText("call unavailable")).toBeInTheDocument();
+ expect(screen.getByText("Call in earlier history")).toHaveClass("earlier-history");
+ expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60");
const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
await userEvent.click(box);
- expect(screen.getByText(/Call unavailable \(earlier history unloaded\)/)).toBeInTheDocument();
+ expect(screen.getByText(/The call is in earlier, unloaded history/)).toBeInTheDocument();
expect(screen.getByText("contents")).toBeInTheDocument();
await rerender({ chunks });
expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
@@ -280,6 +282,12 @@ describe("ChatView — tool cards", () => {
expect(response).toHaveTextContent("output-7");
expect(response.querySelectorAll("pre")).toHaveLength(1);
expect(response.querySelector("pre")?.textContent).toBe(output);
+ const fewer = screen.getByRole("button", { name: "Show fewer lines" });
+ expect(fewer).toHaveClass("btn-primary", "btn-sm");
+ expect(fewer.parentElement).toHaveClass("sticky-controls");
+ const fullOutput = response.querySelector("pre");
+ if (fullOutput === null) throw new Error("expected full output");
+ expect(fewer.compareDocumentPosition(fullOutput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(box).toBeChecked();
expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
"aria-expanded",
@@ -333,7 +341,8 @@ describe("ChatView — tool cards", () => {
},
];
const { container } = render(ChatView, { props: { chunks } });
- expect(screen.getByText("+2 −1")).toBeInTheDocument();
+ expect(screen.getByText("+2")).toHaveClass("text-success");
+ expect(screen.getByText("−1")).toHaveClass("text-error");
expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument();
await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call edit_file" }));
expect(screen.getByText("before")).toHaveClass("text-error");
@@ -342,3 +351,41 @@ describe("ChatView — tool cards", () => {
expect(container).toHaveTextContent("<script>alert(1)</script>");
});
});
+
+it("renders compact, aligned generic fields with legible headings and inline scalar arrays", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "issue",
+ toolName: "mcp__github__create_issue",
+ input: {
+ owner: "realtradam",
+ repo: "frontend",
+ title: "Tool cards",
+ labels: ["ui", "chat"],
+ options: { nested: true },
+ },
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Toggle tool call mcp__github__create_issue" }),
+ );
+ for (const name of ["Call", "Response"]) {
+ expect(screen.getByRole("heading", { name })).toHaveClass("font-semibold", "text-base-content");
+ }
+ const call = screen.getByRole("region", { name: "Call" });
+ expect(call.querySelector("dl")).toHaveClass("call-fields");
+ expect(screen.getByText("owner")).toHaveClass("font-semibold", "text-base-content/85");
+ const labels = screen.getByText('["ui", "chat"]');
+ expect(labels.tagName).toBe("PRE");
+ expect(labels.textContent).not.toContain("\n");
+ expect(container.querySelectorAll("dt")).toHaveLength(5);
+ expect(container.querySelectorAll("dd")).toHaveLength(5);
+ expect(screen.getByText("options").nextElementSibling).toHaveTextContent('"nested": true');
+});
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
index 8c6e325..f1a56cb 100644
--- a/src/features/chat/ui/ToolCard.svelte
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -8,104 +8,228 @@
let showAll = $state(false);
</script>
-<LazyCollapse label={`Toggle tool call ${card.name}`}>
- {#snippet title()}
- {#if card.status === "pending"}
- <span class="loading loading-spinner loading-xs shrink-0" aria-label="Running"></span>
- {:else if card.status === "error"}
- <span class="shrink-0 text-error" aria-label="Failed">✗</span>
- {:else}
- <span class="shrink-0 text-success" aria-label="Succeeded">✓</span>
- {/if}
- <svg
- class="h-4 w-4 shrink-0 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- aria-hidden="true"
- >
- <path
- d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
- />
- </svg>
- <span class="max-w-[35%] shrink-0 truncate" title={card.name}>{card.name}</span>
- <span class="min-w-0 flex-1 truncate opacity-70" title={card.callSummary}
- >{card.callSummary}</span
- >
- {#each card.callBadges as badge}
- <span class="shrink-0 text-xs opacity-70">{badge}</span>
- {/each}
- {#if card.resultSummary !== null}
- <span
- class="max-w-[40%] shrink-0 truncate text-xs"
- class:text-error={card.status === "error"}
- title={card.resultSummary}
- >
- → {card.resultSummary}
- </span>
- {/if}
- {/snippet}
- <div class="min-w-0 space-y-3 text-xs">
- <section aria-label="Call">
- <div class="mb-1 font-medium opacity-60">Call</div>
- {#if card.callDetail.kind === "command"}
- <pre class="overflow-x-auto">$ {card.callDetail.text}</pre>
- {:else if card.callDetail.kind === "edit"}
- <div class="mb-1 break-all font-mono">{card.callDetail.path}</div>
- <div class="space-y-1">
- <div class="opacity-60">− old</div>
- <pre class="overflow-x-auto text-error">{card.callDetail.oldText}</pre>
- <div class="opacity-60">+ new</div>
- <pre class="overflow-x-auto text-success">{card.callDetail.newText}</pre>
- </div>
- {:else}
- <dl class="space-y-1">
- {#each card.callDetail.fields as field}
- <div>
- <dt class="opacity-60">{field.key}</dt>
- <dd>
+<div class="tool-card">
+ <LazyCollapse label={`Toggle tool call ${card.name}`} titleClass="pe-4">
+ {#snippet title()}
+ <div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}>
+ {#if card.status === "pending"}
+ <span class="loading loading-spinner loading-xs shrink-0" aria-label="Running"></span>
+ {:else if card.status === "error"}
+ <span class="shrink-0 text-error" aria-label="Failed">✗</span>
+ {:else}
+ <span
+ class="shrink-0 text-success"
+ class:opacity-60={card.callDetail.kind === "unavailable"}
+ aria-label="Succeeded">✓</span
+ >
+ {/if}
+ <svg
+ class="h-4 w-4 shrink-0 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ aria-hidden="true"
+ >
+ <path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ />
+ </svg>
+ <span class="tool-name truncate" title={card.name}>{card.name}</span>
+ <span
+ class="tool-summary truncate text-base-content/90"
+ class:earlier-history={card.callDetail.kind === "unavailable"}
+ title={card.callDetail.kind === "unavailable" ? card.callDetail.text : card.callSummary}
+ >
+ {card.callSummary}
+ </span>
+ <span class="tool-badges gap-1 text-xs">
+ {#each card.callBadges as badge}
+ <span
+ class:text-success={badge.tone === "success"}
+ class:text-error={badge.tone === "error"}>{badge.label}</span
+ >
+ {/each}
+ </span>
+ {#if card.resultSummary !== null}
+ <span
+ class="tool-result text-xs"
+ class:text-error={card.status === "error"}
+ title={card.resultSummary}
+ >
+ {#if card.resultSummary === card.compactResultSummary}
+ <span>→ {card.resultSummary}</span>
+ {:else}
+ <span class="result-full">→ {card.resultSummary}</span>
+ <span class="result-compact">→ {card.compactResultSummary}</span>
+ {/if}
+ </span>
+ {/if}
+ </div>
+ {/snippet}
+ <div class="min-w-0 space-y-4 text-xs">
+ <section aria-label="Call">
+ <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content">
+ Call
+ </h3>
+ {#if card.callDetail.kind === "unavailable"}
+ <p class="text-base-content/75">{card.callDetail.text}</p>
+ {:else if card.callDetail.kind === "command"}
+ <pre class="overflow-x-auto">$ {card.callDetail.text}</pre>
+ {:else if card.callDetail.kind === "edit"}
+ <div class="mb-2 break-all font-mono">{card.callDetail.path}</div>
+ <div class="space-y-1">
+ <div class="font-medium text-error">− old</div>
+ <pre class="overflow-x-auto text-error">{card.callDetail.oldText}</pre>
+ <div class="font-medium text-success">+ new</div>
+ <pre class="overflow-x-auto text-success">{card.callDetail.newText}</pre>
+ </div>
+ {:else}
+ <dl class="call-fields">
+ {#each card.callDetail.fields as field}
+ <dt class="font-semibold text-base-content/85">{field.key}</dt>
+ <dd class="min-w-0">
<pre
class="overflow-x-auto"
- class:whitespace-pre-wrap={!field.multiline}>{field.value}</pre>
+ class:whitespace-pre-wrap={!field.multiline}
+ class:break-words={!field.multiline}>{field.value}</pre>
</dd>
- </div>
- {/each}
- </dl>
- {/if}
- </section>
- <section aria-label="Response">
- <div class="mb-1 font-medium opacity-60">Response</div>
- {#if card.output === null}
- <span class="opacity-60">running…</span>
- {:else}
- <div class:text-error={card.output.isError}>
- {#if card.output.kind === "full"}
- <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre>
- {:else}
- {#if showAll}
- <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre>
+ {/each}
+ </dl>
+ {/if}
+ </section>
+ <section aria-label="Response">
+ <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content">
+ Response
+ </h3>
+ {#if card.output === null}
+ <span class="text-base-content/75">running…</span>
+ {:else}
+ <div class:text-error={card.output.isError}>
+ {#if card.output.kind === "full"}
+ <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre>
{:else}
- <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre>
- {/if}
- <button
- class="btn btn-ghost btn-xs my-1"
- aria-expanded={showAll}
- onclick={() => {
- showAll = !showAll;
- }}
- >
- {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`}
- </button>
- {#if !showAll}
- <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre>
+ {#if !showAll}
+ <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre>
+ {/if}
+ <div class="output-controls" class:sticky-controls={showAll}>
+ <button
+ class="btn btn-primary btn-sm focus-visible:outline-primary"
+ aria-expanded={showAll}
+ onclick={() => {
+ showAll = !showAll;
+ }}
+ >
+ <span aria-hidden="true">{showAll ? "↑" : "↕"}</span>
+ {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`}
+ </button>
+ </div>
+ {#if showAll}
+ <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre>
+ {:else}
+ <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre>
+ {/if}
{/if}
- {/if}
- </div>
- {/if}
- </section>
- </div>
-</LazyCollapse>
+ </div>
+ {/if}
+ </section>
+ </div>
+ </LazyCollapse>
+</div>
+
+<style>
+ .tool-card {
+ container-type: inline-size;
+ }
+ .tool-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ min-width: 0;
+ }
+ .tool-name {
+ flex: 0 1 auto;
+ max-width: 30%;
+ }
+ .tool-summary {
+ flex: 1;
+ min-width: 0;
+ }
+ .earlier-history {
+ font-style: italic;
+ color: var(--color-base-content);
+ opacity: 0.7;
+ }
+ .tool-badges {
+ display: flex;
+ flex-shrink: 0;
+ }
+ .tool-badges:empty {
+ display: none;
+ }
+ .tool-result {
+ flex-shrink: 0;
+ white-space: nowrap;
+ }
+ .result-compact {
+ display: none;
+ }
+ .call-fields {
+ display: grid;
+ grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr);
+ gap: 0.5rem 1rem;
+ align-items: baseline;
+ }
+ .call-fields dt {
+ max-width: 10rem;
+ overflow-wrap: anywhere;
+ }
+ .output-controls {
+ padding-block: 0.5rem;
+ color: var(--color-base-content);
+ background: var(--color-base-200);
+ }
+ .sticky-controls {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ margin-bottom: 0.5rem;
+ }
+ @container (max-width: 48rem) {
+ .result-full {
+ display: none;
+ }
+ .result-compact {
+ display: inline;
+ }
+ }
+ @container (max-width: 40rem) {
+ .tool-badges {
+ display: none;
+ }
+ .tool-name {
+ max-width: 32%;
+ }
+ .has-earlier-call .tool-result {
+ display: none;
+ }
+ }
+ @container (max-width: 24rem) {
+ .tool-header {
+ gap: 0.375rem;
+ }
+ .call-fields {
+ column-gap: 0.75rem;
+ }
+ }
+ @container (max-width: 18rem) {
+ .tool-result {
+ display: none;
+ }
+ }
+</style>