summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-15 08:59:06 +0900
committerAdam Malczewski <[email protected]>2026-10-15 08:59:06 +0900
commitd594de6934a4c5f20079b86f7a76b7f8ad0cb512 (patch)
tree6cda188268e6e7e68381ab0e1927c1c5c8a0dd6e /src/features
parentc38aba84b29f13c4d6ac940b47ce231e55b6558a (diff)
downloaddispatch-web-d594de6934a4c5f20079b86f7a76b7f8ad0cb512.tar.gz
dispatch-web-d594de6934a4c5f20079b86f7a76b7f8ad0cb512.zip
feat(tool-call-cards): final visual polish — unified edit diff, quiet failed/orphan/pending headers
Diffstat (limited to 'src/features')
-rw-r--r--src/features/chat/logic/tool-summary.test.ts14
-rw-r--r--src/features/chat/logic/tool-summary.ts4
-rw-r--r--src/features/chat/ui/ChatView.test.ts61
-rw-r--r--src/features/chat/ui/ToolCard.svelte71
4 files changed, 128 insertions, 22 deletions
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts
index 381842a..876ede2 100644
--- a/src/features/chat/logic/tool-summary.test.ts
+++ b/src/features/chat/logic/tool-summary.test.ts
@@ -85,6 +85,18 @@ describe("tool summaries", () => {
]);
expect(view("edit_file", { path: "a" }).callBadges).toEqual([]);
});
+ it("omits edit diffstats after failure while retaining attempted edits in call details", () => {
+ const input = { path: "src/missing.ts", oldString: "foo", newString: "bar" };
+ const card = view("edit_file", input, "Error: oldString not found", true);
+ expect(card.callBadges).toEqual([]);
+ expect(card.callDetail).toEqual({
+ kind: "edit",
+ path: input.path,
+ oldText: "foo",
+ newText: "bar",
+ });
+ expect(card.resultSummary).toBe("failed");
+ });
it("preserves exact shell commands in the expanded detail", () => {
expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({
kind: "command",
@@ -228,7 +240,7 @@ describe("tool summaries", () => {
});
expect(viewToolEntry(e).callDetail).toEqual({
kind: "unavailable",
- text: "The call is in earlier, unloaded history. Its response is shown below.",
+ text: "This call is earlier in the conversation. Show earlier messages to see it.",
});
});
});
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
index 2e7680c..3ea4a8e 100644
--- a/src/features/chat/logic/tool-summary.ts
+++ b/src/features/chat/logic/tool-summary.ts
@@ -251,7 +251,7 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
? "Call in earlier history"
: oneLine(builtInSummary || generic || serialize(input));
const callBadges: ToolCardView["callBadges"][number][] = [];
- if (entry.toolName === "edit_file" && entry.call !== null) {
+ if (entry.toolName === "edit_file" && entry.call !== null && !entry.result?.isError) {
if (typeof args.oldString === "string" && typeof args.newString === "string") {
callBadges.push(
{ label: `+${lineCount(args.newString)}`, tone: "success" },
@@ -272,7 +272,7 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
entry.call === null
? {
kind: "unavailable",
- text: "The call is in earlier, unloaded history. Its response is shown below.",
+ text: "This call is earlier in the conversation. Show earlier messages to see it.",
}
: detail(entry.toolName, input, args),
resultSummary,
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
index 412bf2d..9ac4986 100644
--- a/src/features/chat/ui/ChatView.test.ts
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -220,14 +220,15 @@ describe("ChatView — tool cards", () => {
expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull();
});
it("transitions pending → result → sealed without losing open state", async () => {
- const { container, rerender } = render(ChatView, { props: { chunks: shellChunks(null) } });
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(null) } });
expect(screen.getByLabelText("Running")).toHaveClass("loading-spinner");
- expect(container.querySelector(".collapse-title")).not.toHaveTextContent("→");
+ expect(screen.getByText("→ running…")).toHaveClass("tool-result", "text-base-content/60");
const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
await userEvent.click(box);
expect(screen.getByText("running…")).toBeInTheDocument();
await rerender({ chunks: shellChunks("done") });
expect(screen.queryByLabelText("Running")).toBeNull();
+ expect(screen.queryByText("→ running…")).toBeNull();
expect(screen.getByLabelText("Succeeded")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
expect(box).toBeChecked();
@@ -258,7 +259,11 @@ describe("ChatView — tool cards", () => {
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(/The call is in earlier, unloaded history/)).toBeInTheDocument();
+ expect(screen.getByRole("note")).toHaveTextContent(
+ "This call is earlier in the conversation. Show earlier messages to see it.",
+ );
+ expect(screen.getByRole("note")).toHaveClass("italic", "text-base-content/60");
+ expect(screen.queryByRole("heading", { name: "Call" })).toBeNull();
expect(screen.getByText("contents")).toBeInTheDocument();
await rerender({ chunks });
expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
@@ -283,7 +288,7 @@ describe("ChatView — tool cards", () => {
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).toHaveClass("btn-soft", "btn-xs");
expect(fewer.parentElement).toHaveClass("sticky-controls");
const fullOutput = response.querySelector("pre");
if (fullOutput === null) throw new Error("expected full output");
@@ -345,8 +350,12 @@ describe("ChatView — tool cards", () => {
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");
- expect(screen.getByText("after second")).toHaveClass("text-success");
+ // Unified diff: one tinted row per line, each with its own −/+ gutter marker.
+ const diff = screen.getByRole("group", { name: "Edit diff" });
+ const removed = diff.querySelectorAll(".edit-diff-del");
+ const added = diff.querySelectorAll(".edit-diff-add");
+ expect(Array.from(removed, (r) => r.textContent?.trim())).toEqual(["−before"]);
+ expect(Array.from(added, (r) => r.textContent?.trim())).toEqual(["+after", "+second"]);
expect(container.querySelector("script")).toBeNull();
expect(container).toHaveTextContent("<script>alert(1)</script>");
});
@@ -389,3 +398,43 @@ it("renders compact, aligned generic fields with legible headings and inline sca
expect(container.querySelectorAll("dd")).toHaveLength(5);
expect(screen.getByText("options").nextElementSibling).toHaveTextContent('"nested": true');
});
+
+it("removes a pending edit's diffstat when it fails, without hiding the attempted edit", async () => {
+ const call: RenderedChunk = {
+ seq: null,
+ role: "assistant",
+ provisional: true,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "failed-edit",
+ toolName: "edit_file",
+ input: { path: "src/missing.ts", oldString: "foo", newString: "bar" },
+ },
+ };
+ const result: RenderedChunk = {
+ seq: null,
+ role: "tool",
+ provisional: true,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "failed-edit",
+ toolName: "edit_file",
+ content: "Error: oldString not found",
+ isError: true,
+ },
+ };
+ const { container, rerender } = render(ChatView, { props: { chunks: [call] } });
+ expect(screen.getByText("+1")).toBeInTheDocument();
+ expect(screen.getByText("−1")).toBeInTheDocument();
+ expect(screen.getByText("→ running…")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call edit_file" }));
+ await rerender({ chunks: [call, result] });
+ expect(screen.queryByText("+1")).toBeNull();
+ expect(screen.queryByText("−1")).toBeNull();
+ expect(screen.queryByText("→ running…")).toBeNull();
+ expect(container.querySelector(".tool-header")).toHaveTextContent("src/missing.ts");
+ expect(container.querySelector(".tool-header")).toHaveTextContent("→ failed");
+ expect(screen.getByText("foo")).toBeInTheDocument();
+ expect(screen.getByText("bar")).toBeInTheDocument();
+ expect(screen.getByText("Error: oldString not found")).toBeInTheDocument();
+});
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
index f1a56cb..75c562d 100644
--- a/src/features/chat/ui/ToolCard.svelte
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -67,27 +67,48 @@
<span class="result-compact">→ {card.compactResultSummary}</span>
{/if}
</span>
+ {:else}
+ <span class="tool-result text-xs text-base-content/60">→ running…</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"}
+ <aside
+ role="note"
+ class="border-l-2 border-base-content/20 pl-3 italic text-base-content/60"
+ >
+ {card.callDetail.text}
+ </aside>
+ {:else}
+ <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content">
+ Call
+ </h3>
+ {/if}
+ {#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>
+ <!-- Unified-diff style: every removed/added line carries its own −/+ gutter
+ marker and a tinted row, so multi-line edits read as one diff block. -->
+ <div class="edit-diff overflow-x-auto rounded font-mono" role="group" aria-label="Edit diff">
+ {#if card.callDetail.oldText !== ""}
+ {#each card.callDetail.oldText.split("\n") as line, i (i)}
+ <div class="edit-diff-row edit-diff-del">
+ <span class="edit-diff-sign" aria-label="removed">−</span><span>{line}</span>
+ </div>
+ {/each}
+ {/if}
+ {#if card.callDetail.newText !== ""}
+ {#each card.callDetail.newText.split("\n") as line, i (i)}
+ <div class="edit-diff-row edit-diff-add">
+ <span class="edit-diff-sign" aria-label="added">+</span><span>{line}</span>
+ </div>
+ {/each}
+ {/if}
</div>
- {:else}
+ {:else if card.callDetail.kind === "fields"}
<dl class="call-fields">
{#each card.callDetail.fields as field}
<dt class="font-semibold text-base-content/85">{field.key}</dt>
@@ -117,13 +138,13 @@
{/if}
<div class="output-controls" class:sticky-controls={showAll}>
<button
- class="btn btn-primary btn-sm focus-visible:outline-primary"
+ class="btn btn-soft btn-xs focus-visible:outline-primary"
aria-expanded={showAll}
onclick={() => {
showAll = !showAll;
}}
>
- <span aria-hidden="true">{showAll ? "↑" : "↕"}</span>
+ {#if showAll}<span aria-hidden="true">↑</span>{/if}
{showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`}
</button>
</div>
@@ -188,6 +209,30 @@
max-width: 10rem;
overflow-wrap: anywhere;
}
+ .edit-diff {
+ background: var(--color-base-300);
+ padding-block: 0.25rem;
+ }
+ .edit-diff-row {
+ display: flex;
+ min-width: max-content;
+ white-space: pre;
+ padding-inline-end: 0.75rem;
+ }
+ .edit-diff-sign {
+ flex-shrink: 0;
+ width: 1.75rem;
+ text-align: center;
+ user-select: none;
+ }
+ .edit-diff-del {
+ color: var(--color-error);
+ background: color-mix(in oklab, var(--color-error) 12%, transparent);
+ }
+ .edit-diff-add {
+ color: var(--color-success);
+ background: color-mix(in oklab, var(--color-success) 12%, transparent);
+ }
.output-controls {
padding-block: 0.5rem;
color: var(--color-base-content);