diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 20:11:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 20:11:33 +0900 |
| commit | 564b2c1d03cf477d7cfc23414b60d1ec76904ecc (patch) | |
| tree | 16fc1956bb6cd7b83894104b24b9f02acd0751ec /src/features | |
| parent | 7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a (diff) | |
| download | dispatch-web-564b2c1d03cf477d7cfc23414b60d1ec76904ecc.tar.gz dispatch-web-564b2c1d03cf477d7cfc23414b60d1ec76904ecc.zip | |
fix(chat): delay delivery warning and shorten queued action label
Wait one second before displaying pending-input recovery UI, cancelling on confirmation or removal. Label the queued-message stop action Flush without changing behavior. Verified: 2451 frontend tests passed twice, typecheck, Biome, and build.
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/chat/logic/composer.test.ts | 6 | ||||
| -rw-r--r-- | src/features/chat/logic/composer.ts | 4 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 10 | ||||
| -rw-r--r-- | src/features/chat/ui/PendingInputWarning.svelte | 47 | ||||
| -rw-r--r-- | src/features/chat/ui/PendingInputs.svelte | 22 | ||||
| -rw-r--r-- | src/features/chat/ui/PendingInputs.test.ts | 217 |
6 files changed, 276 insertions, 30 deletions
diff --git a/src/features/chat/logic/composer.test.ts b/src/features/chat/logic/composer.test.ts index 6ef5eab..86e9dc4 100644 --- a/src/features/chat/logic/composer.test.ts +++ b/src/features/chat/logic/composer.test.ts @@ -10,10 +10,10 @@ describe("stopButtonLabels", () => { }); }); - it.each([1, 2])("describes interrupt-and-send with queued count %s", (count) => { + it.each([1, 2])("labels the queued Stop action Flush with queued count %s", (count) => { expect(stopButtonLabels(count)).toEqual({ - text: "Interrupt & send queued", - ariaLabel: "Interrupt & send queued", + text: "Flush", + ariaLabel: "Flush", title: "Stop the current response and start the queued message now", }); }); diff --git a/src/features/chat/logic/composer.ts b/src/features/chat/logic/composer.ts index 1ea8173..e56a007 100644 --- a/src/features/chat/logic/composer.ts +++ b/src/features/chat/logic/composer.ts @@ -2,8 +2,8 @@ export function stopButtonLabels(queuedMessageCount: number | undefined) { if (queuedMessageCount !== undefined && queuedMessageCount >= 1) { return { - text: "Interrupt & send queued", - ariaLabel: "Interrupt & send queued", + text: "Flush", + ariaLabel: "Flush", title: "Stop the current response and start the queued message now", }; } diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 480d593..74c0619 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -871,14 +871,14 @@ describe("Composer", () => { for (const queuedMessageCount of [1, 2]) { await rerender({ queuedMessageCount }); - const button = screen.getByRole("button", { name: "Interrupt & send queued" }); - expect(button).toHaveTextContent(/^Interrupt & send queued$/); + const button = screen.getByRole("button", { name: "Flush" }); + expect(button).toHaveTextContent(/^Flush$/); expect(button).toHaveAttribute( "title", "Stop the current response and start the queued message now", ); } - await user.click(screen.getByRole("button", { name: "Interrupt & send queued" })); + await user.click(screen.getByRole("button", { name: "Flush" })); expect(onStop).toHaveBeenCalledTimes(2); expect(onSend).not.toHaveBeenCalled(); expect(onQueue).not.toHaveBeenCalled(); @@ -886,10 +886,10 @@ describe("Composer", () => { const input = screen.getByRole("textbox", { name: "Message input" }); await user.type(input, "next"); expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Interrupt & send queued" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Flush" })).toBeNull(); await user.click(screen.getByRole("button", { name: "Queue" })); expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined); - expect(screen.getByRole("button", { name: "Interrupt & send queued" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Flush" })).toBeInTheDocument(); await rerender({ queuedMessageCount: 0 }); expectPlainStop(); // Queue drained or cancelled. await rerender({ queuedMessageCount: 1, status: "idle" }); 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 index cbaf35f..dcb7aee 100644 --- a/src/features/chat/ui/PendingInputs.svelte +++ b/src/features/chat/ui/PendingInputs.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import type { PendingInput } from "../pending-inputs"; + import PendingInputWarning from "./PendingInputWarning.svelte"; let { inputs, @@ -11,24 +12,5 @@ </script> {#each inputs as input (input.request.requestId)} - <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> + <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(); + }); +}); |
