diff options
| author | Adam Malczewski <[email protected]> | 2026-10-24 16:55:38 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-24 16:55:38 +0900 |
| commit | 83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch) | |
| tree | ebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/chat/ui | |
| parent | a4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff) | |
| parent | c8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff) | |
| download | dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.tar.gz dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.zip | |
Merge local dev into feature/close-tab-cascade-subagents
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 | 109 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.test.ts | 61 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 47 | ||||
| -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 |
7 files changed, 536 insertions, 31 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 18fea7c..c7f566a 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -2,7 +2,9 @@ import type { TurnProviderRetryEvent } from "@dispatch/wire"; import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; import { + type CacheTimerState, interleaveTurnMetrics, + lastTurnRowIndex, viewCacheRate, viewExpectedCache, viewStepMetrics, @@ -10,6 +12,7 @@ type TurnMetricsEntry, } from "../../../core/metrics"; import { Markdown } from "../../markdown"; + import CacheTimerBadge from "./CacheTimerBadge.svelte"; import LazyCollapse from "./LazyCollapse.svelte"; import ToolCard from "./ToolCard.svelte"; @@ -21,14 +24,18 @@ let { chunks, + recovering = false, turnMetrics = [], hasEarlier = false, onShowEarlier, thinkingKeyBase = 0, providerRetry = null, apiBaseUrl = "", + cwd = null, + cacheTimer = null, }: { chunks: readonly RenderedChunk[]; + recovering?: boolean; turnMetrics?: readonly TurnMetricsEntry[]; /** Earlier history is unloaded (chat limit) and can be paged back in. */ hasEarlier?: boolean; @@ -55,10 +62,25 @@ * "" (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; + /** + * 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; @@ -70,10 +92,35 @@ } } - 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 @@ -105,6 +152,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 @@ -117,13 +170,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> @@ -150,7 +212,11 @@ <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 }} + /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} @@ -180,13 +246,14 @@ </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="flex flex-wrap items-center gap-x-1 text-xs"> @@ -203,6 +270,9 @@ 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> @@ -211,6 +281,8 @@ {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} {@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"> @@ -236,6 +308,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> @@ -252,6 +329,18 @@ {/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 index 77f832d..a6bd8ac 100644 --- a/src/features/chat/ui/ChatView.test.ts +++ b/src/features/chat/ui/ChatView.test.ts @@ -1,5 +1,5 @@ import type { StepId } from "@dispatch/wire"; -import { render, screen } from "@testing-library/svelte"; +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"; @@ -497,3 +497,62 @@ it("renders a per-tool Phosphor icon in the header", () => { // 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 c7d9d02..a43ed53 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,6 +1,7 @@ <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"; @@ -59,6 +60,7 @@ contextSize = undefined, contextWindow = undefined, status = "idle", + queuedMessageCount = undefined, }: { /** * Send a message (start a turn via `chat.send`). Carries any staged images @@ -70,10 +72,10 @@ /** * 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; /** @@ -95,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"; @@ -107,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…" @@ -283,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) => imageInput(img)) - : 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 = []; } @@ -322,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} @@ -374,12 +376,17 @@ {#if buttonMode === "stop"} <button - class="btn btn-error w-20 shrink-0" + class="btn btn-error shrink-0" + class:w-20={stopButton.text === "Stop"} + class:w-36={stopButton.text !== "Stop"} + class:whitespace-normal={stopButton.text !== "Stop"} + class:leading-tight={stopButton.text !== "Stop"} 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}> 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(); + }); +}); |
