summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 14:26:35 +0900
committerAdam Malczewski <[email protected]>2026-10-21 14:26:35 +0900
commit8eefbc5287f73414df7feba6c78dc8ca73783b1d (patch)
treeecb0b0286f22eb082c8c4cce6adf1d535b0b7fed
parenta7b041f0f8989373404c35d3fe4b20b1e89d2440 (diff)
downloaddispatch-web-8eefbc5287f73414df7feba6c78dc8ca73783b1d.tar.gz
dispatch-web-8eefbc5287f73414df7feba6c78dc8ca73783b1d.zip
fix(review-followup-fea): clarify Stop with queued input
-rw-r--r--src/app/App.test.ts72
-rw-r--r--src/app/store-types.ts7
-rw-r--r--src/app/store.svelte.ts4
-rw-r--r--src/app/surface-protocol.svelte.ts20
-rw-r--r--src/app/surface-protocol.test.ts65
-rw-r--r--src/app/views/ComposerPane.svelte8
-rw-r--r--src/features/chat/logic/composer.test.ts20
-rw-r--r--src/features/chat/logic/composer.ts11
-rw-r--r--src/features/chat/ui.test.ts51
-rw-r--r--src/features/chat/ui/Composer.svelte18
-rw-r--r--src/features/surface-host/index.ts1
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts35
-rw-r--r--src/features/surface-host/logic/message-queue.ts10
13 files changed, 312 insertions, 10 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 14dac3a..62798d6 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -481,6 +481,78 @@ describe("App component interaction tests", () => {
store.dispose();
});
+ it("uses only the focused conversation's queue snapshot for the interrupt label and POSTs stop", async () => {
+ const ws = fakeSocket();
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const firstId = activeConversationId(store);
+ const startTurn = (conversationId: string) =>
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: `turn-${conversationId}` },
+ });
+ const pushQueue = (conversationId: string, payload: unknown) =>
+ ws.feedSurfaceMessage({
+ type: "surface",
+ conversationId,
+ spec: {
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ },
+ });
+ startTurn(firstId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "message-queue", region: "inline", title: "Message queue", scope: "conversation" },
+ ],
+ });
+ const pending = { messages: [{ id: "m1", text: "next", queuedAt: 1 }] };
+ pushQueue(firstId, pending);
+ const interrupt = await screen.findByRole("button", { name: "Interrupt & send queued" });
+ ws.sent.length = 0;
+ fetchImpl.mockClear();
+ await user.click(interrupt);
+ expect(fetchImpl.mock.calls.filter(([, init]) => init?.method === "POST")).toEqual([
+ [expect.stringContaining(`/conversations/${firstId}/stop`), { method: "POST" }],
+ ]);
+ expect(sentMessages(ws)).toEqual([]); // No new send, queue, or cancel operation.
+
+ store.newDraft();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "second");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const secondId = activeConversationId(store);
+ expect(secondId).not.toBe(firstId);
+ startTurn(secondId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(firstId, pending); // Late old-scope update must not relabel the new conversation.
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ pushQueue(secondId, pending);
+ await screen.findByRole("button", { name: "Interrupt & send queued" });
+ pushQueue(secondId, { messages: [] });
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(secondId, { messages: "malformed" });
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ store.dispose();
+ });
+
it("incoming chat.delta renders text in the chat transcript", async () => {
const ws = fakeSocket();
const store = createAppStore({
diff --git a/src/app/store-types.ts b/src/app/store-types.ts
index 465b21d..774d041 100644
--- a/src/app/store-types.ts
+++ b/src/app/store-types.ts
@@ -153,8 +153,11 @@ export interface AppStore {
/** The localStorage instance the store uses for persistence (tabs, chatLimit).
* Exposed so the shell can persist sidebar layout via the same adapter. */
readonly storage: Storage | undefined;
- /** The current spec for one surface by id (discovery-by-id), or null if absent. */
- surface(surfaceId: string): SurfaceSpec | null;
+ /**
+ * The current spec by id, or null if absent. With conversationId, require a
+ * snapshot received for that conversation (not a retained prior-scope spec).
+ */
+ surface(surfaceId: string, conversationId?: string): SurfaceSpec | null;
/**
* Send a user message (start a turn). Forwards any staged `images`
* (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op;
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 5dcdc7d..fa12036 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -511,8 +511,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
return workspaceConversationId();
},
- surface(surfaceId: string): SurfaceSpec | null {
- return getSurfaceSpec(surfaces.protocol, surfaceId);
+ surface(surfaceId: string, conversationId?: string): SurfaceSpec | null {
+ return surfaces.surfaceSpecOp(surfaceId, conversationId);
},
send(text: string, images?: readonly ImageInput[]): void {
diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts
index 4ba684d..400f612 100644
--- a/src/app/surface-protocol.svelte.ts
+++ b/src/app/surface-protocol.svelte.ts
@@ -16,8 +16,18 @@ export function createSurfaceProtocol(deps: {
send: SurfaceSocketHandle["send"];
}) {
let protocol = $state<ProtocolState>(protocolInitialState());
+ // Rendering may retain the prior spec during re-scope. Consumers making
+ // conversation-specific decisions can opt into a snapshot with a known scope.
+ let receivedScopes = $state<ReadonlyMap<string, string | undefined>>(new Map());
function handleServerMessage(msg: SurfaceServerMessage): void {
- protocol = applyServerMessage(protocol, msg);
+ const next = applyServerMessage(protocol, msg);
+ if (next !== protocol && (msg.type === "surface" || msg.type === "update")) {
+ const snapshot = msg.type === "surface" ? msg : msg.update;
+ const scopes = new Map(receivedScopes);
+ scopes.set(snapshot.spec.id, snapshot.conversationId);
+ receivedScopes = scopes;
+ }
+ protocol = next;
// Surfaces are auto-expanded: whenever the catalog changes, subscribe to
// every entry (and drop subscriptions for entries that vanished).
if (msg.type === "catalog") {
@@ -55,6 +65,7 @@ export function createSurfaceProtocol(deps: {
}
}
}
+ receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id)));
}
/**
@@ -99,12 +110,17 @@ export function createSurfaceProtocol(deps: {
void conversationId;
const result = protocolUnsubscribe(protocol, surfaceId);
protocol = result.state;
+ const scopes = new Map(receivedScopes);
+ scopes.delete(surfaceId);
+ receivedScopes = scopes;
for (const msg of result.outgoing) {
deps.send(msg);
}
}
- function surfaceSpecOp(surfaceId: string): SurfaceSpec | null {
+ function surfaceSpecOp(surfaceId: string, conversationId?: string): SurfaceSpec | null {
+ if (conversationId !== undefined && receivedScopes.get(surfaceId) !== conversationId)
+ return null;
return getSurfaceSpec(protocol, surfaceId);
}
diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts
new file mode 100644
index 0000000..5ac4f70
--- /dev/null
+++ b/src/app/surface-protocol.test.ts
@@ -0,0 +1,65 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it, vi } from "vitest";
+import { createSurfaceProtocol } from "./surface-protocol.svelte";
+
+const spec: SurfaceSpec = { id: "s1", region: "inline", title: "Snapshot", fields: [] };
+
+function fixture() {
+ let focusedId = "c1";
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId: () => focusedId,
+ send: vi.fn(),
+ });
+ surfaces.handleServerMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "inline", title: "Snapshot", scope: "conversation" }],
+ });
+ return {
+ surfaces,
+ focus(id: string) {
+ focusedId = id;
+ surfaces.syncSubscriptions();
+ },
+ };
+}
+
+describe("conversation-scoped surface lookup", () => {
+ it("keeps retained rendering but withholds another conversation's snapshot from decisions", () => {
+ const { surfaces, focus } = fixture();
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec);
+ focus("c2");
+ expect(surfaces.surfaceSpecOp("s1")).toEqual(spec);
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull();
+ const updated = { ...spec, title: "Current" };
+ surfaces.handleServerMessage({
+ type: "update",
+ update: { surfaceId: "s1", conversationId: "c2", spec: updated },
+ });
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toEqual(updated);
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+
+ it("does not treat an unscoped snapshot as known conversation-specific data", () => {
+ const { surfaces } = fixture();
+ surfaces.handleServerMessage({ type: "surface", spec });
+ expect(surfaces.surfaceSpecOp("s1")).toEqual(spec);
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+
+ it("drops snapshot scope on explicit unsubscribe and catalog removal", () => {
+ const { surfaces } = fixture();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ surfaces.unsubscribeSurfaceOp("s1");
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.subscribeSurfaceOp("s1");
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec);
+ surfaces.handleServerMessage({ type: "catalog", catalog: [] });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+});
diff --git a/src/app/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte
index ac95598..00dbc9f 100644
--- a/src/app/views/ComposerPane.svelte
+++ b/src/app/views/ComposerPane.svelte
@@ -6,11 +6,18 @@
* is a persistent column fixture, exactly like today.
*/
import { Composer, type ComposerStatus } from "../../features/chat";
+ import { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "../../features/surface-host";
import type { AppViewPorts } from "../view-ports";
let { ports }: { ports: AppViewPorts } = $props();
const store = $derived(ports.store);
+ const queuedMessageCount = $derived(
+ store.activeConversationId === null
+ ? undefined
+ : messageQueueCount(store.surface(MESSAGE_QUEUE_RENDERER_ID, store.activeConversationId)),
+ );
+
// Priority: error > queued > running > idle. `queued` (the turn is in flight
// but waiting for a concurrency slot — CR-13) wins over `running` so the
// corner shows a ring, not dots, during the wait. `turn-start` fires before
@@ -31,5 +38,6 @@
onStop={() => store.stopGeneration()}
contextSize={store.activeChat.currentContextSize}
contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ {queuedMessageCount}
status={composerStatus}
/>
diff --git a/src/features/chat/logic/composer.test.ts b/src/features/chat/logic/composer.test.ts
new file mode 100644
index 0000000..6ef5eab
--- /dev/null
+++ b/src/features/chat/logic/composer.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { stopButtonLabels } from "./composer";
+
+describe("stopButtonLabels", () => {
+ it.each([undefined, 0])("preserves plain Stop with queued count %s", (count) => {
+ expect(stopButtonLabels(count)).toEqual({
+ text: "Stop",
+ ariaLabel: "Stop generation",
+ title: undefined,
+ });
+ });
+
+ it.each([1, 2])("describes interrupt-and-send with queued count %s", (count) => {
+ expect(stopButtonLabels(count)).toEqual({
+ text: "Interrupt & send queued",
+ ariaLabel: "Interrupt & send queued",
+ title: "Stop the current response and start the queued message now",
+ });
+ });
+});
diff --git a/src/features/chat/logic/composer.ts b/src/features/chat/logic/composer.ts
new file mode 100644
index 0000000..1ea8173
--- /dev/null
+++ b/src/features/chat/logic/composer.ts
@@ -0,0 +1,11 @@
+/** Stop still uses the same action; the backend carries queued input into a new turn. */
+export function stopButtonLabels(queuedMessageCount: number | undefined) {
+ if (queuedMessageCount !== undefined && queuedMessageCount >= 1) {
+ return {
+ text: "Interrupt & send queued",
+ ariaLabel: "Interrupt & send queued",
+ title: "Stop the current response and start the queued message now",
+ };
+ }
+ return { text: "Stop", ariaLabel: "Stop generation", title: undefined };
+}
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 2367e64..480d593 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -849,6 +849,57 @@ describe("ChatView", () => {
});
describe("Composer", () => {
+ it.each([
+ "running",
+ "queued",
+ ] as const)("relabels Stop only with queued input while %s and preserves its action", async (status) => {
+ const onStop = vi.fn();
+ const onSend = vi.fn();
+ const onQueue = vi.fn();
+ const user = userEvent.setup();
+ const { rerender } = render(Composer, { props: { onSend, onQueue, onStop, status } });
+ const expectPlainStop = () => {
+ const button = screen.getByRole("button", { name: "Stop generation" });
+ expect(button).toHaveTextContent(/^Stop$/);
+ expect(button).not.toHaveAttribute("title");
+ };
+ expectPlainStop(); // No queue snapshot available.
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop();
+ await user.click(screen.getByRole("button", { name: "Stop generation" }));
+ expect(onStop).toHaveBeenCalledTimes(1);
+
+ for (const queuedMessageCount of [1, 2]) {
+ await rerender({ queuedMessageCount });
+ const button = screen.getByRole("button", { name: "Interrupt & send queued" });
+ expect(button).toHaveTextContent(/^Interrupt & send queued$/);
+ expect(button).toHaveAttribute(
+ "title",
+ "Stop the current response and start the queued message now",
+ );
+ }
+ await user.click(screen.getByRole("button", { name: "Interrupt & send queued" }));
+ expect(onStop).toHaveBeenCalledTimes(2);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onQueue).not.toHaveBeenCalled();
+
+ const input = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(input, "next");
+ expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Interrupt & send queued" })).toBeNull();
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined);
+ expect(screen.getByRole("button", { name: "Interrupt & send queued" })).toBeInTheDocument();
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop(); // Queue drained or cancelled.
+ await rerender({ queuedMessageCount: 1, status: "idle" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status: "error" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status, queuedMessageCount: undefined });
+ expectPlainStop();
+ });
+
it("calls onSend with the typed text and clears", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index 3cc115b..a43ed53 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import type { ImageInput } from "@dispatch/wire";
import { convertToJpeg, type ConvertedJpeg, type ImageJpegEncoder } from "../adapter/image-encode";
+ import { stopButtonLabels } from "../logic/composer";
import { MAX_IMAGE_EDGE_PX, shouldConvertImage } from "../logic/image-compress";
import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
@@ -59,6 +60,7 @@
contextSize = undefined,
contextWindow = undefined,
status = "idle",
+ queuedMessageCount = undefined,
}: {
/**
* Send a message (start a turn via `chat.send`). Carries any staged images
@@ -95,6 +97,8 @@
* `running` for the send button (steer/stop).
*/
status?: ComposerStatus;
+ /** Current conversation's queued snapshot size; undefined when unavailable. */
+ queuedMessageCount?: number | undefined;
} = $props();
export type ComposerStatus = "idle" | "running" | "queued" | "error";
@@ -123,6 +127,7 @@
if (inFlight) return "queue";
return "send";
});
+ const stopButton = $derived(stopButtonLabels(queuedMessageCount));
const placeholder = $derived(
status === "queued"
? "Queued for a slot…"
@@ -319,7 +324,7 @@
<!-- Top bar: expanding textarea + image-attach button + single context-aware button -->
<div class="flex items-end gap-2 px-4 pt-3 pb-2">
<div
- class="flex-1"
+ class="min-w-0 flex-1"
onpaste={handlePaste}
class:border-2={dragOver}
class:border-primary={dragOver}
@@ -371,12 +376,17 @@
{#if buttonMode === "stop"}
<button
- class="btn btn-error w-20 shrink-0"
+ class="btn btn-error shrink-0"
+ class:w-20={stopButton.text === "Stop"}
+ class:w-36={stopButton.text !== "Stop"}
+ class:whitespace-normal={stopButton.text !== "Stop"}
+ class:leading-tight={stopButton.text !== "Stop"}
type="button"
- aria-label="Stop generation"
+ aria-label={stopButton.ariaLabel}
+ title={stopButton.title}
onclick={() => onStop?.()}
>
- Stop
+ {stopButton.text}
</button>
{:else}
<button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!canSend}>
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
index 1e21e3d..f6a3041 100644
--- a/src/features/surface-host/index.ts
+++ b/src/features/surface-host/index.ts
@@ -1,3 +1,4 @@
+export { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "./logic/message-queue";
export { buildInvoke, planSurface } from "./logic/plan";
export type { FieldView, SurfaceRenderPlan } from "./logic/types";
export { default as SurfaceView } from "./ui/SurfaceView.svelte";
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts
index ae91c8f..93256b4 100644
--- a/src/features/surface-host/logic/message-queue.test.ts
+++ b/src/features/surface-host/logic/message-queue.test.ts
@@ -1,6 +1,8 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
+ messageQueueCount,
parseMessageQueuePayload,
reconcileCancelledIds,
selectVisibleMessages,
@@ -103,3 +105,36 @@ describe("reconcileCancelledIds", () => {
expect([...result]).toEqual(["m2"]);
});
});
+
+describe("messageQueueCount", () => {
+ const spec = (payload: unknown): SurfaceSpec => ({
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ });
+
+ it("counts messages, including image-only input", () => {
+ expect(messageQueueCount(spec({ messages: [msg("m1", ""), msg("m2", "next")] }))).toBe(2);
+ });
+
+ it("distinguishes an empty queue from an unavailable snapshot", () => {
+ expect(messageQueueCount(spec({ messages: [] }))).toBe(0);
+ expect(messageQueueCount(null)).toBeUndefined();
+ });
+
+ it("returns unknown for missing fields or a different renderer", () => {
+ expect(messageQueueCount({ ...spec(null), fields: [] })).toBeUndefined();
+ expect(
+ messageQueueCount({
+ ...spec(null),
+ fields: [{ kind: "custom", rendererId: "other", payload: { messages: [msg("m1", "x")] } }],
+ }),
+ ).toBeUndefined();
+ });
+
+ it("returns unknown for malformed payloads", () => {
+ expect(messageQueueCount(spec({ messages: "nope" }))).toBeUndefined();
+ expect(messageQueueCount(spec({ messages: [{ id: "m1" }] }))).toBeUndefined();
+ });
+});
diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts
index 7a3653e..a49d4d0 100644
--- a/src/features/surface-host/logic/message-queue.ts
+++ b/src/features/surface-host/logic/message-queue.ts
@@ -1,3 +1,4 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";
/**
@@ -41,6 +42,15 @@ export function parseMessageQueuePayload(payload: unknown): MessageQueueData | n
return { messages };
}
+/** Queue size from a validated snapshot; absent/malformed data stays unknown. */
+export function messageQueueCount(spec: SurfaceSpec | null): number | undefined {
+ const field = spec?.fields.find(
+ (field) => field.kind === "custom" && field.rendererId === MESSAGE_QUEUE_RENDERER_ID,
+ );
+ if (field?.kind !== "custom") return undefined;
+ return parseMessageQueuePayload(field.payload)?.messages.length;
+}
+
/** The `rendererId` the message-queue extension's `custom` surface field uses. */
export const MESSAGE_QUEUE_RENDERER_ID = "message-queue";