From 4efe2a7dd27ff65b0527f4ff73c732d707bbf925 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 15:30:36 -0700 Subject: [PATCH] feat(desktop): gate voice dictation behind experiment --- desktop/scripts/check-file-sizes.mjs | 4 +- desktop/src/app/AppShell.tsx | 5 +- .../dictation/hooks/useComposerDictation.ts | 14 +++- .../features/dictation/hooks/useDictation.ts | 4 ++ .../dictation/hooks/useLocalDictation.ts | 7 +- .../features/messages/ui/MessageComposer.tsx | 10 ++- .../settings/ui/KeyboardShortcutsCard.tsx | 39 ++++++----- desktop/tests/e2e/messaging.spec.ts | 67 ++++++++++++++++++- desktop/tests/helpers/settings.ts | 1 + preview-features.json | 8 +++ 10 files changed, 136 insertions(+), 23 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 073f0f67d..e52bc026b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -516,7 +516,9 @@ const overrides = new Map([ // +17: local-dictation integration: useComposerDictation wiring (mic toggle, // transcript append, recording/transcribing state, stop-on-send/edit guards). // Load-bearing feature growth; queued to split with the rest. - ["src/features/messages/ui/MessageComposer.tsx", 1131], + // +8: preview-feature gate wiring keeps dictation hidden and inactive until + // the user opts in through Settings → Experiments. + ["src/features/messages/ui/MessageComposer.tsx", 1139], // global-agent-config: model-tuning section (BuzzAgentModelTuningFields via // EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation // + globalProvider threading into getPersonaProviderOptions. All load-bearing diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 33b455187..820a12077 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -85,6 +85,7 @@ import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; +import { useFeatureEnabled } from "@/shared/features"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; @@ -98,6 +99,7 @@ export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); + const voiceDictationEnabled = useFeatureEnabled("voiceDictation"); const communitiesHook = useCommunities(); const hasCommunityRail = communitiesHook.communities.length > 1; @@ -633,7 +635,7 @@ export function AppShell() { return; } - if (key === "d" && !event.shiftKey) { + if (key === "d" && !event.shiftKey && voiceDictationEnabled) { event.preventDefault(); dictationKeyHeld = true; window.dispatchEvent(new CustomEvent("buzz:dictation-key-down")); @@ -695,6 +697,7 @@ export function AppShell() { handleOpenSearch, goHome, settingsOpen, + voiceDictationEnabled, ]); useSettingsShortcuts({ onClose: handleCloseSettings, diff --git a/desktop/src/features/dictation/hooks/useComposerDictation.ts b/desktop/src/features/dictation/hooks/useComposerDictation.ts index 9a75d6be0..d11b2b304 100644 --- a/desktop/src/features/dictation/hooks/useComposerDictation.ts +++ b/desktop/src/features/dictation/hooks/useComposerDictation.ts @@ -8,6 +8,8 @@ import { import { useDictation } from "./useDictation"; interface UseComposerDictationOptions { + /** Whether the voice-dictation preview feature is enabled. */ + enabled?: boolean; /** Ref to a function that syncs contentRef from the Tiptap editor and returns it. */ syncContentRef: React.MutableRefObject<() => string>; /** Whether the composer is currently disabled (read-only, etc.). */ @@ -33,6 +35,7 @@ interface UseComposerDictationOptions { * Uses the local Parakeet STT engine — fully offline, no relay or API key needed. */ export function useComposerDictation({ + enabled = true, syncContentRef, disabled = false, disabledRef, @@ -47,9 +50,13 @@ export function useComposerDictation({ const instanceId = useId(); const isSendBlockedRef = useRef(false); isSendBlockedRef.current = - disabledRef.current || isSendingRef.current || isUploadingRef.current; + !enabled || + disabledRef.current || + isSendingRef.current || + isUploadingRef.current; const dictation = useDictation({ + disabled: !enabled, getText: () => syncContentRef.current(), setText: (text) => { setComposerContent(text); @@ -124,10 +131,11 @@ export function useComposerDictation({ useEffect(() => { const owningSession = dictation.isRecording || dictation.isStarting || dictation.isTranscribing; - if (disabled && owningSession) { + if ((!enabled || disabled) && owningSession) { dictation.cancelRecording(); } }, [ + enabled, disabled, dictation.isRecording, dictation.isStarting, @@ -142,6 +150,7 @@ export function useComposerDictation({ // biome-ignore lint/correctness/useExhaustiveDependencies: disabledRef/isSendBlockedRef are stable refs read at call time useEffect(() => { function handleKeyDown() { + if (!enabled) return; // Only respond if this is the active composer instance. if (!isActiveDictationComposer(instanceId)) return; // Only respond if focus is still inside this composer. The active-composer @@ -171,6 +180,7 @@ export function useComposerDictation({ }, [ instanceId, composerRef, + enabled, dictation.isRecording, dictation.isStarting, dictation.startRecording, diff --git a/desktop/src/features/dictation/hooks/useDictation.ts b/desktop/src/features/dictation/hooks/useDictation.ts index 19aa3566c..c11fc4b3b 100644 --- a/desktop/src/features/dictation/hooks/useDictation.ts +++ b/desktop/src/features/dictation/hooks/useDictation.ts @@ -9,6 +9,8 @@ import { import { useLocalDictation } from "./useLocalDictation"; interface UseDictationOptions { + /** Disable native availability checks and recording entry points. */ + disabled?: boolean; /** Returns the current composer text (must be fresh — synced from editor). */ getText: () => string; /** Set composer text */ @@ -20,6 +22,7 @@ interface UseDictationOptions { } export function useDictation({ + disabled = false, getText, setText, onSend, @@ -74,6 +77,7 @@ export function useDictation({ ); const dictation = useLocalDictation({ + disabled, onRecordingStart: () => { lastTranscriptRef.current = ""; }, diff --git a/desktop/src/features/dictation/hooks/useLocalDictation.ts b/desktop/src/features/dictation/hooks/useLocalDictation.ts index a8b2aea3f..2a4e3ba44 100644 --- a/desktop/src/features/dictation/hooks/useLocalDictation.ts +++ b/desktop/src/features/dictation/hooks/useLocalDictation.ts @@ -108,6 +108,11 @@ export function useLocalDictation({ // Check availability on mount and poll until available (model may be downloading). useEffect(() => { + if (disabled) { + setIsAvailable(false); + return; + } + let cancelled = false; let pollTimer: ReturnType | null = null; @@ -139,7 +144,7 @@ export function useLocalDictation({ cancelled = true; if (pollTimer) clearInterval(pollTimer); }; - }, []); + }, [disabled]); /** Flush accumulated audio batch to the native STT engine. Returns a promise * that resolves once the IPC call completes (or immediately if nothing to flush). diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index bbea84492..c367dba91 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -45,6 +45,7 @@ import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposer import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; import { cn } from "@/shared/lib/cn"; +import { useFeatureEnabled } from "@/shared/features"; import type { ChannelType } from "@/shared/api/types"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner"; @@ -180,6 +181,7 @@ function MessageComposerImpl({ typingParentEventId = null, typingRootEventId = null, }: MessageComposerProps) { + const voiceDictationEnabled = useFeatureEnabled("voiceDictation"); const { contentRef, isContentEmpty, @@ -302,6 +304,7 @@ function MessageComposerImpl({ const stopDictationRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); const dictation = useComposerDictation({ + enabled: voiceDictationEnabled, syncContentRef: syncContentRefFromEditorRef, disabled, disabledRef, @@ -1090,7 +1093,12 @@ function MessageComposerImpl({ editor={richText.editor} extraActions={ <> - + {voiceDictationEnabled && ( + + )} {toolbarExtraActions} } diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx index c71e62d5d..af7bfafbb 100644 --- a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx +++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx @@ -3,6 +3,7 @@ import { getPlatformKeys, type KeyboardShortcut, } from "@/shared/lib/keyboard-shortcuts"; +import { useFeatureEnabled } from "@/shared/features"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; @@ -29,6 +30,7 @@ function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { } export function KeyboardShortcutsCard() { + const voiceDictationEnabled = useFeatureEnabled("voiceDictation"); const categories = getShortcutsByCategory(); return ( @@ -45,22 +47,27 @@ export function KeyboardShortcutsCard() { {category} - {shortcuts.map((shortcut) => ( - -
- - {shortcut.label} - - - {shortcut.description} - -
- -
- ))} + {shortcuts + .filter( + (shortcut) => + voiceDictationEnabled || shortcut.id !== "voice-dictation", + ) + .map((shortcut) => ( + +
+ + {shortcut.label} + + + {shortcut.description} + +
+ +
+ ))}
))} diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 2487dbbf9..3c77db786 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -109,7 +109,72 @@ test.beforeEach(async ({ page }, testInfo) => { ], } : undefined; - await installMockBridge(page, mock); + await installMockBridge(page, mock, { + seedPreviewFeatures: !testInfo.title.includes("dictation experiment"), + }); +}); + +test("voice dictation stays behind the dictation experiment", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + await page.evaluate(() => { + let dictationStarts = 0; + window.addEventListener("buzz:dictation-key-down", () => { + dictationStarts += 1; + }); + ( + window as typeof window & { __BUZZ_DICTATION_STARTS__?: () => number } + ).__BUZZ_DICTATION_STARTS__ = () => dictationStarts; + }); + + const dispatchDictationShortcut = () => + page.evaluate(() => { + const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + ctrlKey: !isMac, + key: "d", + metaKey: isMac, + }), + ); + window.dispatchEvent( + new KeyboardEvent("keyup", { + bubbles: true, + ctrlKey: !isMac, + key: "d", + metaKey: isMac, + }), + ); + }); + const getDictationStarts = () => + page.evaluate( + () => + ( + window as typeof window & { + __BUZZ_DICTATION_STARTS__?: () => number; + } + ).__BUZZ_DICTATION_STARTS__?.() ?? 0, + ); + + await page.getByTestId("message-input").click(); + await dispatchDictationShortcut(); + await expect.poll(getDictationStarts).toBe(0); + + await openSettings(page, "experimental"); + const dictationToggle = page.getByTestId("feature-toggle-voiceDictation"); + await expect(dictationToggle).not.toBeChecked(); + await dictationToggle.click(); + await expect(dictationToggle).toBeChecked(); + + await page.getByTestId("settings-back-to-app").click(); + await expect(page.getByTestId("message-input")).toBeVisible(); + await page.getByTestId("message-input").click(); + await dispatchDictationShortcut(); + await expect.poll(getDictationStarts).toBe(1); }); test("agent owner label identifies the agent and owner", async ({ page }) => { diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts index 301e62271..20a3e61b5 100644 --- a/desktop/tests/helpers/settings.ts +++ b/desktop/tests/helpers/settings.ts @@ -7,6 +7,7 @@ type SettingsSection = | "channel-templates" | "compute" | "appearance" + | "experimental" | "shortcuts" | "tokens" | "community-members" diff --git a/preview-features.json b/preview-features.json index ad8090d5a..8e151f220 100644 --- a/preview-features.json +++ b/preview-features.json @@ -40,6 +40,14 @@ "platforms": [ "desktop" ] + }, + { + "id": "voiceDictation", + "name": "Voice dictation", + "description": "Compose messages with local, real-time speech recognition", + "platforms": [ + "desktop" + ] } ] }