From 129bb3be45e1446ce4219d7f656ed0ed6f93a29f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 29 Jun 2026 12:12:18 +0900 Subject: feat(chat): cancel a queued steering message from the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../surface-host/ui/MessageQueueList.svelte | 87 +++++++++++-- .../surface-host/ui/MessageQueueList.test.ts | 135 +++++++++++++++++++++ src/features/surface-host/ui/SurfaceView.svelte | 19 ++- 3 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 src/features/surface-host/ui/MessageQueueList.test.ts (limited to 'src/features/surface-host/ui') diff --git a/src/features/surface-host/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte index 554fa02..b260de2 100644 --- a/src/features/surface-host/ui/MessageQueueList.svelte +++ b/src/features/surface-host/ui/MessageQueueList.svelte @@ -1,21 +1,92 @@ {#if data !== null && data.messages.length > 0} 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(); + }); +}); diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index aed8d03..8ce8ade 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -11,7 +11,22 @@ import TodoList from "./TodoList.svelte"; import Toggle from "./Toggle.svelte"; - let { spec, onInvoke }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props(); + let { + spec, + onInvoke, + onCancelQueuedMessage, + }: { + spec: SurfaceSpec; + onInvoke: (msg: InvokeMessage) => void; + /** + * Cancel a queued message by id — threaded ONLY to the `message-queue` + * renderer. Optional + scoped: generic surfaces (which pass nothing) keep + * rendering a read-only queue list. Kept as a typed callback (never a + * stringly-typed bus); the renderer dispatch is on `rendererId` (a renderer + * KIND), never the surface id. + */ + onCancelQueuedMessage?: (messageId: string) => void; + } = $props(); const plan = $derived(planSurface(spec)); // Consecutive stats render together as one aligned table; everything else is @@ -40,7 +55,7 @@ {#if group.field.rendererId === "table"} {:else if group.field.rendererId === "message-queue"} - + {:else if group.field.rendererId === "todo"} {/if} -- cgit v1.2.3