mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
feat(desktop): add voice dictation to composer
Co-authored-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -86,6 +86,7 @@ import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
|
||||
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
|
||||
import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock";
|
||||
import { useVoiceDictationShortcut } from "@/features/dictation";
|
||||
import { joinChannel } from "@/shared/api/tauri";
|
||||
import type { ChannelVisibility, SearchHit } from "@/shared/api/types";
|
||||
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
|
||||
@@ -153,6 +154,7 @@ export function AppShell() {
|
||||
});
|
||||
// Settings lives in history so back returns to the previous app entry.
|
||||
const settingsOpen = location.pathname === "/settings";
|
||||
useVoiceDictationShortcut(settingsOpen);
|
||||
const locationSearchSection = (location.search as { section?: unknown })
|
||||
.section;
|
||||
const settingsSection: SettingsSection = isSettingsSection(
|
||||
|
||||
191
desktop/src/features/dictation/hooks/useComposerDictation.ts
Normal file
191
desktop/src/features/dictation/hooks/useComposerDictation.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type * as React from "react";
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import {
|
||||
clearActiveDictationComposer,
|
||||
isActiveDictationComposer,
|
||||
setActiveDictationComposer,
|
||||
} from "../lib/activeComposer";
|
||||
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.). */
|
||||
disabled?: boolean;
|
||||
disabledRef: React.MutableRefObject<boolean>;
|
||||
isSendingRef: React.MutableRefObject<boolean>;
|
||||
isUploadingRef: React.MutableRefObject<boolean>;
|
||||
/** Updates contentRef + isContentEmpty state. */
|
||||
setComposerContent: (text: string) => void;
|
||||
/** Ref to a function that updates the Tiptap editor document. */
|
||||
setEditorContentRef: React.MutableRefObject<(text: string) => void>;
|
||||
submitMessageRef: React.MutableRefObject<() => void>;
|
||||
/** When this key changes (channel/thread switch), active dictation is stopped. */
|
||||
draftKey?: string | null;
|
||||
/** Ref to the composer's container element for focus tracking. */
|
||||
composerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around `useDictation` pre-wired for the MessageComposer's
|
||||
* state management (syncContentRef, setComposerContent, editor, submitMessageRef).
|
||||
*
|
||||
* Uses the local Parakeet STT engine — fully offline, no relay or API key needed.
|
||||
*/
|
||||
export function useComposerDictation({
|
||||
enabled = true,
|
||||
syncContentRef,
|
||||
disabled = false,
|
||||
disabledRef,
|
||||
isSendingRef,
|
||||
isUploadingRef,
|
||||
setComposerContent,
|
||||
setEditorContentRef,
|
||||
submitMessageRef,
|
||||
draftKey,
|
||||
composerRef,
|
||||
}: UseComposerDictationOptions) {
|
||||
const instanceId = useId();
|
||||
const isSendBlockedRef = useRef(false);
|
||||
isSendBlockedRef.current =
|
||||
!enabled ||
|
||||
disabledRef.current ||
|
||||
isSendingRef.current ||
|
||||
isUploadingRef.current;
|
||||
|
||||
const dictation = useDictation({
|
||||
disabled: !enabled,
|
||||
getText: () => syncContentRef.current(),
|
||||
setText: (text) => {
|
||||
setComposerContent(text);
|
||||
setEditorContentRef.current(text);
|
||||
},
|
||||
onSend: (text) => {
|
||||
setComposerContent(text);
|
||||
setEditorContentRef.current(text);
|
||||
// Submit synchronously — the content ref is already set above, so
|
||||
// syncComposerContentFromEditor() will serialize the editor which now
|
||||
// holds the dictated text.
|
||||
submitMessageRef.current();
|
||||
},
|
||||
isSendBlockedRef,
|
||||
});
|
||||
|
||||
// Track which composer is active (most recently focused) so that the global
|
||||
// ⌘D shortcut only dispatches to one instance when multiple are mounted.
|
||||
useEffect(() => {
|
||||
const el = composerRef?.current;
|
||||
if (!el) {
|
||||
// If no ref provided, this composer is always considered active (single-composer case).
|
||||
setActiveDictationComposer(instanceId);
|
||||
return () => clearActiveDictationComposer(instanceId);
|
||||
}
|
||||
|
||||
function handleFocusIn() {
|
||||
setActiveDictationComposer(instanceId);
|
||||
}
|
||||
|
||||
el.addEventListener("focusin", handleFocusIn);
|
||||
return () => {
|
||||
el.removeEventListener("focusin", handleFocusIn);
|
||||
clearActiveDictationComposer(instanceId);
|
||||
};
|
||||
}, [instanceId, composerRef]);
|
||||
|
||||
// Cancel dictation when the channel/thread changes so that transcript events
|
||||
// from a stale local STT session don't leak into the wrong draft.
|
||||
// Only cancel if this instance owns a live/pending session — avoids killing
|
||||
// another composer's session since the native engine is a singleton.
|
||||
//
|
||||
// Includes `isTranscribing`, not just `isRecording`/`isStarting`:
|
||||
// `stopRecording()` clears `isRecording` immediately but deliberately keeps
|
||||
// the transcript listener alive (isTranscribing=true) until the native
|
||||
// `stopped` event, so the final local STT flush can still be appended. If the
|
||||
// draftKey changes during that grace window, we must still cancel — otherwise
|
||||
// the late transcript is appended through this same composer instance into the
|
||||
// newly restored draft. `cancelRecording()` unlistens the transcript handler
|
||||
// and performs a session-scoped native stop, so this is safe.
|
||||
const isOwningSessionRef = useRef(false);
|
||||
isOwningSessionRef.current =
|
||||
dictation.isRecording || dictation.isStarting || dictation.isTranscribing;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: draftKey is the sole trigger
|
||||
useEffect(() => {
|
||||
if (isOwningSessionRef.current) {
|
||||
dictation.cancelRecording();
|
||||
}
|
||||
}, [draftKey]);
|
||||
|
||||
// Auto-cancel dictation when the composer becomes disabled mid-session
|
||||
// (e.g. channel becomes read-only, parent send state disables thread composer).
|
||||
// Without this, the STT session keeps running with no way to stop it.
|
||||
//
|
||||
// Covers the full ownership window — `isStarting` and `isTranscribing`, not
|
||||
// just `isRecording`. If the composer is disabled while `startRecording()` is
|
||||
// still resolving (mic permission / AudioWorklet setup), the pending start
|
||||
// would otherwise finish and begin microphone capture in a disabled composer.
|
||||
// `cancelRecording()` aborts the in-flight start as well as a live recording,
|
||||
// and re-running when any of these flags flip catches a start that completes
|
||||
// after the disable.
|
||||
useEffect(() => {
|
||||
const owningSession =
|
||||
dictation.isRecording || dictation.isStarting || dictation.isTranscribing;
|
||||
if ((!enabled || disabled) && owningSession) {
|
||||
dictation.cancelRecording();
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
disabled,
|
||||
dictation.isRecording,
|
||||
dictation.isStarting,
|
||||
dictation.isTranscribing,
|
||||
dictation.cancelRecording,
|
||||
]);
|
||||
|
||||
// ⌘D push-to-talk — hold to record, release to stop.
|
||||
// Dispatched from AppShell's keydown/keyup handlers.
|
||||
// Only the active (most recently focused) composer responds, and only while
|
||||
// focus remains inside it and it is not disabled/send-blocked.
|
||||
// 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
|
||||
// registration persists after focusout (until unmount), so without this
|
||||
// check, focusing a composer and then moving to another mounted input or
|
||||
// dialog (quick search, create-channel, etc.) would still let ⌘D start
|
||||
// microphone capture and append transcript into the background draft.
|
||||
const el = composerRef?.current;
|
||||
if (el && !el.contains(document.activeElement)) return;
|
||||
// Don't start dictation in disabled/blocked composers.
|
||||
if (disabledRef.current || isSendBlockedRef.current) return;
|
||||
if (!dictation.isRecording && !dictation.isStarting) {
|
||||
dictation.startRecording();
|
||||
}
|
||||
}
|
||||
function handleKeyUp() {
|
||||
if (dictation.isRecording || dictation.isStarting) {
|
||||
dictation.stopRecording();
|
||||
}
|
||||
}
|
||||
window.addEventListener("buzz:dictation-key-down", handleKeyDown);
|
||||
window.addEventListener("buzz:dictation-key-up", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("buzz:dictation-key-down", handleKeyDown);
|
||||
window.removeEventListener("buzz:dictation-key-up", handleKeyUp);
|
||||
};
|
||||
}, [
|
||||
instanceId,
|
||||
composerRef,
|
||||
enabled,
|
||||
dictation.isRecording,
|
||||
dictation.isStarting,
|
||||
dictation.startRecording,
|
||||
dictation.stopRecording,
|
||||
]);
|
||||
|
||||
return dictation;
|
||||
}
|
||||
90
desktop/src/features/dictation/hooks/useDictation.ts
Normal file
90
desktop/src/features/dictation/hooks/useDictation.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type * as React from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
DEFAULT_AUTO_SUBMIT_PHRASE,
|
||||
getAutoSubmitMatch,
|
||||
parseAutoSubmitPhrases,
|
||||
replaceTrailingTranscribedText,
|
||||
} from "../lib/voiceInput";
|
||||
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 */
|
||||
setText: (value: string) => void;
|
||||
/** Send the message */
|
||||
onSend: (text: string) => void;
|
||||
/** Ref that is `true` when sending is blocked (uploading, preparing mention, etc.) */
|
||||
isSendBlockedRef?: React.MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
export function useDictation({
|
||||
disabled = false,
|
||||
getText,
|
||||
setText,
|
||||
onSend,
|
||||
isSendBlockedRef,
|
||||
}: UseDictationOptions) {
|
||||
const autoSubmitPhrases = useMemo(
|
||||
() => parseAutoSubmitPhrases(DEFAULT_AUTO_SUBMIT_PHRASE),
|
||||
[],
|
||||
);
|
||||
const stopRecordingRef = useRef<() => void>(() => {});
|
||||
const lastTranscriptRef = useRef("");
|
||||
|
||||
const handleTranscript = useCallback(
|
||||
(transcript: string) => {
|
||||
const previous = lastTranscriptRef.current;
|
||||
const latest = getText();
|
||||
const merged = replaceTrailingTranscribedText(
|
||||
latest,
|
||||
previous,
|
||||
transcript,
|
||||
);
|
||||
const match = getAutoSubmitMatch(transcript, autoSubmitPhrases);
|
||||
|
||||
if (!match) {
|
||||
setText(merged);
|
||||
// Reset to empty — each streaming partial is an independent segment
|
||||
// (the native engine flushes and clears its buffer). The next transcript
|
||||
// should be appended, not replace this one.
|
||||
lastTranscriptRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const textWithoutPhrase = replaceTrailingTranscribedText(
|
||||
latest,
|
||||
previous,
|
||||
match.textWithoutPhrase,
|
||||
);
|
||||
if (!textWithoutPhrase.trim()) return;
|
||||
|
||||
stopRecordingRef.current();
|
||||
|
||||
if (isSendBlockedRef?.current) {
|
||||
setText(textWithoutPhrase);
|
||||
return;
|
||||
}
|
||||
|
||||
setText(textWithoutPhrase.trim());
|
||||
onSend(textWithoutPhrase.trim());
|
||||
lastTranscriptRef.current = "";
|
||||
},
|
||||
[autoSubmitPhrases, getText, onSend, isSendBlockedRef, setText],
|
||||
);
|
||||
|
||||
const dictation = useLocalDictation({
|
||||
disabled,
|
||||
onRecordingStart: () => {
|
||||
lastTranscriptRef.current = "";
|
||||
},
|
||||
onTranscriptText: handleTranscript,
|
||||
});
|
||||
|
||||
stopRecordingRef.current = dictation.stopRecording;
|
||||
|
||||
return dictation;
|
||||
}
|
||||
619
desktop/src/features/dictation/hooks/useLocalDictation.ts
Normal file
619
desktop/src/features/dictation/hooks/useLocalDictation.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Raw binary invoke — uses Tauri's internal IPC for zero-copy ArrayBuffer transfer.
|
||||
* Same pattern as huddle's audioWorklet.ts.
|
||||
*/
|
||||
function invokeRawBinary(cmd: string, payload: Uint8Array): Promise<unknown> {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tauri internals have no public type definition
|
||||
const internals = (window as any).__TAURI_INTERNALS__;
|
||||
if (!internals?.invoke) {
|
||||
return Promise.reject(new Error("Tauri internals not available"));
|
||||
}
|
||||
return internals.invoke(cmd, payload);
|
||||
}
|
||||
|
||||
interface UseLocalDictationOptions {
|
||||
disabled?: boolean;
|
||||
onRecordingStart?: () => void;
|
||||
onTranscriptText: (text: string) => void;
|
||||
}
|
||||
|
||||
interface DictationStatus {
|
||||
available: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const DICTATION_TRANSCRIPT_EVENT = "dictation-transcript";
|
||||
const DICTATION_STATE_EVENT = "dictation-state";
|
||||
|
||||
/** Interval (ms) to poll model availability after initial unavailability. */
|
||||
const MODEL_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Batching interval for audio IPC (ms). The worklet posts every render quantum
|
||||
* (~2.67ms at 48kHz/128 samples). Sending each one individually overloads IPC.
|
||||
* We accumulate ~100ms of audio before sending to reduce IPC overhead from
|
||||
* ~375 calls/s to ~10 calls/s.
|
||||
*/
|
||||
const AUDIO_BATCH_MS = 100;
|
||||
|
||||
/**
|
||||
* Max samples per `push_dictation_audio` IPC call. The native command rejects
|
||||
* any raw audio payload over 100 KB (`MAX_AUDIO_BATCH_BYTES` in `dictation.rs`);
|
||||
* at 48 kHz f32 mono that is 25,600 samples (~0.53s). We chunk under that cap
|
||||
* (24,000 samples / 96 KB, leaving headroom) so a stalled main thread that
|
||||
* lets the batch grow past ~0.5s can't produce a single oversized buffer that
|
||||
* native rejects and we silently drop. Chunks are sent in order.
|
||||
*/
|
||||
const MAX_IPC_SAMPLES = 24_000;
|
||||
|
||||
/**
|
||||
* Size (bytes) of the little-endian `u64` session header prepended to each
|
||||
* `push_dictation_audio` payload. Native reads this header and only feeds audio
|
||||
* whose session matches the currently-active one, so late chunks from a
|
||||
* just-stopped session can't leak into a newer session's transcript.
|
||||
*/
|
||||
const SESSION_HEADER_BYTES = 8;
|
||||
|
||||
/**
|
||||
* Local STT dictation hook using the Parakeet model via Tauri native commands.
|
||||
*
|
||||
* Works fully offline — no relay or OpenAI API key needed. Uses the same
|
||||
* sherpa-onnx Parakeet TDT-CTC 110M model as huddle transcription.
|
||||
*
|
||||
* Audio capture uses the Web Audio API (AudioWorklet) on the frontend side,
|
||||
* then sends batched raw PCM bytes to the native STT engine via
|
||||
* `push_dictation_audio`.
|
||||
*/
|
||||
export function useLocalDictation({
|
||||
disabled = false,
|
||||
onRecordingStart,
|
||||
onTranscriptText,
|
||||
}: UseLocalDictationOptions) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [isAvailable, setIsAvailable] = useState(false);
|
||||
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const workletRef = useRef<AudioWorkletNode | null>(null);
|
||||
const outputGainRef = useRef<GainNode | null>(null);
|
||||
const batchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const audioBatchRef = useRef<Float32Array[]>([]);
|
||||
// Tail of the serialized flush chain. Each flush appends its IPC work to
|
||||
// this promise so flushes run strictly one-after-another; awaiting the tail
|
||||
// guarantees every already-started flush's chunks are enqueued native-side.
|
||||
const flushChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const unlistenTranscriptRef = useRef<UnlistenFn | null>(null);
|
||||
const unlistenStateRef = useRef<UnlistenFn | null>(null);
|
||||
const onRecordingStartRef = useRef(onRecordingStart);
|
||||
const onTranscriptTextRef = useRef(onTranscriptText);
|
||||
// Native session ID — set after `start_dictation` returns. Transcript and
|
||||
// state events include this ID so we can definitively ignore stale events
|
||||
// from a previous session's forwarder.
|
||||
const nativeSessionRef = useRef<number>(0);
|
||||
// Abort flag — set when stop/cancel is called while startRecording is still
|
||||
// awaiting async setup. The start resumes and bails before activating.
|
||||
const startAbortedRef = useRef(false);
|
||||
|
||||
onRecordingStartRef.current = onRecordingStart;
|
||||
onTranscriptTextRef.current = onTranscriptText;
|
||||
|
||||
const isEnabled = !disabled && isAvailable;
|
||||
|
||||
// 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;
|
||||
|
||||
function checkAvailability() {
|
||||
invoke<DictationStatus>("get_dictation_status")
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
setIsAvailable(status.available);
|
||||
// Stop polling once the model is ready.
|
||||
if (status.available && pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setIsAvailable(false);
|
||||
});
|
||||
}
|
||||
|
||||
checkAvailability();
|
||||
|
||||
// Poll periodically until available (handles background model download).
|
||||
pollTimer = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
checkAvailability();
|
||||
}, MODEL_POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
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).
|
||||
*
|
||||
* Flushes are serialized through `flushChainRef`: the batch is drained
|
||||
* synchronously here (so each flush owns a distinct set of samples in FIFO
|
||||
* order), but the actual IPC send is chained after any prior in-flight
|
||||
* flush. This keeps chunks strictly ordered and lets `stopRecording`/
|
||||
* `cleanup` await the chain tail so an earlier timer-tick flush can't still
|
||||
* be enqueuing chunks when `stop_dictation` fires. */
|
||||
const flushAudioBatch = useCallback((): Promise<void> => {
|
||||
const batch = audioBatchRef.current;
|
||||
if (batch.length === 0) return flushChainRef.current;
|
||||
|
||||
// Tag this flush with the session that owns the buffered audio. Captured
|
||||
// once here so every chunk of this batch carries the same session; native
|
||||
// drops chunks whose session no longer matches the active one, so a late
|
||||
// flush from a just-stopped session can't leak into a newer session.
|
||||
const session = nativeSessionRef.current;
|
||||
|
||||
// Calculate total byte length and merge into a single buffer. Drain the
|
||||
// batch synchronously so a concurrent timer tick can't grab the same
|
||||
// samples — the merged buffer captured here is this flush's exclusive
|
||||
// payload.
|
||||
let totalSamples = 0;
|
||||
for (const chunk of batch) {
|
||||
totalSamples += chunk.length;
|
||||
}
|
||||
const merged = new Float32Array(totalSamples);
|
||||
let offset = 0;
|
||||
for (const chunk of batch) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
audioBatchRef.current = [];
|
||||
|
||||
// Split into chunks under the native IPC cap and send them in order.
|
||||
// A single unbounded buffer can exceed the 100 KB native limit if the
|
||||
// batch timer was delayed (e.g. main-thread stall), in which case native
|
||||
// rejects it and the whole chunk is silently lost.
|
||||
const sendChunks = async (): Promise<void> => {
|
||||
for (let start = 0; start < merged.length; start += MAX_IPC_SAMPLES) {
|
||||
const slice = merged.subarray(
|
||||
start,
|
||||
Math.min(start + MAX_IPC_SAMPLES, merged.length),
|
||||
);
|
||||
// Build the IPC payload: an 8-byte LE u64 session header followed by
|
||||
// the audio bytes. Copy the slice into the header-prefixed buffer so
|
||||
// the raw payload is exactly this chunk (a subarray view shares the
|
||||
// parent's ArrayBuffer).
|
||||
const payload = new Uint8Array(SESSION_HEADER_BYTES + slice.byteLength);
|
||||
new DataView(payload.buffer).setBigUint64(
|
||||
0,
|
||||
BigInt(session),
|
||||
true, // little-endian
|
||||
);
|
||||
payload.set(
|
||||
new Uint8Array(
|
||||
slice.buffer.slice(
|
||||
slice.byteOffset,
|
||||
slice.byteOffset + slice.byteLength,
|
||||
),
|
||||
),
|
||||
SESSION_HEADER_BYTES,
|
||||
);
|
||||
await invokeRawBinary("push_dictation_audio", payload).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// Chain this flush's IPC work after any prior in-flight flush so chunks
|
||||
// stay strictly ordered and awaiting the tail drains everything.
|
||||
const chained = flushChainRef.current.then(sendChunks);
|
||||
flushChainRef.current = chained;
|
||||
return chained;
|
||||
}, []);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
// Abort any in-flight startRecording so it bails after its next await
|
||||
// instead of resuming and opening the mic/worklet or leaving the native
|
||||
// session/listeners running after teardown. `cleanup` is the unmount
|
||||
// handler (and the catch-path teardown); without this, unmounting while
|
||||
// startRecording awaits `start_dictation`/`listen`/`getUserMedia`/
|
||||
// `addModule` would let the async start finish against a torn-down
|
||||
// instance. A fresh startRecording clears this flag before its first
|
||||
// await, so it never wrongly aborts a subsequent start.
|
||||
startAbortedRef.current = true;
|
||||
// Flush any remaining audio before teardown, then stop the native engine.
|
||||
// Scope the stop to THIS hook instance's session. `cleanup` runs as every
|
||||
// instance's unmount handler, so an unscoped stop here would let a
|
||||
// non-recording composer (e.g. a thread reply composer closing) tear down
|
||||
// the singleton engine owned by another composer that is actively
|
||||
// recording. `nativeSessionRef.current` is 0 for an instance that never
|
||||
// started a session (native IDs start at 1), so its stop can never match
|
||||
// the live session and correctly no-ops.
|
||||
const stoppingSession = nativeSessionRef.current;
|
||||
void flushAudioBatch().then(() => {
|
||||
invoke("stop_dictation", { session: stoppingSession }).catch(() => {});
|
||||
});
|
||||
// Stop batch timer.
|
||||
if (batchTimerRef.current) {
|
||||
clearInterval(batchTimerRef.current);
|
||||
batchTimerRef.current = null;
|
||||
}
|
||||
// Stop mic.
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
}
|
||||
// Disconnect audio worklet. Clear the port handler first so any PCM
|
||||
// messages still queued on the main thread are dropped instead of
|
||||
// appended to the (reused) audio batch.
|
||||
if (workletRef.current) {
|
||||
workletRef.current.port.onmessage = null;
|
||||
workletRef.current.disconnect();
|
||||
workletRef.current = null;
|
||||
}
|
||||
if (outputGainRef.current) {
|
||||
outputGainRef.current.disconnect();
|
||||
outputGainRef.current = null;
|
||||
}
|
||||
// Drop any audio still buffered so it can't leak into the next session.
|
||||
audioBatchRef.current = [];
|
||||
// Close audio context.
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
// Unlisten events.
|
||||
if (unlistenTranscriptRef.current) {
|
||||
unlistenTranscriptRef.current();
|
||||
unlistenTranscriptRef.current = null;
|
||||
}
|
||||
if (unlistenStateRef.current) {
|
||||
unlistenStateRef.current();
|
||||
unlistenStateRef.current = null;
|
||||
}
|
||||
}, [flushAudioBatch]);
|
||||
|
||||
// Cleanup on unmount.
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
// Also guard on `isTranscribing`: after `stopRecording()` the previous
|
||||
// session has cleared `isRecording` but is still awaiting its native
|
||||
// `stopped` event, which delivers the final transcript before its
|
||||
// listeners are unregistered. Starting a new session in that window would
|
||||
// unlisten the old session's handlers (below) before its last words
|
||||
// arrived, dropping them. Wait for the prior session to fully stop.
|
||||
if (!isEnabled || isStarting || isRecording || isTranscribing) return;
|
||||
|
||||
// Clear abort flag for this new start attempt.
|
||||
startAbortedRef.current = false;
|
||||
// Reset any leftover audio buffer so a stale batch from a prior session
|
||||
// can't be flushed into this new session/draft.
|
||||
audioBatchRef.current = [];
|
||||
// Reset the flush chain so this session's flushes don't chain behind a
|
||||
// prior session's (already-settled) tail.
|
||||
flushChainRef.current = Promise.resolve();
|
||||
|
||||
setIsStarting(true);
|
||||
onRecordingStartRef.current?.();
|
||||
|
||||
try {
|
||||
// 1. Start the native STT engine — returns the session ID used to tag events.
|
||||
const sessionId = await invoke<number>("start_dictation");
|
||||
nativeSessionRef.current = sessionId;
|
||||
|
||||
// Bail if aborted during engine start.
|
||||
if (startAbortedRef.current) {
|
||||
invoke("stop_dictation", { session: sessionId }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Unregister any lingering listeners from a previous session so they
|
||||
// can't match the new session ID via the shared ref.
|
||||
if (unlistenTranscriptRef.current) {
|
||||
unlistenTranscriptRef.current();
|
||||
unlistenTranscriptRef.current = null;
|
||||
}
|
||||
if (unlistenStateRef.current) {
|
||||
unlistenStateRef.current();
|
||||
unlistenStateRef.current = null;
|
||||
}
|
||||
|
||||
// 2. Listen for transcript events from the native layer.
|
||||
// Each listener captures `sessionId` by value (closure) so it only
|
||||
// matches events from this specific session — immune to ref mutation.
|
||||
const unlistenTranscript = await listen<{
|
||||
text: string;
|
||||
session: number;
|
||||
}>(DICTATION_TRANSCRIPT_EVENT, (event) => {
|
||||
const { text, session } = event.payload;
|
||||
if (session !== sessionId) return;
|
||||
if (text) {
|
||||
onTranscriptTextRef.current(text);
|
||||
}
|
||||
});
|
||||
// Bail if stop/cancel was called while we were awaiting.
|
||||
if (startAbortedRef.current) {
|
||||
unlistenTranscript();
|
||||
invoke("stop_dictation", { session: sessionId }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
unlistenTranscriptRef.current = unlistenTranscript;
|
||||
|
||||
const unlistenState = await listen<{
|
||||
state: string;
|
||||
session: number;
|
||||
}>(DICTATION_STATE_EVENT, (event) => {
|
||||
const { state, session } = event.payload;
|
||||
if (session !== sessionId) return;
|
||||
if (state === "stopped") {
|
||||
setIsRecording(false);
|
||||
setIsTranscribing(false);
|
||||
// Tear down this instance's local capture pipeline. The native
|
||||
// engine is a singleton: when another mounted composer calls
|
||||
// `start_dictation`, it stops this session's engine, so we can
|
||||
// receive `stopped` without our own `stopRecording()`/`cleanup()`
|
||||
// having run. Without tearing down here, `streamRef`/`workletRef`/
|
||||
// `batchTimerRef` stay alive and this composer keeps the mic open,
|
||||
// pushing stale audio, until it unmounts. Don't re-invoke
|
||||
// `stop_dictation` — the native side already stopped (that's why
|
||||
// this event fired).
|
||||
if (batchTimerRef.current) {
|
||||
clearInterval(batchTimerRef.current);
|
||||
batchTimerRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (workletRef.current) {
|
||||
workletRef.current.port.onmessage = null;
|
||||
workletRef.current.disconnect();
|
||||
workletRef.current = null;
|
||||
}
|
||||
if (outputGainRef.current) {
|
||||
outputGainRef.current.disconnect();
|
||||
outputGainRef.current = null;
|
||||
}
|
||||
audioBatchRef.current = [];
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
// Clean up event listeners now that the session is fully done.
|
||||
if (unlistenTranscriptRef.current) {
|
||||
unlistenTranscriptRef.current();
|
||||
unlistenTranscriptRef.current = null;
|
||||
}
|
||||
if (unlistenStateRef.current) {
|
||||
unlistenStateRef.current();
|
||||
unlistenStateRef.current = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Bail if stop/cancel was called while we were awaiting.
|
||||
if (startAbortedRef.current) {
|
||||
unlistenTranscript();
|
||||
unlistenState();
|
||||
invoke("stop_dictation", { session: sessionId }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
unlistenStateRef.current = unlistenState;
|
||||
|
||||
// 3. Capture mic audio.
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
// Bail if aborted during mic permission prompt.
|
||||
if (startAbortedRef.current) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
streamRef.current = null;
|
||||
invoke("stop_dictation", { session: sessionId }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Set up AudioWorklet to send PCM to native layer.
|
||||
const audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
// Resume if the WebView created the context suspended (autoplay policy).
|
||||
// A suspended context never pulls the worklet's `process()`, so no PCM
|
||||
// would reach `push_dictation_audio` while the UI shows an active
|
||||
// recording. Mirrors the huddle capture path (`huddle/lib/audioWorklet.ts`).
|
||||
if (audioContext.state === "suspended") {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
// Create a processor that accumulates audio frames and posts them
|
||||
// to the main thread. Batching happens on the main thread side via
|
||||
// a timer to reduce IPC overhead.
|
||||
const processorCode = `
|
||||
class DictationProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (input && input[0] && input[0].length > 0) {
|
||||
this.port.postMessage(input[0].buffer);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('dictation-processor', DictationProcessor);
|
||||
`;
|
||||
const blob = new Blob([processorCode], {
|
||||
type: "application/javascript",
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
await audioContext.audioWorklet.addModule(blobUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
// Bail if stop/cancel was called while the worklet module was loading.
|
||||
// Without this the worklet/flush timer would start and `isRecording`
|
||||
// would be set true, leaving dictation running after it was stopped.
|
||||
if (startAbortedRef.current) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
streamRef.current = null;
|
||||
void audioContext.close();
|
||||
audioContextRef.current = null;
|
||||
invoke("stop_dictation", { session: sessionId }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const worklet = new AudioWorkletNode(audioContext, "dictation-processor");
|
||||
workletRef.current = worklet;
|
||||
|
||||
// Accumulate audio frames in a batch array; a timer flushes to native.
|
||||
worklet.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
|
||||
audioBatchRef.current.push(new Float32Array(event.data));
|
||||
};
|
||||
|
||||
// Start the batch flush timer (~10 IPC calls/s instead of ~375).
|
||||
batchTimerRef.current = setInterval(flushAudioBatch, AUDIO_BATCH_MS);
|
||||
|
||||
const outputGain = audioContext.createGain();
|
||||
outputGain.gain.value = 0;
|
||||
outputGainRef.current = outputGain;
|
||||
|
||||
source.connect(worklet);
|
||||
worklet.connect(outputGain);
|
||||
outputGain.connect(audioContext.destination);
|
||||
|
||||
setIsRecording(true);
|
||||
setIsTranscribing(true);
|
||||
} catch (error) {
|
||||
// Tear down and stop the native engine if it was started but a later
|
||||
// step failed (e.g. mic permission denied, AudioWorklet setup error).
|
||||
// `cleanup()` performs the session-scoped stop, so it only tears down
|
||||
// this instance's own session — never another composer's.
|
||||
cleanup();
|
||||
setIsRecording(false);
|
||||
setIsTranscribing(false);
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Local dictation failed";
|
||||
if (/not allowed|denied|permission/i.test(message)) {
|
||||
toast.error("Microphone access denied", {
|
||||
description:
|
||||
"Allow microphone access in System Settings to use dictation.",
|
||||
});
|
||||
} else if (/not found|no audio/i.test(message)) {
|
||||
toast.error("No microphone found", {
|
||||
description: "Connect a microphone and try again.",
|
||||
});
|
||||
} else if (/model not ready/i.test(message)) {
|
||||
toast.error("Voice model downloading", {
|
||||
description:
|
||||
"The speech model is still downloading. Try again shortly.",
|
||||
});
|
||||
} else {
|
||||
toast.error("Dictation failed", { description: message });
|
||||
}
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
}, [
|
||||
cleanup,
|
||||
flushAudioBatch,
|
||||
isEnabled,
|
||||
isRecording,
|
||||
isStarting,
|
||||
isTranscribing,
|
||||
]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
// Signal any in-flight startRecording to bail after its next await.
|
||||
startAbortedRef.current = true;
|
||||
// Stop batch timer immediately.
|
||||
if (batchTimerRef.current) {
|
||||
clearInterval(batchTimerRef.current);
|
||||
batchTimerRef.current = null;
|
||||
}
|
||||
// Stop mic and audio pipeline immediately so the user gets visual feedback.
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (workletRef.current) {
|
||||
// Clear the port handler so PCM messages still queued on the main
|
||||
// thread are dropped rather than appended to the batch after the
|
||||
// final flush below (and leaking into the next session).
|
||||
workletRef.current.port.onmessage = null;
|
||||
workletRef.current.disconnect();
|
||||
workletRef.current = null;
|
||||
}
|
||||
if (outputGainRef.current) {
|
||||
outputGainRef.current.disconnect();
|
||||
outputGainRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
setIsRecording(false);
|
||||
// Flush remaining audio and THEN stop the native engine, ensuring the
|
||||
// final batch arrives before the engine shuts down and flushes its buffer.
|
||||
// isTranscribing stays true — cleared when `dictation-state: stopped` arrives.
|
||||
// Scope the stop to THIS session: if the user restarts during the flush
|
||||
// window, a new session may already be stored by the time this resolves —
|
||||
// passing the session keeps this stop from tearing down the new engine.
|
||||
const stoppingSession = nativeSessionRef.current;
|
||||
void flushAudioBatch().then(() => {
|
||||
invoke("stop_dictation", { session: stoppingSession }).catch(() => {});
|
||||
});
|
||||
}, [flushAudioBatch]);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
// Signal any in-flight startRecording to bail after its next await.
|
||||
startAbortedRef.current = true;
|
||||
// `cleanup()` performs the session-scoped native stop, so cancelling a
|
||||
// composer that isn't the active recorder can't tear down another
|
||||
// composer's session.
|
||||
cleanup();
|
||||
setIsRecording(false);
|
||||
setIsTranscribing(false);
|
||||
}, [cleanup]);
|
||||
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (isRecording || isStarting) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
void startRecording();
|
||||
}, [isRecording, isStarting, startRecording, stopRecording]);
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
isRecording,
|
||||
isStarting,
|
||||
isTranscribing,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
toggleRecording,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type * as React from "react";
|
||||
import { useRef } from "react";
|
||||
import { useFeatureEnabled } from "@/shared/features";
|
||||
import { DictationButton } from "../ui/DictationButton";
|
||||
import { useComposerDictation } from "./useComposerDictation";
|
||||
|
||||
interface UseMessageComposerDictationOptions {
|
||||
syncContentRef: React.MutableRefObject<() => string>;
|
||||
disabled: boolean;
|
||||
disabledRef: React.MutableRefObject<boolean>;
|
||||
isSendingRef: React.MutableRefObject<boolean>;
|
||||
isUploadingRef: React.MutableRefObject<boolean>;
|
||||
setComposerContent: (text: string) => void;
|
||||
setEditorContent: (text: string) => void;
|
||||
submitMessageRef: React.MutableRefObject<() => void>;
|
||||
draftKey: string | null;
|
||||
composerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function useMessageComposerDictation({
|
||||
setEditorContent,
|
||||
...options
|
||||
}: UseMessageComposerDictationOptions) {
|
||||
const enabled = useFeatureEnabled("voiceDictation");
|
||||
const setEditorContentRef = useRef(setEditorContent);
|
||||
setEditorContentRef.current = setEditorContent;
|
||||
return useComposerDictation({
|
||||
...options,
|
||||
enabled,
|
||||
setEditorContentRef,
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageComposerDictationAction({
|
||||
children,
|
||||
dictation,
|
||||
disabled,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
dictation: ReturnType<typeof useMessageComposerDictation>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DictationButton dictation={dictation} disabled={disabled} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { useFeatureEnabled } from "@/shared/features";
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
|
||||
export function useVoiceDictationShortcut(disabled: boolean) {
|
||||
const enabled = useFeatureEnabled("voiceDictation");
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let keyHeld = false;
|
||||
const release = () => {
|
||||
if (!keyHeld) return;
|
||||
keyHeld = false;
|
||||
window.dispatchEvent(new CustomEvent("buzz:dictation-key-up"));
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.repeat ||
|
||||
event.altKey ||
|
||||
event.shiftKey ||
|
||||
event.key.toLowerCase() !== "d" ||
|
||||
!hasPrimaryShortcutModifier(event)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
keyHeld = true;
|
||||
window.dispatchEvent(new CustomEvent("buzz:dictation-key-down"));
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === "d") release();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) release();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", release);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
release();
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", release);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [disabled, enabled]);
|
||||
}
|
||||
8
desktop/src/features/dictation/index.ts
Normal file
8
desktop/src/features/dictation/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useComposerDictation } from "./hooks/useComposerDictation";
|
||||
export { useDictation } from "./hooks/useDictation";
|
||||
export {
|
||||
MessageComposerDictationAction,
|
||||
useMessageComposerDictation,
|
||||
} from "./hooks/useMessageComposerDictation";
|
||||
export { useVoiceDictationShortcut } from "./hooks/useVoiceDictationShortcut";
|
||||
export { DictationButton } from "./ui/DictationButton";
|
||||
23
desktop/src/features/dictation/lib/activeComposer.ts
Normal file
23
desktop/src/features/dictation/lib/activeComposer.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Tracks which composer instance should handle global dictation shortcuts.
|
||||
*
|
||||
* When multiple composers are mounted (e.g. channel + thread), only the
|
||||
* most recently focused one should respond to ⌘D. Each composer registers
|
||||
* on focus and the global shortcut only dispatches to the active instance.
|
||||
*/
|
||||
|
||||
let activeInstanceId: string | null = null;
|
||||
|
||||
export function setActiveDictationComposer(id: string): void {
|
||||
activeInstanceId = id;
|
||||
}
|
||||
|
||||
export function clearActiveDictationComposer(id: string): void {
|
||||
if (activeInstanceId === id) {
|
||||
activeInstanceId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isActiveDictationComposer(id: string): boolean {
|
||||
return activeInstanceId === id;
|
||||
}
|
||||
150
desktop/src/features/dictation/lib/voiceInput.test.mjs
Normal file
150
desktop/src/features/dictation/lib/voiceInput.test.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_AUTO_SUBMIT_PHRASE,
|
||||
getAutoSubmitMatch,
|
||||
parseAutoSubmitPhrases,
|
||||
replaceTrailingTranscribedText,
|
||||
} from "./voiceInput.ts";
|
||||
|
||||
// ── parseAutoSubmitPhrases ──────────────────────────────────────────────────
|
||||
|
||||
test("parseAutoSubmitPhrases_returnsEmptyForNullish", () => {
|
||||
assert.deepEqual(parseAutoSubmitPhrases(null), []);
|
||||
assert.deepEqual(parseAutoSubmitPhrases(undefined), []);
|
||||
assert.deepEqual(parseAutoSubmitPhrases(""), []);
|
||||
});
|
||||
|
||||
test("parseAutoSubmitPhrases_splitsNormalizesAndDedupes", () => {
|
||||
assert.deepEqual(parseAutoSubmitPhrases("Submit, send it, submit, "), [
|
||||
"submit",
|
||||
"send it",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseAutoSubmitPhrases_stripsTrailingPunctuation", () => {
|
||||
assert.deepEqual(parseAutoSubmitPhrases("submit!"), ["submit"]);
|
||||
});
|
||||
|
||||
// ── replaceTrailingTranscribedText ──────────────────────────────────────────
|
||||
|
||||
test("replaceTrailingTranscribedText_appendsWhenNoPrevious", () => {
|
||||
assert.equal(
|
||||
replaceTrailingTranscribedText("Hello", "", "world"),
|
||||
"Hello world",
|
||||
);
|
||||
});
|
||||
|
||||
test("replaceTrailingTranscribedText_appendsToEmptyBase", () => {
|
||||
assert.equal(replaceTrailingTranscribedText("", "", "hello"), "hello");
|
||||
});
|
||||
|
||||
test("replaceTrailingTranscribedText_replacesTrailingInterim", () => {
|
||||
// Interim "hello wor" is refined to "hello world".
|
||||
assert.equal(
|
||||
replaceTrailingTranscribedText("hello wor", "hello wor", "hello world"),
|
||||
"hello world",
|
||||
);
|
||||
});
|
||||
|
||||
test("replaceTrailingTranscribedText_preservesTextTypedBeforeDictation", () => {
|
||||
// User typed "Note: " then dictated; the manual prefix must survive.
|
||||
assert.equal(
|
||||
replaceTrailingTranscribedText("Note: hi", "hi", "hi there"),
|
||||
"Note: hi there",
|
||||
);
|
||||
});
|
||||
|
||||
test("replaceTrailingTranscribedText_appendsWhenPreviousNoLongerMatches", () => {
|
||||
// If the previous transcript isn't the trailing text anymore, append.
|
||||
assert.equal(
|
||||
replaceTrailingTranscribedText("edited text", "old", "new"),
|
||||
"edited text new",
|
||||
);
|
||||
});
|
||||
|
||||
test("replaceTrailingTranscribedText_noDoubleSpaceBeforePunctuation", () => {
|
||||
assert.equal(
|
||||
replaceTrailingTranscribedText("Hello", "", ", world"),
|
||||
"Hello, world",
|
||||
);
|
||||
});
|
||||
|
||||
// ── getAutoSubmitMatch ──────────────────────────────────────────────────────
|
||||
|
||||
test("getAutoSubmitMatch_returnsNullWhenPhraseAbsent", () => {
|
||||
assert.equal(
|
||||
getAutoSubmitMatch("hello there", parseAutoSubmitPhrases("submit")),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoSubmitMatch_returnsNullWhenPhrasesEmpty", () => {
|
||||
// DEFAULT_AUTO_SUBMIT_PHRASE is empty (auto-submit disabled by default).
|
||||
assert.equal(
|
||||
getAutoSubmitMatch(
|
||||
"send this message submit",
|
||||
parseAutoSubmitPhrases(DEFAULT_AUTO_SUBMIT_PHRASE),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoSubmitMatch_matchesTrailingPhraseAndStripsIt", () => {
|
||||
const match = getAutoSubmitMatch(
|
||||
"send this message submit",
|
||||
parseAutoSubmitPhrases("submit"),
|
||||
);
|
||||
assert.ok(match);
|
||||
assert.equal(match.matchedPhrase, "submit");
|
||||
assert.equal(match.textWithoutPhrase, "send this message");
|
||||
});
|
||||
|
||||
test("getAutoSubmitMatch_ignoresPhraseMidSentence", () => {
|
||||
// "submit" is not at the end, so it must not auto-send.
|
||||
assert.equal(
|
||||
getAutoSubmitMatch(
|
||||
"submit the form later",
|
||||
parseAutoSubmitPhrases("submit"),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoSubmitMatch_requiresWordBoundaryBeforePhrase", () => {
|
||||
// "resubmit" ends with "submit" but is not a standalone word → no match.
|
||||
assert.equal(
|
||||
getAutoSubmitMatch("resubmit", parseAutoSubmitPhrases("submit")),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoSubmitMatch_toleratesTrailingPunctuation", () => {
|
||||
const match = getAutoSubmitMatch(
|
||||
"ship it submit.",
|
||||
parseAutoSubmitPhrases("submit"),
|
||||
);
|
||||
assert.ok(match);
|
||||
assert.equal(match.textWithoutPhrase, "ship it");
|
||||
});
|
||||
|
||||
test("getAutoSubmitMatch_matchesMultiWordPhrase", () => {
|
||||
const match = getAutoSubmitMatch(
|
||||
"please do this send it",
|
||||
parseAutoSubmitPhrases("send it"),
|
||||
);
|
||||
assert.ok(match);
|
||||
assert.equal(match.matchedPhrase, "send it");
|
||||
assert.equal(match.textWithoutPhrase, "please do this");
|
||||
});
|
||||
|
||||
test("getAutoSubmitMatch_prefersLongestPhrase", () => {
|
||||
const match = getAutoSubmitMatch(
|
||||
"text please submit now",
|
||||
parseAutoSubmitPhrases("submit now, now"),
|
||||
);
|
||||
assert.ok(match);
|
||||
assert.equal(match.matchedPhrase, "submit now");
|
||||
assert.equal(match.textWithoutPhrase, "text please");
|
||||
});
|
||||
114
desktop/src/features/dictation/lib/voiceInput.ts
Normal file
114
desktop/src/features/dictation/lib/voiceInput.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Default auto-submit phrase. Empty string disables auto-submit — the user
|
||||
* must manually press Enter/Send after dictation. The infrastructure for
|
||||
* configurable phrases is in place (parseAutoSubmitPhrases, getAutoSubmitMatch)
|
||||
* and can be wired to a user setting when we're ready to ship auto-submit.
|
||||
*/
|
||||
export const DEFAULT_AUTO_SUBMIT_PHRASE = "";
|
||||
|
||||
const TRAILING_PUNCTUATION_REGEX = /[\s"'`.,!?;:)\]}]+$/u;
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function normalizePhrase(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(TRAILING_PUNCTUATION_REGEX, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseAutoSubmitPhrases(
|
||||
rawValue: string | null | undefined,
|
||||
): string[] {
|
||||
if (!rawValue) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
rawValue
|
||||
.split(",")
|
||||
.map((value) => normalizePhrase(value))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function appendTranscribedText(baseText: string, fragment: string): string {
|
||||
const normalizedFragment = fragment.replace(/\s+/g, " ").trim();
|
||||
if (!normalizedFragment) return baseText;
|
||||
if (!baseText.trim()) return normalizedFragment;
|
||||
if (/[\s([{/-]$/.test(baseText) || /^[,.;!?)]/.test(normalizedFragment)) {
|
||||
return `${baseText}${normalizedFragment}`;
|
||||
}
|
||||
return `${baseText} ${normalizedFragment}`;
|
||||
}
|
||||
|
||||
export function replaceTrailingTranscribedText(
|
||||
fullText: string,
|
||||
previousTranscribedText: string,
|
||||
nextTranscribedText: string,
|
||||
): string {
|
||||
if (!previousTranscribedText) {
|
||||
return appendTranscribedText(fullText, nextTranscribedText);
|
||||
}
|
||||
|
||||
if (fullText.endsWith(previousTranscribedText)) {
|
||||
return appendTranscribedText(
|
||||
fullText.slice(0, -previousTranscribedText.length),
|
||||
nextTranscribedText,
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedPreviousText = previousTranscribedText.trim();
|
||||
if (trimmedPreviousText && fullText.endsWith(trimmedPreviousText)) {
|
||||
return appendTranscribedText(
|
||||
fullText.slice(0, -trimmedPreviousText.length),
|
||||
nextTranscribedText,
|
||||
);
|
||||
}
|
||||
|
||||
return appendTranscribedText(fullText, nextTranscribedText);
|
||||
}
|
||||
|
||||
export function getAutoSubmitMatch(
|
||||
transcribedText: string,
|
||||
autoSubmitPhrases: string[],
|
||||
): { matchedPhrase: string; textWithoutPhrase: string } | null {
|
||||
const normalizedTranscribedText = normalizePhrase(transcribedText);
|
||||
if (!normalizedTranscribedText) return null;
|
||||
|
||||
const sortedPhrases = [...autoSubmitPhrases].sort(
|
||||
(left, right) => right.length - left.length,
|
||||
);
|
||||
|
||||
for (const phrase of sortedPhrases) {
|
||||
if (!normalizedTranscribedText.endsWith(phrase)) continue;
|
||||
|
||||
const phraseStartIndex = normalizedTranscribedText.length - phrase.length;
|
||||
if (
|
||||
phraseStartIndex > 0 &&
|
||||
normalizedTranscribedText[phraseStartIndex - 1] !== " "
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmedText = transcribedText.replace(TRAILING_PUNCTUATION_REGEX, "");
|
||||
const phraseWords = phrase.split(" ").filter(Boolean).map(escapeRegExp);
|
||||
const phrasePattern = new RegExp(
|
||||
`(^|\\s)(${phraseWords.join("\\s+")})\\s*$`,
|
||||
"iu",
|
||||
);
|
||||
const rawMatch = trimmedText.match(phrasePattern);
|
||||
const phraseStartOffset =
|
||||
rawMatch && rawMatch.index !== undefined
|
||||
? rawMatch.index + (rawMatch[1]?.length ?? 0)
|
||||
: trimmedText.length - phrase.length;
|
||||
const textWithoutPhrase = trimmedText.slice(0, phraseStartOffset).trimEnd();
|
||||
|
||||
return { matchedPhrase: phrase, textWithoutPhrase };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
73
desktop/src/features/dictation/ui/DictationButton.tsx
Normal file
73
desktop/src/features/dictation/ui/DictationButton.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Mic, Square } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
|
||||
interface DictationState {
|
||||
isEnabled: boolean;
|
||||
isRecording: boolean;
|
||||
isStarting: boolean;
|
||||
isTranscribing: boolean;
|
||||
toggleRecording: () => void;
|
||||
}
|
||||
|
||||
interface DictationButtonProps {
|
||||
dictation: DictationState;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DictationButton({
|
||||
dictation,
|
||||
disabled = false,
|
||||
}: DictationButtonProps) {
|
||||
if (!dictation.isEnabled) return null;
|
||||
|
||||
const shortcutHint = isMacPlatform() ? "⌘D" : "Ctrl+D";
|
||||
|
||||
const tooltipText = dictation.isRecording
|
||||
? "Stop recording"
|
||||
: dictation.isTranscribing
|
||||
? "Transcribing…"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={tooltipText ?? "Voice Dictation"}
|
||||
aria-pressed={dictation.isRecording}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
dictation.isRecording &&
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 hover:text-destructive-foreground active:bg-destructive active:text-destructive-foreground",
|
||||
dictation.isTranscribing && "animate-pulse",
|
||||
)}
|
||||
disabled={
|
||||
dictation.isRecording ? false : disabled || dictation.isStarting
|
||||
}
|
||||
onClick={dictation.toggleRecording}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={dictation.isRecording ? "default" : "ghost"}
|
||||
>
|
||||
{dictation.isRecording ? (
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
) : (
|
||||
<Mic />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltipText ?? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
Voice Dictation
|
||||
<kbd className="rounded border border-foreground/10 px-1 py-0.5 text-2xs font-medium text-foreground/60">
|
||||
{shortcutHint}
|
||||
</kbd>
|
||||
</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import * as React from "react";
|
||||
|
||||
import { EditorContent } from "@tiptap/react";
|
||||
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
|
||||
import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard";
|
||||
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
|
||||
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
|
||||
import { useDrafts } from "@/features/messages/lib/useDrafts";
|
||||
import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey";
|
||||
import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
@@ -39,22 +39,26 @@ import {
|
||||
import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
|
||||
import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles";
|
||||
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
|
||||
import {
|
||||
MessageComposerDictationAction,
|
||||
useMessageComposerDictation,
|
||||
} from "@/features/dictation";
|
||||
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { ChannelAutocomplete } from "./ChannelAutocomplete";
|
||||
import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner";
|
||||
import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
|
||||
import { EmojiAutocomplete } from "./EmojiAutocomplete";
|
||||
import {
|
||||
MentionAutocomplete,
|
||||
type MentionSuggestion,
|
||||
} from "./MentionAutocomplete";
|
||||
import type { MentionSuggestion } from "./MentionAutocomplete";
|
||||
import { ComposerDockToolbar } from "./ComposerDockToolbar";
|
||||
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
|
||||
import { useMentionSendFlow } from "./useMentionSendFlow";
|
||||
import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration";
|
||||
import { useComposerContentState } from "./useComposerContentState";
|
||||
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
|
||||
import {
|
||||
MessageComposerAutocompletes,
|
||||
MessageComposerUploadError,
|
||||
} from "./MessageComposerOverlays";
|
||||
import { useComposerScrollToBottom } from "./useComposerScrollToBottom";
|
||||
|
||||
import type { MessageComposerProps } from "./MessageComposer.types";
|
||||
|
||||
@@ -206,6 +210,7 @@ function MessageComposerImpl({
|
||||
emojiAutocomplete.isEmojiAutocompleteOpen;
|
||||
|
||||
const submitMessageRef = React.useRef<() => void>(() => {});
|
||||
const stopDictationRef = React.useRef<() => void>(() => {});
|
||||
const composerScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Set after `useLinkEditor` exists below; the editor's link-click handler
|
||||
@@ -219,13 +224,7 @@ function MessageComposerImpl({
|
||||
>(null);
|
||||
const onLinkShortcutRef = React.useRef<(() => boolean) | null>(null);
|
||||
|
||||
const scrollComposerToBottom = React.useCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const scrollElement = composerScrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
const scrollComposerToBottom = useComposerScrollToBottom(composerScrollRef);
|
||||
|
||||
const computedPlaceholder = editTarget
|
||||
? "Edit your message"
|
||||
@@ -268,6 +267,19 @@ function MessageComposerImpl({
|
||||
},
|
||||
});
|
||||
|
||||
const dictation = useMessageComposerDictation({
|
||||
syncContentRef: syncContentRefFromEditorRef,
|
||||
disabled,
|
||||
disabledRef,
|
||||
isSendingRef,
|
||||
isUploadingRef,
|
||||
setComposerContent,
|
||||
setEditorContent: richText.setContent,
|
||||
submitMessageRef,
|
||||
draftKey: effectiveDraftKey,
|
||||
composerRef: composerScrollRef,
|
||||
});
|
||||
stopDictationRef.current = dictation.cancelRecording;
|
||||
const linkEditor = useLinkEditor(richText);
|
||||
syncContentRefFromEditorRef.current = () => {
|
||||
const markdown = richText.getMarkdown();
|
||||
@@ -278,7 +290,6 @@ function MessageComposerImpl({
|
||||
onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
|
||||
onLinkShortcutRef.current = linkEditor.openFromShortcut;
|
||||
useComposerSpoilerParticles(richText.editor, composerScrollRef);
|
||||
|
||||
const persistentMentionHydration = usePersistentAgentMentionHydration({
|
||||
audienceScope,
|
||||
hydrationKey: effectiveDraftKey,
|
||||
@@ -512,6 +523,7 @@ function MessageComposerImpl({
|
||||
// Edit mode
|
||||
if (editTargetRef.current && onEditSaveRef.current) {
|
||||
if (isSendingRef.current || isUploadingRef.current) return;
|
||||
stopDictationRef.current();
|
||||
const currentPendingImeta = media.pendingImetaRef.current;
|
||||
// No empty-edit guard here: clearing an edit to empty (no text, no
|
||||
// attachments) flows through to onEditSave as empty content, which
|
||||
@@ -587,6 +599,7 @@ function MessageComposerImpl({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
stopDictationRef.current();
|
||||
|
||||
const capturedThreadContext = onCaptureSendContext?.() ?? null;
|
||||
if (
|
||||
@@ -919,42 +932,22 @@ function MessageComposerImpl({
|
||||
}}
|
||||
>
|
||||
{ownsDropZone && media.isDragOver && <DropZoneOverlay />}
|
||||
<EmojiAutocomplete
|
||||
onSelect={applyEmojiInsert}
|
||||
selectedIndex={emojiAutocomplete.emojiSelectedIndex}
|
||||
suggestions={
|
||||
emojiAutocomplete.isEmojiAutocompleteOpen
|
||||
? emojiAutocomplete.emojiSuggestions
|
||||
: []
|
||||
<MessageComposerAutocompletes
|
||||
applyChannelInsert={applyChannelInsert}
|
||||
applyEmojiInsert={applyEmojiInsert}
|
||||
applyMentionInsert={applyMentionInsert}
|
||||
channelLinks={channelLinks}
|
||||
emojiAutocomplete={emojiAutocomplete}
|
||||
mentions={mentions}
|
||||
/>
|
||||
<MessageComposerUploadError
|
||||
message={
|
||||
media.uploadState.status === "error"
|
||||
? media.uploadState.message
|
||||
: null
|
||||
}
|
||||
onDismiss={() => media.setUploadState({ status: "idle" })}
|
||||
/>
|
||||
<ChannelAutocomplete
|
||||
onSelect={applyChannelInsert}
|
||||
selectedIndex={channelLinks.channelSelectedIndex}
|
||||
suggestions={
|
||||
channelLinks.isChannelOpen
|
||||
? channelLinks.channelSuggestions
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<MentionAutocomplete
|
||||
onFetchMore={mentions.fetchMoreSuggestions}
|
||||
onSelect={applyMentionInsert}
|
||||
selectedIndex={mentions.mentionSelectedIndex}
|
||||
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
|
||||
/>
|
||||
{media.uploadState.status === "error" ? (
|
||||
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Upload failed: {media.uploadState.message}
|
||||
<button
|
||||
className="ml-2 underline"
|
||||
onClick={() => media.setUploadState({ status: "idle" })}
|
||||
type="button"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(media.pendingImeta.length > 0 || media.isUploading) && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -988,7 +981,14 @@ function MessageComposerImpl({
|
||||
layoutMode={layoutMode}
|
||||
composerDisabled={disabled}
|
||||
editor={richText.editor}
|
||||
extraActions={toolbarExtraActions}
|
||||
extraActions={
|
||||
<MessageComposerDictationAction
|
||||
dictation={dictation}
|
||||
disabled={disabled}
|
||||
>
|
||||
{toolbarExtraActions}
|
||||
</MessageComposerDictationAction>
|
||||
}
|
||||
formattingDisabled={disabled}
|
||||
isEmojiPickerOpen={isEmojiPickerOpen}
|
||||
isFormattingOpen={isFormattingOpen}
|
||||
|
||||
72
desktop/src/features/messages/ui/MessageComposerOverlays.tsx
Normal file
72
desktop/src/features/messages/ui/MessageComposerOverlays.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
import type { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
import type { useMentions } from "@/features/messages/lib/useMentions";
|
||||
import { ChannelAutocomplete } from "./ChannelAutocomplete";
|
||||
import { EmojiAutocomplete } from "./EmojiAutocomplete";
|
||||
import {
|
||||
MentionAutocomplete,
|
||||
type MentionSuggestion,
|
||||
} from "./MentionAutocomplete";
|
||||
|
||||
export function MessageComposerAutocompletes({
|
||||
applyChannelInsert,
|
||||
applyEmojiInsert,
|
||||
applyMentionInsert,
|
||||
channelLinks,
|
||||
emojiAutocomplete,
|
||||
mentions,
|
||||
}: {
|
||||
applyChannelInsert: (suggestion: ChannelSuggestion) => void;
|
||||
applyEmojiInsert: (suggestion: EmojiSuggestion) => void;
|
||||
applyMentionInsert: (suggestion: MentionSuggestion) => void;
|
||||
channelLinks: ReturnType<typeof useChannelLinks>;
|
||||
emojiAutocomplete: ReturnType<typeof useEmojiAutocomplete>;
|
||||
mentions: ReturnType<typeof useMentions>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<EmojiAutocomplete
|
||||
onSelect={applyEmojiInsert}
|
||||
selectedIndex={emojiAutocomplete.emojiSelectedIndex}
|
||||
suggestions={
|
||||
emojiAutocomplete.isEmojiAutocompleteOpen
|
||||
? emojiAutocomplete.emojiSuggestions
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<ChannelAutocomplete
|
||||
onSelect={applyChannelInsert}
|
||||
selectedIndex={channelLinks.channelSelectedIndex}
|
||||
suggestions={
|
||||
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
|
||||
}
|
||||
/>
|
||||
<MentionAutocomplete
|
||||
onFetchMore={mentions.fetchMoreSuggestions}
|
||||
onSelect={applyMentionInsert}
|
||||
selectedIndex={mentions.mentionSelectedIndex}
|
||||
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageComposerUploadError({
|
||||
message,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string | null | undefined;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
if (message == null) return null;
|
||||
return (
|
||||
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Upload failed: {message}
|
||||
<button className="ml-2 underline" onClick={onDismiss} type="button">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
export function useComposerScrollToBottom(
|
||||
composerScrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
return React.useCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const scrollElement = composerScrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
}, [composerScrollRef]);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -181,6 +181,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
|
||||
keysWindows: "Ctrl+Space",
|
||||
category: "Messages",
|
||||
},
|
||||
{
|
||||
id: "voice-dictation",
|
||||
label: "Voice Dictation",
|
||||
description: "Hold to dictate, release to stop (push-to-talk)",
|
||||
keys: "⌘D",
|
||||
keysWindows: "Ctrl+D",
|
||||
category: "Messages",
|
||||
},
|
||||
|
||||
// Formatting
|
||||
{
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
"name": "Agent-managed profiles",
|
||||
"description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy",
|
||||
"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