diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 14:26:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 14:26:35 +0900 |
| commit | 8eefbc5287f73414df7feba6c78dc8ca73783b1d (patch) | |
| tree | ecb0b0286f22eb082c8c4cce6adf1d535b0b7fed /src/app | |
| parent | a7b041f0f8989373404c35d3fe4b20b1e89d2440 (diff) | |
| download | dispatch-web-8eefbc5287f73414df7feba6c78dc8ca73783b1d.tar.gz dispatch-web-8eefbc5287f73414df7feba6c78dc8ca73783b1d.zip | |
fix(review-followup-fea): clarify Stop with queued input
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.test.ts | 72 | ||||
| -rw-r--r-- | src/app/store-types.ts | 7 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 4 | ||||
| -rw-r--r-- | src/app/surface-protocol.svelte.ts | 20 | ||||
| -rw-r--r-- | src/app/surface-protocol.test.ts | 65 | ||||
| -rw-r--r-- | src/app/views/ComposerPane.svelte | 8 |
6 files changed, 170 insertions, 6 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} /> |
