mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)";
|
||||
|
||||
Reference in New Issue
Block a user