summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat/ui')
-rw-r--r--src/features/chat/ui/CacheTimerBadge.svelte70
-rw-r--r--src/features/chat/ui/ChatView.svelte277
-rw-r--r--src/features/chat/ui/ChatView.test.ts558
-rw-r--r--src/features/chat/ui/Composer.svelte155
-rw-r--r--src/features/chat/ui/LazyCollapse.svelte46
-rw-r--r--src/features/chat/ui/ModelSelector.svelte214
-rw-r--r--src/features/chat/ui/PendingInputWarning.svelte47
-rw-r--r--src/features/chat/ui/PendingInputs.svelte16
-rw-r--r--src/features/chat/ui/PendingInputs.test.ts217
-rw-r--r--src/features/chat/ui/ToolCard.svelte274
-rw-r--r--src/features/chat/ui/tool-icons.ts52
11 files changed, 1725 insertions, 201 deletions
diff --git a/src/features/chat/ui/CacheTimerBadge.svelte b/src/features/chat/ui/CacheTimerBadge.svelte
new file mode 100644
index 0000000..9e6f96d
--- /dev/null
+++ b/src/features/chat/ui/CacheTimerBadge.svelte
@@ -0,0 +1,70 @@
+<script lang="ts">
+ /**
+ * One cache-timer badge. LIVE when given `since` (counts up from that epoch
+ * ms, ticking once a second, and stops ticking at the 10-minute cap); STAMPED
+ * when given `elapsedMs` (a finished gap — static). Formatting lives in
+ * `core/metrics` (`viewCacheTimer`). Owning the tick here means only this
+ * badge re-renders each second, not the transcript.
+ */
+ import { untrack } from "svelte";
+ import { CACHE_TIMER_MAX_MS, viewCacheTimer } from "../../../core/metrics";
+
+ let {
+ since,
+ elapsedMs,
+ size = "sm",
+ now = Date.now,
+ }: {
+ /** Live: epoch ms the gap started. */
+ since?: number;
+ /** Stamped: the gap's final duration (wins over `since`). */
+ elapsedMs?: number;
+ /** Badge size — match the neighbouring cache badge (`xs` on step lines). */
+ size?: "xs" | "sm";
+ /** Wall clock (injected for tests). */
+ now?: () => number;
+ } = $props();
+
+ // Initial read only (the effect below keeps it current while live).
+ let current = $state(untrack(() => now()));
+
+ // Depends ONLY on `since`/`elapsedMs`/`now`. It must never READ `current`
+ // (it writes it): reading its own write makes the effect re-run itself on
+ // every clock change → `effect_update_depth_exceeded`, which aborts the whole
+ // reactive flush and stalls the transcript (turn data rendering late).
+ $effect(() => {
+ if (elapsedMs !== undefined || since === undefined) return;
+ const start = since;
+ const clock = now;
+ const t0 = clock();
+ current = t0;
+ if (t0 - start >= CACHE_TIMER_MAX_MS) return;
+ const id = setInterval(() => {
+ const t = clock();
+ current = t;
+ if (t - start >= CACHE_TIMER_MAX_MS) clearInterval(id);
+ }, 1000);
+ return () => clearInterval(id);
+ });
+
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
+
+ const live = $derived(elapsedMs === undefined);
+ const view = $derived(viewCacheTimer(elapsedMs ?? current - (since ?? current)));
+</script>
+
+<span
+ class="badge font-mono tabular-nums {badgeClass[view.level]}"
+ class:badge-xs={size === "xs"}
+ class:badge-sm={size === "sm"}
+ class:badge-outline={!live}
+ title={live
+ ? "Cache timer (running): time since the last model request. The Claude prompt cache expires after 5 minutes idle."
+ : "Cache timer (stopped): how long the cache sat idle before the next request. The Claude prompt cache expires after 5 minutes idle."}
+ data-testid={live ? "cache-timer-live" : "cache-timer-stamp"}
+ >{view.label}</span
+>
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index e67ca5b..84e66d8 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -2,14 +2,19 @@
import type { TurnProviderRetryEvent } from "@dispatch/wire";
import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index";
import {
+ type CacheTimerState,
interleaveTurnMetrics,
+ lastTurnRowIndex,
viewCacheRate,
viewExpectedCache,
viewStepMetrics,
viewTurnMetrics,
type TurnMetricsEntry,
} from "../../../core/metrics";
- import { Markdown } from "../../markdown";
+ import { Markdown, type TextFileClient } from "../../markdown";
+ import CacheTimerBadge from "./CacheTimerBadge.svelte";
+ import LazyCollapse from "./LazyCollapse.svelte";
+ import ToolCard from "./ToolCard.svelte";
const badgeClass = {
success: "badge-success",
@@ -19,14 +24,19 @@
let {
chunks,
+ recovering = false,
turnMetrics = [],
hasEarlier = false,
onShowEarlier,
thinkingKeyBase = 0,
providerRetry = null,
apiBaseUrl = "",
+ cwd = null,
+ textFiles = undefined,
+ cacheTimer = null,
}: {
chunks: readonly RenderedChunk[];
+ recovering?: boolean;
turnMetrics?: readonly TurnMetricsEntry[];
/** Earlier history is unloaded (chat limit) and can be paged back in. */
hasEarlier?: boolean;
@@ -53,10 +63,27 @@
* "" (root-relative — a browser resolves `/images/…` against its origin).
*/
apiBaseUrl?: string;
+ /**
+ * The active conversation's working directory (null when unset). Relative
+ * Markdown image srcs (`![x](shot.png)`) resolve against it; local absolute
+ * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`.
+ */
+ cwd?: string | null;
+ /** Context-bound, injected live-file display port (never model history). */
+ textFiles?: TextFileClient | undefined;
+ /**
+ * The cache timer (gaps between model requests): a running step gap shows
+ * live at the bottom (under the executing tools), a running turn gap live
+ * on that turn's metrics row; finished gaps are stamped on their step /
+ * turn metrics line. `null` (the default) hides it entirely — the caller
+ * gates on the model.
+ */
+ cacheTimer?: CacheTimerState | null;
} = $props();
// True while a show-earlier page-in is awaited (disables the button).
let loadingEarlier = $state(false);
+ let failedImages = $state<string[]>([]);
async function showEarlier() {
if (!onShowEarlier || loadingEarlier) return;
@@ -68,10 +95,36 @@
}
}
- const groups = $derived(groupRenderedChunks(chunks));
+ const groups = $derived(groupRenderedChunks(chunks, recovering));
const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ // A SEEDED turn gap (no turnId — e.g. after a reload) renders on the most
+ // recent turn's row (when no user message follows it).
+ const seededTurnRow = $derived(
+ cacheTimer?.running?.kind === "turn" && cacheTimer.running.turnId === null
+ ? lastTurnRowIndex(rows)
+ : null,
+ );
+
+ /** Start of the running turn gap if it belongs on this turn row, else undefined. */
+ function liveTurnSince(turnId: string, rowIndex: number): number | undefined {
+ const r = cacheTimer?.running;
+ if (r?.kind !== "turn") return undefined;
+ if (r.turnId === turnId || (r.turnId === null && rowIndex === seededTurnRow)) {
+ return r.startedAt;
+ }
+ return undefined;
+ }
+
+ // A running step gap with tool calls → the live timer under the executing tools.
+ const liveStepGap = $derived(
+ cacheTimer?.running?.kind === "step" && cacheTimer.running.toolCount > 0
+ ? cacheTimer.running
+ : null,
+ );
+
+ // Tool cards use toolCallId keys across pending/result/seal and orphan page-in.
// Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
// survives the provisional→committed (seq null → seq N) transition, so the
// collapse's open/close state is NOT lost when a turn seals. The ordinal
@@ -88,8 +141,8 @@
}
const group = row.group;
let key: string;
- if (group.kind === "tool-batch") {
- key = `b${group.stepId}`;
+ if (group.kind === "tool") {
+ key = `t${group.entry.toolCallId}`;
} else if (group.chunk.chunk.type === "thinking") {
key = `think${thinking++}`;
} else if (group.chunk.seq != null) {
@@ -102,6 +155,12 @@
});
</script>
+{#if recovering}
+ <p role="status" class="px-4 py-2 text-sm text-base-content/70">
+ Recovering conversation history… Omitted tool input/output is unavailable until persisted.
+ </p>
+{/if}
+
{#snippet chunkRow(rendered: RenderedChunk)}
{#if rendered.role === "user"}
<!-- User: a speech bubble, left-aligned. A user message may be multi-chunk
@@ -114,13 +173,22 @@
{#if rendered.chunk.type === "text"}
<p>{rendered.chunk.text}</p>
{:else if rendered.chunk.type === "image"}
- <img
- src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
- alt={rendered.chunk.mimeType ?? "pasted image"}
- loading="lazy"
- decoding="async"
- class="max-h-80 max-w-full rounded"
- />
+ {#if failedImages.includes(rendered.chunk.url)}
+ <span class="text-sm opacity-70">image expired</span>
+ {:else}
+ <img
+ src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
+ alt={rendered.chunk.mimeType ?? "pasted image"}
+ loading="lazy"
+ decoding="async"
+ onerror={() => {
+ if (rendered.chunk.type === "image" && rendered.chunk.url.startsWith("/images/")) {
+ failedImages = [...failedImages, rendered.chunk.url];
+ }
+ }}
+ class="max-h-80 max-w-full rounded"
+ />
+ {/if}
{/if}
</div>
</div>
@@ -130,83 +198,15 @@
while generating, then "Thoughts" with no dots once complete. -->
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
<div class="chat-bubble w-full bg-transparent">
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle thoughts" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <LazyCollapse label="Toggle thoughts">
+ {#snippet title()}
<span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
{#if rendered.streaming}
<span class="loading loading-dots loading-sm" aria-label="Generating"></span>
{/if}
- </div>
- <div class="collapse-content">
- <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
- </div>
- </div>
- </div>
- </div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Single tool call/result: a collapsible card (collapsed by default,
- like thinking). Title shows the tool name; content shows the
- input/output. Same chat-start grid shim as the thinking block. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "tool-call"}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg
- class="h-4 w-4 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"
- ><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>{rendered.chunk.toolName}</span>
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(
- rendered.chunk.input,
- null,
- 2,
- )}</pre>
- </div>
- </div>
- {:else}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool result" />
- <div
- class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"
- class:text-error={rendered.chunk.isError}
- >
- <svg
- class="h-4 w-4 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"
- ><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>{rendered.chunk.toolName}</span>
- {#if rendered.chunk.isError}
- <span class="badge badge-error badge-xs">error</span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
- </div>
- </div>
- {/if}
+ {/snippet}
+ <p class="whitespace-pre-wrap">{rendered.chunk.type === "thinking" ? rendered.chunk.text : ""}</p>
+ </LazyCollapse>
</div>
</div>
{:else}
@@ -215,7 +215,12 @@
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
<div class="chat-bubble w-full bg-transparent">
{#if rendered.chunk.type === "text"}
- <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
+ <Markdown
+ text={rendered.chunk.text}
+ streaming={rendered.streaming ?? false}
+ image={{ apiBase: apiBaseUrl, cwd }}
+ {textFiles}
+ />
{:else if rendered.chunk.type === "error"}
<div class="text-error" role="alert">
{rendered.chunk.message}
@@ -245,17 +250,33 @@
</button>
</div>
{/if}
- {#each keyedRows as { row, key } (key)}
+ {#each keyedRows as { row, key }, i (key)}
+ <!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint
+ for rows outside the viewport (see app.css). -->
+ <div class="chat-row">
{#if row.kind === "step-metrics"}
{@const sv = viewStepMetrics(row.step, row.index)}
+ {@const stepCache = viewCacheRate(row.step.usage)}
+ {@const stepStamp = cacheTimer?.stepStamps.get(row.step.stepId)}
<div class="chat chat-start">
<div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
- <div class="text-xs opacity-70">
- {sv.label} · {sv.tokensLabel}
- {#if sv.tps}
- · {sv.tps}{/if}
- {#if sv.genTotal}
- · {sv.genTotal}{/if}
+ <div class="flex flex-wrap items-center gap-x-1 text-xs">
+ <span class="opacity-70">
+ {sv.label} · {sv.tokensLabel}
+ {#if sv.tps}
+ · {sv.tps}{/if}
+ {#if sv.ttft}
+ · {sv.ttft} to first token{/if}
+ ·
+ </span>
+ <span class="opacity-70">cache:</span>
+ <span
+ class="badge badge-xs {badgeClass[stepCache.level]}"
+ data-testid="step-cache-badge">{stepCache.pct}%</span
+ >
+ {#if stepStamp !== undefined}
+ <CacheTimerBadge elapsedMs={stepStamp} size="xs" />
+ {/if}
</div>
</div>
</div>
@@ -263,12 +284,14 @@
{@const turnView = viewTurnMetrics(row.turn, row.turnNumber)}
{@const lastCache = viewCacheRate(row.turn.usage)}
{@const chatCache = viewCacheRate(row.cumulativeUsage)}
- {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)}
+ {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)}
+ {@const turnLive = liveTurnSince(row.turn.turnId, i)}
+ {@const turnStamp = cacheTimer?.turnStamps.get(row.turn.turnId)}
<div class="chat chat-start">
<div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
<div class="flex flex-col gap-1 text-xs">
<div class="opacity-70">
- {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
+ {turnView.label} · {turnView.tokensLabel} · {turnView.breakdown}
{#if turnView.tps}
· {turnView.tps}{/if}
{#if turnView.duration}
@@ -289,6 +312,11 @@
<span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
</span>
{/if}
+ {#if turnLive !== undefined}
+ <CacheTimerBadge since={turnLive} />
+ {:else if turnStamp !== undefined}
+ <CacheTimerBadge elapsedMs={turnStamp} />
+ {/if}
</div>
</div>
</div>
@@ -296,54 +324,27 @@
{:else if row.group.kind === "single"}
{@render chunkRow(row.group.chunk)}
{:else}
- <!-- Batched tool calls (one step): each entry is a collapsible card.
- Click to expand and see the input/output. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="flex flex-col gap-1">
- {#each row.group.entries as entry (entry.call.toolCallId)}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg
- class="h-4 w-4 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"
- ><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>{entry.call.toolName}</span>
- {#if entry.result?.isError}
- <span class="badge badge-error badge-xs">error</span>
- {:else if entry.result === null}
- <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(
- entry.call.input,
- null,
- 2,
- )}</pre>
- {#if entry.result}
- <pre
- class="mt-1 max-h-96 overflow-auto text-xs"
- class:text-error={entry.result.isError}>{entry.result.content}</pre>
- {/if}
- </div>
- </div>
- {/each}
- </div>
+ <!-- Each call owns one bubble; the matching result is folded into its card. -->
+ <div class="chat chat-start [grid-template-columns:auto_minmax(0,1fr)] [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble min-w-0 w-full bg-transparent">
+ <ToolCard entry={row.group.entry} />
</div>
</div>
{/if}
+ </div>
{/each}
+ {#if liveStepGap}
+ <!-- Live cache timer while the last step's tools execute; stamped onto that
+ step's metrics line once they return (or the next step starts). -->
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="flex items-center gap-1 text-xs" data-testid="cache-timer-tools">
+ <span class="opacity-70">Running tools</span>
+ <CacheTimerBadge since={liveStepGap.startedAt} size="xs" />
+ </div>
+ </div>
+ </div>
+ {/if}
{#if providerRetry}
{@const rv = viewProviderRetry(providerRetry)}
<!-- Transient yellow warning: a provider error is being retried with backoff.
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
new file mode 100644
index 0000000..157ccac
--- /dev/null
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -0,0 +1,558 @@
+import type { StepId } from "@dispatch/wire";
+import { fireEvent, render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { RenderedChunk } from "../../../core/chunks";
+import ChatView from "./ChatView.svelte";
+
+/** Collapsed card bodies render lazily — open every card so its body mounts. */
+async function openAllCollapses(container: HTMLElement): Promise<void> {
+ const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]');
+ for (const box of boxes) {
+ if (!box.checked) await userEvent.click(box);
+ }
+}
+
+// Defensive rendering: a conversation transcript can contain duplicate
+// `toolCallId`s (persisted provider double-emission). The
+// grouped render path MUST dedupe them so the keyed {#each} never throws
+// Svelte `each_key_duplicate` (which aborts the reactive flush and makes the
+// tab appear dead). See fix-toolcall-duplicate-keys plan Part 1.
+describe("ChatView — duplicate toolCallIds", () => {
+ it("renders duplicated toolCallIds without throwing, showing one bubble per unique call", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ // The duplicate-emission pattern: the same ids re-emitted immediately after.
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 4,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 5,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 6,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "b",
+ toolName: "list_dir",
+ content: "contents-of-b",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ // Duplicate results too — absorbed into the corresponding cards.
+ {
+ seq: 7,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 8,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "b",
+ toolName: "list_dir",
+ content: "contents-of-b",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
+
+ // No each_key_duplicate crash. Calls render as independent collapsible cards —
+ // exactly ONE per unique toolCallId (deduped), not 4.
+ const collapses = container.querySelectorAll(".collapse");
+ expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
+
+ // Both unique tool names shown; each result rendered once (deduped).
+ expect(screen.getByText("Read file")).toBeInTheDocument();
+ expect(screen.getByText("List dir")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-a")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-b")).toBeInTheDocument();
+ });
+});
+
+describe("ChatView — lazy collapse bodies", () => {
+ it("does not mount a collapsed card's body until it is first opened, then keeps it", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "x",
+ toolName: "run_shell",
+ content: "HUGE-OUTPUT",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ // English display name; the raw wire name stays available as the tooltip.
+ expect(screen.getByText("Run command")).toHaveAttribute("title", "run_shell");
+ expect(screen.queryByText("HUGE-OUTPUT")).toBeNull();
+
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
+
+ await userEvent.click(box); // close: body stays mounted (close animation)
+ expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
+ expect(container.querySelectorAll(".chat-row")).toHaveLength(1);
+ });
+});
+
+function shellChunks(content: string | null, isError = false, committed = false): RenderedChunk[] {
+ const call: RenderedChunk = {
+ seq: committed ? 1 : null,
+ role: "assistant",
+ provisional: !committed,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ input: { command: "bun run test" },
+ },
+ };
+ return content === null
+ ? [call]
+ : [
+ call,
+ {
+ seq: committed ? 2 : null,
+ role: "tool",
+ provisional: !committed,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ content,
+ isError,
+ },
+ },
+ ];
+}
+
+describe("ChatView — tool cards", () => {
+ it("pairs a single call/result into one collapsed bubble with both summaries and a success icon", async () => {
+ const { container } = render(ChatView, { props: { chunks: shellChunks("first\nsecond") } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(container.querySelectorAll(".collapse")).toHaveLength(1);
+ expect(screen.getByText("bun run test")).toBeInTheDocument();
+ expect(screen.getByText("→ 2 lines")).toBeInTheDocument();
+ expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ expect(box).not.toBeChecked();
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ expect(screen.getByText("first second")).toBeInTheDocument();
+ expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull();
+ });
+ it("transitions pending → result → sealed without losing open state", async () => {
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(null) } });
+ expect(screen.getByLabelText("Running")).toHaveClass("loading-dots", "loading-sm");
+ expect(screen.getByText("→ running…")).toHaveClass("tool-result", "text-base-content/60");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ 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 command" })).toBe(box);
+ expect(box).toBeChecked();
+ await rerender({ chunks: shellChunks("done", false, true) });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("done")).toBeInTheDocument();
+ });
+ it("keeps errors collapsed with a failure icon and tints the expanded output", async () => {
+ const { container } = render(ChatView, {
+ props: { chunks: shellChunks("Permission denied", true) },
+ });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ expect(box).not.toBeChecked();
+ expect(screen.getByLabelText("Failed")).toHaveClass("text-error");
+ expect(screen.getByLabelText("Failed")).toHaveTextContent("✗");
+ 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");
+ });
+ it("renders an orphan result as the same card and retains state when its call is paged back in", async () => {
+ 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 in earlier history")).toHaveClass("earlier-history");
+ expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ 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 command" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ });
+ it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => {
+ const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n");
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ const response = screen.getByRole("region", { name: "Response" });
+ expect(response).toHaveTextContent("output-1");
+ expect(response).toHaveTextContent("output-6");
+ expect(response).not.toHaveTextContent("output-7");
+ expect(response).toHaveTextContent("output-8");
+ expect(response).toHaveTextContent("output-13");
+ const toggle = screen.getByRole("button", { name: "⋯ 1 more lines" });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await userEvent.click(toggle);
+ 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-soft", "btn-xs");
+ 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",
+ "true",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Show fewer lines" }));
+ expect(response).not.toHaveTextContent("output-7");
+ expect(box).toBeChecked();
+ // The separate output-fold state also survives sealing.
+ await userEvent.click(screen.getByRole("button", { name: "⋯ 1 more lines" }));
+ expect(response).toHaveTextContent("output-7");
+ await rerender({ chunks: shellChunks(output, false, true) });
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ expect(response).toHaveTextContent("output-7");
+ });
+ it("shows 12 output lines without an inner toggle", async () => {
+ const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n");
+ render(ChatView, { props: { chunks: shellChunks(output) } });
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Run command" }));
+ expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull();
+ expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12");
+ });
+ it("renders edit details and treats tool output as text, not executable markup", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ input: { path: "a.ts", oldString: "before", newString: "after\nsecond" },
+ },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ content: 'Replaced 1 occurrence(s) in "a.ts".\n<script>alert(1)</script>',
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ 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" }));
+ // 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>");
+ });
+});
+
+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 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');
+});
+
+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();
+});
+
+it("shows an argument-less call without `{}` — empty header summary, 'No arguments' in the body", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: { type: "tool-call", toolCallId: "m", toolName: "list_agent_models", input: {} },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "m",
+ toolName: "list_agent_models",
+ content: "Available named agents (3)",
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ expect(screen.getByText("List agent models")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("{}");
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Toggle tool call List agent models" }),
+ );
+ expect(screen.getByText("No arguments")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("{}");
+});
+
+it("renders a per-tool Phosphor icon in the header", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "s",
+ toolName: "run_shell",
+ input: { command: "ls" },
+ },
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ provisional: false,
+ chunk: { type: "tool-call", toolCallId: "r", toolName: "read_file", input: { path: "a.ts" } },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ const icons = Array.from(container.querySelectorAll(".tool-header svg[aria-hidden='true']"));
+ expect(icons).toHaveLength(2);
+ // Different tools → different glyphs.
+ expect(icons[0]?.innerHTML).not.toBe(icons[1]?.innerHTML);
+});
+
+it("replaces failed persisted images with an expired placeholder, without marking inline images expired", async () => {
+ const chunks: RenderedChunk[] = ["/images/conv/gone.png", "data:image/png;base64,AAAA"].map(
+ (url, i) => ({ seq: i + 1, role: "user", chunk: { type: "image", url }, provisional: false }),
+ );
+ render(ChatView, { chunks, apiBaseUrl: "https://backend.test" });
+ const [stored, inline] = screen.getAllByRole("img");
+ if (!stored || !inline) throw new Error("expected both images");
+ expect(stored).toHaveAttribute("src", "https://backend.test/images/conv/gone.png");
+ await fireEvent.error(inline);
+ expect(screen.queryByText("image expired")).toBeNull();
+ await fireEvent.error(stored);
+ expect(screen.getByText("image expired")).toBeInTheDocument();
+ expect(screen.getAllByRole("img")).toEqual([inline]);
+});
+
+it("shows omitted in-progress tool input as recovering/unavailable, never No arguments", async () => {
+ const { container, rerender } = render(ChatView, {
+ recovering: true,
+ chunks: [
+ {
+ seq: null,
+ role: "tool",
+ provisional: true,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "missing",
+ toolName: "run_shell",
+ content: "real output",
+ isError: false,
+ },
+ },
+ ],
+ });
+ expect(screen.getByRole("status").textContent).toContain("unavailable until persisted");
+ expect(screen.getByText("Recovering tool input…")).toBeTruthy();
+ await openAllCollapses(container);
+ expect(screen.getByText(/Tool input is unavailable until it is persisted/)).toBeTruthy();
+ expect(screen.queryByText("No arguments")).toBeNull();
+ expect(screen.getByText("real output")).toBeTruthy();
+ await rerender({
+ recovering: false,
+ chunks: [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "missing",
+ toolName: "run_shell",
+ input: { command: "recovered command" },
+ },
+ },
+ ],
+ });
+ expect(screen.queryByRole("status")).toBeNull();
+ expect(screen.queryByText("Recovering tool input…")).toBeNull();
+});
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index 04c28cd..cd451bc 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,5 +1,8 @@
<script lang="ts">
import type { ImageInput } from "@dispatch/wire";
+ import { convertToJpeg, type ConvertedJpeg, type ImageJpegEncoder } from "../adapter/image-encode";
+ import { stopButtonLabels } from "../logic/composer";
+ import { MAX_IMAGE_EDGE_PX, shouldConvertImage } from "../logic/image-compress";
import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
const FALLBACK_CONTEXT_WINDOW = 1_000_000;
@@ -8,20 +11,56 @@
const IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp";
/** Reject images larger than this before base64-encoding (keeps payloads sane). */
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
+ /** Tooltip on the per-image compress toggle (PNG → JPEG + resize). */
+ const COMPRESS_TOOLTIP = `Re-encode PNG to JPEG and resize to ${MAX_IMAGE_EDGE_PX}px (smaller upload). Unchecked sends the original bytes.`;
- /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */
+ /**
+ * A staged image awaiting send: a stable id + everything needed to resolve
+ * the `ImageInput` that will actually be sent. The ORIGINAL `File` (and its
+ * data URL) is kept so the per-image compress toggle is reversible until
+ * send; the async conversion result is cached so flipping the toggle never
+ * re-encodes.
+ */
interface StagedImage {
readonly id: string;
- readonly input: ImageInput;
+ /** The original untouched File — sent verbatim when compress is off. */
+ readonly file: File;
+ /** Data URL of the original (staged immediately — conversion is async). */
+ readonly originalUrl: string;
+ readonly originalMimeType: string | undefined;
+ /** Whether the converted (JPEG) variant is used for send. Default ON. */
+ compress: boolean;
+ /** Cached conversion (data URL of the JPEG); `null` = none/failed/n.a. */
+ converted: { url: string } | null;
+ }
+
+ /**
+ * The `ImageInput` this staged image will actually SEND: the converted JPEG
+ * (data URL + `image/jpeg`) when compress is on AND a conversion exists,
+ * otherwise the original untouched bytes.
+ */
+ function imageInput(img: StagedImage): ImageInput {
+ if (img.compress && img.converted !== null) {
+ return { url: img.converted.url, mimeType: "image/jpeg" };
+ }
+ const { originalUrl: url, originalMimeType: mimeType } = img;
+ return { url, ...(mimeType ? { mimeType } : {}) };
+ }
+
+ /** Whether the compress toggle is rendered for this image (convertible formats only). */
+ function compressible(img: StagedImage): boolean {
+ return shouldConvertImage(img.originalMimeType);
}
let {
onSend,
onQueue,
onStop,
+ encodeJpeg = convertToJpeg,
contextSize = undefined,
contextWindow = undefined,
status = "idle",
+ queuedMessageCount = undefined,
}: {
/**
* Send a message (start a turn via `chat.send`). Carries any staged images
@@ -33,12 +72,17 @@
/**
* Enqueue a steering message (`chat.queue`). When provided AND the status
* is `running`, the send button becomes a "Queue" button that steers the
- * in-flight turn instead of starting a new one. Steering is text-only —
- * it never carries images (a mid-turn injection has no image surface).
+ * in-flight turn instead of starting a new one. Carries staged images just
+ * like onSend; without this callback, in-flight input stays staged.
*/
- onQueue?: (text: string) => void;
+ onQueue?: (text: string, images?: ImageInput[]) => void;
/** Stop the in-flight generation (`POST /conversations/:id/stop`). */
onStop?: () => void;
+ /**
+ * Injected compression edge (a `File` in, a maybe-smaller JPEG out).
+ * Defaults to the real canvas converter; tests stub this outermost edge.
+ */
+ encodeJpeg?: ImageJpegEncoder;
// Current context occupancy — updated progressively during a turn (the
// latest step's input+output) and finalized to the turn's `contextSize` on
// seal, or `undefined` when unknown — the status bar then shows
@@ -53,6 +97,8 @@
* `running` for the send button (steer/stop).
*/
status?: ComposerStatus;
+ /** Current conversation's queued snapshot size; undefined when unavailable. */
+ queuedMessageCount?: number | undefined;
} = $props();
export type ComposerStatus = "idle" | "running" | "queued" | "error";
@@ -65,25 +111,23 @@
const hasText = $derived(text.trim().length > 0);
const hasImages = $derived(images.length > 0);
- const canSend = $derived(hasText || hasImages);
+ const inFlight = $derived(status === "running" || status === "queued");
+ const canSend = $derived((hasText || hasImages) && (!inFlight || onQueue !== undefined));
const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
const usage = $derived(computeContextUsage(contextSize, effectiveMax));
// One button, three modes:
// - idle → "Send" (starts a turn via chat.send)
- // - running/queued + text → "Queue" (steers via chat.queue — text only)
+ // - running/queued + text/images → "Queue" (steers via chat.queue)
// - running/queued + empty → "Stop" (aborts via POST /stop)
// (`queued` behaves like `running` — the turn is in flight, just waiting for a
// concurrency slot; the user can still steer or stop it.)
- // Steering never carries images: when running with images staged but no text,
- // the images stay staged (queue is text-only). Images-without-text while running
- // is an unusual case that still sends (the server auto-starts/resolves).
- const inFlight = $derived(status === "running" || status === "queued");
const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop";
- if (inFlight && hasText && onQueue !== undefined) return "queue";
+ if (inFlight) return "queue";
return "send";
});
+ const stopButton = $derived(stopButtonLabels(queuedMessageCount));
const placeholder = $derived(
status === "queued"
? "Queued for a slot…"
@@ -120,8 +164,8 @@
resize();
});
- /** Read a File into a base64 data URL (`data:image/…;base64,…`). */
- function fileToDataUrl(file: File): Promise<string> {
+ /** Read a File/Blob into a base64 data URL (`data:image/…;base64,…`). */
+ function fileToDataUrl(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -142,10 +186,51 @@
// Prefer the file's declared MIME; fall back to the data URL's prefix.
const mimeType = file.type || undefined;
const id = `img-${Date.now()}-${imgSeq++}`;
- images = [...images, { id, input: { url, ...(mimeType ? { mimeType } : {}) } }];
+ images = [
+ ...images,
+ {
+ id,
+ file,
+ originalUrl: url,
+ originalMimeType: mimeType,
+ compress: true, // compress is ON by default for every staged image
+ converted: null,
+ },
+ ];
+ // Convert async in the background — staging is never blocked on it; the
+ // staged input stays the original until the converted URL arrives.
+ if (shouldConvertImage(mimeType)) {
+ void runConversion(id);
+ }
return true;
}
+ /**
+ * Run the (injected) compression edge for a staged image and cache the
+ * result. ANY failure (rejecting encoder included) resolves to no
+ * conversion → the original is sent; compression never breaks staging. If
+ * the image was removed while converting, the result is dropped.
+ */
+ async function runConversion(id: string): Promise<void> {
+ const staged = images.find((img) => img.id === id);
+ if (staged === undefined) return;
+ let result: ConvertedJpeg | null = null;
+ try {
+ result = await encodeJpeg(staged.file);
+ } catch {
+ result = null; // fall back to the original — never block or break sending
+ }
+ const entry = images.find((img) => img.id === id);
+ if (entry === undefined) return; // removed mid-conversion
+ entry.converted = result === null ? null : { url: await fileToDataUrl(result.blob) };
+ }
+
+ /** Flip the per-image compress toggle (reversible until send). */
+ function setCompress(id: string, compress: boolean): void {
+ const entry = images.find((img) => img.id === id);
+ if (entry !== undefined) entry.compress = compress;
+ }
+
function removeImage(id: string): void {
images = images.filter((img) => img.id !== id);
}
@@ -200,15 +285,15 @@
const trimmed = text.trim();
// Allow a send with images even when text is empty (an image-only turn).
if (trimmed.length === 0 && !hasImages) return;
- if (buttonMode === "queue") {
- // Steering is text-only — never forward images.
- onQueue?.(trimmed);
+ const toSend = hasImages ? images.map((img) => imageInput(img)) : undefined;
+ if (inFlight) {
+ if (onQueue === undefined) return; // Keep staged input if no queue port is wired.
+ onQueue(trimmed, toSend);
} else {
- const toSend: ImageInput[] | undefined = hasImages
- ? images.map((img) => img.input)
- : undefined;
onSend(trimmed, toSend);
}
+ // Clear only after handing the complete input to a callback. WS is still
+ // fire-and-forget: this does not imply backend storage has completed.
text = "";
images = [];
}
@@ -239,7 +324,7 @@
<!-- Top bar: expanding textarea + image-attach button + single context-aware button -->
<div class="flex items-end gap-2 px-4 pt-3 pb-2">
<div
- class="flex-1"
+ class="min-w-0 flex-1"
onpaste={handlePaste}
class:border-2={dragOver}
class:border-primary={dragOver}
@@ -293,10 +378,11 @@
<button
class="btn btn-error w-20 shrink-0"
type="button"
- aria-label="Stop generation"
+ aria-label={stopButton.ariaLabel}
+ title={stopButton.title}
onclick={() => onStop?.()}
>
- Stop
+ {stopButton.text}
</button>
{:else}
<button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!canSend}>
@@ -310,9 +396,10 @@
<div class="flex flex-wrap gap-2 px-4 pb-1">
{#each images as img (img.id)}
<div class="group relative h-20 w-20 shrink-0 overflow-hidden rounded border border-base-300">
+ <!-- Preview what will actually be sent (the converted JPEG when compress is on). -->
<img
- src={img.input.url}
- alt={img.input.mimeType ?? "staged image"}
+ src={imageInput(img).url}
+ alt={imageInput(img).mimeType ?? "staged image"}
class="h-full w-full object-cover"
/>
<button
@@ -335,6 +422,22 @@
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
+ <!-- Per-image compress toggle (convertible formats only; reversible until send). -->
+ {#if compressible(img)}
+ <label
+ class="absolute bottom-0 left-0 flex items-center gap-1 bg-base-100/80 px-1 py-0.5 text-[10px] leading-none"
+ title={COMPRESS_TOOLTIP}
+ >
+ <input
+ type="checkbox"
+ class="checkbox checkbox-xs"
+ aria-label="Compress image"
+ checked={img.compress}
+ onchange={(e) => setCompress(img.id, e.currentTarget.checked)}
+ />
+ <span class="whitespace-nowrap">Compress</span>
+ </label>
+ {/if}
</div>
{/each}
</div>
diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte
new file mode 100644
index 0000000..3ce34dd
--- /dev/null
+++ b/src/features/chat/ui/LazyCollapse.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import type { Snippet } from "svelte";
+
+ /**
+ * A daisyUI checkbox collapse whose BODY is rendered lazily: nothing inside
+ * `.collapse-content` is created until the user first opens it. A long turn
+ * has hundreds of collapsed tool/thinking cards, each holding a full JSON
+ * input / tool output / thought — building all of that DOM up front (only to
+ * be hidden) dominated first paint on slow devices. Once opened the body
+ * stays mounted, so the close animation and any inner scroll state work as
+ * before. Same markup + classes as the inline collapses it replaces.
+ */
+ let {
+ label,
+ titleClass = "",
+ title,
+ children,
+ }: {
+ /** aria-label of the toggle checkbox. */
+ label: string;
+ /** Extra classes on the `.collapse-title` row. */
+ titleClass?: string;
+ title: Snippet;
+ children: Snippet;
+ } = $props();
+
+ let open = $state(false);
+ let everOpened = $state(false);
+ $effect(() => {
+ if (open) everOpened = true;
+ });
+</script>
+
+<!-- `.inset-well` (app.css): the recessed panel shared by thinking/tool blocks
+ and the sidebar views. -->
+<div class="collapse inset-well w-full text-sm">
+ <input type="checkbox" aria-label={label} bind:checked={open} />
+ <div class="collapse-title flex min-h-0 min-w-0 items-center gap-2 py-2 whitespace-nowrap font-medium {titleClass}">
+ {@render title()}
+ </div>
+ <div class="collapse-content">
+ {#if open || everOpened}
+ {@render children()}
+ {/if}
+ </div>
+</div>
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index 11b9feb..a9b0cc9 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,12 +1,28 @@
<script lang="ts">
import type { ModelMetadata } from "@dispatch/transport-contract";
- import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+ import type { NamedAgent, NamedAgentSelectionSnapshot } from "../../named-agents";
+ import {
+ isNamedAgentSelection,
+ isVisionModel,
+ joinModelName,
+ modelKeyLabel,
+ modelsForKey,
+ NAMED_AGENTS_PROVIDER,
+ namedAgentNames,
+ namedAgentSelectionLabel,
+ providerKeysWithNamedAgents,
+ splitModelName,
+ } from "../model-select";
let {
models,
selected,
onSelect,
modelInfo = {},
+ namedAgents = [],
+ namedAgentSnapshot = null,
+ onSelectNamedAgent,
+ zeroProviders = false,
}: {
models: readonly string[];
selected: string;
@@ -18,75 +34,199 @@
* Optional — absent metadata → no badge (treated as non-vision).
*/
modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ /** The user-curated Named Agents (plan §6) offered under the synthetic provider. */
+ namedAgents?: readonly NamedAgent[];
+ /** The active conversation's Named Agent snapshot (null when not selected via one). */
+ namedAgentSnapshot?: NamedAgentSelectionSnapshot | null;
+ /** Called when a Named Agent is selected (the selector resolves it to a raw model + snapshot). */
+ onSelectNamedAgent?: (snapshot: NamedAgentSelectionSnapshot) => void;
+ /**
+ * First-boot guardrail (§7): true when the ACTIVE workspace has ZERO
+ * enabled providers (plan 06's `providers-status`) — renders a visible
+ * prompt above the picker instead of a silently empty one.
+ */
+ zeroProviders?: boolean;
} = $props();
- const keys = $derived(modelKeys(models));
+ // Whether the current selection is a Named Agent snapshot (the conversation was
+ // selected via one; its captured raw model equals `selected`).
+ const currentIsNamedAgent = $derived(isNamedAgentSelection(selected, namedAgentSnapshot));
+
+ const providerKeys = $derived(providerKeysWithNamedAgents(models));
const current = $derived(splitModelName(selected));
const keyModels = $derived(modelsForKey(models, current.key));
+ // The provider shown in the provider dropdown: the synthetic Named Agents
+ // provider when the selection is a Named Agent snapshot, else the raw key.
+ const currentProvider = $derived(
+ currentIsNamedAgent || current.key === NAMED_AGENTS_PROVIDER
+ ? NAMED_AGENTS_PROVIDER
+ : current.key,
+ );
+
+ const namedNames = $derived(namedAgentNames(namedAgents));
+
// Whether the currently-selected full model name is vision-capable.
const selectedVision = $derived(isVisionModel(modelInfo, selected));
- // Switching key jumps to the first model available under it.
- function selectKey(key: string): void {
+ // Whether the currently-selected raw model is PRESENT in the catalog (only
+ // meaningful for a raw selection — a Named Agent snapshot resolves to a raw
+ // model that may have left the catalog).
+ const rawMissing = $derived(
+ !currentIsNamedAgent && selected !== "" && !models.includes(selected),
+ );
+ const keyUnavailable = $derived(
+ !currentIsNamedAgent && selected !== "" && !providerKeys.includes(current.key),
+ );
+
+ // Switching provider. Going TO the Named Agents provider: jump to the first
+ // Named Agent (mirrors selectKey jumping to the first model); the model
+ // dropdown then lists named names. Going to a raw provider: existing behavior.
+ function selectProvider(key: string): void {
+ if (key === currentProvider) return;
+ if (key === NAMED_AGENTS_PROVIDER) {
+ selectFirstNamedAgent();
+ return;
+ }
const first = modelsForKey(models, key)[0] ?? "";
onSelect(joinModelName(key, first));
}
- function selectModel(model: string): void {
+ // Selecting a raw model suffix under the current raw provider.
+ function selectRawModel(model: string): void {
onSelect(joinModelName(current.key, model));
}
- // The full `<key>/<model>` name for a model suffix under the current key.
+ // Selecting a Named Agent name (under the synthetic provider): resolve it to a
+ // snapshot + raw model and report via onSelectNamedAgent.
+ function selectNamedAgent(name: string): void {
+ const agent = namedAgents.find((a) => a.name === name);
+ if (agent === undefined) return;
+ onSelectNamedAgent?.({
+ namedAgentId: agent.id,
+ namedAgentName: agent.name,
+ model: agent.model,
+ });
+ }
+
+ // Jump to the first configured Named Agent (used when the provider is switched
+ // to Named Agents), matching the raw-provider "jump to first model" behavior.
+ function selectFirstNamedAgent(): void {
+ const first = namedAgents[0];
+ if (first === undefined) return; // no named agents — the model dropdown shows the disabled placeholder
+ onSelectNamedAgent?.({
+ namedAgentId: first.id,
+ namedAgentName: first.name,
+ model: first.model,
+ });
+ }
+
+ // The full `<provider>/<model>` name for a raw model suffix under `current.key`.
function fullNameFor(modelSuffix: string): string {
return joinModelName(current.key, modelSuffix);
}
</script>
<div class="flex flex-col gap-2">
+ {#if zeroProviders}
+ <!-- First-boot guardrail (§7): a workspace with zero enabled providers
+ shows a visible prompt instead of a silently empty picker. -->
+ <div class="alert alert-warning text-sm" role="alert">
+ <span>
+ No providers enabled for this workspace — open the workspace <strong>Plugins</strong> to
+ enable one.
+ </span>
+ </div>
+ {/if}
<select
class="select w-full"
- value={current.key}
- onchange={(e) => selectKey(e.currentTarget.value)}
+ value={currentProvider}
+ onchange={(e) => selectProvider(e.currentTarget.value)}
aria-label="Key selector"
>
- {#each keys as key (key)}
- <option value={key}>{key}</option>
+ {#if keyUnavailable && currentProvider !== NAMED_AGENTS_PROVIDER}
+ <option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option>
+ {/if}
+ {#each providerKeys as key (key)}
+ <option value={key}>
+ {key === NAMED_AGENTS_PROVIDER ? "Named Agents" : modelKeyLabel(key)}
+ </option>
{/each}
</select>
+
<select
class="select w-full"
- value={current.model}
- onchange={(e) => selectModel(e.currentTarget.value)}
+ value={currentIsNamedAgent ? (namedAgentSnapshot?.namedAgentName ?? "") : current.model}
+ onchange={
+ currentIsNamedAgent
+ ? (e) => selectNamedAgent(e.currentTarget.value)
+ : (e) => selectRawModel(e.currentTarget.value)
+ }
aria-label="Model selector"
+ disabled={currentProvider === NAMED_AGENTS_PROVIDER && namedAgents.length === 0}
>
- {#each keyModels as model (model)}
- <option value={model}>
- {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
- </option>
- {/each}
+ {#if currentProvider === NAMED_AGENTS_PROVIDER}
+ {#if namedAgents.length === 0 && !currentIsNamedAgent}
+ <option value="" disabled>No named agents configured</option>
+ {:else}
+ {#each namedNames as name (name)}
+ <option value={name}>{namedAgentSelectionLabel(name)}</option>
+ {/each}
+ {#if currentIsNamedAgent && !namedNames.includes(namedAgentSnapshot?.namedAgentName ?? "")}
+ <!-- The selected Named Agent was renamed/removed — keep showing the
+ captured snapshot name rather than silently switching (plan §7). -->
+ <option value={namedAgentSnapshot?.namedAgentName ?? ""} disabled>
+ {namedAgentSelectionLabel(namedAgentSnapshot?.namedAgentName ?? "")} (unavailable)
+ </option>
+ {/if}
+ {/if}
+ {:else}
+ {#each keyModels as model (model)}
+ <option value={model}>
+ {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
+ </option>
+ {/each}
+ {#if rawMissing && !keyUnavailable}
+ <option value={current.model} disabled>{current.model} (unavailable)</option>
+ {/if}
+ {/if}
</select>
- {#if selectedVision}
- <div class="flex items-center gap-1 text-xs text-base-content/60">
- <svg
- 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"
- class="h-3.5 w-3.5"
- aria-hidden="true"
- >
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
- <circle cx="12" cy="12" r="3"></circle>
- </svg>
- <span>Vision — this model sees images natively</span>
+
+ {#if rawMissing}
+ <div class="text-xs text-warning" role="status">
+ Selected model “{selected}” is unavailable — choose another credential.
</div>
- {:else}
+ {/if}
+
+ {#if currentIsNamedAgent}
<div class="text-xs text-base-content/40">
- Pasted images are auto-described (vision handoff)
+ Selected via <strong>Named Agents</strong> — snapshot of {namedAgentSnapshot?.namedAgentName}.
</div>
{/if}
-</div>
+
+ {#if !currentIsNamedAgent}
+ {#if selectedVision}
+ <div class="flex items-center gap-1 text-xs text-base-content/60">
+ <svg
+ 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"
+ class="h-3.5 w-3.5"
+ aria-hidden="true"
+ >
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
+ <circle cx="12" cy="12" r="3"></circle>
+ </svg>
+ <span>Vision — this model sees images natively</span>
+ </div>
+ {:else}
+ <div class="text-xs text-base-content/40">
+ Pasted images are auto-described (vision handoff)
+ </div>
+ {/if}
+ {/if}
+</div> \ No newline at end of file
diff --git a/src/features/chat/ui/PendingInputWarning.svelte b/src/features/chat/ui/PendingInputWarning.svelte
new file mode 100644
index 0000000..c9e675e
--- /dev/null
+++ b/src/features/chat/ui/PendingInputWarning.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PendingInput } from "../pending-inputs";
+
+ let {
+ input,
+ onDiscard,
+ }: {
+ input: PendingInput;
+ onDiscard: (requestId: string) => void;
+ } = $props();
+
+ let visible = $state(false);
+
+ // Presentation only: each keyed row gets one grace period, including restored
+ // or rejected inputs. Prop updates never restart it. Confirmation/discard
+ // removes the row, cancelling the timer along with any other unmount.
+ onMount(() => {
+ const timer = setTimeout(() => {
+ visible = true;
+ }, 1000);
+ return () => clearTimeout(timer);
+ });
+</script>
+
+{#if visible}
+ <div class="m-3 rounded border border-warning p-3 text-sm" role="status">
+ <p>
+ {input.rejection
+ ? `Message rejected: ${input.rejection}`
+ : "Delivery unconfirmed. Your input is saved on this device."}
+ </p>
+ <p>Check the conversation history before sending again to avoid a duplicate.</p>
+ <textarea
+ class="textarea textarea-bordered mt-2 w-full"
+ aria-label="Saved message"
+ readonly
+ value={input.request.type === "chat.send" ? input.request.message : input.request.text}
+ ></textarea>
+ {#each input.request.images ?? [] as image}
+ <img class="my-2 max-h-32" src={image.url} alt="Saved attachment" />
+ {/each}
+ <button class="btn btn-sm mt-2" onclick={() => onDiscard(input.request.requestId)}
+ >Discard saved copy</button
+ >
+ </div>
+{/if}
diff --git a/src/features/chat/ui/PendingInputs.svelte b/src/features/chat/ui/PendingInputs.svelte
new file mode 100644
index 0000000..dcb7aee
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+ import type { PendingInput } from "../pending-inputs";
+ import PendingInputWarning from "./PendingInputWarning.svelte";
+
+ let {
+ inputs,
+ onDiscard,
+ }: {
+ inputs: readonly PendingInput[];
+ onDiscard: (requestId: string) => void;
+ } = $props();
+</script>
+
+{#each inputs as input (input.request.requestId)}
+ <PendingInputWarning {input} {onDiscard} />
+{/each}
diff --git a/src/features/chat/ui/PendingInputs.test.ts b/src/features/chat/ui/PendingInputs.test.ts
new file mode 100644
index 0000000..cea770d
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.test.ts
@@ -0,0 +1,217 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PendingInput } from "../pending-inputs";
+import { createChatStore } from "../store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "../test-helpers";
+import PendingInputs from "./PendingInputs.svelte";
+
+function input(requestId: string, type: "chat.send" | "chat.queue" = "chat.send"): PendingInput {
+ return {
+ request: {
+ ...(type === "chat.send" ? { type, message: requestId } : { type, text: requestId }),
+ conversationId: "c",
+ requestId,
+ images: [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }],
+ },
+ };
+}
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ cleanup();
+ const remaining = vi.getTimerCount();
+ vi.useRealTimers();
+ expect(remaining).toBe(0);
+});
+
+describe("pending input warning grace period", () => {
+ it.each([
+ "chat.send",
+ "chat.queue",
+ ] as const)("renders no %s recovery UI before 1000 ms, then keeps its content stable", async (type) => {
+ const { container } = render(PendingInputs, {
+ inputs: [input("saved message", type)],
+ onDiscard: vi.fn(),
+ });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(999);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Delivery unconfirmed. Your input is saved on this device.");
+ expect(warning).toHaveTextContent(
+ "Check the conversation history before sending again to avoid a duplicate.",
+ );
+ expect(screen.getByRole("textbox", { name: "Saved message" })).toHaveValue("saved message");
+ expect(screen.getByRole("img", { name: "Saved attachment" })).toHaveAttribute(
+ "src",
+ "data:image/png;base64,AAAA",
+ );
+ const content = warning.innerHTML;
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning.innerHTML).toBe(content);
+ });
+
+ it.each([
+ "confirmation",
+ "discard",
+ ])("%s before the threshold cancels the timer and never flashes later", async (resolution) => {
+ const transport = createFakeTransport();
+ const save = vi.fn();
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => "first",
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ pendingInputStorage: { load: () => [], save },
+ });
+ try {
+ store.send("first");
+ // Saving and transmission stay immediate, independent of warning display.
+ expect(save).toHaveBeenCalledExactlyOnceWith(store.pendingInputs);
+ expect(transport.sent).toHaveLength(1);
+ const { container, rerender } = render(PendingInputs, {
+ inputs: store.pendingInputs,
+ onDiscard: store.discardPendingInput,
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ if (resolution === "confirmation") {
+ store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "first" });
+ } else {
+ store.discardPendingInput("first");
+ }
+ expect(store.pendingInputs).toEqual([]);
+ expect(save).toHaveBeenLastCalledWith([]);
+ await rerender({ inputs: store.pendingInputs });
+ expect(vi.getTimerCount()).toBe(0);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("hides an already visible warning immediately on confirmation", async () => {
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("preserves the discard action and hides the saved copy without another delay", async () => {
+ const onDiscard = vi.fn();
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard,
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ await fireEvent.click(screen.getByRole("button", { name: "Discard saved copy" }));
+ expect(onDiscard).toHaveBeenCalledExactlyOnceWith("first");
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("cancels all waiting timers on unmount", async () => {
+ const { unmount } = render(PendingInputs, {
+ inputs: [input("first"), input("second")],
+ onDiscard: vi.fn(),
+ });
+ expect(vi.getTimerCount()).toBe(2);
+ await vi.advanceTimersByTimeAsync(500);
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("gives successive inputs independent delays without restarting existing rows", async () => {
+ const first = input("first");
+ const second = input("second");
+ const { rerender } = render(PendingInputs, { inputs: [first], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [second, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(screen.getByRole("textbox")).toHaveValue("first");
+ await rerender({ inputs: [{ ...second }, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(599);
+ expect(screen.getAllByRole("status")).toEqual([warning]);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(
+ screen.getAllByRole("textbox").map((node) => (node as HTMLTextAreaElement).value),
+ ).toEqual(["second", "first"]);
+ // Removing the older warning must not re-hide/restart the newer warning.
+ await rerender({ inputs: [second] });
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("starts a fresh delay after an earlier input was confirmed during its grace period", async () => {
+ const { rerender } = render(PendingInputs, { inputs: [input("first")], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [] });
+ await rerender({ inputs: [input("second")] });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ });
+
+ it("keeps the original deadline when rejection arrives, and updates visible content immediately", async () => {
+ const pending = input("first");
+ const { rerender } = render(PendingInputs, { inputs: [pending], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [{ ...pending, rejection: "disk full" }] });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Message rejected: disk full");
+ expect(warning).not.toHaveTextContent("Delivery unconfirmed");
+ await rerender({ inputs: [{ ...pending, rejection: "still rejected" }] });
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning).toHaveTextContent("Message rejected: still rejected");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it.each([
+ undefined,
+ "disk full",
+ ])("restored entries (rejection: %s) use the same grace period and retain recovery content", async (rejection) => {
+ const restored = input("restored saved message");
+ render(PendingInputs, {
+ inputs: [rejection === undefined ? restored : { ...restored, rejection }],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("status")).toHaveTextContent(
+ rejection === undefined ? "Delivery unconfirmed" : "Message rejected: disk full",
+ );
+ expect(screen.getByRole("textbox")).toHaveValue("restored saved message");
+ expect(screen.getByRole("button", { name: "Discard saved copy" })).toBeEnabled();
+ });
+});
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
new file mode 100644
index 0000000..10f8528
--- /dev/null
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -0,0 +1,274 @@
+<script lang="ts">
+ import type { ToolEntry } from "../index";
+ import { viewToolEntry } from "../logic/tool-summary";
+ import LazyCollapse from "./LazyCollapse.svelte";
+ import { TOOL_ICON_COMPONENTS } from "./tool-icons";
+
+ let { entry }: { entry: ToolEntry } = $props();
+ const card = $derived(viewToolEntry(entry));
+ const ToolIcon = $derived(TOOL_ICON_COMPONENTS[card.icon]);
+ let showAll = $state(false);
+</script>
+
+<div class="tool-card">
+ <LazyCollapse label={`Toggle tool call ${card.label}`} titleClass="pe-4">
+ {#snippet title()}
+ <div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}>
+ {#if card.status === "pending"}
+ <!-- Same in-progress animation as the streaming thinking block. All three
+ status icons share a fixed w-5 slot so the row never shifts on settle. -->
+ <span class="loading loading-dots loading-sm w-5 shrink-0" aria-label="Running"></span>
+ {:else if card.status === "error"}
+ <span class="w-5 shrink-0 text-center text-error" aria-label="Failed">✗</span>
+ {:else}
+ <span
+ class="w-5 shrink-0 text-center text-success"
+ class:opacity-60={card.callDetail.kind === "unavailable"}
+ aria-label="Succeeded">✓</span
+ >
+ {/if}
+ <!-- Per-tool Phosphor icon (key chosen by the pure view-model). -->
+ <ToolIcon size={16} class="shrink-0 opacity-70" aria-hidden="true" />
+ <span class="tool-name truncate" title={card.name}>{card.label}</span>
+ <span
+ class="tool-summary truncate font-normal text-base-content/60"
+ 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>
+ {: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">
+ {#if card.callDetail.kind === "unavailable"}
+ <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 === "none"}
+ <p class="italic text-base-content/60">No arguments</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>
+ <!-- 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 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>
+ <dd class="min-w-0">
+ <pre
+ class="overflow-x-auto"
+ class:whitespace-pre-wrap={!field.multiline}
+ class:break-words={!field.multiline}>{field.value}</pre>
+ </dd>
+ {/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}
+ {#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-soft btn-xs focus-visible:outline-primary"
+ aria-expanded={showAll}
+ onclick={() => {
+ showAll = !showAll;
+ }}
+ >
+ {#if showAll}<span aria-hidden="true">↑</span>{/if}
+ {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}
+ </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;
+ }
+ /* English names are short phrases: keep them whole and let the call
+ summary absorb the squeeze. The cap only bites on long MCP names. */
+ .tool-name {
+ flex: 0 0 auto;
+ max-width: 60%;
+ }
+ .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;
+ }
+ .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);
+ /* Same fill as the LazyCollapse inset well this sits in (opaque, so
+ scrolled output never shows through the sticky bar). */
+ background: var(--inset-well-bg, 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;
+ }
+ .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>
diff --git a/src/features/chat/ui/tool-icons.ts b/src/features/chat/ui/tool-icons.ts
new file mode 100644
index 0000000..1d68ebe
--- /dev/null
+++ b/src/features/chat/ui/tool-icons.ts
@@ -0,0 +1,52 @@
+// Phosphor icon per tool-icon key (the pure view-model picks the KEY — see
+// `logic/tool-summary.ts` `toolIcon`; this is the only place that knows the
+// concrete components). Deep imports (`phosphor-svelte/lib/<Name>Icon`) on
+// purpose: the package root re-exports ~1,500 components, and Svelte libraries
+// are not prebundled in dev, so a root import would load every one of them.
+
+import ArrowSquareOutIcon from "phosphor-svelte/lib/ArrowSquareOutIcon";
+import BookOpenIcon from "phosphor-svelte/lib/BookOpenIcon";
+import BracketsCurlyIcon from "phosphor-svelte/lib/BracketsCurlyIcon";
+import ChatCircleTextIcon from "phosphor-svelte/lib/ChatCircleTextIcon";
+import CpuIcon from "phosphor-svelte/lib/CpuIcon";
+import EyeIcon from "phosphor-svelte/lib/EyeIcon";
+import FileTextIcon from "phosphor-svelte/lib/FileTextIcon";
+import FloppyDiskIcon from "phosphor-svelte/lib/FloppyDiskIcon";
+import ImageIcon from "phosphor-svelte/lib/ImageIcon";
+import ListChecksIcon from "phosphor-svelte/lib/ListChecksIcon";
+import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlassIcon";
+import PaperPlaneTiltIcon from "phosphor-svelte/lib/PaperPlaneTiltIcon";
+import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon";
+import ProhibitIcon from "phosphor-svelte/lib/ProhibitIcon";
+import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon";
+import RobotIcon from "phosphor-svelte/lib/RobotIcon";
+import TerminalWindowIcon from "phosphor-svelte/lib/TerminalWindowIcon";
+import UsersThreeIcon from "phosphor-svelte/lib/UsersThreeIcon";
+import WrenchIcon from "phosphor-svelte/lib/WrenchIcon";
+import YoutubeLogoIcon from "phosphor-svelte/lib/YoutubeLogoIcon";
+import type { Component } from "svelte";
+import type { ToolIconKey } from "../logic/tool-summary";
+
+// biome-ignore lint/suspicious/noExplicitAny: Phosphor's IconComponentProps is internal; any icon accepts size/class.
+export const TOOL_ICON_COMPONENTS: Readonly<Record<ToolIconKey, Component<any>>> = {
+ terminal: TerminalWindowIcon,
+ "file-read": FileTextIcon,
+ "file-edit": PencilSimpleIcon,
+ "file-write": FloppyDiskIcon,
+ search: MagnifyingGlassIcon,
+ code: BracketsCurlyIcon,
+ todos: ListChecksIcon,
+ skill: BookOpenIcon,
+ vision: EyeIcon,
+ image: ImageIcon,
+ youtube: YoutubeLogoIcon,
+ "agent-summon": RobotIcon,
+ "agent-send": PaperPlaneTiltIcon,
+ "agent-read": ChatCircleTextIcon,
+ "agent-open": ArrowSquareOutIcon,
+ "agent-cancel": ProhibitIcon,
+ agents: UsersThreeIcon,
+ models: CpuIcon,
+ mcp: PuzzlePieceIcon,
+ tool: WrenchIcon,
+};