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/features | |
| 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/features')
| -rw-r--r-- | src/features/chat/logic/composer.test.ts | 20 | ||||
| -rw-r--r-- | src/features/chat/logic/composer.ts | 11 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 51 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 18 | ||||
| -rw-r--r-- | src/features/surface-host/index.ts | 1 | ||||
| -rw-r--r-- | src/features/surface-host/logic/message-queue.test.ts | 35 | ||||
| -rw-r--r-- | src/features/surface-host/logic/message-queue.ts | 10 |
7 files changed, 142 insertions, 4 deletions
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"; |
