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/features/chat | |
| 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/features/chat')
| -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 |
4 files changed, 117 insertions, 5 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); } |
