diff options
Diffstat (limited to 'src/features/chat')
| -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 |
4 files changed, 96 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}> |
