summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 20:11:33 +0900
committerAdam Malczewski <[email protected]>2026-10-21 20:11:33 +0900
commit564b2c1d03cf477d7cfc23414b60d1ec76904ecc (patch)
tree16fc1956bb6cd7b83894104b24b9f02acd0751ec /src
parent7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a (diff)
downloaddispatch-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')
-rw-r--r--src/app/App.test.ts8
-rw-r--r--src/features/chat/logic/composer.test.ts6
-rw-r--r--src/features/chat/logic/composer.ts4
-rw-r--r--src/features/chat/ui.test.ts10
-rw-r--r--src/features/chat/ui/PendingInputWarning.svelte47
-rw-r--r--src/features/chat/ui/PendingInputs.svelte22
-rw-r--r--src/features/chat/ui/PendingInputs.test.ts217
7 files changed, 280 insertions, 34 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 42d30ed..9c5f5cc 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -482,7 +482,7 @@ describe("App component interaction tests", () => {
store.dispose();
});
- it("uses only the focused conversation's queue snapshot for the interrupt label and POSTs stop", async () => {
+ it("uses only the focused conversation's queue snapshot for the Flush label and POSTs stop", async () => {
const ws = fakeSocket();
const fetchImpl = vi.fn(fakeFetchImpl());
const store = createAppStore({
@@ -523,10 +523,10 @@ describe("App component interaction tests", () => {
});
const pending = { messages: [{ id: "m1", text: "next", queuedAt: 1 }] };
pushQueue(firstId, pending);
- const interrupt = await screen.findByRole("button", { name: "Interrupt & send queued" });
+ const flush = await screen.findByRole("button", { name: "Flush" });
ws.sent.length = 0;
fetchImpl.mockClear();
- await user.click(interrupt);
+ await user.click(flush);
expect(fetchImpl.mock.calls.filter(([, init]) => init?.method === "POST")).toEqual([
[expect.stringContaining(`/conversations/${firstId}/stop`), { method: "POST" }],
]);
@@ -544,7 +544,7 @@ describe("App component interaction tests", () => {
expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
);
pushQueue(secondId, pending);
- await screen.findByRole("button", { name: "Interrupt & send queued" });
+ await screen.findByRole("button", { name: "Flush" });
pushQueue(secondId, { messages: [] });
await screen.findByRole("button", { name: "Stop generation" });
pushQueue(secondId, { messages: "malformed" });
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();
+ });
+});