diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
index 6cb2bc9e89..f15f2bade9 100644
--- a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
+++ b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => {
isPointerCoarse: false,
scrollToBottom: vi.fn(),
permissionModePicker: vi.fn(),
+ voiceState: "idle" as "idle" | "recording" | "transcribing" | "error",
};
return Object.assign(values, {});
});
@@ -62,6 +63,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
zenMode,
heightAnimationKey,
minHeight,
+ voice,
}: {
footerStart?: ReactNode;
compact?: {
@@ -81,6 +83,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
zenMode?: { resetKey: string | number };
heightAnimationKey?: string | number;
minHeight?: number;
+ voice?: { state: "idle" | "recording" | "transcribing" | "error" };
}) => (
({
data-zen-reset-key={zenMode?.resetKey}
data-height-animation-key={heightAnimationKey}
data-min-height={minHeight}
+ data-voice-state={voice?.state}
data-plugin-customizations-suppressed={
suppressPluginComposerCustomizations ? "true" : "false"
}
@@ -133,7 +137,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
vi.mock("@/components/promptbox/usePromptVoice", () => ({
usePromptVoice: () => ({
- state: "idle",
+ state: mocks.voiceState,
isSupported: false,
stream: null,
start: vi.fn(),
@@ -259,6 +263,7 @@ afterEach(() => {
beforeEach(() => {
mocks.isCompactViewport = false;
mocks.isPointerCoarse = false;
+ mocks.voiceState = "idle";
resizeObserverCallback = null;
vi.stubGlobal(
"ResizeObserver",
@@ -977,6 +982,23 @@ describe("FollowUpPromptBox", () => {
expect(screen.getByText("Local environment")).toBeTruthy();
});
+ it.each(["recording", "transcribing"] as const)(
+ "keeps the status footer while the prompt box handles voice controls during %s",
+ (state) => {
+ mocks.voiceState = state;
+ const props = createFollowUpPromptBoxProps({ kind: "ready" });
+ props.environmentSummary =
Local environment;
+
+ render(
);
+
+ expect(screen.getByTestId("prompt-box").dataset.voiceState).toBe(state);
+ expect(
+ document.querySelector("[data-follow-up-composer-footer]"),
+ ).toBeTruthy();
+ expect(screen.getByText("Local environment")).toBeTruthy();
+ },
+ );
+
it("exposes focus state so narrow prompt containers can expand", async () => {
render(
<>
diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx
index d80f58db47..a57e7561e8 100644
--- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx
+++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx
@@ -263,6 +263,9 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({
promptBoxRef.current?.insertTextAtCursor(text);
},
getTextBeforeCursor: () => promptBoxRef.current?.getTextBeforeCursor(),
+ playVoiceCompletionTransition: () =>
+ promptBoxRef.current?.playVoiceCompletionTransition() ??
+ Promise.resolve(),
}),
[],
);
diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx
index 938c93b11a..0c080072f7 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx
@@ -927,6 +927,27 @@ function RecordingActiveRow() {
);
}
+function RecordingWithExistingDraftRow() {
+ const { value, mentionRanges, onChange } = useControlledValue(
+ "Keep this existing prompt visible while I dictate the rest of the request.",
+ );
+ return (
+
}
+ />
+ );
+}
+
function RecordingProcessingRow() {
const { value, mentionRanges, onChange } = useControlledValue("");
return (
@@ -1011,6 +1032,21 @@ export function PromptActions() {
);
}
+export function VoiceActionRowRecommendation() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
export function Overview() {
return (
diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
index 0d96a9f298..e879884b2a 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
@@ -2278,29 +2278,475 @@ describe("PromptBoxInternal compact layout", () => {
expect(submitGroup?.contains(voice)).toBe(false);
});
- it("keeps collapsed composer controls from covering voice controls", () => {
+ it("keeps the existing prompt content when voice recording activates", () => {
+ const onChange = vi.fn();
+ const voice = {
+ state: "idle" as const,
+ isSupported: true,
+ stream: null,
+ start: vi.fn(),
+ stop: vi.fn(),
+ cancel: vi.fn(),
+ };
+ const view = render(
+ ,
+ );
+
+ const editor = getPromptEditorElement();
+ expect(editor.textContent).toBe("Keep this prompt visible while I dictate");
+
+ view.rerender(
+ ,
+ );
+
+ expect(getPromptEditorElement()).toBe(editor);
+ expect(editor.textContent).toBe("Keep this prompt visible while I dictate");
+ expect(
+ onChange.mock.calls.every(
+ ([nextValue]) =>
+ nextValue === "Keep this prompt visible while I dictate",
+ ),
+ ).toBe(true);
+ });
+
+ it.each(["recording", "transcribing"] as const)(
+ "keeps the visible draft keyboard-read-only and standard controls inert while %s",
+ async (state) => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ const editor = getPromptEditorElement();
+ await waitFor(() =>
+ expect(editor.getAttribute("contenteditable")).toBe("false"),
+ );
+ expect(editor.getAttribute("tabindex")).toBe("-1");
+ expect(editor.getAttribute("aria-readonly")).toBe("true");
+ expect(screen.getByRole("textbox")).toBe(editor);
+ onChange.mockClear();
+ editor.focus();
+ fireEvent.keyDown(editor, { key: "x", code: "KeyX" });
+
+ expect(editor.textContent).toBe("Keep this prompt unchanged");
+ expect(onChange).not.toHaveBeenCalled();
+ expect(
+ document
+ .querySelector("[data-promptbox-input-region]")
+ ?.hasAttribute("inert"),
+ ).toBe(false);
+ for (const controls of document.querySelectorAll(
+ "[data-promptbox-standard-actions]",
+ )) {
+ expect(controls.hasAttribute("inert")).toBe(true);
+ }
+ expect(
+ document
+ .querySelector("[data-promptbox-voice-controls]")
+ ?.hasAttribute("inert"),
+ ).toBe(false);
+ },
+ );
+
+ it("keeps the prompt editor visible while the waveform occupies the action row", () => {
+ const stop = vi.fn();
+ const cancel = vi.fn();
render(
,
);
const main = document.querySelector("[data-promptbox-main]");
+ const layout = document.querySelector(
+ "[data-promptbox-layout]",
+ );
+ const actionRow = document.querySelector("[data-promptbox-action-row]");
+ const waveform = document.querySelector("canvas[aria-hidden]");
+
+ expect(main?.classList.contains("opacity-0")).toBe(false);
expect(main?.classList.contains("pointer-events-none")).toBe(true);
- expect(
- screen.getByRole("button", { name: "Stop and transcribe recording" }),
- ).toBeTruthy();
+ expect(layout?.style.gridTemplateRows).toBe("1fr");
+ expect(getPromptEditorElement().textContent).toBe(
+ "Keep this prompt visible while I dictate",
+ );
+ expect(waveform).toBeTruthy();
+ expect(actionRow?.contains(waveform)).toBe(true);
+ const confirm = screen.getByRole("button", {
+ name: "Stop and transcribe recording",
+ });
+ const cancelButton = screen.getByRole("button", {
+ name: "Cancel recording",
+ });
+ const voiceControls = document.querySelector(
+ "[data-promptbox-voice-controls]",
+ );
+ expect(voiceControls?.classList.contains("pointer-events-auto")).toBe(true);
+ expect(voiceControls?.contains(confirm)).toBe(true);
+ expect(voiceControls?.contains(cancelButton)).toBe(true);
+ fireEvent.click(confirm);
+ fireEvent.click(cancelButton);
+ expect(stop).toHaveBeenCalledOnce();
+ expect(cancel).toHaveBeenCalledOnce();
+ });
+
+ it("keeps newly mounted voice controls entering until the reveal frame", () => {
+ let nextFrameId = 1;
+ const pendingFrames = new Map();
+ const requestFrame = vi
+ .spyOn(window, "requestAnimationFrame")
+ .mockImplementation((callback) => {
+ const frameId = nextFrameId++;
+ pendingFrames.set(frameId, callback);
+ return frameId;
+ });
+ const cancelFrame = vi
+ .spyOn(window, "cancelAnimationFrame")
+ .mockImplementation((frameId) => {
+ pendingFrames.delete(frameId);
+ });
+ try {
+ const idleVoice: PromptVoiceConfig = {
+ state: "idle",
+ isSupported: true,
+ stream: null,
+ start: vi.fn(),
+ stop: vi.fn(),
+ cancel: vi.fn(),
+ };
+ const view = render(
+ ,
+ );
+
+ view.rerender(
+ ,
+ );
+
+ const voiceControls = document.querySelector(
+ "[data-promptbox-voice-controls]",
+ );
+ expect(voiceControls?.dataset.voiceTransition).toBe("entering");
+ expect(voiceControls?.hasAttribute("inert")).toBe(true);
+
+ act(() => {
+ const callbacks = Array.from(pendingFrames.values());
+ pendingFrames.clear();
+ for (const callback of callbacks) callback(0);
+ });
+
+ expect(voiceControls?.dataset.voiceTransition).toBe("active");
+ expect(voiceControls?.hasAttribute("inert")).toBe(false);
+ } finally {
+ requestFrame.mockRestore();
+ cancelFrame.mockRestore();
+ }
});
+ it("finishes the voice exit transition before a ready transcript can be inserted", async () => {
+ vi.useFakeTimers();
+ try {
+ const promptBoxRef = createRef();
+ render(
+ ,
+ );
+
+ let transitionFinished = false;
+ let transition: Promise | undefined;
+ act(() => {
+ transition = promptBoxRef.current?.playVoiceCompletionTransition();
+ void transition?.then(() => {
+ transitionFinished = true;
+ });
+ });
+
+ expect(
+ document
+ .querySelector("[data-promptbox-voice-controls]")
+ ?.getAttribute("data-voice-transition"),
+ ).toBe("exiting");
+
+ const voiceControls = document.querySelector(
+ "[data-promptbox-voice-controls]",
+ );
+ expect(voiceControls?.hasAttribute("inert")).toBe(true);
+ expect(voiceControls?.getAttribute("aria-hidden")).toBe("true");
+ expect(
+ voiceControls?.querySelector('[aria-label="Cancel transcription"]'),
+ ).toBeTruthy();
+ expect(
+ screen.queryByRole("button", { name: "Cancel transcription" }),
+ ).toBeNull();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(179);
+ });
+ expect(transitionFinished).toBe(false);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1);
+ await transition;
+ });
+ expect(transitionFinished).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("cancels immediately while retaining the voice bar for its exit transition", async () => {
+ vi.useFakeTimers();
+ try {
+ const cancel = vi.fn();
+ const recordingVoice: PromptVoiceConfig = {
+ state: "recording",
+ isSupported: true,
+ stream: null,
+ start: vi.fn(),
+ stop: vi.fn(),
+ cancel,
+ };
+ const view = render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel recording" }));
+ expect(cancel).toHaveBeenCalledOnce();
+
+ view.rerender(
+ ,
+ );
+ expect(
+ document
+ .querySelector("[data-promptbox-voice-controls]")
+ ?.getAttribute("data-voice-transition"),
+ ).toBe("exiting");
+ const voiceControls = document.querySelector(
+ "[data-promptbox-voice-controls]",
+ );
+ expect(voiceControls?.hasAttribute("inert")).toBe(true);
+ expect(voiceControls?.getAttribute("aria-hidden")).toBe("true");
+ expect(
+ voiceControls?.querySelector('[aria-label="Cancel recording"]'),
+ ).toBeTruthy();
+ expect(
+ voiceControls?.querySelector('[aria-label="Cancel transcription"]'),
+ ).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Cancel recording" }),
+ ).toBeNull();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(180);
+ });
+ expect(
+ document.querySelector("[data-promptbox-voice-controls]"),
+ ).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("does not delay transcript insertion while the document is hidden", async () => {
+ const originalVisibilityState = Object.getOwnPropertyDescriptor(
+ document,
+ "visibilityState",
+ );
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "hidden",
+ });
+ try {
+ const promptBoxRef = createRef();
+ render(
+ ,
+ );
+
+ await expect(
+ promptBoxRef.current?.playVoiceCompletionTransition(),
+ ).resolves.toBeUndefined();
+ } finally {
+ if (originalVisibilityState) {
+ Object.defineProperty(
+ document,
+ "visibilityState",
+ originalVisibilityState,
+ );
+ } else {
+ Reflect.deleteProperty(document, "visibilityState");
+ }
+ }
+ });
+
+ it("does not delay transcript insertion for reduced motion", async () => {
+ const originalMatchMedia = window.matchMedia;
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: query === "(prefers-reduced-motion: reduce)",
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }));
+ try {
+ const promptBoxRef = createRef();
+ render(
+ ,
+ );
+
+ await expect(
+ promptBoxRef.current?.playVoiceCompletionTransition(),
+ ).resolves.toBeUndefined();
+ } finally {
+ window.matchMedia = originalMatchMedia;
+ }
+ });
+
+ it.each(["recording", "transcribing"] as const)(
+ "keeps zen sizing coherent while voice is %s",
+ async (state) => {
+ const storageKey = `bb.test.promptbox.voice-zen-${state}`;
+ window.localStorage.removeItem(storageKey);
+ const voice = {
+ state: "idle" as const,
+ isSupported: true,
+ stream: null,
+ start: vi.fn(),
+ stop: vi.fn(),
+ cancel: vi.fn(),
+ };
+ const view = render(
+ ,
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Make prompt box larger" }),
+ );
+ await waitFor(() =>
+ expect(
+ document
+ .querySelector("[data-promptbox]")
+ ?.hasAttribute("data-promptbox-zen"),
+ ).toBe(true),
+ );
+
+ view.rerender(
+ ,
+ );
+
+ const form = document.querySelector("[data-promptbox]");
+ const editorScroll = document.querySelector(
+ "[data-promptbox-editor-scroll]",
+ );
+ const actionRow = document.querySelector("[data-promptbox-action-row]");
+ const waveform = document.querySelector("canvas[aria-hidden]");
+ expect(form?.hasAttribute("data-promptbox-zen")).toBe(true);
+ expect(form?.classList.contains("h-[50dvh]")).toBe(true);
+ expect(editorScroll?.style.height).toBe("100%");
+ expect(editorScroll?.style.maxHeight).toBe("none");
+ expect(getPromptEditorElement().textContent).toBe(
+ "Keep this zen prompt visible",
+ );
+ expect(actionRow?.contains(waveform)).toBe(true);
+
+ window.localStorage.removeItem(storageKey);
+ },
+ );
+
it("does not expose zen controls in the full mobile layout", () => {
render(
= {
const COLLAPSING_GRID_CLASS =
"grid transition-[grid-template-rows] duration-[180ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none";
+const VOICE_ACTION_TRANSITION_MS = 180;
+type VoiceActionTransition = "entering" | "active" | "exiting";
+
+function prefersReducedMotion(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+function shouldFinishVoiceCompletionTransitionImmediately(): boolean {
+ return (
+ prefersReducedMotion() ||
+ (typeof document !== "undefined" && document.visibilityState === "hidden")
+ );
+}
export interface PromptBoxSubmissionConfig {
isSubmitting?: boolean;
@@ -327,6 +344,8 @@ export interface PromptBoxHandle {
insertTextAtCursor: (text: string) => void;
/** Return the trimmed text before the cursor, used as voice transcript context. */
getTextBeforeCursor: () => string | undefined;
+ /** Exit the voice controls before inserting a completed transcript. */
+ playVoiceCompletionTransition: () => Promise;
}
export type { PromptBoxAction } from "./PromptBoxActionsMenu";
@@ -1251,9 +1270,126 @@ export function PromptBoxInternal({
const isVoiceProcessing = voice?.state === "transcribing";
const showVoiceActionGroup = isVoiceRecording || isVoiceProcessing;
const isVoiceBusy = showVoiceActionGroup;
- // Zen styling is suppressed while the voice bar shows, since the box
- // collapses to the pill instead.
- const showZenLayout = isZenMode && !showVoiceActionGroup;
+ const voiceActionState = isVoiceRecording
+ ? "recording"
+ : isVoiceProcessing
+ ? "transcribing"
+ : null;
+ const lastVoiceActionStateRef = useRef<"recording" | "transcribing">(
+ voiceActionState ?? "recording",
+ );
+ const renderedVoiceActionState =
+ voiceActionState ?? lastVoiceActionStateRef.current;
+ useLayoutEffect(() => {
+ if (voiceActionState !== null) {
+ lastVoiceActionStateRef.current = voiceActionState;
+ }
+ }, [voiceActionState]);
+ const [isVoiceActionPresent, setIsVoiceActionPresent] =
+ useState(showVoiceActionGroup);
+ const [voiceActionTransition, setVoiceActionTransition] =
+ useState(
+ showVoiceActionGroup ? "active" : "exiting",
+ );
+ const isVoiceActionVisible = voiceActionTransition === "active";
+ const wasVoiceActionShownRef = useRef(showVoiceActionGroup);
+ const voiceActionRevealFrameRef = useRef(null);
+ const voiceActionRemovalTimeoutRef = useRef(null);
+ const voiceCompletionTimeoutRef = useRef(null);
+ const voiceCompletionPromiseRef = useRef | null>(null);
+ const voiceCompletionResolveRef = useRef<(() => void) | null>(null);
+
+ useLayoutEffect(() => {
+ const wasVoiceActionShown = wasVoiceActionShownRef.current;
+ wasVoiceActionShownRef.current = showVoiceActionGroup;
+ if (voiceActionRevealFrameRef.current !== null) {
+ window.cancelAnimationFrame(voiceActionRevealFrameRef.current);
+ voiceActionRevealFrameRef.current = null;
+ }
+ if (voiceActionRemovalTimeoutRef.current !== null) {
+ window.clearTimeout(voiceActionRemovalTimeoutRef.current);
+ voiceActionRemovalTimeoutRef.current = null;
+ }
+
+ if (showVoiceActionGroup) {
+ setIsVoiceActionPresent(true);
+ if (wasVoiceActionShown || prefersReducedMotion()) {
+ setVoiceActionTransition("active");
+ return;
+ }
+ setVoiceActionTransition("entering");
+ voiceActionRevealFrameRef.current = window.requestAnimationFrame(() => {
+ voiceActionRevealFrameRef.current = null;
+ setVoiceActionTransition("active");
+ });
+ return;
+ }
+
+ setVoiceActionTransition("exiting");
+ if (!wasVoiceActionShown) {
+ setIsVoiceActionPresent(false);
+ return;
+ }
+ if (prefersReducedMotion()) {
+ setIsVoiceActionPresent(false);
+ return;
+ }
+ voiceActionRemovalTimeoutRef.current = window.setTimeout(() => {
+ voiceActionRemovalTimeoutRef.current = null;
+ setIsVoiceActionPresent(false);
+ }, VOICE_ACTION_TRANSITION_MS);
+ }, [showVoiceActionGroup]);
+
+ useEffect(
+ () => () => {
+ if (voiceActionRevealFrameRef.current !== null) {
+ window.cancelAnimationFrame(voiceActionRevealFrameRef.current);
+ }
+ if (voiceActionRemovalTimeoutRef.current !== null) {
+ window.clearTimeout(voiceActionRemovalTimeoutRef.current);
+ }
+ if (voiceCompletionTimeoutRef.current !== null) {
+ window.clearTimeout(voiceCompletionTimeoutRef.current);
+ }
+ voiceCompletionResolveRef.current?.();
+ },
+ [],
+ );
+
+ const playVoiceCompletionTransition = useCallback((): Promise => {
+ if (voiceActionRevealFrameRef.current !== null) {
+ window.cancelAnimationFrame(voiceActionRevealFrameRef.current);
+ voiceActionRevealFrameRef.current = null;
+ }
+ setVoiceActionTransition("exiting");
+ if (shouldFinishVoiceCompletionTransitionImmediately()) {
+ if (voiceCompletionTimeoutRef.current !== null) {
+ window.clearTimeout(voiceCompletionTimeoutRef.current);
+ voiceCompletionTimeoutRef.current = null;
+ }
+ const resolvePendingTransition = voiceCompletionResolveRef.current;
+ voiceCompletionPromiseRef.current = null;
+ voiceCompletionResolveRef.current = null;
+ resolvePendingTransition?.();
+ return Promise.resolve();
+ }
+ if (voiceCompletionPromiseRef.current) {
+ return voiceCompletionPromiseRef.current;
+ }
+
+ const transition = new Promise((resolve) => {
+ voiceCompletionResolveRef.current = resolve;
+ voiceCompletionTimeoutRef.current = window.setTimeout(() => {
+ voiceCompletionTimeoutRef.current = null;
+ voiceCompletionPromiseRef.current = null;
+ voiceCompletionResolveRef.current = null;
+ resolve();
+ }, VOICE_ACTION_TRANSITION_MS);
+ });
+ voiceCompletionPromiseRef.current = transition;
+ return transition;
+ }, []);
+ const showZenLayout = isZenMode;
const showCompactLayout =
compact?.isCompact === true && !showVoiceActionGroup && !isZenMode;
const effectivePlaceholder = showCompactLayout
@@ -1704,9 +1840,15 @@ export function PromptBoxInternal({
useEffect(() => {
if (!editor || editor.isDestroyed) return;
- const editable = !composerInputLocked;
+ const editable = !composerInputLocked && !isVoiceBusy;
if (editor.isEditable !== editable) editor.setEditable(editable);
- }, [composerInputLocked, editor]);
+ editor.view.dom.tabIndex = editable ? 0 : -1;
+ if (editable) {
+ editor.view.dom.removeAttribute("aria-readonly");
+ } else {
+ editor.view.dom.setAttribute("aria-readonly", "true");
+ }
+ }, [composerInputLocked, editor, isVoiceBusy]);
useEffect(() => {
editorRef.current = editor;
@@ -2007,6 +2149,7 @@ export function PromptBoxInternal({
activeTrigger.char !== DEFAULT_PLUGIN_MENTION_TRIGGER &&
activeMentionQuery.length === 0;
const showTypeaheadMenu =
+ !isVoiceBusy &&
activeTrigger !== null &&
!isCommandTriggerLiteral &&
!isBareNonDefaultMentionTrigger;
@@ -2397,8 +2540,15 @@ export function PromptBoxInternal({
focusEnd,
insertTextAtCursor,
getTextBeforeCursor,
+ playVoiceCompletionTransition,
}),
- [capturePromptBoxHeight, focusEnd, insertTextAtCursor, getTextBeforeCursor],
+ [
+ capturePromptBoxHeight,
+ focusEnd,
+ getTextBeforeCursor,
+ insertTextAtCursor,
+ playVoiceCompletionTransition,
+ ],
);
const canSubmit =
@@ -2432,6 +2582,14 @@ export function PromptBoxInternal({
}
void voice?.start();
}, [isPointerCoarse, voice]);
+ const cancelVoiceInput = useCallback(() => {
+ if (voiceActionRevealFrameRef.current !== null) {
+ window.cancelAnimationFrame(voiceActionRevealFrameRef.current);
+ voiceActionRevealFrameRef.current = null;
+ }
+ setVoiceActionTransition("exiting");
+ voice?.cancel();
+ }, [voice]);
const effectiveSubmitTitle = isZenMode
? submitTitle.replace(/^Submit\s+/, "")
: submitTitle;
@@ -2892,16 +3050,15 @@ export function PromptBoxInternal({
// whole box), instead of leaking to the collapsed editor.
useEffect(() => {
if (!showVoiceActionGroup || !voice) return;
- const cancelVoice = voice.cancel;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
- cancelVoice();
+ cancelVoiceInput();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
- }, [showVoiceActionGroup, voice]);
+ }, [cancelVoiceInput, showVoiceActionGroup, voice]);
return (