feat(desktop): gate voice dictation behind experiment

This commit is contained in:
kenny lopez
2026-07-23 15:30:36 -07:00
parent d736ed6051
commit 4efe2a7dd2
10 changed files with 136 additions and 23 deletions
+3 -1
View File
@@ -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
+4 -1
View File
@@ -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,
@@ -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,
@@ -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 = "";
},
@@ -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<typeof setInterval> | 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).
@@ -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<HTMLDivElement>(null);
const dictation = useComposerDictation({
enabled: voiceDictationEnabled,
syncContentRef: syncContentRefFromEditorRef,
disabled,
disabledRef,
@@ -1090,7 +1093,12 @@ function MessageComposerImpl({
editor={richText.editor}
extraActions={
<>
<DictationButton dictation={dictation} disabled={disabled} />
{voiceDictationEnabled && (
<DictationButton
dictation={dictation}
disabled={disabled}
/>
)}
{toolbarExtraActions}
</>
}
@@ -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}
</h2>
<SettingsOptionGroup>
{shortcuts.map((shortcut) => (
<SettingsOptionRow
className="min-h-12 px-3 py-2"
key={shortcut.id}
>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-foreground">
{shortcut.label}
</span>
<span className="ml-2 text-muted-foreground">
{shortcut.description}
</span>
</div>
<KeyCombo shortcut={shortcut} />
</SettingsOptionRow>
))}
{shortcuts
.filter(
(shortcut) =>
voiceDictationEnabled || shortcut.id !== "voice-dictation",
)
.map((shortcut) => (
<SettingsOptionRow
className="min-h-12 px-3 py-2"
key={shortcut.id}
>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-foreground">
{shortcut.label}
</span>
<span className="ml-2 text-muted-foreground">
{shortcut.description}
</span>
</div>
<KeyCombo shortcut={shortcut} />
</SettingsOptionRow>
))}
</SettingsOptionGroup>
</div>
))}
+66 -1
View File
@@ -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 }) => {
+1
View File
@@ -7,6 +7,7 @@ type SettingsSection =
| "channel-templates"
| "compute"
| "appearance"
| "experimental"
| "shortcuts"
| "tokens"
| "community-members"
+8
View File
@@ -40,6 +40,14 @@
"platforms": [
"desktop"
]
},
{
"id": "voiceDictation",
"name": "Voice dictation",
"description": "Compose messages with local, real-time speech recognition",
"platforms": [
"desktop"
]
}
]
}