mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): gate voice dictation behind experiment
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ type SettingsSection =
|
||||
| "channel-templates"
|
||||
| "compute"
|
||||
| "appearance"
|
||||
| "experimental"
|
||||
| "shortcuts"
|
||||
| "tokens"
|
||||
| "community-members"
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "voiceDictation",
|
||||
"name": "Voice dictation",
|
||||
"description": "Compose messages with local, real-time speech recognition",
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user