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/surface-host/ui/MessageQueueList.svelte | |
| 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/surface-host/ui/MessageQueueList.svelte')
| -rw-r--r-- | src/features/surface-host/ui/MessageQueueList.svelte | 87 |
1 files changed, 79 insertions, 8 deletions
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> |
