diff options
| author | Adam Malczewski <[email protected]> | 2026-06-29 12:12:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-29 12:12:18 +0900 |
| commit | 129bb3be45e1446ce4219d7f656ed0ed6f93a29f (patch) | |
| tree | 5fe54f4bc10c90116a8b0dd5f140c5585b55120c /src | |
| parent | 1cdb866fbb708a1f6c5e802a075789cece85800d (diff) | |
| download | dispatch-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')
| -rw-r--r-- | src/app/App.svelte | 10 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 18 | ||||
| -rw-r--r-- | src/core/wire/conformance.test.ts | 2 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 2 | ||||
| -rw-r--r-- | src/features/chat/ports.ts | 11 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 27 | ||||
| -rw-r--r-- | src/features/chat/store.test.ts | 72 | ||||
| -rw-r--r-- | src/features/chat/test-helpers.ts | 12 | ||||
| -rw-r--r-- | src/features/surface-host/logic/message-queue.test.ts | 59 | ||||
| -rw-r--r-- | src/features/surface-host/logic/message-queue.ts | 61 | ||||
| -rw-r--r-- | src/features/surface-host/ui/MessageQueueList.svelte | 87 | ||||
| -rw-r--r-- | src/features/surface-host/ui/MessageQueueList.test.ts | 135 | ||||
| -rw-r--r-- | src/features/surface-host/ui/SurfaceView.svelte | 19 |
13 files changed, 498 insertions, 17 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 7e5ac7d..e4e5bbc 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -296,6 +296,10 @@ store.queueMessage(text); } + function handleCancelQueuedMessage(messageId: string) { + store.cancelQueuedMessage(messageId); + } + function handleStop() { store.stopGeneration(); } @@ -625,7 +629,11 @@ the generic SurfaceView (dispatches on rendererId, never surface id); only shown when the queue is non-empty — an idle queue is hidden. --> <div class="px-4 pt-2"> - <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} /> + <SurfaceView + spec={messageQueueSpec} + onInvoke={handleInvoke} + onCancelQueuedMessage={handleCancelQueuedMessage} + /> </div> {/if} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 22b0a25..87d2bfb 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -206,6 +206,15 @@ export interface AppStore { * wants to add input — the server owns the idle-vs-generating decision. */ queueMessage(text: string): void; + /** + * Cancel (remove) a single queued steering message by id so it never runs + * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: the + * message-queue surface update reconciles the queue UI (the cancelled message + * leaves the snapshot). Targets the focused conversation's queue. The caller + * optimistically hides the row; a cancel of an already-drained / unknown + * message is a silent server no-op (nothing to roll back). + */ + cancelQueuedMessage(messageId: string): void; selectModel(model: string): void; newDraft(): void; /** Switch the active workspace (on route change) + reset to a fresh draft in it. */ @@ -1357,6 +1366,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { activeChat.queueMessage(text); }, + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent. The message-queue surface (conversation- + // scoped) reconciles the queue UI — the cancelled row leaves the snapshot. + // A cancel of an already-drained / unknown message is a silent no-op, so + // there is no local state to roll back. Delegates to the focused + // conversation's chat store, which owns the conversationId + transport. + activeChat.cancelQueuedMessage(messageId); + }, + selectModel(model: string): void { activeModel = model; const activeId = tabsStore.activeConversationId; diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index 0d955d5..2b98ca6 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -212,6 +212,7 @@ describe("classifies every WsClientMessage type", () => { { type: "chat.subscribe" as const, conversationId: "c1" }, { type: "chat.unsubscribe" as const, conversationId: "c1" }, { type: "chat.queue" as const, conversationId: "c1", text: "steer" }, + { type: "chat.queue.cancel" as const, conversationId: "c1", messageId: "m1" }, ]; const labels = msgs.map(assertWsClientMessageExhaustive); expect(labels).toEqual([ @@ -222,6 +223,7 @@ describe("classifies every WsClientMessage type", () => { "chat.subscribe", "chat.unsubscribe", "chat.queue", + "chat.queue.cancel", ]); }); }); diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index bfa67dc..16558cd 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -116,6 +116,8 @@ export function assertWsClientMessageExhaustive(msg: WsClientMessage): string { return "chat.unsubscribe"; case "chat.queue": return "chat.queue"; + case "chat.queue.cancel": + return "chat.queue.cancel"; default: return msg satisfies never; } diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts index 2fe10dc..53ac236 100644 --- a/src/features/chat/ports.ts +++ b/src/features/chat/ports.ts @@ -1,4 +1,5 @@ import type { + ChatQueueCancelMessage, ChatQueueMessage, ChatSendMessage, ConversationHistoryResponse, @@ -6,12 +7,14 @@ import type { } from "@dispatch/transport-contract"; /** - * Injected transport port — sends chat messages to the server. Accepts both - * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message; - * auto-starts a turn if idle). + * Injected transport port — sends chat messages to the server. Accepts + * `chat.send` (start a turn), `chat.queue` (enqueue a steering message; + * auto-starts a turn if idle), and `chat.queue.cancel` (remove a single queued + * message by id so it never runs — fire-and-forget, idempotent; the + * message-queue surface confirms the removal). */ export interface ChatTransport { - send(msg: ChatSendMessage | ChatQueueMessage): void; + send(msg: ChatSendMessage | ChatQueueMessage | ChatQueueCancelMessage): void; } /** diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 9911438..24a1d06 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -1,6 +1,7 @@ import type { ChatDeltaMessage, ChatErrorMessage, + ChatQueueCancelMessage, ChatQueueMessage, ChatSendMessage, } from "@dispatch/transport-contract"; @@ -131,6 +132,17 @@ export interface ChatStore { * transcript. `text` must be non-empty (the server 400/errors otherwise). */ queueMessage(text: string): void; + /** + * Cancel (remove) a single queued steering message by id so it never runs + * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: success is + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot); a cancel of an already-drained / unknown message is a + * silent server-side no-op. The caller optimistically hides the row; the + * surface update reconciles. `messageId` is the stable `QueuedMessage.id` + * the queue surface snapshot carries. No transcript change — a cancelled + * message is never delivered as steering. + */ + cancelQueuedMessage(messageId: string): void; setModel(model: string): void; /** * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. @@ -348,6 +360,21 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { deps.transport.send(msg); }, + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent (per the contract). The caller optimistically + // hides the row; the message-queue surface update reconciles. A cancel of + // an already-drained / unknown message is a silent server no-op, so there + // is no local-state change to make and nothing to roll back on a stray + // `chat.error` (which only fires for a malformed send — a client that + // sends the id it just rendered never hits it). + const msg: ChatQueueCancelMessage = { + type: "chat.queue.cancel", + conversationId: deps.conversationId, + messageId, + }; + deps.transport.send(msg); + }, + setModel(model: string): void { _model = model; }, diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index 8f36994..aa5560f 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -318,6 +318,78 @@ describe("createChatStore", () => { }); }); + describe("cancelQueuedMessage (chat.queue.cancel)", () => { + it("posts a chat.queue.cancel with conversationId + messageId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(transport.sent).toHaveLength(0); // chat.send stays empty + expect(transport.sentQueue).toHaveLength(0); // chat.queue stays empty + expect(transport.sentCancels).toHaveLength(1); + expect(transport.sentCancels[0]?.type).toBe("chat.queue.cancel"); + expect(transport.sentCancels[0]?.conversationId).toBe(CONV_ID); + expect(transport.sentCancels[0]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + + it("does NOT touch the transcript (a cancelled message never runs)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(store.chunks).toHaveLength(0); // no transcript echo / change + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("sends for any messageId (cancel is idempotent server-side, no FE guard)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // The server no-ops an already-drained / unknown id; the FE fires-and- + // forgetgets, so even a repeat cancel is forwarded. + store.cancelQueuedMessage("msg-42"); + store.cancelQueuedMessage("msg-42"); + + expect(transport.sentCancels).toHaveLength(2); + expect(transport.sentCancels[1]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + }); + it("chat.error sets error", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts index 26c5590..c99d1f4 100644 --- a/src/features/chat/test-helpers.ts +++ b/src/features/chat/test-helpers.ts @@ -1,4 +1,8 @@ -import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract"; +import type { + ChatQueueCancelMessage, + ChatQueueMessage, + ChatSendMessage, +} from "@dispatch/transport-contract"; import type { StoredChunk } from "@dispatch/wire"; import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; @@ -8,19 +12,25 @@ export interface FakeTransport { readonly sent: ChatSendMessage[]; /** All `chat.queue` messages sent through the fake transport. */ readonly sentQueue: ChatQueueMessage[]; + /** All `chat.queue.cancel` messages sent through the fake transport. */ + readonly sentCancels: ChatQueueCancelMessage[]; readonly impl: ChatTransport; } export function createFakeTransport(): FakeTransport { const sent: ChatSendMessage[] = []; const sentQueue: ChatQueueMessage[] = []; + const sentCancels: ChatQueueCancelMessage[] = []; return { sent, sentQueue, + sentCancels, impl: { send(msg) { if (msg.type === "chat.queue") { sentQueue.push(msg); + } else if (msg.type === "chat.queue.cancel") { + sentCancels.push(msg); } else { sent.push(msg); } diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts index 8d55eb7..ae91c8f 100644 --- a/src/features/surface-host/logic/message-queue.test.ts +++ b/src/features/surface-host/logic/message-queue.test.ts @@ -1,6 +1,10 @@ import type { QueuedMessage } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { parseMessageQueuePayload } from "./message-queue"; +import { + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, +} from "./message-queue"; const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({ id, @@ -46,3 +50,56 @@ describe("parseMessageQueuePayload", () => { expect(parseMessageQueuePayload(payload)).toBeNull(); }); }); + +describe("selectVisibleMessages", () => { + it("returns the snapshot unchanged when nothing is cancelled", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + expect(selectVisibleMessages(messages, new Set())).toBe(messages); + }); + + it("hides the optimistically-cancelled row", () => { + const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")]; + expect(selectVisibleMessages(messages, new Set(["m2"])).map((m) => m.id)).toEqual(["m1", "m3"]); + }); + + it("hides multiple cancelled rows", () => { + const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")]; + expect(selectVisibleMessages(messages, new Set(["m1", "m3"])).map((m) => m.id)).toEqual(["m2"]); + }); + + it("tolerates a cancelled id not present in the snapshot (no-op)", () => { + const messages = [msg("m1", "a")]; + expect(selectVisibleMessages(messages, new Set(["ghost"])).map((m) => m.id)).toEqual(["m1"]); + }); +}); + +describe("reconcileCancelledIds", () => { + it("returns an empty set when nothing was cancelled", () => { + const result = reconcileCancelledIds([msg("m1", "a")], new Set()); + expect(result.size).toBe(0); + }); + + it("drops ids the surface confirmed gone (no longer queued)", () => { + // m1 still queued (cancel pending), m2 confirmed gone (left the snapshot). + const messages = [msg("m1", "a")]; + const result = reconcileCancelledIds(messages, new Set(["m1", "m2"])); + expect([...result]).toEqual(["m1"]); + }); + + it("returns the SAME set identity when nothing changed (no spurious cycle)", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + const cancelled = new Set(["m1", "m2"]); + expect(reconcileCancelledIds(messages, cancelled)).toBe(cancelled); + }); + + it("returns an empty set when all cancels were confirmed", () => { + const messages = [msg("m1", "a")]; + expect(reconcileCancelledIds(messages, new Set(["m2", "m3"])).size).toBe(0); + }); + + it("keeps a still-queued cancelled id (cancel still pending)", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + const result = reconcileCancelledIds(messages, new Set(["m2"])); + expect([...result]).toEqual(["m2"]); + }); +}); diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts index 79707a5..7a3653e 100644 --- a/src/features/surface-host/logic/message-queue.ts +++ b/src/features/surface-host/logic/message-queue.ts @@ -43,3 +43,64 @@ export function parseMessageQueuePayload(payload: unknown): MessageQueueData | n /** The `rendererId` the message-queue extension's `custom` surface field uses. */ export const MESSAGE_QUEUE_RENDERER_ID = "message-queue"; + +/** + * Optimistic-removal view-model for the queue list. + * + * The `chat.queue.cancel` op is fire-and-forget + idempotent: success is + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot), not by a reply. To avoid a flash of the row lingering + * for a round-trip, the renderer hides a row the instant the user clicks cancel + * (tracking the cancelled id locally), then reconciles when the surface pushes + * the post-cancel snapshot. These two pure helpers drive that — the component + * holds the cancelled-id set as a thin `$state` wrapper and delegates all + * decisions here. + */ + +/** + * The messages the renderer should show: the surface snapshot MINUS any + * optimistically-cancelled ids (a cancel whose surface confirmation hasn't + * arrived yet). Pure — no mutation of inputs. + */ +export function selectVisibleMessages( + messages: readonly QueuedMessage[], + cancelledIds: ReadonlySet<string>, +): readonly QueuedMessage[] { + if (cancelledIds.size === 0) return messages; + return messages.filter((m) => !cancelledIds.has(m.id)); +} + +/** + * Reconcile the cancelled-id set against a NEW surface snapshot: keep only the + * ids that are STILL queued (the cancel is pending — its surface confirmation + * hasn't landed). Drop ids that have left the snapshot: the server confirmed + * the removal (or the message drained as steering / the queue cleared), so the + * optimistic hide is no longer needed. This keeps the set bounded — it never + * outlives the rows it tracks. Pure — returns a NEW set (callers assign it to + * the reactive `$state`). + */ +export function reconcileCancelledIds( + messages: readonly QueuedMessage[], + cancelledIds: ReadonlySet<string>, +): ReadonlySet<string> { + if (cancelledIds.size === 0) return EMPTY_STRING_SET; + const stillQueued = new Set<string>(); + for (const m of messages) { + if (cancelledIds.has(m.id)) stillQueued.add(m.id); + } + // Same set back → return the input identity so the component's `$state` setter + // sees no change (avoids a spurious reactive cycle). + if (stillQueued.size === cancelledIds.size) { + let same = true; + for (const id of cancelledIds) { + if (!stillQueued.has(id)) { + same = false; + break; + } + } + if (same) return cancelledIds; + } + return stillQueued; +} + +const EMPTY_STRING_SET: ReadonlySet<string> = new Set<string>(); 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 @@ <script lang="ts"> - import { parseMessageQueuePayload } from "../logic/message-queue"; + import { + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, + } from "../logic/message-queue"; - let { payload }: { readonly payload: unknown } = $props(); + let { + payload, + onCancel, + }: { + readonly payload: unknown; + /** + * Cancel (remove) a single queued message by id (`chat.queue.cancel`). + * Required-but-nullable (not optional) so a parent can thread a + * `| undefined` callback through under `exactOptionalPropertyTypes`: + * `undefined` → a read-only list (no × affordance), e.g. a generic surface + * context with no conversation scope. The list still reconciles from the + * surface either way. + */ + readonly onCancel: ((messageId: string) => void) | undefined; + } = $props(); // Parse defensively; an unparseable payload yields null → render nothing // (graceful skip, per the custom-field contract). const data = $derived(parseMessageQueuePayload(payload)); + + // Optimistic-removal: a cancelled id is hidden the instant the user clicks, + // ahead of the surface's post-cancel snapshot. Reconcile on every payload + // change so a confirmed-removed id (no longer in the snapshot) is dropped + // from the set — keeping it bounded (pure helpers in logic/message-queue). + let cancelledIds = $state<ReadonlySet<string>>(new Set()); + + $effect(() => { + const parsed = data; + if (parsed === null) return; + const next = reconcileCancelledIds(parsed.messages, cancelledIds); + if (next !== cancelledIds) cancelledIds = next; + }); + + const visible = $derived( + data === null ? [] : selectVisibleMessages(data.messages, cancelledIds), + ); + + function handleCancel(messageId: string): void { + // Optimistically hide the row + fire the cancel (fire-and-forget; the + // surface update reconciles). Idempotent server-side, so a double-click or + // a cancel of an already-drained message is a silent no-op — no rollback. + if (cancelledIds.has(messageId)) return; + const next = new Set(cancelledIds); + next.add(messageId); + cancelledIds = next; + onCancel?.(messageId); + } </script> {#if data !== null && data.messages.length > 0} <ul class="flex flex-col gap-1 text-sm"> - {#each data.messages as msg (msg.id)} - <li class="rounded-box bg-base-200 px-3 py-2"> - <p class="whitespace-pre-wrap">{msg.text}</p> - <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> - {new Date(msg.queuedAt).toLocaleTimeString()} - </time> + {#each visible as msg (msg.id)} + <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2"> + <div class="min-w-0 flex-1"> + <p class="whitespace-pre-wrap break-words">{msg.text}</p> + <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> + {new Date(msg.queuedAt).toLocaleTimeString()} + </time> + </div> + {#if onCancel !== undefined} + <button + type="button" + class="btn btn-ghost btn-xs btn-square shrink-0 opacity-60 hover:opacity-100" + title="Cancel this queued message" + aria-label="Cancel this queued message" + onclick={() => handleCancel(msg.id)} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3.5 w-3.5" + > + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + {/if} </li> {/each} </ul> 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"} <SurfaceTable payload={group.field.payload} /> {:else if group.field.rendererId === "message-queue"} - <MessageQueueList payload={group.field.payload} /> + <MessageQueueList payload={group.field.payload} onCancel={onCancelQueuedMessage} /> {:else if group.field.rendererId === "todo"} <TodoList payload={group.field.payload} /> {/if} |
