summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/logic/message-queue.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/logic/message-queue.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/logic/message-queue.ts')
-rw-r--r--src/features/surface-host/logic/message-queue.ts61
1 files changed, 61 insertions, 0 deletions
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>();