Merge PR114 dictation foundation for approved provider-row finishing
Preserve original contributor commit 3bb28e5f29. Resolve only the settings-dialog conflict by retaining current General/update behavior: the donor General-only language placement is explicitly superseded by the human-approved row BELOW the full-width Deepgram key. The assigned implementation worker will implement that placement and approved shared dropdown finishing. Foundation only, not a checked candidate.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import {
|
||||
DICTATION_LANGUAGES, fuzzyScore, type DictationLanguage,
|
||||
} from "@/src/core";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { SettingsRow } from "@/components/settings/controls";
|
||||
import { useDictationActive } from "@/components/dictation-state";
|
||||
import { useDictationLanguage } from "@/components/dictation-preference";
|
||||
|
||||
type LanguagePreference = ReturnType<typeof useDictationLanguage>;
|
||||
|
||||
export function DictationLanguageSetting() {
|
||||
const preference = useDictationLanguage();
|
||||
const active = useDictationActive();
|
||||
return <SettingsRow label="Language" testid="row-dictation-language" dimmed={active}>
|
||||
<DictationLanguagePicker preference={preference} disabled={active} />
|
||||
</SettingsRow>;
|
||||
}
|
||||
|
||||
function DictationLanguagePicker({ preference, disabled }: {
|
||||
preference: LanguagePreference;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { language, saving, error, load, save } = preference;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—";
|
||||
// Own ordering so clearing a search restores the catalog's order.
|
||||
// cmdk handles keyboard/ARIA, but its DOM sorting retains old ranks.
|
||||
const results = query.trim() ? DICTATION_LANGUAGES
|
||||
.map((item) => ({ item, score: fuzzyScore(query, item.code, item.label) }))
|
||||
.filter((item) => item.score !== null)
|
||||
.sort((a, b) => b.score! - a.score!)
|
||||
.map(({ item }) => item) : DICTATION_LANGUAGES;
|
||||
|
||||
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (target instanceof Element && target.closest('[data-testid="dictation-language-menu"]')) return;
|
||||
if (!triggerRef.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
};
|
||||
}, [open]);
|
||||
const closeOnEscape = (event: ReactKeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
const pick = async (code: DictationLanguage) => {
|
||||
if (disabled || saving || language === undefined) return;
|
||||
if (await save(code)) {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<Hint label={error ?? `Dictation language: ${label}`} side="top">
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
data-testid="dictation-language"
|
||||
aria-label={`Dictation language: ${label}`}
|
||||
aria-expanded={open && !disabled}
|
||||
aria-haspopup="listbox"
|
||||
disabled={disabled || saving}
|
||||
onKeyDown={(event) => { if (open) closeOnEscape(event); }}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setOpen((value) => !value);
|
||||
if (language === undefined || error) void load().catch(() => {});
|
||||
}}
|
||||
className="h-8 min-w-0 max-w-[65%] px-2.5 font-normal"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<ChevronDownIcon aria-hidden className="size-3" />
|
||||
</Button>
|
||||
</Hint>
|
||||
{open && !disabled && <MenuShell
|
||||
anchorRef={triggerRef}
|
||||
testid="dictation-language-menu"
|
||||
preferredSide="down"
|
||||
menuClass="flex w-72 max-w-[calc(100vw-1rem)] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
|
||||
>
|
||||
{error && <div role="alert" className="mb-1.5 text-destructive">{error}</div>}
|
||||
<Command
|
||||
label="Dictation languages"
|
||||
vimBindings={false}
|
||||
shouldFilter={false}
|
||||
onKeyDown={(event) => {
|
||||
closeOnEscape(event);
|
||||
// Keep menu navigation out of the composer's submit/shortcut
|
||||
// handlers. cmdk owns selection, scrolling and ARIA state.
|
||||
if (["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Command.Input asChild disabled={saving} value={query} onValueChange={setQuery}>
|
||||
<Input aria-label="Search dictation languages" placeholder="Search languages…" className="mb-1.5 h-8 bg-background px-2 text-xs md:text-xs" />
|
||||
</Command.Input>
|
||||
<Command.List className="max-h-72 overflow-y-auto px-1">
|
||||
<Command.Empty className="px-2 py-3 text-center text-muted-foreground">No languages match.</Command.Empty>
|
||||
{results.map((item) => <Command.Item
|
||||
key={item.code}
|
||||
value={item.code}
|
||||
disabled={saving || language === undefined}
|
||||
onSelect={() => { void pick(item.code); }}
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
disabled={saving || language === undefined}
|
||||
data-testid={`dictation-language-${item.code}`}
|
||||
className="h-auto w-full justify-between whitespace-normal rounded-lg px-2 py-1.5 text-left text-xs font-normal text-card-foreground data-[selected=true]:bg-accent/40"
|
||||
>
|
||||
{item.label}
|
||||
{language === item.code && <CheckIcon aria-hidden className="size-3.5" />}
|
||||
</Button>
|
||||
</Command.Item>)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</MenuShell>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
FACTORY_SETTINGS, isDictationLanguage, sanitizeUserSettings,
|
||||
type DictationLanguage, type UserSettings,
|
||||
} from "@/src/core";
|
||||
import { notifySettingsSaved, onSettingsSaved } from "@/components/settings-signal";
|
||||
|
||||
const INITIAL = { language: undefined as DictationLanguage | undefined, saving: false, error: null as string | null };
|
||||
|
||||
/** One confirmed preference and one write in flight, shared by every surface.
|
||||
* The factory keeps transport tests isolated without a second persistence path.
|
||||
*/
|
||||
export function createDictationLanguageStore(request: typeof fetch, saved: () => void = () => {}) {
|
||||
let snapshot = INITIAL;
|
||||
let reading: Promise<DictationLanguage> | null = null;
|
||||
let writing: Promise<boolean> | null = null;
|
||||
let revision = 0;
|
||||
let needsRead = true;
|
||||
const listeners = new Set<() => void>();
|
||||
const publish = (patch: Partial<typeof INITIAL>) => {
|
||||
snapshot = { ...snapshot, ...patch };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
function load(refresh = false): Promise<DictationLanguage> {
|
||||
if (writing) return getLanguage();
|
||||
if (reading && !refresh) return reading;
|
||||
const version = ++revision;
|
||||
needsRead = true;
|
||||
reading = Promise.resolve().then(async () => {
|
||||
try {
|
||||
const res = await request("/api/settings");
|
||||
if (!res.ok) throw new Error();
|
||||
const body = await res.json() as { settings: UserSettings | null };
|
||||
if (!("settings" in body)) throw new Error();
|
||||
const record = sanitizeUserSettings(body.settings);
|
||||
if (body.settings !== null && record === null) throw new Error();
|
||||
if (version !== revision) return getLanguage();
|
||||
const language = record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language;
|
||||
needsRead = false;
|
||||
publish({ language, error: null });
|
||||
return language;
|
||||
} catch {
|
||||
if (version !== revision) return getLanguage();
|
||||
const message = "Could not load dictation language. Try again.";
|
||||
publish({ error: message });
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
if (version === revision) reading = null;
|
||||
}
|
||||
});
|
||||
return reading;
|
||||
}
|
||||
|
||||
function save(language: DictationLanguage): Promise<boolean> {
|
||||
// A different selection must never inherit the previous write's success.
|
||||
if (writing || snapshot.language === undefined) return Promise.resolve(false);
|
||||
revision++;
|
||||
reading = null;
|
||||
writing = Promise.resolve().then(async () => {
|
||||
try {
|
||||
const res = await request("/api/settings/dictation", {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ language }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const body = await res.json() as { settings: UserSettings };
|
||||
const confirmed = body.settings?.dictation?.language;
|
||||
if (!isDictationLanguage(confirmed)) throw new Error();
|
||||
needsRead = false;
|
||||
publish({ language: confirmed, error: null });
|
||||
saved();
|
||||
return true;
|
||||
} catch {
|
||||
publish({ error: "Could not save dictation language. Try again." });
|
||||
return false;
|
||||
} finally {
|
||||
writing = null;
|
||||
publish({ saving: false });
|
||||
}
|
||||
});
|
||||
publish({ saving: true, error: null });
|
||||
return writing;
|
||||
}
|
||||
|
||||
async function getLanguage(): Promise<DictationLanguage> {
|
||||
for (;;) {
|
||||
if (writing) await writing;
|
||||
else if (reading) await reading;
|
||||
else if (needsRead || snapshot.language === undefined) await load();
|
||||
else return snapshot.language;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
load, save, getLanguage,
|
||||
getSnapshot: () => snapshot,
|
||||
getServerSnapshot: () => INITIAL,
|
||||
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
};
|
||||
}
|
||||
|
||||
let announcingSave = false;
|
||||
const store = createDictationLanguageStore((...args) => fetch(...args), () => {
|
||||
// All our readers already received the confirmed PUT response. Keep the
|
||||
// repo's notification contract without depending on a redundant GET.
|
||||
announcingSave = true;
|
||||
try { notifySettingsSaved("dictation"); } finally { announcingSave = false; }
|
||||
});
|
||||
let readers = 0;
|
||||
let stopSignals: (() => void) | undefined;
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
const off = store.subscribe(listener);
|
||||
if (readers++ === 0) {
|
||||
stopSignals = onSettingsSaved((section) => {
|
||||
if (section === "dictation" && !announcingSave) void store.load(true).catch(() => {});
|
||||
});
|
||||
if (!store.getSnapshot().saving) void store.load(true).catch(() => {});
|
||||
}
|
||||
return () => { off(); if (--readers === 0) { stopSignals?.(); stopSignals = undefined; } };
|
||||
}
|
||||
|
||||
export function useDictationLanguage() {
|
||||
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot, store.getServerSnapshot);
|
||||
return { ...snapshot, load: store.load, save: store.save, getLanguage: store.getLanguage };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// The keyboard chain needs a synchronous answer; settings also subscribe
|
||||
// so the language control locks from connecting until cleanup completes.
|
||||
let active = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function isDictationActive(): boolean { return active; }
|
||||
|
||||
export function setDictationActive(next: boolean): void {
|
||||
if (active === next) return;
|
||||
active = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
export function useDictationActive(): boolean {
|
||||
return useSyncExternalStore(subscribe, isDictationActive, () => false);
|
||||
}
|
||||
+49
-64
@@ -4,6 +4,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { deepgramListenUrl, type DictationLanguage } from "@/src/core";
|
||||
import { useDictationLanguage } from "@/components/dictation-preference";
|
||||
import { setDictationActive } from "@/components/dictation-state";
|
||||
export { isDictationActive } from "@/components/dictation-state";
|
||||
|
||||
/**
|
||||
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
|
||||
@@ -86,15 +91,7 @@ import { Hint } from "@/components/ui/hint";
|
||||
* endpointing=300, no interims. Silence streams too (the worklet never
|
||||
* pauses), so the socket never idles — no KeepAlive needed.
|
||||
*/
|
||||
const DG_URL =
|
||||
"wss://api.deepgram.com/v1/listen" +
|
||||
"?model=nova-3" +
|
||||
"&encoding=linear16" +
|
||||
"&sample_rate=16000" +
|
||||
"&channels=1" +
|
||||
"&smart_format=true" +
|
||||
"&punctuate=true" +
|
||||
"&endpointing=300";
|
||||
// URL construction lives in the browser-safe core; only language varies.
|
||||
|
||||
/** Meter tuning — the owner's tested values, see the file header. */
|
||||
const METER_FLOOR_DB = -50;
|
||||
@@ -204,16 +201,6 @@ export function rmsToMeter(rms: number): number {
|
||||
*/
|
||||
let questionTarget: ((text: string) => void) | null = null;
|
||||
|
||||
/* Is a dictation run live on this page? The Esc chain (keyboard wave A,
|
||||
* components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the
|
||||
* stop handler can't report back through a CustomEvent. Set at start(),
|
||||
* cleared in cleanup() (every terminal path runs through cleanup). */
|
||||
let dictationActiveFlag = false;
|
||||
|
||||
export function isDictationActive(): boolean {
|
||||
return dictationActiveFlag;
|
||||
}
|
||||
|
||||
/* The finisher registry: submit paths FAR from the hook's owner (the
|
||||
* pending question's dock) need finish() without a prop bridge. Same
|
||||
* module-level pattern as questionTarget — one window, one instance,
|
||||
@@ -285,6 +272,7 @@ export type Dictation = {
|
||||
* question waits.
|
||||
*/
|
||||
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
||||
const { getLanguage } = useDictationLanguage();
|
||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||
@@ -327,9 +315,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
}, []);
|
||||
|
||||
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
||||
const cleanup = useCallback(() => {
|
||||
const cleanup = useCallback((warmNext = true) => {
|
||||
genRef.current++;
|
||||
dictationActiveFlag = false;
|
||||
setDictationActive(false);
|
||||
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
||||
if (safetyRef.current) {
|
||||
clearTimeout(safetyRef.current);
|
||||
@@ -357,16 +345,16 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
for (const w of waitersRef.current.splice(0)) w();
|
||||
// Pre-mint for the next run (mitigation D) — best-effort; a keyless
|
||||
// page just doesn't warm.
|
||||
warmTokens.refresh();
|
||||
if (warmNext) warmTokens.refresh();
|
||||
}, []);
|
||||
|
||||
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
|
||||
const openSocket = useCallback(
|
||||
(gen: number, token: string) => {
|
||||
(gen: number, token: string, language: DictationLanguage) => {
|
||||
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
||||
// jwt] is rejected at the handshake (verified live; "token" is for
|
||||
// raw API keys only, as in the owner's pi dictate extension).
|
||||
const ws = new WebSocket(DG_URL, ["bearer", token]);
|
||||
const ws = new WebSocket(deepgramListenUrl(language), ["bearer", token]);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
@@ -408,7 +396,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
reconnectsRef.current++;
|
||||
mintFreshToken() // always FRESH — a stale warm token is the likely culprit
|
||||
.then((t) => {
|
||||
if (gen === genRef.current) openSocket(gen, t);
|
||||
if (gen === genRef.current) openSocket(gen, t, language);
|
||||
})
|
||||
.catch(() => {
|
||||
if (gen === genRef.current) {
|
||||
@@ -426,13 +414,23 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
dictationActiveFlag = true;
|
||||
setDictationActive(true);
|
||||
setError(null);
|
||||
setPhase("connecting");
|
||||
reconnectsRef.current = 0;
|
||||
bufferRef.current = []; // fresh run, fresh buffer (round 2)
|
||||
pendingRef.current = []; // fresh run, fresh queue (mitigation A)
|
||||
const gen = ++genRef.current;
|
||||
let language: DictationLanguage;
|
||||
try {
|
||||
language = await getLanguage();
|
||||
} catch (e) {
|
||||
if (gen !== genRef.current) return;
|
||||
setError(e instanceof Error ? e.message : "Dictation language unavailable");
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
if (gen !== genRef.current) return;
|
||||
|
||||
// Mitigation D: the warm token (pre-minted at mount / after the last
|
||||
// run) resolves ~instantly; a cold/keyless mint surfaces below. The
|
||||
@@ -509,8 +507,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
if (gen !== genRef.current) return;
|
||||
// Mitigation B: the handshake fires the moment the graph is live,
|
||||
// overlapping any remaining token wait — mic-gated, never before it.
|
||||
openSocket(gen, token);
|
||||
}, [openSocket, cleanup]);
|
||||
openSocket(gen, token, language);
|
||||
}, [openSocket, cleanup, getLanguage]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const gen = genRef.current;
|
||||
@@ -586,7 +584,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// attached.
|
||||
useEffect(() => {
|
||||
warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless
|
||||
}, []);
|
||||
return () => {
|
||||
// Navigation cancels capture: invalidate every pending await before
|
||||
// releasing resources, and never append into a different page's input.
|
||||
bufferRef.current = [];
|
||||
cleanup(false);
|
||||
};
|
||||
}, [cleanup]);
|
||||
useEffect(() => {
|
||||
window.addEventListener("lh:kbd-toggle-dictation", toggle);
|
||||
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
|
||||
@@ -630,7 +634,6 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
* error keeps the old behavior (click = retry, which clears). */
|
||||
const keyless = error === DICTATION_KEYLESS;
|
||||
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
|
||||
const recording = phase === "recording" || phase === "stopping";
|
||||
/** Adaptive cell count (owner round 2): derived from the meter's
|
||||
* measured width — narrow rows show fewer bars instead of
|
||||
* overflowing under neighbors. */
|
||||
@@ -673,17 +676,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<Hint
|
||||
// Errored: the button span truncates the line — the tooltip is its
|
||||
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
|
||||
// it's the Keys row label, the truth the user needs). Otherwise the
|
||||
// quiet label + the live ⌘D binding.
|
||||
label={error ?? "Dictate"}
|
||||
action={error ? undefined : "toggle-dictation"}
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
<Hint label={error ?? "Dictate"} action={error ? undefined : "toggle-dictation"} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
if (keyless) {
|
||||
clearError();
|
||||
@@ -694,45 +691,33 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
}}
|
||||
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
||||
data-testid="dictation-toggle"
|
||||
// Same blurring as the floating chrome (owner, fix wave round 3),
|
||||
// but lower opacity — the mic stays quieter than the model pills.
|
||||
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
|
||||
>
|
||||
className="h-7 w-full min-w-0 gap-1.5 overflow-hidden border-border/50 bg-card/50 font-normal text-muted-foreground/70 shadow-lg backdrop-blur hover:border-border hover:bg-card/50 hover:text-muted-foreground dark:bg-card/50 dark:hover:bg-card/50"
|
||||
>
|
||||
<MicIcon className="size-3.5" />
|
||||
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</Hint>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
||||
<Hint
|
||||
// The owner flagged this copy good ("Stop dictation (Esc)") —
|
||||
// kept verbatim; only the machinery moved to the styled tooltip.
|
||||
label="Stop dictation (Esc)"
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
<Hint label="Stop dictation (Esc)" side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={toggle}
|
||||
disabled={phase === "stopping"}
|
||||
aria-label="Stop dictation"
|
||||
data-testid="dictation-toggle"
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
||||
>
|
||||
{phase === "stopping" ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<MicIcon className="size-3.5" />
|
||||
)}
|
||||
data-testid="dictation-toggle"
|
||||
className="h-7 gap-1.5 bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
>
|
||||
{phase === "stopping" ? <Loader2Icon className="size-3.5 animate-spin" /> : <MicIcon className="size-3.5" />}
|
||||
Stop
|
||||
</button>
|
||||
</Button>
|
||||
</Hint>
|
||||
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
||||
{/* Fixed width while recording — nothing ever appends text beside it
|
||||
(no interims, owner round 1). Cells carry no minimum width; the
|
||||
COUNT adapts to the measured row space (owner round 2). */}
|
||||
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
||||
{levels.slice(-cellCount).map((v, i) => (
|
||||
<span
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
|
||||
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
||||
// dispatches it after save AND delete — deletions used to be invisible to
|
||||
// the zero-keys pill and the setup dialog's auto-advance.
|
||||
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction";
|
||||
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation";
|
||||
|
||||
/** Writers call this after the PUT resolves ok — never before (a failed
|
||||
* write notifies nothing; the record is unchanged). */
|
||||
@@ -47,4 +47,4 @@ export function useSettingsSaved(handler: (section: SettingsSection) => void): v
|
||||
const saved = useRef(handler);
|
||||
saved.current = handler;
|
||||
useEffect(() => onSettingsSaved((section) => saved.current(section)), []);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user