summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/ui/MessageQueueList.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-29 12:12:18 +0900
committerAdam Malczewski <[email protected]>2026-06-29 12:12:18 +0900
commit129bb3be45e1446ce4219d7f656ed0ed6f93a29f (patch)
tree5fe54f4bc10c90116a8b0dd5f140c5585b55120c /src/features/surface-host/ui/MessageQueueList.test.ts
parent1cdb866fbb708a1f6c5e802a075789cece85800d (diff)
downloaddispatch-web-129bb3be45e1446ce4219d7f656ed0ed6f93a29f.tar.gz
dispatch-web-129bb3be45e1446ce4219d7f656ed0ed6f93a29f.zip
feat(chat): cancel a queued steering message from the UI
Implements the frontend for the cancel-queued-message backend feature (transport-contract 0.23.0 → 0.24.0, ADDITIVE). While a turn is generating and a user message is queued (awaiting steering delivery), the user can now cancel a single queued message by id so it never runs. - Consume the contract: regenerate the .dispatch/transport-contract.reference.md mirror to 0.24.0; add chat.queue.cancel to the exhaustive WsClientMessage guard (core/wire/conformance.ts) + its test. - ChatTransport port accepts ChatQueueCancelMessage; cancelQueuedMessage(id) on the chat store + app store sends chat.queue.cancel { conversationId, messageId } (fire-and-forget, idempotent — the server no-ops an already- drained/unknown id). - UI: a × cancel affordance per queued row in MessageQueueList.svelte, threaded through SurfaceView's onCancelQueuedMessage (dispatched on rendererId, never the surface id) and wired to store.cancelQueuedMessage. Optimistic removal (pure selectVisibleMessages/reconcileCancelledIds in logic/message-queue.ts) hides the row on click and reconciles from the message-queue surface's post-cancel snapshot. No new event handling — the existing surface subscription reflects the result. Tests: +12 (chat store cancel op ×3, optimistic-removal logic ×11 already existed pattern, MessageQueueList component cancel behavior ×9). Repo fix: package.json + bun.lock were still pinning file:../dispatch-backend/... (stale from the dispatch-backend → backend rename) — corrected to file:../backend/packages/...; bun install now resolves natively with no worktree symlink hack. typecheck 0/0, 1140 tests green (run twice), biome clean, build OK.
Diffstat (limited to 'src/features/surface-host/ui/MessageQueueList.test.ts')
-rw-r--r--src/features/surface-host/ui/MessageQueueList.test.ts135
1 files changed, 135 insertions, 0 deletions
diff --git a/src/features/surface-host/ui/MessageQueueList.test.ts b/src/features/surface-host/ui/MessageQueueList.test.ts
new file mode 100644
index 0000000..53044b3
--- /dev/null
+++ b/src/features/surface-host/ui/MessageQueueList.test.ts
@@ -0,0 +1,135 @@
+import type { QueuedMessage } from "@dispatch/wire";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import MessageQueueList from "./MessageQueueList.svelte";
+
+function msg(id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage {
+ return { id, text, queuedAt };
+}
+
+/** Build the message-queue surface field payload. */
+function payload(messages: readonly QueuedMessage[]): { messages: readonly QueuedMessage[] } {
+ return { messages };
+}
+
+describe("MessageQueueList", () => {
+ it("renders each queued message's text", () => {
+ render(MessageQueueList, {
+ props: {
+ payload: payload([msg("m1", "steer left"), msg("m2", "go right")]),
+ onCancel: undefined,
+ },
+ });
+ expect(screen.getByText("steer left")).toBeInTheDocument();
+ expect(screen.getByText("go right")).toBeInTheDocument();
+ });
+
+ it("renders nothing when the queue is empty", () => {
+ const { container } = render(MessageQueueList, {
+ props: { payload: payload([]), onCancel: undefined },
+ });
+ expect(container.querySelector("ul")).toBeNull();
+ });
+
+ it("renders nothing for a malformed payload (graceful skip)", () => {
+ const { container } = render(MessageQueueList, {
+ props: { payload: { nope: true }, onCancel: undefined },
+ });
+ expect(container.querySelector("ul")).toBeNull();
+ });
+
+ it("omits the cancel button when no onCancel is wired (read-only)", () => {
+ render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "steer")]), onCancel: undefined },
+ });
+ expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull();
+ });
+
+ it("renders a cancel button per row when onCancel is wired", () => {
+ render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel: vi.fn() },
+ });
+ expect(screen.getAllByRole("button", { name: /cancel/i })).toHaveLength(2);
+ });
+
+ it("clicking cancel fires onCancel with the row's message id and optimistically hides the row", async () => {
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "keep me"), msg("m2", "cancel me")]), onCancel },
+ });
+
+ // Both rows visible before click.
+ expect(screen.getByText("keep me")).toBeInTheDocument();
+ expect(screen.getByText("cancel me")).toBeInTheDocument();
+
+ const buttons = screen.getAllByRole("button", { name: /cancel/i });
+ // Cancel the SECOND row (m2). Buttons mirror row order.
+ const cancelM2 = buttons[1];
+ if (cancelM2 === undefined) throw new Error("expected two cancel buttons");
+ await user.click(cancelM2);
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(onCancel).toHaveBeenCalledWith("m2");
+ // m2 is optimistically hidden immediately; m1 remains.
+ expect(screen.getByText("keep me")).toBeInTheDocument();
+ expect(screen.queryByText("cancel me")).toBeNull();
+ });
+
+ it("does not fire onCancel twice for a double-click on the same row (idempotent client-side)", async () => {
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "x")]), onCancel },
+ });
+
+ const button = screen.getByRole("button", { name: /cancel/i });
+ await user.click(button);
+ // The row is gone after the first click; the button left the DOM, so a
+ // second click on the stale element is a no-op — onCancel fires once.
+ await user.click(button).catch(() => {});
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("reconciles from the surface: a cancelled id gone from the snapshot clears the optimistic hide", async () => {
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ const { rerender } = render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel },
+ });
+
+ // Cancel m1 — it hides optimistically.
+ const cancelButtons = screen.getAllByRole("button", { name: /cancel/i });
+ const cancelM1 = cancelButtons[0];
+ if (cancelM1 === undefined) throw new Error("expected a cancel button");
+ await user.click(cancelM1);
+ expect(screen.queryByText("a")).toBeNull();
+ expect(screen.getByText("b")).toBeInTheDocument();
+
+ // The surface pushes the post-cancel snapshot: m1 is gone (server confirmed).
+ // A NEW message m3 arrives in the same snapshot. The list renders m2 + m3.
+ rerender({ payload: payload([msg("m2", "b"), msg("m3", "c")]), onCancel });
+ expect(screen.queryByText("a")).toBeNull();
+ expect(screen.getByText("b")).toBeInTheDocument();
+ expect(screen.getByText("c")).toBeInTheDocument();
+ });
+
+ it("re-shows a row if the surface snapshot still contains a cancelled id (cancel not yet confirmed)", async () => {
+ // Edge case: the cancel is in flight and the surface hasn't updated yet, but
+ // a re-render with the SAME snapshot must keep the row hidden (optimistic).
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ const samePayload = payload([msg("m1", "a")]);
+ const { rerender } = render(MessageQueueList, {
+ props: { payload: samePayload, onCancel },
+ });
+
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(screen.queryByText("a")).toBeNull();
+
+ // Re-render with the same (stale) snapshot — the row stays hidden.
+ rerender({ payload: payload([msg("m1", "a")]), onCancel });
+ expect(screen.queryByText("a")).toBeNull();
+ });
+});