From 46ddec7c8ca71cbfa787d5e5675396cd1428f87f Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Sat, 22 Aug 2026 00:01:18 -0300 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20dictation=20v2=20=E2=80=94=20co?= =?UTF-8?q?met=20edge,=20scrolling=20wave,=20live=20transcript,=20stop/sen?= =?UTF-8?q?d,=20bench=20axis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COMPOSER-SELECTS-NOTES.md | 10 ++ scripts/control-ui-mock-dev.ts | 10 ++ ui/src/i18n/locales/en.ts | 3 +- .../chat/components/chat-composer-controls.ts | 37 +++---- .../chat/components/chat-composer-view.ts | 29 +++-- .../chat/components/chat-voice-activity.ts | 19 +++- ui/src/pages/chat/composer-dictation.ts | 6 +- ui/src/styles/chat/layout.css | 102 ++++++------------ ui/src/test-helpers/composer-bench.ts | 85 +++++++++++++++ 9 files changed, 191 insertions(+), 110 deletions(-) diff --git a/COMPOSER-SELECTS-NOTES.md b/COMPOSER-SELECTS-NOTES.md index 16a70c7f1255..79e57e3d0cbc 100644 --- a/COMPOSER-SELECTS-NOTES.md +++ b/COMPOSER-SELECTS-NOTES.md @@ -65,3 +65,13 @@ Lane: composer selects (worktree `composer-bench`). Bench files intocados. - i18n: `+discardDictation` ("Cancel dictation"), `-dictationReleaseToInsert` (sem usos); baseline+verify ok. - Limpeza: CSS morto `chat-send-btn__dictation-time` e `agent-chat__dictation-copy` removidos. - Verificação: DOM do estado dictating injetado no bench (:5230) — screenshots dark/light inspecionados (`/tmp/herdr/dict-*.png`); vitest dictation/composer suites: mesmas 5 falhas pré-existentes do checkpoint, 72 pass; tsgo:ui sem erros nos arquivos tocados; stylelint só com os 3 erros pré-existentes. + +## Voice mode v2 (iteração) + +- Borda: sem tint no frame; só o cometa accent (rastro longo atrás, cabeça brilhante, escuro à frente) com `corner-shape: superellipse(1.5)` no ::after. +- Wave: modo `scroll` no `openclaw-microphone-activity` — ring buffer de níveis, 48 barras finas, história correndo direita→esquerda (waveform streamando). +- Texto: partial do ditado streama direto no textarea (preview via `insertComposerDictation` na seleção capturada); placeholder oculto no modo; commit real no stop/release. +- Ações: **stop** (mesmo elemento do mic; commita o texto no draft) + **send** (commita e envia via `finishActive().then(onSend)`); sem descarte por botão — Esc é o único discard. Status vira sr-only. `finishActive` agora retorna Promise. +- i18n: `dictationStop` no lugar de `discardDictation`; `insertDictation` removido (sem usos). +- Bench: novo eixo `dictate` (off/connecting/recording/finalizing) — stub de controller semeado em `getChatComposerState("composer-bench").dictation` (marcado `benchStub`), níveis animados + partial roteirizado; linha "Dictation" no painel (`scripts/control-ui-mock-dev.ts` — requer restart do mock server para o botão aparecer; via URL `?bench={"dictate":"recording"}` já funciona). +- Verificado em :5230 via URL param: screenshots inspecionados (wave rolando, texto streamando, stop/send, borda). tsgo pulado a pedido (fase de iteração). diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 3876cdc83f46..749ee73a10e3 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -457,6 +457,16 @@ const composerBenchHtml = ` ["camera-error", "Camera error"], ["error", "Error"], ])}` + + composerBenchDisclosure( + "Dictation", + "dictate", + [ + ["off", "Off"], + ["connecting", "Starting"], + ["recording", "Recording"], + ["finalizing", "Finishing"], + ], + ) + composerBenchDisclosure( "Microphone", "voiceInput", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 6ef62da4a667..f90ed8ba8ce5 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5974,11 +5974,10 @@ export const en: TranslationMap = { dictationFinalizing: "Finishing dictation…", dictationFinalizationTimedOut: "Dictation stopped before the last partial transcript could be finalized.", - insertDictation: "Insert dictation", dictationProviderUnavailable: "No transcription provider is configured for dictation. Choose one in Settings to dictate.", dictationRecording: "Recording {elapsed}", - discardDictation: "Cancel dictation", + dictationStop: "Stop dictation", realtimeTalkRequiresMicrophone: "Realtime voice input requires browser microphone access.", selectedMicrophoneUnavailable: "The selected microphone is unavailable. Choose another input or System default.", diff --git a/ui/src/pages/chat/components/chat-composer-controls.ts b/ui/src/pages/chat/components/chat-composer-controls.ts index 440429938097..d249845648a7 100644 --- a/ui/src/pages/chat/components/chat-composer-controls.ts +++ b/ui/src/pages/chat/components/chat-composer-controls.ts @@ -232,7 +232,7 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) { const label = finalizing ? t("chat.composer.dictationFinalizing") : active - ? t("chat.composer.discardDictation") + ? t("chat.composer.dictationStop") : (props.idleLabel ?? t("chat.composer.startVoiceInput")); const tooltip = props.dictation && !(active || finalizing) ? t("chat.composer.voiceGestureHint") : label; @@ -249,7 +249,9 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) { @pointerdown=${(event: PointerEvent) => props.onDictationPointerDown?.(event)} @click=${(event: MouseEvent) => { if (active) { - props.dictation?.cancelActive(); + // Stop = keep the words: capture ends and the transcript lands + // in the draft. The only discard path is Escape, by design. + void props.dictation?.finishActive(); return; } if (props.dictation) { @@ -266,12 +268,7 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) { ${finalizing ? icons.loader : active - ? html` - ${icons.x} - ${t("common.cancel")} - ` + ? icons.stop : html` ${icons.mic} ${label} @@ -362,13 +359,9 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) { hasComposedContent ? activeRunActionDescription : t("chat.composer.emptyHint"), activeRunActionLabel, ); - const dictationConfirmAction = props.dictation?.active + const dictationSendAction = props.dictation?.active ? html` - ${props.dictation.finalizing ? t("chat.composer.dictationFinalizing") : props.dictation.connecting @@ -377,16 +370,20 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) { elapsed: props.dictation.elapsed, })} - + ` @@ -458,7 +455,7 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) { : html` ${voiceControl} ${props.dictation?.active - ? dictationConfirmAction + ? dictationSendAction : props.canAbort ? hasComposedContent ? sendAction diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index af0df12784f6..df50bafb146c 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -8,6 +8,7 @@ import { renderSessionProgressCard } from "../../../components/session-progress- import { t } from "../../../i18n/index.ts"; import { detectTextDirection } from "../../../lib/text-direction.ts"; import type { ComposerDictationController } from "../composer-dictation.ts"; +import { insertComposerDictation } from "../composer-dictation.ts"; import { handleChatAttachmentPaste, renderAttachmentPreview, @@ -236,6 +237,20 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ? html`
${composerAlerts}${offlineHint}
` : nothing; const skillDraftOverlay = renderSkillDraftOverlay(visibleDraft); + // Dictation streams into the editor: the live partial previews at the spot + // the commit will use (the selection captured at hold start), so the operator + // reads their words landing in the draft rather than in a side status row. + // The textarea is read-only for the whole capture, so the preview swap never + // fights operator edits; release performs the real insert. + const dictationPreviewDraft = + dictation?.active && dictation.partial + ? insertComposerDictation( + visibleDraft, + dictation.partial, + state.dictationSelection?.start ?? visibleDraft.length, + state.dictationSelection?.end ?? visibleDraft.length, + ).value + : visibleDraft; const queue = renderChatQueue({ queue: props.queue, @@ -361,14 +376,10 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ${renderMicrophoneActivity({ status: dictation.connecting ? "connecting" : "listening", inputLevel: dictation.inputLevel, - bars: 36, + bars: 48, + mode: "scroll", })} - ${dictation.partial - ? html`${dictation.partial}` - : nothing} ` : nothing} @@ -424,8 +435,8 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ${skillDraftOverlay} diff --git a/ui/src/pages/chat/components/chat-voice-activity.ts b/ui/src/pages/chat/components/chat-voice-activity.ts index 689f600d8cdc..2098b7a1c106 100644 --- a/ui/src/pages/chat/components/chat-voice-activity.ts +++ b/ui/src/pages/chat/components/chat-voice-activity.ts @@ -27,6 +27,9 @@ class MicrophoneActivityElement extends HTMLElement { private levelSignal: RealtimeTalkLevelSignal | undefined; private unsubscribe: (() => void) | null = null; private gains: number[] = BAR_GAINS; + // `mode="scroll"`: bars render a right-to-left history of levels (newest on + // the right) so speech reads as a streaming waveform instead of one meter. + private history: number[] | null = null; set signal(signal: RealtimeTalkLevelSignal | undefined) { if (signal === this.levelSignal) { @@ -58,6 +61,7 @@ class MicrophoneActivityElement extends HTMLElement { this.gains = activityBarGains( Number.isInteger(requested) && requested > 0 ? requested : BAR_GAINS.length, ); + this.history = this.getAttribute("mode") === "scroll" ? this.gains.map(() => 0) : null; for (const [index] of this.gains.entries()) { const bar = document.createElement("span"); bar.className = "agent-chat__voice-activity-bar"; @@ -74,12 +78,15 @@ class MicrophoneActivityElement extends HTMLElement { private renderLevel(level: number): void { this.dataset.level = String(level); + if (this.history) { + this.history.push(level); + this.history.shift(); + } for (const [index, bar] of [...this.children].entries()) { - const gain = this.gains[index] ?? 1; - (bar as HTMLElement).style.setProperty( - "--talk-bar-scale", - String(0.18 + level * gain * 0.82), - ); + const scale = this.history + ? 0.12 + (this.history[index] ?? 0) * 0.88 + : 0.18 + level * (this.gains[index] ?? 1) * 0.82; + (bar as HTMLElement).style.setProperty("--talk-bar-scale", String(scale)); } } } @@ -115,6 +122,7 @@ type MicrophoneActivityProps = { status?: RealtimeTalkStatus; inputLevel?: RealtimeTalkLevelSignal; bars?: number; + mode?: "scroll"; }; // Class names and data attributes are asserted by the talk e2e suite; the @@ -127,6 +135,7 @@ export function renderMicrophoneActivity(props: MicrophoneActivityProps): Templa data-status=${activeStatus(props.status)} data-source="microphone" bars=${ifDefined(props.bars)} + mode=${ifDefined(props.mode)} aria-hidden="true" .signal=${props.inputLevel ?? EMPTY_LEVEL_SIGNAL} > diff --git a/ui/src/pages/chat/composer-dictation.ts b/ui/src/pages/chat/composer-dictation.ts index 638f2da56c2c..9bad1c83aa66 100644 --- a/ui/src/pages/chat/composer-dictation.ts +++ b/ui/src/pages/chat/composer-dictation.ts @@ -449,8 +449,10 @@ export class ComposerDictationController { return `${minutes}:${seconds}`; } - finishActive(): void { - void this.stop({ commit: true }); + // Returns the stop promise so callers can sequence work (e.g. send) after + // the transcript lands in the draft. + finishActive(): Promise { + return this.stop({ commit: true }); } cancelActive(): void { diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index fe1f1dd6de49..1685ab502473 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -3041,14 +3041,11 @@ button.chat-pr__diff { } /* Dictation is a temporary mode of the writing surface, not another card in - it. A narrow moving edge in the brand accent signals live capture while the - editor and its transcript stay stable underneath. Accent, not danger, on - purpose: capture is the product's signature action, not a destructive one. */ + it. The frame itself stays quiet — only a brand-accent light with a soft + trail orbits the edge to signal live capture. */ .agent-chat__input--dictating, .agent-chat__input--dictating:focus-within { isolation: isolate; - border-color: color-mix(in srgb, var(--accent) 24%, var(--chat-composer-hairline)); - box-shadow: 0 4px 18px rgb(0 0 0 / 6%); } .agent-chat__input--dictating::after { @@ -3058,13 +3055,19 @@ button.chat-pr__diff { inset: -1px; padding: 1px; border-radius: inherit; + /* corner-shape does not travel with border-radius: inherit; restate it so + the light hugs the composer's superellipse instead of a rounder ghost. */ + corner-shape: superellipse(1.5); + /* Comet, not a ring: a long fading tail behind a bright head, dark again + right in front of it, so the light reads as travelling, not as a tinted + border. */ background: conic-gradient( from var(--chat-dictation-edge-angle), - transparent 0deg 220deg, - color-mix(in srgb, var(--accent) 18%, transparent) 256deg, - color-mix(in srgb, var(--accent) 88%, white) 294deg, - color-mix(in srgb, var(--accent) 30%, transparent) 326deg, - transparent 360deg + transparent 0deg 200deg, + color-mix(in srgb, var(--accent) 10%, transparent) 252deg, + color-mix(in srgb, var(--accent) 45%, transparent) 286deg, + color-mix(in srgb, var(--accent) 92%, white) 294deg, + transparent 301deg 360deg ); -webkit-mask: linear-gradient(#fff 0 0) content-box, @@ -3498,9 +3501,9 @@ button.chat-pr__diff { padding: 12px 14px 0; } -/* Capture mode pares the footer back to the two decisions that matter: cancel - the recording or accept the transcript. Draft content and attachments remain - in place so entering the mode never discards operator context. */ +/* Capture mode pares the footer back to the two decisions that matter: stop + dictating (transcript stays in the draft) or send it. Draft content and + attachments remain in place so entering the mode never discards context. */ .agent-chat__input--dictating .agent-chat__composer-lead, .agent-chat__input--dictating .agent-chat__composer-meta, .agent-chat__input--dictating .agent-chat__composer-controls, @@ -3516,17 +3519,6 @@ button.chat-pr__diff { grid-column: 1; } -/* Capture bar: cancel pill on the leading edge, live status centered, accept - on the trailing edge — one row, mirroring the reference voice UIs. */ -.agent-chat__input--dictating .agent-chat__composer-trail, -.agent-chat__input--dictating .agent-chat__composer-actions { - width: 100%; -} - -.agent-chat__input--dictating .agent-chat__composer-actions { - justify-content: space-between; -} - /* Keep settings, split view, and run status together in the footer's middle track. The track is sized to content, so this group shrinks before it ever pushes mic and send off the right edge. */ @@ -4951,16 +4943,14 @@ button.chat-pr__diff { user-select: none; } -/* Cancel pill while capturing. Same element as the idle mic button — swapping - it would release pointer capture mid-hold — so the mode only restyles it. */ +/* Capture button while dictating. Same element as the idle mic — swapping it + would release pointer capture mid-hold — so the mode only restyles it. + Stop = keep the transcript; Escape is the only discard. */ .chat-send-btn.chat-send-btn--dictating { - display: inline-flex; - align-items: center; - gap: 6px; - width: auto; + width: var(--chat-composer-send-size); min-width: var(--chat-composer-send-size); height: var(--chat-composer-send-size); - padding: 0 12px; + padding: 5px; border-radius: var(--radius-full); color: var(--text-strong); background: color-mix(in srgb, var(--text) 10%, transparent); @@ -4978,15 +4968,8 @@ button.chat-pr__diff { .chat-send-btn--dictating > svg { width: 14px; height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2px; -} - -.chat-send-btn__dictation-cancel-label { - font-size: 13px; - font-weight: 600; - line-height: 1; + fill: currentColor; + stroke: none; } .chat-send-btn--dictating .agent-chat__voice-activity-bar { @@ -4994,30 +4977,18 @@ button.chat-pr__diff { box-shadow: 0 0 5px color-mix(in srgb, var(--accent) 30%, transparent); } -/* Accept keeps the send button's station and the brand accent: committing the - transcript is this mode's primary action. */ -.agent-chat__input .chat-send-btn--dictation-confirm { - background: var(--accent); - color: var(--accent-foreground); -} - -.agent-chat__input .chat-send-btn--dictation-confirm:hover:not(:disabled) { - background: var(--accent-hover); -} - .agent-chat__dictation-status { display: grid; min-width: 0; align-items: center; justify-items: center; - gap: 6px; width: 100%; color: var(--accent); font-size: 12px; } -/* Full-width strip: the level meter is the room's main signal while capturing, - so it spans the editor instead of hiding in a pill. */ +/* Full-width strip: the scrolling level history is the room's main signal + while capturing, so it spans the editor instead of hiding in a pill. */ .agent-chat__dictation-wave { display: flex; align-items: center; @@ -5027,11 +4998,16 @@ button.chat-pr__diff { } .agent-chat__dictation-wave .agent-chat__voice-activity { + gap: 4px; width: 100%; + height: 24px; justify-content: space-between; } .agent-chat__dictation-wave .agent-chat__voice-activity-bar { + width: 2.5px; + height: 22px; + flex: 0 1 2.5px; background: color-mix(in srgb, var(--accent) 88%, var(--text)); box-shadow: 0 0 5px color-mix(in srgb, var(--accent) 24%, transparent); } @@ -5045,24 +5021,6 @@ button.chat-pr__diff { box-shadow: none; } -.agent-chat__dictation-label { - min-width: 0; - flex: 1 1 auto; - color: var(--muted); - font-size: 13px; - font-variant-numeric: tabular-nums; - text-align: center; -} - -.agent-chat__dictation-partial { - max-width: 100%; - min-width: 0; - overflow: hidden; - color: var(--muted); - text-overflow: ellipsis; - white-space: nowrap; -} - .agent-chat__video-preview { position: absolute; left: 50%; diff --git a/ui/src/test-helpers/composer-bench.ts b/ui/src/test-helpers/composer-bench.ts index 530ed5dff8d8..d4a8bf4321f8 100644 --- a/ui/src/test-helpers/composer-bench.ts +++ b/ui/src/test-helpers/composer-bench.ts @@ -16,6 +16,8 @@ import type { SessionToolOverrides } from "../lib/sessions/patch.ts"; import { renderExecApprovalCard } from "../components/exec-approval-card.ts"; import { renderBackgroundTasksStatusRow } from "../pages/chat/components/chat-background-tasks-status.ts"; import { renderChatComposer, resetChatComposerState } from "../pages/chat/components/chat-composer.ts"; +import { getChatComposerState } from "../pages/chat/components/chat-composer-state.ts"; +import type { ComposerDictationController } from "../pages/chat/composer-dictation.ts"; import { renderChatModelControls } from "../pages/chat/components/chat-model-controls.ts"; import { renderChatPermissionPicker } from "../pages/chat/components/chat-permission-picker.ts"; import { renderChatPullRequests } from "../pages/chat/components/chat-pull-requests.ts"; @@ -94,6 +96,7 @@ type BenchState = { | "camera-pending" | "camera-error" | "error"; + dictate: "off" | "connecting" | "recording" | "finalizing"; voiceInput: | "available" | "unsupported" @@ -165,6 +168,7 @@ const defaults: BenchState = { capabilities: "attachments", toolOverrides: null, voice: "off", + dictate: "off", voiceInput: "available", content: "multiline", attachments: "none", @@ -514,6 +518,71 @@ let newProjectQuery = ""; let newWorktree = true; const realtimeTalkLevel = new RealtimeTalkLevelSignal(); realtimeTalkLevel.set(0.42); + +// Dictation simulation: the bench cannot run a real capture session (no +// gateway client, getUserMedia is mocked to fail), so it pre-seeds the +// composer's state slot with a stub controller. `renderChatComposer` keeps it +// (`state.dictation ??=`), and the real template renders the real mode — +// border light, scrolling wave, streaming partial, stop/send — end to end. +const benchDictationLevel = new RealtimeTalkLevelSignal(); +const benchDictationScript = + "Tailor the composer spacing so every control stays aligned while I dictate this sentence".split( + " ", + ); +let benchDictationTimer: number | null = null; +let benchDictationPartial = ""; +let benchDictationTicks = 0; + +function benchDictationController(): ComposerDictationController { + const phase = state.dictate; + const stub = { + active: phase !== "off", + connecting: phase === "connecting", + finalizing: phase === "finalizing", + locksComposer: phase !== "off", + partial: phase === "recording" ? benchDictationPartial : "", + elapsed: `0:${String(Math.floor(benchDictationTicks / 10) % 60).padStart(2, "0")}`, + inputLevel: benchDictationLevel, + finishActive: () => { + publishState({ dictate: "off" }); + return Promise.resolve(); + }, + cancelActive: () => publishState({ dictate: "off" }), + handleClick: () => {}, + handleContextMenu: () => {}, + handlePointerDown: () => {}, + update: () => {}, + dispose: () => {}, + }; + return stub as unknown as ComposerDictationController; +} + +function syncBenchDictation(): void { + const recording = state.surface === "chat" && state.dictate === "recording"; + if (recording && benchDictationTimer === null) { + benchDictationTimer = window.setInterval(() => { + benchDictationTicks += 1; + const speaking = Math.sin(benchDictationTicks / 4) > -0.4; + benchDictationLevel.set( + speaking ? 0.2 + Math.random() * 0.75 : Math.random() * 0.06, + ); + const spokenWords = Math.floor(benchDictationTicks / 4); + const nextPartial = benchDictationScript.slice(0, spokenWords).join(" "); + if (nextPartial !== benchDictationPartial) { + benchDictationPartial = nextPartial; + renderBench(); + } + }, 100); + } else if (!recording && benchDictationTimer !== null) { + window.clearInterval(benchDictationTimer); + benchDictationTimer = null; + benchDictationLevel.set(0); + } + if (state.dictate === "off") { + benchDictationPartial = ""; + benchDictationTicks = 0; + } +} const benchCameraStream = new MediaStream(); const newSessionTextarea = new NewSessionComposerTextareaController(); const attachmentDraft = new NewSessionAttachmentDraft(renderBench, mirrorAttachmentState); @@ -1090,6 +1159,19 @@ function renderChatSurface(sessionList: SessionsListResult) { onAction: () => publishState({ inset: "none" }), } : undefined; + // Seed the composer's dictation slot before render; `??=` inside keeps the + // stub alive across rerenders. Clearing only our own stub (marked) lets the + // real controller path own the slot whenever the axis is off. + const composerState = getChatComposerState("composer-bench"); + if (state.dictate !== "off") { + const stub = benchDictationController() as ComposerDictationController & { + benchStub?: boolean; + }; + stub.benchStub = true; + composerState.dictation = stub; + } else if ((composerState.dictation as { benchStub?: boolean } | null)?.benchStub) { + composerState.dictation = null; + } const composer = renderChatComposer({ paneId: "composer-bench", sessionKey: "agent:main:main", @@ -1589,6 +1671,7 @@ function renderBench(): void { if (!stage) { return; } + syncBenchDictation(); const sessionList = sessions(); stage.style.setProperty("--composer-bench-width", `${state.width}px`); stage.dataset.composerBenchSurface = state.surface; @@ -1660,6 +1743,7 @@ function commitState(next: Partial, markScenarioCustom = true): void if ( next.neighbor !== undefined || next.voice !== undefined || + next.dictate !== undefined || next.voiceInput !== undefined ) { state.surface = "chat"; @@ -1748,6 +1832,7 @@ function syncControls(): void { "usage", "neighbor", "voice", + "dictate", "voiceInput", ]); const newOnlyAxes = new Set(["visibility", "newAction"]);