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 (
{header && !showCompactLayout ? ( @@ -2965,12 +3120,14 @@ export function PromptBoxInternal({ // mode also gets more top room since the card fills the viewport.
{header}
) : null}
{isZenMode ? ( @@ -3142,7 +3301,10 @@ export function PromptBoxInternal({ {!showCompactLayout ? ( <> -
+
+ {voice && isVoiceActionPresent ? ( +
+ +
+ ) : null} {!showCompactLayout ? (
) : null} -
+
{!showCompactLayout ? ( <> {!suppressPluginComposerCustomizations ? ( @@ -3300,21 +3499,6 @@ export function PromptBoxInternal({
-
-
- {voice && showVoiceActionGroup ? ( - - ) : null} -
-
); } diff --git a/apps/app/src/components/promptbox/usePromptVoice.test.tsx b/apps/app/src/components/promptbox/usePromptVoice.test.tsx new file mode 100644 index 0000000000..80b81996de --- /dev/null +++ b/apps/app/src/components/promptbox/usePromptVoice.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { transcribeVoiceInput } from "@/lib/api"; +import { useVoiceInput } from "@/hooks/useVoiceInput"; +import type { PromptBoxHandle } from "./PromptBoxInternal"; +import { usePromptVoice } from "./usePromptVoice"; + +vi.mock("@/lib/api", () => ({ + transcribeVoiceInput: vi.fn(), +})); + +vi.mock("@/hooks/useVoiceInput", () => ({ + useVoiceInput: vi.fn(), +})); + +const voiceInput = { + state: "transcribing" as const, + isSupported: true, + stream: null, + start: vi.fn(), + stop: vi.fn(), + cancel: vi.fn(), +}; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("usePromptVoice", () => { + it("waits for the completion transition after transcription resolves", async () => { + vi.mocked(useVoiceInput).mockReturnValue({ + ...voiceInput, + isRecording: false, + isProcessing: true, + isListening: false, + }); + vi.mocked(transcribeVoiceInput).mockResolvedValue({ text: "Transcript" }); + + let finishTransition: (() => void) | undefined; + const playVoiceCompletionTransition = vi.fn( + () => + new Promise((resolve) => { + finishTransition = resolve; + }), + ); + const insertTextAtCursor = vi.fn(); + const promptBoxRef = { + current: { + captureHeightForLayoutChange: vi.fn(), + focusEnd: vi.fn(), + getTextBeforeCursor: vi.fn(), + insertTextAtCursor, + playVoiceCompletionTransition, + } satisfies PromptBoxHandle, + }; + + renderHook(() => usePromptVoice(promptBoxRef)); + const options = vi.mocked(useVoiceInput).mock.calls[0]?.[0]; + const transcription = options?.onTranscribe({ + file: new File([], "recording.webm", { type: "audio/webm" }), + }); + + await act(async () => { + await Promise.resolve(); + }); + expect(playVoiceCompletionTransition).toHaveBeenCalledOnce(); + + let settled = false; + void transcription?.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + finishTransition?.(); + await expect(transcription).resolves.toBe("Transcript"); + expect(insertTextAtCursor).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/promptbox/usePromptVoice.ts b/apps/app/src/components/promptbox/usePromptVoice.ts index 7eeeb1bd50..c24ba47e87 100644 --- a/apps/app/src/components/promptbox/usePromptVoice.ts +++ b/apps/app/src/components/promptbox/usePromptVoice.ts @@ -16,6 +16,10 @@ async function requestVoiceTranscription({ return transcription.text; } +function createVoiceAbortError(): DOMException { + return new DOMException("Voice transcription was cancelled", "AbortError"); +} + export function usePromptVoice( promptBoxRef: RefObject, ): PromptVoiceConfig { @@ -31,9 +35,21 @@ export function usePromptVoice( [promptBoxRef], ); + const transcribeAfterCompletionTransition = useCallback( + async (args: Parameters[0]) => { + const text = await requestVoiceTranscription(args); + await promptBoxRef.current?.playVoiceCompletionTransition(); + if (args.signal?.aborted) { + throw createVoiceAbortError(); + } + return text; + }, + [promptBoxRef], + ); + const voiceInput = useVoiceInput({ onTranscript, - onTranscribe: requestVoiceTranscription, + onTranscribe: transcribeAfterCompletionTransition, getPromptContext, });