From 45a6ec867dfb57edfb6000251d32802971c28821 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 02:36:54 +0000 Subject: [PATCH 1/3] feat(web): style dictation with the redesign primitives --- apps/web/src/components/chat/ChatComposer.tsx | 166 ++++++++++------- .../chat/ComposerFooterControls.tsx | 8 +- .../chat/ComposerPrimaryActions.tsx | 64 ++++++- .../components/chat/ComposerSpeechButton.tsx | 173 +++++++++++++++--- .../components/chat/VoiceInputErrorPill.tsx | 2 +- .../src/components/chat/VoiceInputPill.tsx | 2 +- .../src/components/chat/VoiceInputSetup.tsx | 20 +- .../settings/MicrophoneOrderList.tsx | 8 +- .../components/settings/MicrophoneTest.tsx | 35 ++-- .../settings/SpeechModelComparison.tsx | 14 +- .../settings/VoiceSettingsPanel.tsx | 38 ++-- .../src/speech/useEnvironmentSpeechInput.ts | 34 ++++ apps/web/src/speech/useTextFieldSpeech.tsx | 2 +- docs/user/voice-input.md | 7 +- vite.config.ts | 10 - 15 files changed, 413 insertions(+), 170 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fed7b0a6cb633..676c10a7346fe 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -143,8 +143,13 @@ import { import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState"; import { useDictationShortcut } from "../../speech/useDictationShortcut"; import { useDictationSubmissionShortcut } from "../../speech/useDictationSubmissionShortcut"; -import { useEnvironmentSpeechInput } from "../../speech/useEnvironmentSpeechInput"; -import { ComposerSpeechButton, ComposerSpeechRecordingPill } from "./ComposerSpeechButton"; +import { + useEnvironmentSpeechInput, + useSpeechUnavailableReason, +} from "../../speech/useEnvironmentSpeechInput"; +import { ComposerSpeechButton } from "./ComposerSpeechButton"; +import { VoiceInputErrorPill } from "./VoiceInputErrorPill"; +import { COMPOSER_PILL_SLOT_BUTTON_CLASS_NAME } from "./ComposerPrimaryActions"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { shortcutLabelForCommand } from "../../keybindings"; import { @@ -1029,7 +1034,6 @@ export function ChatComposer(props: ChatComposerProps) { null, ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); @@ -1572,7 +1576,6 @@ export function ChatComposer(props: ChatComposerProps) { const measureCompactLayouts = () => { const composerFormWidth = composerForm.clientWidth; const options = { hasWideActions: false }; - setIsComposerFooterCompact(shouldUseCompactComposerFooter(composerFormWidth)); setIsComposerPrimaryActionsCompact( shouldUseCompactComposerFooter(composerFormWidth, options) && shouldUseCompactComposerPrimaryActions(composerFormWidth, options), @@ -1908,8 +1911,6 @@ export function ChatComposer(props: ChatComposerProps) { }, }); const { blocksSubmission: voiceInputActive, submitAfterDictation } = speechInput; - // A compact footer gives the dictation capsule the whole row beside the send button. - const dictationTakesFooter = isComposerFooterCompact && speechInput.state.phase !== "idle"; const submissionTarget = activePendingProgress ? "pending-user-input" : "provider-turn"; const submissionBlocked = isComposerSubmissionBlocked({ submissionTarget, @@ -1933,6 +1934,13 @@ export function ChatComposer(props: ChatComposerProps) { modelPickerOpen: isComposerModelPickerOpen, ...(backgroundThread ? { targetRef: composerFormRef } : {}), }); + // The editor freezes while dictation runs and drops focus; collapsing then would hide the recording pill. + const dictationActiveRef = useRef(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes dictation activity to the asynchronous blur check + useEffect(() => { + dictationActiveRef.current = speechInput.state.phase !== "idle"; + }); + const speechUnavailableReason = useSpeechUnavailableReason(speechInput); const resolveActiveComposerTrigger = (): { snapshot: { value: string; cursor: number; expandedCursor: number }; @@ -2357,6 +2365,29 @@ export function ChatComposer(props: ChatComposerProps) { }); }; const expandMobileComposerStable = useStableCallback(expandMobileComposer); + const showsSpeechAction = speechInput.available || speechUnavailableReason !== null; + const renderSpeechAction = (inPill: boolean) => + showsSpeechAction ? ( + { + if (isComposerCollapsedMobile) expandMobileComposerStable(); + void speechInput.start(); + }} + dictation={{ + level: speechInput.level, + onStop: () => void speechInput.stop(), + onSkipPostProcessing: () => speechInput.skipPostProcessing(), + }} + /> + ) : null; + // While recording, the send slot becomes Stop and finishes the recording. + const stopDictationStable = useStableCallback(() => void speechInput.stop()); + const onStopDictation = speechInput.state.phase === "recording" ? stopDictationStable : undefined; // Escape dismisses the suggestion so Tab moves focus again. const { suggestion: promptSuggestion, dismiss: dismissPromptSuggestion } = usePromptSuggestion( @@ -3003,7 +3034,7 @@ export function ChatComposer(props: ChatComposerProps) { } composerBlurFrameRef.current = window.requestAnimationFrame(() => { composerBlurFrameRef.current = null; - if (mobileComposerExpandInFlightRef.current) { + if (mobileComposerExpandInFlightRef.current || dictationActiveRef.current) { return; } const composerSurface = composerSurfaceRef.current; @@ -3587,41 +3618,56 @@ export function ChatComposer(props: ChatComposerProps) { isEditingQueuedMessage={isEditingQueuedMessage} onInterrupt={handleInterruptPrimaryActionStable} > - + {onStopDictation ? ( + + ) : null} @@ -3793,8 +3839,9 @@ export function ChatComposer(props: ChatComposerProps) { data-chat-composer-transition-actions="true" className="z-1 flex w-max max-w-full items-center justify-end gap-1.5 self-start justify-self-end [grid-area:prompt]" > - {dictationTakesFooter ? null : composerAttachButton} + {composerAttachButton} - {speechInput.available ? ( - <> - void speechInput.start()} - onStop={() => void speechInput.stop()} - onCancel={() => speechInput.cancel()} - onSkipPostProcessing={() => speechInput.skipPostProcessing()} - /> - void speechInput.start()} - /> - + {speechInput.available && speechInput.state.phase === "error" ? ( + void speechInput.start()} + onDismiss={() => speechInput.cancel()} + /> ) : null} {speechInput.preview.committed} {speechInput.preview.tentative} diff --git a/apps/web/src/components/chat/ComposerFooterControls.tsx b/apps/web/src/components/chat/ComposerFooterControls.tsx index b39160c0a1071..6dde903eed4fe 100644 --- a/apps/web/src/components/chat/ComposerFooterControls.tsx +++ b/apps/web/src/components/chat/ComposerFooterControls.tsx @@ -185,6 +185,7 @@ export function ComposerPermissionsSection(props: { export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { children?: React.ReactNode; + renderSpeechAction?: ((inPill: boolean) => React.ReactNode) | undefined; compact: boolean; canOperateThread: boolean; isPreparingWorktree: boolean; @@ -213,8 +214,9 @@ export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryA onResume: () => void; onPreviousPendingQuestion: () => void; onInterrupt: () => void; + onStopDictation?: (() => void) | undefined; + stopDictationShortcutLabel?: string | null; compactBeforeSendTokens: number | null; - hideCompactToggle: boolean; keepFullHistory: boolean; onToggleKeepFullHistory: () => void; }) { @@ -239,12 +241,14 @@ export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryA canResume={props.canResume} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} isEditingQueuedMessage={props.isEditingQueuedMessage} + renderSpeechAction={props.renderSpeechAction} onSubmitMessage={props.onSubmitMessage} onResume={props.onResume} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} + onStopDictation={props.onStopDictation} + stopDictationShortcutLabel={props.stopDictationShortcutLabel ?? null} compactBeforeSendTokens={props.compactBeforeSendTokens} - hideCompactToggle={props.hideCompactToggle} keepFullHistory={props.keepFullHistory} onToggleKeepFullHistory={props.onToggleKeepFullHistory} > diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 80b65bf97a4ae..884338a947296 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -57,12 +57,16 @@ interface ComposerPrimaryActionsProps { canResume?: boolean; preserveComposerFocusOnPointerDown?: boolean; isEditingQueuedMessage?: boolean; + /** The dictation mic: a slot in the send and stop pill, a quiet icon before the other actions. */ + renderSpeechAction?: ((inPill: boolean) => ReactNode) | undefined; onSubmitMessage?: MouseEventHandler; onResume?: () => void; onPreviousPendingQuestion: () => void; onInterrupt: () => void; + /** Set while dictation records: the stop slot finishes it instead of sending. */ + onStopDictation?: (() => void) | undefined; + stopDictationShortcutLabel?: string | null; compactBeforeSendTokens?: number | null; - hideCompactToggle?: boolean; keepFullHistory?: boolean; onToggleKeepFullHistory?: () => void; } @@ -152,6 +156,9 @@ function StopGenerationButton({ preserveComposerFocusOnPointerDown, onStopRequested, onInterrupt, + leading = null, + onStopDictation, + stopDictationShortcutLabel = null, }: { stopPhase: StopPhase; canOperateThread: boolean; @@ -159,10 +166,21 @@ function StopGenerationButton({ preserveComposerFocusOnPointerDown: boolean; onStopRequested: () => void; onInterrupt: () => void; + /** The dictation mic slot that shares the pill. */ + leading?: ReactNode; + /** Set while dictation records: the stop slot finishes it instead of interrupting. */ + onStopDictation?: (() => void) | undefined; + stopDictationShortcutLabel?: string | null; }) { const isStopDisabled = stopPhase === "pending"; + const stopLabel = onStopDictation + ? "Stop voice input" + : stopPhase === "pending" + ? "Stopping" + : "Stop generation"; return ( + {leading} { + if (onStopDictation) { + onStopDictation(); + return; + } if (!canOperateThread || isStopDisabled) return; onStopRequested(); onInterrupt(); }} - aria-label={stopPhase === "pending" ? "Stopping" : "Stop generation"} + aria-label={stopLabel} /> } > - {stopPhase === "pending" ? "Stopping…" : "Interrupt"} + + {onStopDictation + ? `${stopLabel}${stopDictationShortcutLabel ? ` (${stopDictationShortcutLabel})` : ""}` + : stopPhase === "pending" + ? "Stopping…" + : "Interrupt"} + ); @@ -256,12 +284,14 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ canResume = false, preserveComposerFocusOnPointerDown = false, isEditingQueuedMessage = false, + renderSpeechAction, onSubmitMessage, onResume, onPreviousPendingQuestion, onInterrupt, + onStopDictation, + stopDictationShortcutLabel = null, compactBeforeSendTokens = null, - hideCompactToggle = false, keepFullHistory = false, onToggleKeepFullHistory, }: ComposerPrimaryActionsProps) { @@ -282,6 +312,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const isSendDisabled = !canOperateThread || sendDisabledReason !== null; // The stop square sits in the same pill as send, so the pill never changes shape. + // While dictating it ends the recording; a running turn's stop returns after. + const stopsDictation = onStopDictation !== undefined && !pendingAction; const renderStopGenerationButton = (insidePendingAction: boolean) => ( setStopPhase("pending")} onInterrupt={onInterrupt} + leading={insidePendingAction ? null : renderSpeechAction?.(true)} + {...(stopsDictation ? { onStopDictation } : {})} + stopDictationShortcutLabel={stopDictationShortcutLabel} /> ); @@ -307,6 +342,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ if (pendingAction) { return (
+ {renderSpeechAction?.(false)} {pendingAction.questionIndex > 0 ? ( compact ? ( - + {glyph} + + + {unavailable ?? label} + {showsShortcut && props.shortcutLabel ? ` (${props.shortcutLabel})` : ""} + {phase !== "idle" && !isRecording ? " (Esc cancels)" : ""} + + ); } @@ -186,9 +315,9 @@ export function ComposerSpeechRecordingPill(props: { ) : ( <> - + {presentation.status} - + )}
diff --git a/apps/web/src/components/chat/VoiceInputErrorPill.tsx b/apps/web/src/components/chat/VoiceInputErrorPill.tsx index e4b04e6792303..d28889d24eab9 100644 --- a/apps/web/src/components/chat/VoiceInputErrorPill.tsx +++ b/apps/web/src/components/chat/VoiceInputErrorPill.tsx @@ -79,7 +79,7 @@ export function VoiceInputErrorPill(props: { tabIndex={0} role="status" aria-label={message} - className="flex min-w-0 items-center gap-1.5 rounded-sm text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" + className="flex min-w-0 items-center gap-1.5 rounded-sm text-label outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" /> } > diff --git a/apps/web/src/components/chat/VoiceInputPill.tsx b/apps/web/src/components/chat/VoiceInputPill.tsx index 1aa238a8c4b28..0971d31cbd2f1 100644 --- a/apps/web/src/components/chat/VoiceInputPill.tsx +++ b/apps/web/src/components/chat/VoiceInputPill.tsx @@ -66,7 +66,7 @@ export function VoiceInputPill({ "h-7 rounded-md max-sm:order-first max-sm:basis-full sm:h-6" : "h-9 max-w-fit grow-[1.5] basis-0 rounded-full p-0.5 sm:h-8", tone === "error" - ? "bg-destructive/6 text-destructive ring-destructive/24" + ? "bg-destructive/6 text-destructive-foreground ring-destructive/24" : "bg-muted/48 ring-border", )} > diff --git a/apps/web/src/components/chat/VoiceInputSetup.tsx b/apps/web/src/components/chat/VoiceInputSetup.tsx index 9e694e8b263a0..c5588a1deddb4 100644 --- a/apps/web/src/components/chat/VoiceInputSetup.tsx +++ b/apps/web/src/components/chat/VoiceInputSetup.tsx @@ -103,16 +103,16 @@ export function VoiceInputSetup(props: { /> {props.step === 0 ? ( -
+
{model ? ( -
+
-

{model.name}

- +

{model.name}

+ {Math.round(model.size / 1024 / 1024)} MB
-

+

{model.recommended && props.language ? `Recommended for ${languageLabel(props.language)}. ` : ""} @@ -133,7 +133,7 @@ export function VoiceInputSetup(props: { style={{ width: `${progress ?? 0}%` }} />

-

+

{model.state === "verifying" ? "Verifying…" : progress === null @@ -149,14 +149,14 @@ export function VoiceInputSetup(props: {

) : null} {props.error ? ( -

+

{props.error}

) : null}
) : ( -
+
@@ -214,12 +214,12 @@ export function VoiceInputSetup(props: { {selectedIsUnavailable ? ( -

+

That microphone is unplugged. Pick another one.

) : null} {microphoneError ? ( -

+

{microphoneError}

) : null} diff --git a/apps/web/src/components/settings/MicrophoneOrderList.tsx b/apps/web/src/components/settings/MicrophoneOrderList.tsx index e5bd92755d27a..7a9cd657b42d9 100644 --- a/apps/web/src/components/settings/MicrophoneOrderList.tsx +++ b/apps/web/src/components/settings/MicrophoneOrderList.tsx @@ -111,17 +111,17 @@ function MicrophoneRow(props: { isDragging && "relative z-10 rounded-lg bg-background shadow-md", )} > - {props.position} - + {props.position} + {name} {props.inUse ? ( - + In use ) : props.notConnected ? ( - Not connected + Not connected ) : null} ); const panel = error ? ( -

+

{error}

) : phase === "starting" ? ( - + Opening microphone… ) : phase === "recording" ? ( @@ -226,7 +227,7 @@ export function MicrophoneTest({ {formatTime(elapsed)} / {formatTime(LOOPBACK_SECONDS)} @@ -253,7 +254,7 @@ export function MicrophoneTest({ {panel ? (
@@ -277,7 +278,6 @@ function MicrophonePlayback({ src, duration }: { src: string; duration: number } const [playing, setPlaying] = useState(false); const [position, setPosition] = useState(0); const [failed, setFailed] = useState(false); - const progress = duration > 0 ? Math.min(position / duration, 1) * 100 : 0; const togglePlayback = async () => { const player = audio.current; @@ -316,27 +316,24 @@ function MicrophonePlayback({ src, duration }: { src: string; duration: number } {playing ? : } {failed ? ( -

+

Could not play this recording. Try recording again.

) : ( - { - const next = Number(event.target.value); + label="Recording playback position" + trackClassName="bg-muted" + playedClassName="bg-primary" + onSeek={(next) => { if (audio.current) audio.current.currentTime = next; setPosition(next); }} - className="h-1 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:size-2.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-primary" /> )} diff --git a/apps/web/src/components/settings/SpeechModelComparison.tsx b/apps/web/src/components/settings/SpeechModelComparison.tsx index 4842c688b36dd..46d5553a3fe14 100644 --- a/apps/web/src/components/settings/SpeechModelComparison.tsx +++ b/apps/web/src/components/settings/SpeechModelComparison.tsx @@ -1,5 +1,6 @@ import type { EnvironmentSpeechModel, SpeechBenchmarkTier } from "@t3tools/contracts"; +import { Text } from "../ui/text"; import { languageLabel } from "./SpeechLanguageSelect"; const commonLanguages = [ @@ -65,7 +66,7 @@ export function ModelBenchmarks({ (code): code is string => code !== undefined, ); return ( -
+
{visible.map((code) => { const score = data?.accuracy[code]; return ( @@ -73,8 +74,10 @@ export function ModelBenchmarks({ {languageLabel(code)}:{" "} {score ? ( <> - {score.grade} ( - {score.errorPercent}% error) + + {score.grade} + {" "} + ({score.errorPercent}% error) ) : ( "not benchmarked" @@ -90,7 +93,10 @@ export function ModelBenchmarks({ "not benchmarked" ) : ( <> - {seconds} s / 30 s + + {seconds} s + {" "} + / 30 s )} diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index ef2d22268e7aa..ac665c0c08376 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -192,7 +192,7 @@ type ModelCardProps = { function ModelBadge(props: { icon: ReactNode; label: string; tooltip: string }) { return ( - }> + }> {props.icon} {props.label} @@ -216,36 +216,36 @@ function ModelCard(props: ModelCardProps) { >
- {model.name} + {model.name} {model.recommended ? ( } + icon={} label="Recommended" tooltip="A good default pick for the languages it covers." /> ) : null} {model.supportsStreaming ? ( } + icon={} label="Live preview" tooltip="Shows words in your draft as you speak, before the recording finishes." /> ) : null} {model.supportsTranslation ? ( } + icon={} label="Translate" tooltip="Can write English text from speech in another language. Turn on Translate to English to use it." /> ) : null}
{model.description ? ( -

+

{model.description}

) : null} {model.linkedFrom ? ( -

+

Using your file at {model.linkedFrom}

) : null} @@ -277,7 +277,7 @@ function ModelCard(props: ModelCardProps) { style={{ width: `${progress}%` }} />
-

+

{model.state === "verifying" ? "Verifying download…" : `${Math.round(progress)}% downloaded`} @@ -295,7 +295,7 @@ function ModelCard(props: ModelCardProps) { disabled={!props.canCancel} onClick={props.onCancel} > - + ) : model.state === "downloadable" ? ( ) : null ) : ( - + {speech.status?.supported === false ? speech.status.reason : "Waiting for the environment to connect"} @@ -1134,7 +1134,7 @@ function TranscriptionTest(props: {

{speech.preview || transcript !== null ? null : ( -

+

Say a sentence. Cleanup is skipped, so you see exactly what the model heard.

)} @@ -1142,7 +1142,7 @@ function TranscriptionTest(props: {

{speech.preview ? ( <> @@ -1339,7 +1339,7 @@ export function VoiceSettingsPanel() { {SHORTCUT_MODES[mode].label} - + {SHORTCUT_MODES[mode].hint} diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts index 94dd7cfb8b4cf..d81b82ae64a9e 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.ts +++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts @@ -141,6 +141,40 @@ export function useVoiceTranscriptionDestination() { }; } +/** + * Why the composer mic cannot start yet, or null when it can or should not show at all + * (a browser that cannot capture audio has nothing to turn on). + */ +export function useSpeechUnavailableReason(speech: { + readonly available: boolean; + readonly status: EnvironmentSpeechStatus | null; +}) { + const hydrated = useClientSettingsHydrated(); + const choice = useAudioChoice(); + const grant = choice === "off" ? null : choice; + const destination = useVoiceTranscriptionDestination(); + const environment = useEnvironment(destination.environmentId); + const connection = Option.getOrNull(usePreparedConnection(destination.environmentId)); + const canCapture = + typeof navigator !== "undefined" && + Boolean(navigator.mediaDevices?.getUserMedia) && + typeof AudioWorkletNode !== "undefined"; + if (!canCapture || speech.available) return null; + const where = destination.label ?? "The voice destination"; + if (destination.prepared) { + return speech.status?.supported === false ? speech.status.reason : "Checking voice input."; + } + if (!hydrated) return "Loading voice settings."; + if (!grant) return "Voice input is off. Turn it on in Settings > Voice."; + if (!connection || !environment?.serverConfig) { + return `${where} is not connected. Check Settings > Voice.`; + } + if (environment.serverConfig.environment.capabilities.voiceTranscription !== true) { + return `${where} can't transcribe voice. Pick another destination in Settings > Voice.`; + } + return `Voice input is not ready on ${where}. Check Settings > Voice.`; +} + export function useEnvironmentSpeechInput(input: HookInput) { const navigate = useNavigate(); const destination = useVoiceTranscriptionDestination(); diff --git a/apps/web/src/speech/useTextFieldSpeech.tsx b/apps/web/src/speech/useTextFieldSpeech.tsx index b8ccad995fa06..5ee484fd03504 100644 --- a/apps/web/src/speech/useTextFieldSpeech.tsx +++ b/apps/web/src/speech/useTextFieldSpeech.tsx @@ -76,7 +76,7 @@ export function TextFieldSpeechControls({ {speech.preview ? (

{speech.preview.committed} {speech.preview.tentative} diff --git a/docs/user/voice-input.md b/docs/user/voice-input.md index 592e110c896cc..39798526851e0 100644 --- a/docs/user/voice-input.md +++ b/docs/user/voice-input.md @@ -79,15 +79,16 @@ while you record; models with built-in long-form support transcribe when you sto ## Dictate Press the microphone in the composer, or `mod+shift+d` (`mod` is Command on -macOS and Ctrl elsewhere). Press again, or the check mark, to finish; `Esc` +macOS and Ctrl elsewhere). While you record, the microphone shows live bars and +Send becomes Stop. Press the shortcut again, the bars, or Stop to finish; `Esc` discards the recording and leaves your draft unchanged. Recordings last up to five minutes. Text is inserted at the cursor or replaces the selection you had when recording started, and the editor is read-only while dictation runs. Changes that reach the draft meanwhile, such as a cited message, are kept; the text then goes after your selection instead of replacing it. -Pressing Send while dictating finishes the recording and sends the draft once -the transcript has been inserted. Cancelling also cancels that queued send. In +Using your send shortcut while dictating finishes the recording and sends the +draft once the transcript has been inserted. Cancelling also cancels that queued send. In the other editors, Save, Comment, Reply, Submit review, or Attach works the same way, and the shortcut targets the editor that has focus, or the composer when none does. In the desktop browser's annotation editor, the microphone and diff --git a/vite.config.ts b/vite.config.ts index 11d546ccfa6d0..06556db20fd7b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -770,8 +770,6 @@ export default defineConfig({ "apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx": 1, "apps/web/src/components/ThreadStatusIndicators.tsx": 1, "apps/web/src/components/usage/UsageLimitsPooled.tsx": 1, - "apps/web/src/components/settings/MicrophoneTest.tsx": 1, - "apps/web/src/components/settings/SpeechModelComparison.tsx": 1, "apps/web/src/components/settings/TextGeneratorsSettings.tsx": 2, }).map(([file, maxOccurrences]) => { const rule: ["error", { maxOccurrences: number }] = ["error", { maxOccurrences }]; @@ -1007,16 +1005,8 @@ export default defineConfig({ "apps/web/src/components/sidebar/SidebarChrome.tsx": 1, "apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx": 1, "apps/web/src/components/ThreadCommandSubtitle.tsx": 1, - "apps/web/src/components/chat/ComposerSpeechButton.tsx": 2, - "apps/web/src/components/chat/VoiceInputErrorPill.tsx": 1, - "apps/web/src/components/chat/VoiceInputSetup.tsx": 8, - "apps/web/src/components/settings/MicrophoneTest.tsx": 4, "apps/web/src/components/settings/SecretsSettings.tsx": 1, - "apps/web/src/components/settings/SpeechModelComparison.tsx": 3, "apps/web/src/components/settings/CloudVoiceConfiguration.tsx": 8, - "apps/web/src/components/settings/MicrophoneOrderList.tsx": 4, - "apps/web/src/components/settings/VoiceSettingsPanel.tsx": 14, - "apps/web/src/speech/useTextFieldSpeech.tsx": 1, }).map(([file, maxOccurrences]) => { const rule: ["error", { maxOccurrences: number }] = ["error", { maxOccurrences }]; return { files: [file], rules: { "t3code/no-raw-type-classes": rule } }; From 67a1e3d28390ce2d4bf0d0f209b7e1d1a1d4e844 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 12 Nov 2026 09:48:16 +0000 Subject: [PATCH 2/3] fix(web): format main's voice settings and keep the recording time on the meta role --- apps/web/src/components/chat/ComposerSpeechButton.tsx | 2 +- apps/web/src/components/settings/VoiceSettingsPanel.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ComposerSpeechButton.tsx b/apps/web/src/components/chat/ComposerSpeechButton.tsx index de2a8d743f484..7d5754eef628f 100644 --- a/apps/web/src/components/chat/ComposerSpeechButton.tsx +++ b/apps/web/src/components/chat/ComposerSpeechButton.tsx @@ -65,7 +65,7 @@ function RecordingStatus(props: { level: number; recordingLimitSeconds?: number return ( <> - + {formatElapsed(elapsedSeconds)} {props.recordingLimitSeconds !== undefined ? ` / ${formatElapsed(props.recordingLimitSeconds)}` diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index ac665c0c08376..2f8d5d9f4dd39 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -240,9 +240,7 @@ function ModelCard(props: ModelCardProps) { ) : null}

{model.description ? ( -

- {model.description} -

+

{model.description}

) : null} {model.linkedFrom ? (

From 3e789f5bb762dfa5b10aed3e3d7b6171e166eb79 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:43:15 +0530 Subject: [PATCH 3/3] fix(web): dictation can be cancelled by touch again (#33897) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 11 +++++++- .../components/chat/ComposerSpeechButton.tsx | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 676c10a7346fe..d0fd819eaee71 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -147,7 +147,7 @@ import { useEnvironmentSpeechInput, useSpeechUnavailableReason, } from "../../speech/useEnvironmentSpeechInput"; -import { ComposerSpeechButton } from "./ComposerSpeechButton"; +import { ComposerSpeechButton, ComposerSpeechCancelButton } from "./ComposerSpeechButton"; import { VoiceInputErrorPill } from "./VoiceInputErrorPill"; import { COMPOSER_PILL_SLOT_BUTTON_CLASS_NAME } from "./ComposerPrimaryActions"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; @@ -3898,6 +3898,15 @@ export function ChatComposer(props: ChatComposerProps) { onDismiss={() => speechInput.cancel()} /> ) : null} + {speechInput.available && + speechInput.state.phase !== "idle" && + speechInput.state.phase !== "error" ? ( + speechInput.cancel()} + /> + ) : null} + + + ); +} + // Each bar reads the level a sample apart, so the three never move in lockstep, and the // middle bar reaches highest so loud speech reads as a wave rather than a flat block. const SPEECH_BAR_SAMPLE_OFFSETS = [1, 0, 2] as const;