summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-29 12:13:10 +0900
committerAdam Malczewski <[email protected]>2026-06-29 12:13:10 +0900
commit6861a10c30039b4086f11e2b5bfc515844d8a7b3 (patch)
tree882e84589dac93524e3f6feea047f0ec3e4a5184 /src/features
parentc1082294dd98cadfecda58d854174610659cadec (diff)
parent129bb3be45e1446ce4219d7f656ed0ed6f93a29f (diff)
downloaddispatch-web-6861a10c30039b4086f11e2b5bfc515844d8a7b3.tar.gz
dispatch-web-6861a10c30039b4086f11e2b5bfc515844d8a7b3.zip
Merge branch 'feature/cancel-queued-message' into predev
# Conflicts: # backend-handoff.md
Diffstat (limited to 'src/features')
-rw-r--r--src/features/chat/ports.ts11
-rw-r--r--src/features/chat/store.svelte.ts27
-rw-r--r--src/features/chat/store.test.ts72
-rw-r--r--src/features/chat/test-helpers.ts12
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts59
-rw-r--r--src/features/surface-host/logic/message-queue.ts61
-rw-r--r--src/features/surface-host/ui/MessageQueueList.svelte87
-rw-r--r--src/features/surface-host/ui/MessageQueueList.test.ts135
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte19
9 files changed, 467 insertions, 16 deletions
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}