fix(site/src): swap Stop for Queue once a draft exists while streaming (#29507)

On viewports below 640 px, Enter inserts a newline, so once a turn was
streaming the footer offered only Stop and there was no way to queue a
follow-up message from touch.

The rightmost footer slot now follows one rule while a turn streams:
Stop while the draft is empty, and the send button labelled Queue once
the draft has sendable text, an active upload, or a live voice
recording. Editing a history message while streaming keeps Stop. When
not streaming, nothing changes. Exactly one of Stop or the send button
renders in the slot at any time.

Two fixes from the UAT report are included:

- Below 640 px, the send button omits `aria-keyshortcuts` and the
tooltip shortcut suffix, and the queued list hides its Enter hint
(`hidden sm:flex`), since Enter does not send there.
- Voice input is available while a turn streams. The mic renders
whenever speech recognition is supported, so a recording can start
mid-stream; Accept holds the primary slot while recording, then Queue
once the transcript lands, and Cancel with an empty draft returns to
Stop. Accept and Cancel refocus the editor on non-mobile viewports.

UAT report:
https://github.com/coder/scott-misc/blob/main/uat/mobile-queue-send-uat/README.md

**Merge point with #29216:** that PR changes the label to
`isEditingMessage ? "Save Edit" : isChatPaused ? "Queue" : "Send"` and
renames `isEditingHistoryMessage` to `isEditingMessage`. Whichever lands
second should resolve the label to `isEditingMessage ? "Save Edit" :
isStreaming || isChatPaused ? "Queue" : "Send"`.

<details>
<summary>UAT findings disposition</summary>

| # | Finding | Disposition |
|---|---------|-------------|
| 1 | Stop unreachable while a draft exists; live Stop returns under
thumb after Queue | Accepted as designed (single-slot rule) |
| 2 | Queue tooltip and `aria-keyshortcuts` say Enter below 640 px;
queued-list hint mismatches | Fixed here |
| 3 | Focus lands on `body` after Queue on phone | Deferred until #29216
lands (rewrites the same button block) |
| 4 | Interrupt with queued messages promotes the queue instead of
stopping | Skipped; pre-existing chatd behavior, needs design atop
#29215 hold semantics |
| 5 | Voice recording loses Accept and Cancel when a turn starts | Fixed
here, extended: the mic is available throughout a stream, not only for a
recording already in progress |
| 6 | 384 px attachment-error toast covers the footer on a 390 px phone
| Skipped; global `Toaster` width, needs design sign-off |

</details>

<details>
<summary>Implementation plan</summary>

Repository: /home/coder/coder, branch `scott/mobile-queue-send` off
`origin/main`.

**Confirmed reading**

- `AgentChatInput.tsx`: `handleSubmit` does not check `isStreaming`; the
editor's `onEnter={handleSubmit}` therefore queues a message while a
turn streams, and the page's send path handles `response.queued`. Footer
right group: voice input hidden while streaming, context usage visible,
Stop under `isStreaming && onInterrupt`, send button only under
`!isStreaming`. `sendButtonLabel` is `"Save Edit" | "Send"`; open PR
#29216 changes it to `isEditingMessage ? "Save Edit" : isChatPaused ?
"Queue" : "Send"`.
- Gap: on touch/narrow viewports Enter inserts a newline, so there is no
way to queue.

**Decisions and constraints**

- Do not add a Queue button beside Stop. Render one primary footer
button whose role depends on the composer draft while a turn streams:
draft empty shows Stop; draft non-empty shows the send button labelled
"Queue", submitting through the existing `handleSubmit` path. Not
streaming: unchanged. The button stays in the rightmost footer slot.
- "Draft non-empty" is `draftOccupiesSlot = hasSendableContent ||
hasActiveUploads`: an attachment still uploading shows a transiently
disabled Queue, an errored-only attachment keeps Stop. Stop is also kept
while a history message is being edited during a stream; Enter still
saves the edit.
- While streaming with an empty draft and no `onInterrupt`, nothing
renders in the slot (same as `main`).
- Smallest change, Vitest for behaviour, setup-only stories with a
narrow-viewport streaming story.

**Scope**

Single component change plus test and story. No page wiring, no server
change.

**Changes**

1. `AgentChatInput.tsx`: label `isEditingHistoryMessage ? "Save Edit" :
isStreaming ? "Queue" : "Send"`; footer slot shows Stop when
`isStreaming && onInterrupt && !draftOccupiesSlot`, the send button when
`!isStreaming || draftOccupiesSlot`; spinner only when `isLoading &&
!isInterruptPending`; tooltip `Queue: <shortcut>` follows from the
label.
2. `AgentChatInput.test.tsx`: Stop -> Queue -> Stop after draft clear,
with callback assertions; interrupt-pending with a draft renders a
disabled Queue that does not call `onSend`.
3. `AgentChatInput.stories.tsx`: narrow-viewport streaming story with a
draft so the 390 px footer with Queue in the Stop slot is captured.
4. `ChatPageContent.stories.tsx` `InterruptingShowsBusyComposer`: the
absent-send-button assertion queries the streaming label "Queue".

**UAT follow-up (this PR)**

- Item 2: `mobileViewportMediaQuery` constant in `utils/mobile.ts`;
`useMediaQuery` in `AgentChatInput` drops `aria-keyshortcuts` and the
tooltip suffix below 640 px; queued-list hint `hidden sm:flex`.
- Item 5: mic cluster rendered whenever `speech.isSupported`;
`speech.isRecording` added to `draftOccupiesSlot`; a live recording
takes precedence over history editing in the slot.

</details>

---

This PR was generated by Coder Agents on behalf of @jscottmiller.
This commit is contained in:
J. Scott Miller
2026-09-22 17:12:55 -05:00
committed by GitHub
parent 8b3f032ca1
commit fb61a1fc64
7 changed files with 343 additions and 56 deletions
@@ -304,6 +304,13 @@ export const StreamingInterruptPending: Story = {
},
};
export const StreamingInterruptPendingWithDraft: Story = {
args: {
...StreamingInterruptPending.args,
initialValue: "Also update the docs",
},
};
const longContent = Array.from(
{ length: 60 },
(_, i) =>
@@ -1353,6 +1360,23 @@ export const WithContextUsage: Story = {
},
};
/** Streaming on a phone with a draft. */
export const StreamingWithDraftMobile: Story = {
args: {
isStreaming: true,
onInterrupt: fn(),
isInterruptPending: false,
initialValue: "Also update the docs",
contextUsage: baseContextUsage,
onAttach: fn(),
onRemoveAttachment: fn(),
},
parameters: {
viewport: { defaultViewport: "mobile1" },
pixel: { matrix: { viewports: ["phone"] } },
},
};
/** Tooltip lists the chat's pinned context resources. */
export const WithContextFiles: Story = {
args: {
@@ -1,16 +1,48 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, type ReactNode } from "react";
import { toast } from "sonner";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { AppProviders } from "#/App";
import { createMockFile } from "#/testHelpers/files";
import { mobileViewportMediaQuery } from "#/utils/mobile";
import type * as speechRecognition from "../hooks/useSpeechRecognition";
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
vi.mock("#/modules/dashboard/useDashboard", () => ({
useDashboard: () => ({ organizations: [] }),
}));
vi.mock("../hooks/useSpeechRecognition", async (importOriginal) => {
const actual = await importOriginal<typeof speechRecognition>();
return {
...actual,
useSpeechRecognition: vi.fn(actual.useSpeechRecognition),
};
});
const mockedUseSpeechRecognition = vi.mocked(useSpeechRecognition);
// Captured once so repeated stubs within a test wrap the real
// implementation rather than a previous stub.
const originalMatchMedia = window.matchMedia;
const stubViewport = (isMobile: boolean) => {
vi.stubGlobal("matchMedia", (query: string) => {
const result = originalMatchMedia(query);
return query === mobileViewportMediaQuery
? { ...result, matches: isMobile }
: result;
});
};
const modelOptions = [
{
id: "model-config-1",
@@ -31,6 +63,11 @@ beforeAll(() => {
});
});
afterEach(() => {
vi.unstubAllGlobals();
mockedUseSpeechRecognition.mockReset();
});
describe("AgentChatInput", () => {
it("accepts drafts without sending while submission is disabled", async () => {
const user = userEvent.setup();
@@ -92,4 +129,189 @@ describe("AgentChatInput", () => {
"Unsupported file type: archive.zip",
);
});
it("swaps Stop for the Queue button once a draft is entered while streaming", async () => {
const user = userEvent.setup();
const inputRef = createRef<ChatMessageInputRef>();
const onSend = vi.fn();
const onInterrupt = vi.fn();
renderInput(
<AgentChatInput
onSend={onSend}
inputRef={inputRef}
isDisabled={false}
isLoading={false}
isStreaming
onInterrupt={onInterrupt}
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
expect(screen.queryByRole("button", { name: "Queue" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Stop" }));
expect(onInterrupt).toHaveBeenCalledTimes(1);
expect(onSend).not.toHaveBeenCalled();
await user.click(screen.getByRole("textbox", { name: "Chat message" }));
await user.paste("Also update the docs");
await waitFor(() => {
expect(inputRef.current?.getValue()).toBe("Also update the docs");
});
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Queue" }));
expect(onSend).toHaveBeenCalledWith("Also update the docs");
expect(onInterrupt).toHaveBeenCalledTimes(1);
inputRef.current?.clear();
await waitFor(() => {
expect(inputRef.current?.getValue()).toBe("");
});
expect(screen.queryByRole("button", { name: "Queue" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Stop" }));
expect(onInterrupt).toHaveBeenCalledTimes(2);
});
it("shows a disabled Queue button without a spinner while an interrupt is pending", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
renderInput(
<AgentChatInput
onSend={onSend}
isDisabled={false}
isLoading
isStreaming
isInterruptPending
onInterrupt={vi.fn()}
initialValue="Also update the docs"
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
const queueButton = screen.getByRole("button", { name: "Queue" });
expect(queueButton).toBeDisabled();
expect(within(queueButton).queryByTitle("Loading spinner")).toBeNull();
await user.click(queueButton);
expect(onSend).not.toHaveBeenCalled();
});
it("advertises the Enter send shortcut only on desktop viewports", () => {
const props = {
onSend: vi.fn(),
isDisabled: false,
isLoading: false,
isStreaming: true,
onInterrupt: vi.fn(),
initialValue: "Also update the docs",
selectedModel: modelOptions[0].id,
onModelChange: vi.fn(),
modelOptions,
modelSelectorPlaceholder: "Select model",
hasModelOptions: true,
canConfigureAgentSetup: false,
};
stubViewport(false);
const desktop = renderInput(<AgentChatInput {...props} />);
expect(
screen
.getByRole("button", { name: "Queue" })
.getAttribute("aria-keyshortcuts"),
).toMatch(/Enter/);
desktop.unmount();
stubViewport(true);
renderInput(<AgentChatInput {...props} />);
expect(screen.getByRole("button", { name: "Queue" })).not.toHaveAttribute(
"aria-keyshortcuts",
);
});
it("keeps Accept and Cancel available while a recording outlives the start of a turn", async () => {
const user = userEvent.setup();
const stop = vi.fn();
const cancel = vi.fn();
mockedUseSpeechRecognition.mockReturnValue({
isSupported: true,
isRecording: true,
transcript: "",
error: null,
start: vi.fn(),
stop,
cancel,
});
renderInput(
<AgentChatInput
onSend={vi.fn()}
isDisabled={false}
isLoading={false}
isStreaming
onInterrupt={vi.fn()}
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
await user.click(
screen.getByRole("button", { name: "Accept voice input" }),
);
expect(stop).toHaveBeenCalledTimes(1);
await user.click(
screen.getByRole("button", { name: "Cancel voice input" }),
);
expect(cancel).toHaveBeenCalledTimes(1);
});
it("lets a recording start while a turn is streaming", async () => {
const user = userEvent.setup();
const start = vi.fn();
mockedUseSpeechRecognition.mockReturnValue({
isSupported: true,
isRecording: false,
transcript: "",
error: null,
start,
stop: vi.fn(),
cancel: vi.fn(),
});
renderInput(
<AgentChatInput
onSend={vi.fn()}
isDisabled={false}
isLoading={false}
isStreaming
onInterrupt={vi.fn()}
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
await user.click(screen.getByRole("button", { name: "Voice input" }));
expect(start).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Stop" })).toBeEnabled();
});
});
@@ -56,13 +56,18 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useMediaQuery } from "#/hooks/useMediaQuery";
import {
ModelSelector,
type ModelSelectorOption,
} from "#/modules/aiModels/ModelSelector";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { isBelowMdViewport, isMobileViewport } from "#/utils/mobile";
import {
isBelowMdViewport,
isMobileViewport,
mobileViewportMediaQuery,
} from "#/utils/mobile";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { useMCPOAuthFlow } from "../hooks/useMCPOAuthFlow";
import { useOverflowCount } from "../hooks/useOverflowCount";
@@ -429,6 +434,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
preferencesQuery.isLoading,
);
const [chatFullWidth] = useChatFullWidth();
const isMobile = useMediaQuery(mobileViewportMediaQuery);
const { organizations } = useDashboard();
const chatOrganization = organizations.find(
(organization) => organization.id === chatOrganizationId,
@@ -969,6 +975,9 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const handleAcceptRecording = () => {
speech.stop();
if (!isMobileViewport()) {
internalRef.current?.focus();
}
};
const handleCancelRecording = () => {
@@ -980,6 +989,9 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
if (original) {
editor.insertText(original);
}
if (!isMobileViewport()) {
editor.focus();
}
}
setPreRecordingValue("");
};
@@ -1078,15 +1090,37 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
applyCycleValue(nextPrompt);
};
const sendButtonLabel = isEditingHistoryMessage ? "Save Edit" : "Send";
const sendShortcutLabel =
sendShortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT
const sendButtonLabel = isEditingHistoryMessage
? "Save Edit"
: isStreaming
? "Queue"
: "Send";
// Stop and the send button are mutually exclusive while streaming; a
// non-empty draft or a live recording selects the send button. A live
// recording also takes precedence over history editing.
const draftOccupiesSlot =
hasSendableContent || hasActiveUploads || speech.isRecording;
const editingHoldsStop = isEditingHistoryMessage && !speech.isRecording;
const showStopButton =
isStreaming &&
onInterrupt !== undefined &&
(!draftOccupiesSlot || editingHoldsStop);
const showSendButton =
!isStreaming || (draftOccupiesSlot && !editingHoldsStop);
// Mobile viewports advertise no send shortcut.
const sendShortcutLabel = isMobile
? undefined
: sendShortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT
? "Cmd/Ctrl+Enter"
: "Enter";
const sendButtonKeyShortcuts =
sendShortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT
const sendButtonKeyShortcuts = isMobile
? undefined
: sendShortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT
? "Control+Enter Meta+Enter"
: "Enter";
const sendButtonTooltip = sendShortcutLabel
? `${sendButtonLabel}: ${sendShortcutLabel}`
: sendButtonLabel;
const content = (
<div
@@ -1620,7 +1654,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{speech.isSupported && !isStreaming && (
{speech.isSupported && (
<>
<Button
type="button"
@@ -1659,10 +1693,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<div
className={cn(
"flex",
speech.isSupported &&
!isStreaming &&
!speech.error &&
"-ml-2",
speech.isSupported && !speech.error && "-ml-2",
)}
>
<ContextUsageIndicator
@@ -1672,7 +1703,41 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
/>
</div>
)}
{isStreaming && onInterrupt && (
{showSendButton && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:size-5! [&>svg]:p-0"
onClick={
speech.isRecording ? handleAcceptRecording : handleSubmit
}
disabled={speech.isRecording ? false : !canSend}
aria-keyshortcuts={sendButtonKeyShortcuts}
>
{isLoading && !isInterruptPending ? (
<Spinner size="sm" loading aria-hidden="true" />
) : speech.isRecording ? (
<CheckIcon />
) : (
<ArrowUpIcon />
)}
<span className="sr-only">
{speech.isRecording
? "Accept voice input"
: sendButtonLabel}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{speech.isRecording
? "Accept voice input"
: sendButtonTooltip}
</TooltipContent>
</Tooltip>
)}
{showStopButton && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1692,47 +1757,11 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
</Tooltip>
)}
{isInterruptPending && isStreaming && (
// The disabled Stop button is skipped by Tab order, so the
// pending interruption is also announced through a live
// region and a tooltip.
// Live region announcing the pending interruption.
<span role="status" className="sr-only">
Interrupting. Waiting for the agent to stop.
</span>
)}
{!isStreaming && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:size-5! [&>svg]:p-0"
onClick={
speech.isRecording ? handleAcceptRecording : handleSubmit
}
disabled={speech.isRecording ? false : !canSend}
aria-keyshortcuts={sendButtonKeyShortcuts}
>
{isLoading ? (
<Spinner size="sm" loading aria-hidden="true" />
) : speech.isRecording ? (
<CheckIcon />
) : (
<ArrowUpIcon />
)}
<span className="sr-only">
{speech.isRecording
? "Accept voice input"
: sendButtonLabel}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{speech.isRecording
? "Accept voice input"
: `${sendButtonLabel}: ${sendShortcutLabel}`}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</div>
@@ -360,7 +360,7 @@ export const InterruptingShowsBusyComposer: Story = {
expect(canvas.getByRole("status")).toHaveTextContent(
"Interrupting. Waiting for the agent to stop.",
);
expect(canvas.queryByRole("button", { name: "Send" })).toBeNull();
expect(canvas.queryByRole("button", { name: "Queue" })).toBeNull();
expect(canvas.getByText("Interrupting")).toBeInTheDocument();
expect(canvas.queryByText("Thinking")).toBeNull();
@@ -46,7 +46,8 @@ export const SingleMessage: Story = {
},
};
// Several messages queued up at once.
// Several messages queued up at once. The phone viewport hides the
// Enter-to-send hint below the sm breakpoint.
export const SeveralMessages: Story = {
args: {
messages: [
@@ -55,6 +56,10 @@ export const SeveralMessages: Story = {
buildMessage(3, textContent("Start the dev server")),
],
},
parameters: {
viewport: { defaultViewport: "mobile1" },
pixel: { matrix: { viewports: ["phone"] } },
},
};
// Messages with different content shapes to exercise the parsing logic.
@@ -206,11 +206,12 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
{isFirst && (
<span
className={cn(
"flex shrink-0 items-center gap-1 text-xs text-content-secondary transition-opacity",
"hidden shrink-0 items-center gap-1 text-xs text-content-secondary transition-opacity sm:flex",
showActions ? "opacity-100" : "opacity-0",
)}
>
<CornerDownLeftIcon className="size-3" />
<CornerDownLeftIcon className="size-3" aria-hidden="true" />
<span className="sr-only">Enter</span>
to send
</span>
)}
+7 -1
View File
@@ -1,3 +1,9 @@
/**
* Matches viewports below the `sm` Tailwind breakpoint (< 640 px), a
* reasonable proxy for a mobile / touch device with a virtual keyboard.
*/
export const mobileViewportMediaQuery = "(max-width: 639px)";
/**
* Returns `true` when the viewport width is at or below the `sm`
* Tailwind breakpoint (< 640 px), which is a reasonable proxy for a
@@ -5,7 +11,7 @@
* virtual keyboard to pop up unexpectedly.
*/
export const isMobileViewport = (): boolean => {
return window.matchMedia("(max-width: 639px)").matches;
return window.matchMedia(mobileViewportMediaQuery).matches;
};
export const belowMdViewportMediaQuery = "(max-width: 767px)";