diff options
Diffstat (limited to 'src/features/chat/ui')
| -rw-r--r-- | src/features/chat/ui/CacheTimerBadge.svelte | 70 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 277 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.test.ts | 558 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 155 | ||||
| -rw-r--r-- | src/features/chat/ui/LazyCollapse.svelte | 46 | ||||
| -rw-r--r-- | src/features/chat/ui/ModelSelector.svelte | 214 | ||||
| -rw-r--r-- | src/features/chat/ui/PendingInputWarning.svelte | 47 | ||||
| -rw-r--r-- | src/features/chat/ui/PendingInputs.svelte | 16 | ||||
| -rw-r--r-- | src/features/chat/ui/PendingInputs.test.ts | 217 | ||||
| -rw-r--r-- | src/features/chat/ui/ToolCard.svelte | 274 | ||||
| -rw-r--r-- | src/features/chat/ui/tool-icons.ts | 52 |
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 (``) 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, +}; |
