Merge reviewed Deepgram dictation language feature for 0.3.9

This commit is contained in:
amosblomqvist
2026-10-27 22:39:34 +02:00
28 changed files with 1483 additions and 177 deletions
+2
View File
@@ -52,6 +52,8 @@ Add your provider API key and choose your models, then start a session with what
All three platforms support in-app updates. All three platforms support in-app updates.
**Dictation:** Create a [Deepgram API key](https://console.deepgram.com/) with the **Member role or higher** ([required for token minting](https://developers.deepgram.com/reference/auth/tokens/grant)) and add it under **Settings → Providers → Dictation**. Choose the language below the key; **Multilingual** supports automatic recognition and mixed-language speech. A `403 Insufficient permissions` error during token minting can indicate an insufficient key role.
## Your keys, your data ## Your keys, your data
- **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs. - **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs.
+1 -1
View File
@@ -5,7 +5,7 @@
* discipline, same as the per-chat and defaults routes); sections are * discipline, same as the per-chat and defaults routes); sections are
* independent, so partial writes can never clobber each other. * independent, so partial writes can never clobber each other.
* *
* section ∈ general | chat | agent | appearance | graph | compaction * section ∈ general | chat | agent | appearance | graph | compaction | models | dictation
* Body: the complete desired section object. Parse + validate strictly * Body: the complete desired section object. Parse + validate strictly
* (server/settings-body.ts): a bad shape/type is a 400 carrying the * (server/settings-body.ts): a bad shape/type is a 400 carrying the
* supported list where one exists. The chat and models sections * supported list where one exists. The chat and models sections
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core";
import { MenuShell } from "@/components/menu-shell";
import { Hint } from "@/components/ui/hint";
import { SettingsMenuTrigger, SettingsRow } from "@/components/settings/controls";
import { SearchMenuList } from "@/components/settings/search-menu-list";
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 triggerRef = useRef<HTMLButtonElement>(null);
const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—";
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">
<SettingsMenuTrigger
ref={triggerRef}
value={label}
data-testid="dictation-language"
aria-label={`Dictation language: ${label}`}
aria-expanded={open && !disabled}
disabled={disabled || saving}
onKeyDown={(event) => { if (open) closeOnEscape(event); }}
onClick={() => {
setOpen((value) => !value);
if (language === undefined) void load().catch(() => {});
}}
className="min-w-20 max-w-[65%]"
/>
</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 border-border bg-card p-2 text-xs shadow-xl"
>
<div onKeyDown={(event) => {
closeOnEscape(event);
if (event.key === "Enter") {
// Empty search results must not submit the composer, but a
// focused option button still needs Enter's native click.
if (event.target instanceof HTMLInputElement) event.preventDefault();
event.stopPropagation();
}
}}>
{error && <div role="alert" className="mb-1.5 text-destructive">{error}</div>}
<SearchMenuList
items={DICTATION_LANGUAGES}
itemKey={(item) => item.code}
searchTerms={(item) => [item.code, item.label]}
isSelected={(item) => item.code === language}
onPick={(item) => { void pick(item.code); }}
disabled={saving || language === undefined}
searchTestid="dictation-language-search"
listTestid="dictation-language-list"
optionTestid={(item) => `dictation-language-${item.code}`}
ariaLabel="Search dictation languages"
placeholder="Search languages…"
empty="No languages match."
renderItem={(item) => <span className="text-card-foreground">{item.label}</span>}
/>
</div>
</MenuShell>}
</>;
}
+132
View File
@@ -0,0 +1,132 @@
"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;
if (!refresh && !needsRead && snapshot.language !== undefined) return Promise.resolve(snapshot.language);
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(() => {});
});
// A confirmed preference lives for the window, not the page. Navigation
// and reopening Settings must not add a new network failure point.
void store.getLanguage().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 };
}
+25
View File
@@ -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);
}
+35 -50
View File
@@ -4,6 +4,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react"; import { Loader2Icon, MicIcon } from "lucide-react";
import { useSettingsDialog } from "@/components/settings/settings-dialog"; import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
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). * Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
@@ -86,15 +90,7 @@ import { Hint } from "@/components/ui/hint";
* endpointing=300, no interims. Silence streams too (the worklet never * endpointing=300, no interims. Silence streams too (the worklet never
* pauses), so the socket never idles — no KeepAlive needed. * pauses), so the socket never idles — no KeepAlive needed.
*/ */
const DG_URL = // URL construction lives in the browser-safe core; only language varies.
"wss://api.deepgram.com/v1/listen" +
"?model=nova-3" +
"&encoding=linear16" +
"&sample_rate=16000" +
"&channels=1" +
"&smart_format=true" +
"&punctuate=true" +
"&endpointing=300";
/** Meter tuning — the owner's tested values, see the file header. */ /** Meter tuning — the owner's tested values, see the file header. */
const METER_FLOOR_DB = -50; const METER_FLOOR_DB = -50;
@@ -204,16 +200,6 @@ export function rmsToMeter(rms: number): number {
*/ */
let questionTarget: ((text: string) => void) | null = null; 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 /* The finisher registry: submit paths FAR from the hook's owner (the
* pending question's dock) need finish() without a prop bridge. Same * pending question's dock) need finish() without a prop bridge. Same
* module-level pattern as questionTarget — one window, one instance, * module-level pattern as questionTarget — one window, one instance,
@@ -285,6 +271,7 @@ export type Dictation = {
* question waits. * question waits.
*/ */
export function useDictation({ append }: { append: (text: string) => void }): Dictation { export function useDictation({ append }: { append: (text: string) => void }): Dictation {
const { getLanguage } = useDictationLanguage();
const [phase, setPhase] = useState<DictationPhase>("idle"); const [phase, setPhase] = useState<DictationPhase>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */ /** Latest RMS from the worklet — the meter samples this (dictate pattern). */
@@ -327,9 +314,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
}, []); }, []);
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */ /** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
const cleanup = useCallback(() => { const cleanup = useCallback((warmNext = true) => {
genRef.current++; genRef.current++;
dictationActiveFlag = false; setDictationActive(false);
flush(); // the deferred transcript appears when the run ends (owner round 2) flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) { if (safetyRef.current) {
clearTimeout(safetyRef.current); clearTimeout(safetyRef.current);
@@ -357,16 +344,16 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
for (const w of waitersRef.current.splice(0)) w(); for (const w of waitersRef.current.splice(0)) w();
// Pre-mint for the next run (mitigation D) — best-effort; a keyless // Pre-mint for the next run (mitigation D) — best-effort; a keyless
// page just doesn't warm. // page just doesn't warm.
warmTokens.refresh(); if (warmNext) warmTokens.refresh();
}, []); }, []);
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */ /** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
const openSocket = useCallback( const openSocket = useCallback(
(gen: number, token: string) => { (gen: number, token: string, language: DictationLanguage) => {
// Minted JWTs authenticate via the "bearer" subprotocol — ["token", // Minted JWTs authenticate via the "bearer" subprotocol — ["token",
// jwt] is rejected at the handshake (verified live; "token" is for // jwt] is rejected at the handshake (verified live; "token" is for
// raw API keys only, as in the owner's pi dictate extension). // 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; wsRef.current = ws;
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
@@ -408,7 +395,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
reconnectsRef.current++; reconnectsRef.current++;
mintFreshToken() // always FRESH — a stale warm token is the likely culprit mintFreshToken() // always FRESH — a stale warm token is the likely culprit
.then((t) => { .then((t) => {
if (gen === genRef.current) openSocket(gen, t); if (gen === genRef.current) openSocket(gen, t, language);
}) })
.catch(() => { .catch(() => {
if (gen === genRef.current) { if (gen === genRef.current) {
@@ -426,13 +413,23 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
); );
const start = useCallback(async () => { const start = useCallback(async () => {
dictationActiveFlag = true; setDictationActive(true);
setError(null); setError(null);
setPhase("connecting"); setPhase("connecting");
reconnectsRef.current = 0; reconnectsRef.current = 0;
bufferRef.current = []; // fresh run, fresh buffer (round 2) bufferRef.current = []; // fresh run, fresh buffer (round 2)
pendingRef.current = []; // fresh run, fresh queue (mitigation A) pendingRef.current = []; // fresh run, fresh queue (mitigation A)
const gen = ++genRef.current; 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 // Mitigation D: the warm token (pre-minted at mount / after the last
// run) resolves ~instantly; a cold/keyless mint surfaces below. The // run) resolves ~instantly; a cold/keyless mint surfaces below. The
@@ -509,8 +506,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
if (gen !== genRef.current) return; if (gen !== genRef.current) return;
// Mitigation B: the handshake fires the moment the graph is live, // Mitigation B: the handshake fires the moment the graph is live,
// overlapping any remaining token wait — mic-gated, never before it. // overlapping any remaining token wait — mic-gated, never before it.
openSocket(gen, token); openSocket(gen, token, language);
}, [openSocket, cleanup]); }, [openSocket, cleanup, getLanguage]);
const stop = useCallback(() => { const stop = useCallback(() => {
const gen = genRef.current; const gen = genRef.current;
@@ -586,7 +583,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// attached. // attached.
useEffect(() => { useEffect(() => {
warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless 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(() => { useEffect(() => {
window.addEventListener("lh:kbd-toggle-dictation", toggle); window.addEventListener("lh:kbd-toggle-dictation", toggle);
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle); return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
@@ -630,7 +633,6 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
* error keeps the old behavior (click = retry, which clears). */ * error keeps the old behavior (click = retry, which clears). */
const keyless = error === DICTATION_KEYLESS; const keyless = error === DICTATION_KEYLESS;
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0)); 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 /** Adaptive cell count (owner round 2): derived from the meter's
* measured width — narrow rows show fewer bars instead of * measured width — narrow rows show fewer bars instead of
* overflowing under neighbors. */ * overflowing under neighbors. */
@@ -674,10 +676,7 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
if (!active) { if (!active) {
return ( return (
<Hint <Hint
// Errored: the button span truncates the line — the tooltip is its // The error line truncates in the button; its tooltip reveals it.
// 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"} label={error ?? "Dictate"}
action={error ? undefined : "toggle-dictation"} action={error ? undefined : "toggle-dictation"}
side="top" side="top"
@@ -694,8 +693,6 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
}} }}
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"} aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
data-testid="dictation-toggle" 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="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"
> >
<MicIcon className="size-3.5" /> <MicIcon className="size-3.5" />
@@ -707,12 +704,7 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
return ( return (
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar"> <div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
<Hint <Hint label="Stop dictation (Esc)" side="top">
// 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 <button
type="button" type="button"
onClick={toggle} onClick={toggle}
@@ -721,18 +713,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
data-testid="dictation-toggle" 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" 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" ? ( {phase === "stopping" ? <Loader2Icon className="size-3.5 animate-spin" /> : <MicIcon className="size-3.5" />}
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<MicIcon className="size-3.5" />
)}
Stop Stop
</button> </button>
</Hint> </Hint>
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden /> <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"> <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) => ( {levels.slice(-cellCount).map((v, i) => (
<span <span
+1 -1
View File
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section // "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
// dispatches it after save AND delete — deletions used to be invisible to // dispatches it after save AND delete — deletions used to be invisible to
// the zero-keys pill and the setup dialog's auto-advance. // 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 /** Writers call this after the PUT resolves ok — never before (a failed
* write notifies nothing; the record is unchanged). */ * write notifies nothing; the record is unchanged). */
+6 -12
View File
@@ -71,7 +71,7 @@ import {
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format"; import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx"; import { SettingsGroup, SettingsRow, SettingsMenuTrigger, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu"; import { ModelMenuList, ThinkingMenuList } from "./model-menu";
import { ChatGPTBackgroundAccess } from "./chatgpt-connection"; import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
import { NativeSubscriptionBackgroundAccess } from "./subscription-connections"; import { NativeSubscriptionBackgroundAccess } from "./subscription-connections";
@@ -245,23 +245,17 @@ function MenuTrigger({
disabled?: boolean; disabled?: boolean;
}) { }) {
const button = ( const button = (
<button <SettingsMenuTrigger
type="button" value={value || emptyLabel}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-expanded={open} aria-expanded={open}
data-testid={testid} data-testid={testid}
onClick={() => { onClick={() => {
if (!disabled) onClick(); if (!disabled) onClick();
}} }}
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${ unavailable={disabled}
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50" className={widthClass}
} ${widthClass}`} />
>
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
); );
return hint ? <Hint label={hint}>{button}</Hint> : button; return hint ? <Hint label={hint}>{button}</Hint> : button;
} }
+8 -15
View File
@@ -41,7 +41,7 @@ import { MenuShell } from "@/components/menu-shell";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
import { useAppearance } from "./appearance.tsx"; import { useAppearance } from "./appearance.tsx";
import { FontMenuList } from "./model-menu"; import { FontMenuList } from "./model-menu";
import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; import { CommitInput, SettingsMenuTrigger, SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
/** The factory reading family — the DEFAULT row's identity (unset = /** The factory reading family — the DEFAULT row's identity (unset =
* this). Exported for tests. */ * this). Exported for tests. */
@@ -210,29 +210,22 @@ export function AppearanceSection({
testid="row-font" testid="row-font"
> >
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2"> <span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
{/* The MenuTrigger look (agent-section's collapsed {/* The shared Agent/Appearance collapsed-menu trigger.
select — it lives unexported there, so the The tooltip rides the CLOSED button ONLY
classes ride inline; the searchable machine is (owner ruling 2026-09-26: never while
the shared part). The tooltip rides the CLOSED
button ONLY (owner ruling 2026-09-26: never while
the dropdown is out — it popped over the open the dropdown is out — it popped over the open
menu when the wrap covered the whole anchor menu when the wrap covered the whole anchor
span). */} span). */}
{(() => { {(() => {
const triggerButton = ( const triggerButton = (
<button <SettingsMenuTrigger
type="button" value={appearance?.fontFamily || DEFAULT_READING_FONT}
aria-label="Reading font" aria-label="Reading font"
aria-expanded={fontMenuOpen} aria-expanded={fontMenuOpen}
data-testid="font-trigger" data-testid="font-trigger"
onClick={openFontMenu} onClick={openFontMenu}
className="flex h-7 min-w-20 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50" className="min-w-20"
> />
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || DEFAULT_READING_FONT}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
); );
return fontMenuOpen ? ( return fontMenuOpen ? (
triggerButton triggerButton
+2 -2
View File
@@ -328,12 +328,12 @@ function AccountDialog({ children, onClose, busy }: { children: React.ReactNode;
} }
/** Shared compact settings/setup row; account selection precedes the quiet action. */ /** Shared compact settings/setup row; account selection precedes the quiet action. */
export function ChatGPTConnection(_props: { setup?: boolean } = {}) { export function ChatGPTConnection({ labelClassName }: { setup?: boolean; labelClassName?: string } = {}) {
const ui = useContext(Context); const ui = useContext(Context);
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
const accounts = ui.status.accounts.filter((a) => a.connected); const accounts = ui.status.accounts.filter((a) => a.connected);
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection"> return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}> <ConnectionRow label="ChatGPT" labelClassName={labelClassName} actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
{active && <select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground"> {active && <select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)} {accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select>} </select>}
+2 -2
View File
@@ -4,9 +4,9 @@ import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss"; import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string }) { export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}> return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
<span className="font-ui text-xs text-foreground">{label}</span> <span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
{children} {children}
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button> <Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
</div>; </div>;
+25 -1
View File
@@ -9,7 +9,7 @@
* control together) and the control itself locks — the row owns the dim. * control together) and the control itself locks — the row owns the dim.
*/ */
import { useState, type ReactNode } from "react"; import { useState, type ComponentPropsWithRef, type ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** A section block: pronounced mono heading + content. */ /** A section block: pronounced mono heading + content. */
@@ -81,6 +81,30 @@ export function SettingsRow({
); );
} }
/** The existing Agent/Appearance collapsed-menu look, shared with dictation.
* `unavailable` preserves callers that gate clicks without native disabling;
* `disabled` retains native button semantics where the caller requires them. */
export function SettingsMenuTrigger({
value, unavailable, disabled, className, ...props
}: Omit<ComponentPropsWithRef<"button">, "value" | "children"> & {
value: string;
unavailable?: boolean;
}) {
return <button
type="button"
{...props}
disabled={disabled}
className={cn(
"flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors",
disabled || unavailable ? "cursor-not-allowed" : "hover:border-muted-foreground/50",
className,
)}
>
<span className="min-w-0 flex-1 truncate">{value}</span>
<span aria-hidden className="text-muted-foreground">▾</span>
</button>;
}
/** The on/off switch — a plain button in the switch role. */ /** The on/off switch — a plain button in the switch role. */
export function Toggle({ export function Toggle({
checked, checked,
+15
View File
@@ -30,6 +30,7 @@ import { SubscriptionConnections } from "./subscription-connections.tsx";
import { ProviderMenuList } from "./model-menu.tsx"; import { ProviderMenuList } from "./model-menu.tsx";
import { MenuShell } from "@/components/menu-shell"; import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal"; import { notifySettingsSaved } from "@/components/settings-signal";
import { DictationLanguageSetting } from "@/components/dictation-language";
/** One pi-ai provider with plain API-key auth (the Platform seam — /** One pi-ai provider with plain API-key auth (the Platform seam —
* derived from pi-ai's own env-key table; no drift possible). */ * derived from pi-ai's own env-key table; no drift possible). */
@@ -350,6 +351,9 @@ export function KeysSection({
if (openRow === null && adding === null) return; if (openRow === null && adding === null) return;
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
// A language dropdown above this editor in the dismissal stack owns
// the first Escape; do not swallow it at document capture phase.
if (document.querySelector('[data-testid="dictation-language-menu"]')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setOpenRow(null); setOpenRow(null);
@@ -486,6 +490,17 @@ export function KeysSection({
testid={`key-${status.id.toLowerCase()}`} testid={`key-${status.id.toLowerCase()}`}
/> />
))} ))}
{label === "Dictation" && <>
<DictationLanguageSetting />
<p data-testid="dictation-setup-hint" className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
Create a <a
href="https://console.deepgram.com/"
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>Deepgram API key</a> with the Member role or higher, then add it above.
</p>
</>}
</SettingsGroup>)} </SettingsGroup>)}
</> </>
+16 -71
View File
@@ -23,6 +23,7 @@ import { useEffect, useRef, useState } from "react";
import type { ModelCatalogEntry } from "@/src/core"; import type { ModelCatalogEntry } from "@/src/core";
import { fuzzyScore, subscriptionLabel } from "@/src/core"; import { fuzzyScore, subscriptionLabel } from "@/src/core";
import { formatTokens } from "@/components/usage-format"; import { formatTokens } from "@/components/usage-format";
import { SearchMenuList } from "./search-menu-list";
function priceLabel(cost: ModelCatalogEntry["cost"]): string { function priceLabel(cost: ModelCatalogEntry["cost"]): string {
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
@@ -53,76 +54,22 @@ export function ModelMenuList({
/** Quiet footer note under the list (context-specific). */ /** Quiet footer note under the list (context-specific). */
footer?: string; footer?: string;
}) { }) {
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
// Keep the arrow-walked result visible in the scrolling list.
useEffect(() => {
if (menuIdx === null) return;
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
// The filtered, ranked model list.
const results = (models ?? [])
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
.filter((r) => r.s !== null)
.sort((a, b) => b.s! - a.s! || a.m.id.localeCompare(b.m.id))
.map((r) => r.m);
return ( return (
<> <>
<input <SearchMenuList
ref={searchRef} items={models}
data-testid="model-search" itemKey={(m) => `${m.provider}/${m.id}`}
value={query} searchTerms={(m) => [m.provider, m.id, m.name]}
onChange={(e) => { compare={(a, b) => a.id.localeCompare(b.id)}
setQuery(e.target.value); isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
setMenuIdx(null); // new query → the walk restarts on Enter/↓ onPick={onPick}
}} searchTestid="model-search"
onKeyDown={(e) => { listTestid="model-list"
if (e.key === "ArrowDown" || e.key === "ArrowUp") { optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
e.preventDefault();
e.stopPropagation();
if (results.length === 0) return;
setMenuIdx((i) =>
e.key === "ArrowDown"
? Math.min((i ?? -1) + 1, results.length - 1)
: Math.max((i ?? results.length) - 1, 0),
);
} else if (e.key === "Enter") {
const chosen = results[menuIdx ?? 0];
if (chosen) {
e.preventDefault();
e.stopPropagation();
onPick(chosen);
}
}
}}
placeholder="Search models…" placeholder="Search models…"
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" loading="Loading models…"
/> empty="No models match."
{!models && <div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>} renderItem={(m) => <>
{models && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
)}
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((m, i) => (
<li key={`${m.provider}/${m.id}`}>
<button
type="button"
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
data-idx={i}
onClick={() => onPick(m)}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider) && i !== menuIdx ? " bg-muted/60" : ""}`}
>
<span className="flex items-center justify-between gap-2"> <span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span> <span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}> <span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
@@ -133,10 +80,8 @@ export function ModelMenuList({
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span> <span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span> <span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
</span> </span>
</button> </>}
</li> />
))}
</ul>
{footer && ( {footer && (
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div> <div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
)} )}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { fuzzyScore } from "@/src/core";
/** The model picker's search, arrow-walk, row styling and scrolling machine.
* Models and dictation languages supply only their catalog and row content.
* The caller owns the shared MenuShell and dismissal behavior. */
export function SearchMenuList<T>({
items, itemKey, searchTerms, compare, isSelected, onPick, renderItem,
searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty,
disabled = false,
}: {
items: readonly T[] | undefined;
itemKey: (item: T) => string;
searchTerms: (item: T) => string[];
compare?: (a: T, b: T) => number;
isSelected: (item: T) => boolean;
onPick: (item: T) => void;
renderItem: (item: T) => ReactNode;
searchTestid: string;
listTestid: string;
optionTestid: (item: T) => string;
placeholder: string;
ariaLabel?: string;
loading?: string;
empty: string;
disabled?: boolean;
}) {
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => { searchRef.current?.focus(); }, []);
useEffect(() => {
if (menuIdx === null) return;
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
const results = (items ?? [])
.map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) }))
.filter((result) => result.score !== null)
.sort((a, b) => b.score! - a.score! || (compare?.(a.item, b.item) ?? 0))
.map(({ item }) => item);
return <>
<input
type="text"
ref={searchRef}
data-testid={searchTestid}
aria-label={ariaLabel}
value={query}
disabled={disabled}
onChange={(event) => { setQuery(event.target.value); setMenuIdx(null); }}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
if (disabled || results.length === 0) return;
setMenuIdx((i) => event.key === "ArrowDown"
? Math.min((i ?? -1) + 1, results.length - 1)
: Math.max((i ?? results.length) - 1, 0));
} else if (event.key === "Enter") {
const chosen = results[menuIdx ?? 0];
if (!disabled && chosen !== undefined) {
event.preventDefault();
event.stopPropagation();
onPick(chosen);
}
}
}}
placeholder={placeholder}
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
{!items && loading && <div className="px-2 py-3 text-center text-muted-foreground">{loading}</div>}
{items && results.length === 0 && <div className="px-2 py-3 text-center text-muted-foreground">{empty}</div>}
<ul ref={listRef} data-testid={listTestid} className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((item, i) => <li key={itemKey(item)}>
<button
type="button"
data-testid={optionTestid(item)}
data-idx={i}
disabled={disabled}
onClick={() => onPick(item)}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${isSelected(item) && i !== menuIdx ? " bg-muted/60" : ""}`}
>{renderItem(item)}</button>
</li>)}
</ul>
</>;
}
@@ -119,16 +119,19 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
</Context.Provider>; </Context.Provider>;
} }
export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) { export function NativeSubscriptionConnection({ providerId, labelClassName }: { providerId: NativeSubscriptionProviderId; labelClassName?: string }) {
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!; const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId); const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}> return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
<ConnectionRow label={descriptor.label} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} /> <ConnectionRow label={descriptor.label} labelClassName={labelClassName} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
{status.error && <p role="alert" className="text-xs text-red-400">{status.error}</p>} {status.error && <p role="alert" className="text-xs text-red-400">{status.error}</p>}
</div>; </div>;
} }
export function SubscriptionConnections({ setup = false }: { setup?: boolean }) { export function SubscriptionConnections({ setup = false }: { setup?: boolean }) {
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} />)}</div>; // Providers uses ordinary settings-label typography. The setup surface
// keeps its existing compact labels; dialogs and actions are untouched.
const labelClassName = setup ? undefined : "font-prose text-(length:--lh-prose-size) text-foreground";
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} labelClassName={labelClassName} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} labelClassName={labelClassName} />)}</div>;
} }
export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) { export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) {
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider); const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider);
+1 -1
View File
@@ -176,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority
**Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. **Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. **Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Deepgram Console and states the Member-or-higher key-role requirement.
## Testing ## Testing
+90
View File
@@ -0,0 +1,90 @@
/** Nova-3 monolingual language codes, including documented locale aliases.
* Source: https://developers.deepgram.com/docs/models-languages-overview
* Multilingual is a separate mode; its supported language set differs.
*/
const NOVA_3_LANGUAGES = [
["Afrikaans", ["af", "af-ZA"]],
["Arabic", ["ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR"]],
["Armenian", ["hy"]],
["Assamese", ["as", "as-IN"]],
["Belarusian", ["be"]],
["Bengali", ["bn"]],
["Bosnian", ["bs"]],
["Bulgarian", ["bg"]],
["Catalan", ["ca"]],
["Chinese (Cantonese, Traditional)", ["zh-HK"]],
["Chinese (Mandarin, Simplified)", ["zh", "zh-CN", "zh-Hans"]],
["Chinese (Mandarin, Traditional)", ["zh-TW", "zh-Hant"]],
["Croatian", ["hr"]],
["Czech", ["cs", "cs-CZ"]],
["Danish", ["da", "da-DK"]],
["Dutch", ["nl"]],
["English", ["en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ"]],
["Estonian", ["et"]],
["Finnish", ["fi"]],
["Flemish", ["nl-BE"]],
["French", ["fr", "fr-CA"]],
["Georgian", ["ka", "ka-GE"]],
["German", ["de"]],
["German (Switzerland)", ["de-CH"]],
["Greek", ["el"]],
["Gujarati", ["gu", "gu-IN"]],
["Hebrew", ["he"]],
["Hindi", ["hi"]],
["Hungarian", ["hu"]],
["Indonesian", ["id"]],
["Italian", ["it"]],
["Japanese", ["ja"]],
["Kannada", ["kn"]],
["Kazakh", ["kk", "kk-KZ"]],
["Korean", ["ko", "ko-KR"]],
["Latvian", ["lv"]],
["Lithuanian", ["lt"]],
["Macedonian", ["mk"]],
["Malay", ["ms"]],
["Marathi", ["mr"]],
["Mongolian", ["mn"]],
["Nepali", ["ne"]],
["Norwegian", ["no"]],
["Pashto", ["ps", "ps-AF"]],
["Persian", ["fa"]],
["Polish", ["pl"]],
["Portuguese", ["pt", "pt-BR", "pt-PT"]],
["Punjabi", ["pa", "pa-IN"]],
["Romanian", ["ro"]],
["Russian", ["ru"]],
["Serbian", ["sr"]],
["Slovak", ["sk"]],
["Slovenian", ["sl"]],
["Spanish", ["es", "es-419"]],
["Swedish", ["sv", "sv-SE"]],
["Tagalog", ["tl"]],
["Tamil", ["ta"]],
["Telugu", ["te"]],
["Thai", ["th", "th-TH"]],
["Turkish", ["tr", "tr-TR"]],
["Ukrainian", ["uk"]],
["Urdu", ["ur"]],
["Vietnamese", ["vi"]],
] as const;
export type DictationLanguage = "multi" | (typeof NOVA_3_LANGUAGES)[number][1][number];
export const DICTATION_LANGUAGES: readonly { code: DictationLanguage; label: string }[] = [
{ code: "multi", label: "Multilingual" },
...NOVA_3_LANGUAGES.flatMap(([name, codes]) =>
codes.map((code, i) => ({ code, label: i === 0 ? name : `${name} (${code})` })),
).sort((a, b) => a.label.localeCompare(b.label, "en")),
];
export function isDictationLanguage(value: unknown): value is DictationLanguage {
return DICTATION_LANGUAGES.some(({ code }) => code === value);
}
/** Only language varies. Keep the owner's tuned streaming parameters. */
export function deepgramListenUrl(language: DictationLanguage): string {
return "wss://api.deepgram.com/v1/listen" +
"?model=nova-3&encoding=linear16&sample_rate=16000&channels=1" +
"&smart_format=true&punctuate=true&endpointing=300" +
`&language=${encodeURIComponent(language)}`;
}
+1
View File
@@ -59,6 +59,7 @@ export * from "./fuzzy.ts";
// the user-settings record's shape/sanitizer (pure) and its fully-resolved // the user-settings record's shape/sanitizer (pure) and its fully-resolved
// view. Browser-safe — the Wave-4 panel imports these directly. // view. Browser-safe — the Wave-4 panel imports these directly.
export * from "./settings.ts"; export * from "./settings.ts";
export * from "./dictation.ts";
// graph-deletion.ts (wave 6): the whole module is PURE but storage-port // graph-deletion.ts (wave 6): the whole module is PURE but storage-port
// driven — the deletion RUNS server-side only. The shell needs exactly one // driven — the deletion RUNS server-side only. The shell needs exactly one
// two type-only things from it: the ChatDeleteMode union (the delete // two type-only things from it: the ChatDeleteMode union (the delete
+5
View File
@@ -35,6 +35,7 @@ import {
type BackgroundModelRole, type BackgroundModelRole,
type FactorySettings, type FactorySettings,
type GeneralSettings, type GeneralSettings,
type DictationSettings,
type CompactionSettings, type CompactionSettings,
type GraphSettings, type GraphSettings,
type ModelSlot, type ModelSlot,
@@ -69,6 +70,7 @@ export type EffectiveAppearance = FactorySettings["appearance"];
/** The concrete view: every knob resolved, no absent fields. */ /** The concrete view: every knob resolved, no absent fields. */
export interface EffectiveSettings { export interface EffectiveSettings {
general: Required<GeneralSettings>; general: Required<GeneralSettings>;
dictation: Required<DictationSettings>;
agent: { agent: {
models: Record<BackgroundModelRole, EffectiveModelSpec | undefined>; models: Record<BackgroundModelRole, EffectiveModelSpec | undefined>;
/** Delta map as stored — consumers resolve absent key → enabled. */ /** Delta map as stored — consumers resolve absent key → enabled. */
@@ -176,6 +178,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
general: { general: {
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate, autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
}, },
dictation: {
language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language,
},
agent: { agent: {
models, models,
tools: agent.tools ?? {}, tools: agent.tools ?? {},
+16
View File
@@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
import type { ModelRole } from "../../config.ts"; import type { ModelRole } from "../../config.ts";
import type { ChatSettings } from "./contracts/chat.ts"; import type { ChatSettings } from "./contracts/chat.ts";
import type { SearchEngineId } from "./search.ts"; import type { SearchEngineId } from "./search.ts";
import { isDictationLanguage, type DictationLanguage } from "./dictation.ts";
/** The schema version this code understands. Bump ONLY for breaking shape /** The schema version this code understands. Bump ONLY for breaking shape
* changes (a field renamed/meaning changed); pure additions never bump. */ * changes (a field renamed/meaning changed); pure additions never bump. */
@@ -235,6 +236,11 @@ export interface GeneralSettings {
autoUpdate?: boolean; autoUpdate?: boolean;
} }
/** One app-wide dictation preference, shared by every composer. */
export interface DictationSettings {
language?: DictationLanguage;
}
/** Agent settings — everything about the non-conversation machinery. */ /** Agent settings — everything about the non-conversation machinery. */
export interface AgentSettings { export interface AgentSettings {
/** Background-role model/thinking overrides (absent role = config pin). */ /** Background-role model/thinking overrides (absent role = config pin). */
@@ -361,6 +367,7 @@ export interface CompactionSettings {
export interface UserSettings { export interface UserSettings {
version: number; version: number;
general?: GeneralSettings; general?: GeneralSettings;
dictation?: DictationSettings;
agent?: AgentSettings; agent?: AgentSettings;
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
models?: ModelsSettings; models?: ModelsSettings;
@@ -378,6 +385,7 @@ export interface UserSettings {
export interface FactorySettings { export interface FactorySettings {
version: number; version: number;
general: Required<GeneralSettings>; general: Required<GeneralSettings>;
dictation: Required<DictationSettings>;
appearance: { appearance: {
mode: "dark" | "light"; mode: "dark" | "light";
background: string | null; background: string | null;
@@ -397,6 +405,7 @@ export interface FactorySettings {
export const FACTORY_SETTINGS: FactorySettings = { export const FACTORY_SETTINGS: FactorySettings = {
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
general: { autoUpdate: true }, general: { autoUpdate: true },
dictation: { language: "en" },
appearance: { appearance: {
mode: "dark", mode: "dark",
background: null, // the dark default (#141414) lives in the mode's derivation curve background: null, // the dark default (#141414) lives in the mode's derivation curve
@@ -615,6 +624,13 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) { if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) {
out.general = sanitizeGeneral(r.general as Record<string, unknown>); out.general = sanitizeGeneral(r.general as Record<string, unknown>);
} }
if (r.dictation !== undefined) {
const raw = r.dictation;
out.dictation = raw !== null && typeof raw === "object" && !Array.isArray(raw) &&
isDictationLanguage((raw as Record<string, unknown>).language)
? { language: (raw as DictationSettings).language }
: {};
}
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
out.agent = sanitizeAgent(r.agent as Record<string, unknown>); out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
} }
+12
View File
@@ -19,12 +19,14 @@ import type {
BackgroundModelRole, BackgroundModelRole,
CompactionSettings, CompactionSettings,
GeneralSettings, GeneralSettings,
DictationSettings,
GraphSettings, GraphSettings,
ModelsSettings, ModelsSettings,
RoleModelOverride, RoleModelOverride,
} from "../core/settings.ts"; } from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts"; import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
import { isDictationLanguage } from "../core/dictation.ts";
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */ /** Parsed-or-rejected — `ok: false` carries the 400's error message. */
export type SettingsBodyResult<T> = export type SettingsBodyResult<T> =
@@ -375,6 +377,15 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult<C
return { ok: true, settings }; return { ok: true, settings };
} }
/** dictation: { language?: one of Nova-3's supported language codes } */
export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<DictationSettings> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
if (raw.language === undefined) return { ok: true, settings: {} };
if (!isDictationLanguage(raw.language)) return { ok: false, error: "Unsupported dictation language" };
return { ok: true, settings: { language: raw.language } };
}
/** The section→parser map for PUT /api/settings/[section] (the route also /** The section→parser map for PUT /api/settings/[section] (the route also
* carries the special prompts sub-resource). */ * carries the special prompts sub-resource). */
export const SETTINGS_SECTIONS = { export const SETTINGS_SECTIONS = {
@@ -385,6 +396,7 @@ export const SETTINGS_SECTIONS = {
graph: parseGraphSettingsBody, graph: parseGraphSettingsBody,
compaction: parseCompactionSettingsBody, compaction: parseCompactionSettingsBody,
models: parseModelsSettingsBody, models: parseModelsSettingsBody,
dictation: parseDictationSettingsBody,
} as const; } as const;
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
+322
View File
@@ -0,0 +1,322 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { useDictation, type Dictation } from "../components/dictation.tsx";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import { useDictationLanguage } from "../components/dictation-preference.ts";
import { isDictationActive, useDictationActive } from "../components/dictation-state.ts";
import type { UserSettings } from "../src/core/index.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
class Socket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static all: Socket[] = [];
readyState = Socket.CONNECTING;
sent: unknown[] = [];
constructor(readonly url: string, readonly protocols: string[]) { super(); Socket.all.push(this); }
get language() { return new URL(this.url).searchParams.get("language"); }
send(data: unknown) { this.sent.push(data); }
close() { this.readyState = 3; }
open() { this.readyState = Socket.OPEN; this.dispatchEvent(new Event("open")); }
end(code = 1000) { this.close(); this.dispatchEvent(Object.assign(new Event("close"), { code })); }
final(text: string) {
this.dispatchEvent(Object.assign(new Event("message"), {
data: JSON.stringify({ type: "Results", is_final: true, channel: { alternatives: [{ transcript: text }] } }),
}));
}
}
class AudioGraph {
state = "running";
destination = {};
audioWorklet = { addModule: async () => {} };
createMediaStreamSource() { return { connect() {}, disconnect() {} }; }
}
class Worklet { port = {}; connect() {} disconnect() {} }
const globals = globalThis as Record<string, unknown>;
let restore: (() => void) | undefined;
let dispose: (() => Promise<void>) | undefined;
async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Response>) {
const dom = parseHTML("<html><body><div id='root'></div></body></html>");
const state = {
record: { version: 1, dictation: { language } } as UserSettings,
nextRead: delayedRead,
nextWrite: undefined as Promise<Response> | undefined,
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
readFails: false,
reads: 0,
active: false,
captures: 0,
stops: 0,
appends: [] as string[],
};
Socket.all = [];
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({
React, window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent,
navigator: { userAgent: "", platform: "", mediaDevices: { getUserMedia: async () => {
state.captures++;
if (state.nextMic) return state.nextMic;
return { getTracks: () => [{ stop() { state.stops++; } }] };
} } },
WebSocket: Socket, AudioContext: AudioGraph, AudioWorkletNode: Worklet,
IS_REACT_ACT_ENVIRONMENT: true,
fetch: async (url: string, init?: RequestInit) => {
if (url === "/api/stt/token") return Response.json({ token: "test-token" });
if (url === "/api/settings") {
state.reads++;
if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; }
return state.readFails ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
}
assert.equal(url, "/api/settings/dictation");
assert.equal(init?.method, "PUT");
if (state.nextWrite) { const response = state.nextWrite; state.nextWrite = undefined; return response; }
state.record = { ...state.record, dictation: JSON.parse(init.body as string) };
return Response.json({ settings: state.record });
},
})) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
restore = () => {
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globals[key];
}
};
const { createRoot } = await import("react-dom/client");
let latest!: Dictation;
let setting!: ReturnType<typeof useDictationLanguage>;
function Harness() {
latest = useDictation({ append: (text) => state.appends.push(text) });
setting = useDictationLanguage();
state.active = useDictationActive();
return null;
}
let root = createRoot(document.getElementById("root")!);
let firstMount = true;
const mount = async () => {
await act(async () => { root.render(<Harness />); });
// Each fixture is a new server record. Explicitly invalidate a prior
// fixture's window-lifetime cache, but do not refetch on remounts.
if (firstMount && setting.language !== undefined) {
await act(async () => { notifySettingsSaved("dictation"); });
}
firstMount = false;
};
dispose = async () => { await act(async () => { root.unmount(); }); };
return {
state,
get dictation() { return latest; },
get setting() { return setting; },
mount,
unmount: () => dispose!(),
remount: async () => {
await dispose!();
root = createRoot(document.getElementById("root")!);
await mount();
},
};
}
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
test("waits for the saved language; reconnect preserves it and finals still flush exactly once", async () => {
const read = deferred<Response>();
const f = await fixture("pl", read.promise);
await f.mount();
await act(async () => { f.dictation.toggle(); });
assert.equal(f.dictation.phase, "connecting");
assert.equal(f.state.active, true, "the settings row locks while connecting");
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0, "must not open a default-English socket before settings arrive");
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
const first = Socket.all[0]!;
assert.equal(first.language, "pl");
await act(async () => { first.open(); first.final("Cześć"); });
assert.deepEqual(f.state.appends, []);
// A preference refresh during capture affects the next run only.
f.state.record.dictation = { language: "multi" };
await act(async () => { notifySettingsSaved("dictation"); });
await act(async () => { first.end(1006); });
const second = Socket.all[1]!;
assert.equal(second.language, "pl");
assert.deepEqual(second.protocols, ["bearer", "test-token"]);
await act(async () => { second.open(); second.final("świecie"); });
await act(async () => { f.dictation.stop(); });
assert.equal(f.dictation.phase, "stopping");
assert.equal(f.state.active, true, "the settings row remains locked while stopping");
assert.equal(second.sent.at(-1), JSON.stringify({ type: "CloseStream" }));
await act(async () => { second.end(); });
assert.deepEqual(f.state.appends, ["Cześć świecie"]);
assert.equal(f.dictation.phase, "idle");
assert.equal(f.state.active, false);
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[2]!.language, "multi");
});
test("a failed save keeps the previous language, including a shortcut pressed while saving", async () => {
const f = await fixture();
await f.mount();
const write = deferred<Response>();
f.state.nextWrite = write.promise;
let saved!: Promise<boolean>;
await act(async () => { saved = f.setting.save("multi"); });
assert.equal(f.setting.saving, true);
await act(async () => { f.dictation.toggle(); });
assert.equal(f.state.captures, 0);
await act(async () => { write.resolve(Response.json({}, { status: 500 })); await saved; });
assert.equal(await saved, false);
assert.equal(f.setting.language, "pl");
assert.match(f.setting.error!, /Could not save/);
assert.equal(Socket.all[0]!.language, "pl");
});
test("confirmed choices stay cached across navigation; failed explicit refreshes block capture", async () => {
const f = await fixture();
await f.mount();
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
const reads = f.state.reads;
f.state.readFails = true;
await f.remount();
assert.equal(f.setting.language, "multi");
assert.equal(f.state.reads, reads, "navigation must not reread a confirmed preference");
await act(async () => { notifySettingsSaved("dictation"); });
await act(async () => { f.dictation.toggle(); });
assert.match(f.dictation.error!, /Could not load/);
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0);
f.state.readFails = false;
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("a shortcut waits for a language save made by the separate settings control", async () => {
const f = await fixture();
await f.mount();
const write = deferred<Response>();
f.state.nextWrite = write.promise;
let saved!: Promise<boolean>;
await act(async () => { saved = f.setting.save("multi"); });
await act(async () => { f.dictation.toggle(); });
assert.equal(f.state.captures, 0);
f.state.record.dictation = { language: "multi" };
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await saved; });
assert.equal(Socket.all[0]!.language, "multi");
});
test("a saved-language refresh gates capture even when an older choice is cached", async () => {
const f = await fixture();
await f.mount();
const read = deferred<Response>();
f.state.nextRead = read.promise;
f.state.record.dictation = { language: "multi" };
await act(async () => { notifySettingsSaved("dictation"); });
await act(async () => { f.dictation.toggle(); });
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0);
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("a stale settings refresh cannot overwrite a newer confirmed choice", async () => {
const f = await fixture();
await f.mount();
const read = deferred<Response>();
f.state.nextRead = read.promise;
await act(async () => { notifySettingsSaved("dictation"); });
await act(async () => { await f.setting.save("multi"); });
await act(async () => { read.resolve(Response.json({ settings: { version: 1, dictation: { language: "pl" } } })); });
assert.equal(f.setting.language, "multi");
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("cancel while settings load does not open a microphone or socket later", async () => {
const read = deferred<Response>();
const f = await fixture("pl", read.promise);
await f.mount();
await act(async () => { f.dictation.toggle(); });
await act(async () => { f.dictation.cancel(); });
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
assert.equal(f.dictation.phase, "idle");
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0);
});
test("reopening settings during a save shares its busy state and rejects a competing selection", async () => {
const f = await fixture();
await f.mount();
const write = deferred<Response>();
f.state.nextWrite = write.promise;
let first!: Promise<boolean>;
await act(async () => { first = f.setting.save("multi"); });
await f.remount();
assert.equal(f.setting.saving, true);
assert.equal(await f.setting.save("en"), false, "a different selection cannot inherit another write's success");
f.state.record.dictation = { language: "multi" };
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await first; });
assert.equal(f.setting.saving, false);
assert.equal(f.setting.language, "multi");
});
test("a confirmed save applies to capture even when subsequent settings GET requests would fail", async () => {
const f = await fixture();
await f.mount();
f.state.readFails = true;
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
assert.equal(f.state.record.dictation?.language, "multi");
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("unmount during a pending language read prevents orphaned capture", async () => {
const read = deferred<Response>();
const f = await fixture("pl", read.promise);
await f.mount();
await act(async () => { f.dictation.toggle(); });
await f.unmount();
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0);
assert.equal(isDictationActive(), false);
});
test("unmount during recording stops resources and discards the departing page's buffered text", async () => {
const f = await fixture();
await f.mount();
await act(async () => { f.dictation.toggle(); });
const socket = Socket.all[0]!;
await act(async () => { socket.open(); socket.final("do not append into another page"); });
await f.unmount();
assert.equal(f.state.stops, 1);
assert.equal(socket.readyState, 3);
assert.equal(isDictationActive(), false);
assert.deepEqual(f.state.appends, []);
await act(async () => { socket.final("late event"); socket.end(1006); });
assert.equal(Socket.all.length, 1);
assert.deepEqual(f.state.appends, []);
});
test("microphone permission resolving after unmount immediately stops the late stream", async () => {
const f = await fixture();
await f.mount();
const mic = deferred<{ getTracks: () => { stop: () => void }[] }>();
f.state.nextMic = mic.promise;
await act(async () => { f.dictation.toggle(); });
assert.equal(f.state.captures, 1);
await f.unmount();
await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); });
assert.equal(f.state.stops, 1);
assert.equal(Socket.all.length, 0);
assert.equal(isDictationActive(), false);
});
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { after, describe, it } from "node:test";
import { DICTATION_LANGUAGES, deepgramListenUrl, isDictationLanguage } from "../src/core/dictation.ts";
import { sanitizeUserSettings } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { parseDictationSettingsBody } from "../src/server/settings-body.ts";
describe("Nova-3 dictation language contract", () => {
it("offers monolingual languages/locales and a distinct multilingual mode", () => {
assert.equal(DICTATION_LANGUAGES[0]?.code, "multi");
assert.equal(new Set(DICTATION_LANGUAGES.map((item) => item.code)).size, DICTATION_LANGUAGES.length);
for (const code of ["en", "fr", "ru", "sv", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) {
assert.ok(isDictationLanguage(code), code);
}
// Xhosa and Zulu are documented for multi, not monolingual Nova-3.
for (const code of ["xh", "zu", "en-CA", "auto", "xx", "pl&model=other", null, 3]) {
assert.equal(isDictationLanguage(code), false, String(code));
}
const labels = DICTATION_LANGUAGES.slice(1).map((item) => item.label);
assert.deepEqual(labels, [...labels].sort((a, b) => a.localeCompare(b, "en")));
});
it("only language varies in the streaming URL; all tuned parameters survive", () => {
for (const { code: language } of DICTATION_LANGUAGES) {
const url = new URL(deepgramListenUrl(language));
assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen");
assert.deepEqual(Object.fromEntries(url.searchParams), {
model: "nova-3", encoding: "linear16", sample_rate: "16000", channels: "1",
smart_format: "true", punctuate: "true", endpointing: "300", language,
});
}
});
it("old records default to English and explicit preferences resolve without a migration", () => {
assert.equal(resolveSettings(null).dictation.language, "en");
assert.equal(resolveSettings({ version: 1 }).dictation.language, "en");
for (const language of ["pl", "multi", "en-GB"] as const) {
const record = sanitizeUserSettings({ version: 1, dictation: { language } });
assert.equal(record?.version, 1);
assert.equal(resolveSettings(record).dictation.language, language);
}
});
it("rejects invalid API values and drops invalid stored preferences", () => {
assert.deepEqual(parseDictationSettingsBody({}), { ok: true, settings: {} });
assert.deepEqual(parseDictationSettingsBody({ language: "multi" }), { ok: true, settings: { language: "multi" } });
for (const value of [null, [], "pl", { language: "xx" }, { language: null }, { language: 3 }]) {
assert.equal(parseDictationSettingsBody(value).ok, false);
}
for (const dictation of [null, [], "pl", { language: "xx" }, { language: 3 }]) {
const record = sanitizeUserSettings({ version: 1, dictation, general: { autoUpdate: false } });
assert.equal(resolveSettings(record).dictation.language, "en");
assert.equal(record?.general?.autoUpdate, false);
}
});
});
const testData = mkdtempSync(path.join(tmpdir(), "lh-dictation-settings-"));
process.env.LH_DATA_DIR = testData;
after(() => { rmSync(testData, { recursive: true, force: true }); });
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
it("the section route persists language independently and rejects unsupported values without writing", async () => {
const { PUT } = await import("../app/api/settings/[section]/route.ts");
writeSettings({ version: 1, general: { autoUpdate: false } });
const put = (language: string) => PUT(new Request("http://localhost/api/settings/dictation", {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ language }),
}), { params: Promise.resolve({ section: "dictation" }) });
assert.equal((await put("pl")).status, 200);
const saved = { version: 1, dictation: { language: "pl" }, general: { autoUpdate: false } };
assert.deepEqual(readSettings(), saved);
assert.equal((await put("unsupported")).status, 400);
assert.deepEqual(readSettings(), saved);
const general = await PUT(new Request("http://localhost/api/settings/general", {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ autoUpdate: true }),
}), { params: Promise.resolve({ section: "general" }) });
assert.equal(general.status, 200);
assert.deepEqual(readSettings(), { ...saved, general: { autoUpdate: true } }, "auto-update cannot erase dictation language");
});
+300
View File
@@ -0,0 +1,300 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { setDictationActive } from "../components/dictation-state.ts";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import { AUTH_KEYS, DICTATION_LANGUAGES, type AuthKeyStatus, type ModelCatalogEntry, type UserSettings } from "../src/core/index.ts";
import { ModelMenuList } from "../components/settings/model-menu.tsx";
let dispose: (() => Promise<void>) | undefined;
let restore: (() => void) | undefined;
const byTest = (id: string) => document.querySelector<HTMLButtonElement>(`[data-testid="${id}"]`)!;
const trigger = () => byTest("dictation-language");
const input = () => document.querySelector<HTMLInputElement>('input[aria-label="Search dictation languages"]')!;
const menu = () => document.querySelector('[data-testid="dictation-language-menu"]');
const click = async (element: HTMLElement) => { assert(element); await act(async () => { element.click(); }); };
async function key(element: HTMLElement, value: string) {
await act(async () => {
const event = new window.Event("keydown", { bubbles: true, cancelable: true });
Object.assign(event, { key: value });
element.dispatchEvent(event);
});
}
async function search(value: string, element = input()) {
await act(async () => {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
element.dispatchEvent(new window.Event("input", { bubbles: true }));
});
}
async function fixture(failRead = false) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div></body></html>");
let active: Element = doc.body;
Object.defineProperty(doc, "activeElement", { get: () => active });
Object.assign(doc, { oninput: null });
Object.assign(win, { innerWidth: 1280, innerHeight: 900 });
win.HTMLElement.prototype.focus = function () { active = this; };
win.HTMLElement.prototype.scrollIntoView = () => {};
win.HTMLElement.prototype.getBoundingClientRect = () => ({ top: 200, bottom: 230, left: 400, right: 500, width: 100, height: 30, x: 400, y: 200, toJSON() {} });
const state = {
record: { version: 1, dictation: { language: "en" } } as UserSettings,
writes: [] as string[], failWrite: false, outerEscapes: 0,
failRead,
keys: [] as AuthKeyStatus[],
nextWrite: undefined as Promise<Response> | undefined,
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries({
React, window: win, document: doc, Element: win.Element, HTMLElement: win.HTMLElement,
HTMLInputElement: win.HTMLInputElement, HTMLTextAreaElement: win.HTMLTextAreaElement,
Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
cancelAnimationFrame: clearTimeout,
fetch: async (url: string, init?: RequestInit) => {
if (url === "/api/keys") return Response.json({ keys: state.keys, providers: [] });
if (url === "/api/settings") return state.failRead ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
assert.equal(url, "/api/settings/dictation");
const body = JSON.parse(init!.body as string);
state.writes.push(body.language);
if (state.nextWrite) { const next = state.nextWrite; state.nextWrite = undefined; return next; }
if (state.failWrite) return Response.json({}, { status: 500 });
state.record = { ...state.record, dictation: body };
return Response.json({ settings: state.record });
},
})) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
restore = () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
// Register the outer dialog listener first: one Escape must close only
// the language menu, even when the settings dialog owns another listener.
doc.addEventListener("keydown", (event) => { if ((event as KeyboardEvent).key === "Escape") state.outerEscapes++; });
const { createRoot } = await import("react-dom/client");
const { DictationLanguageSetting } = await import("../components/dictation-language.tsx");
let root = createRoot(doc.getElementById("root")!);
const mount = async () => { await act(async () => { root.render(<DictationLanguageSetting />); }); };
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
await mount();
// Fixtures represent distinct server records; remounts within a fixture
// deliberately keep the window-lifetime confirmed preference.
await act(async () => { notifySettingsSaved("dictation"); });
return {
state,
render: async (element: React.ReactNode) => { await act(async () => { root.render(element); }); },
remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); },
};
}
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
test("an initial load failure is visible and opening the menu again can retry", async () => {
const f = await fixture(true);
await click(trigger());
assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not load/);
f.state.failRead = false;
await click(trigger());
await click(trigger());
assert.equal(menu()!.querySelector('[role="alert"]'), null);
await search("Polish");
await key(input(), "Enter");
assert.match(trigger().textContent!, /Polish/);
});
test("the real picker searches by language, chooses with Arrow/Enter, and restores focus", async () => {
const f = await fixture();
assert.match(trigger().textContent!, /English/);
for (const token of ["h-7", "rounded-sm", "border-border", "bg-background", "font-ui", "text-xs"]) {
assert.ok(trigger().className.split(" ").includes(token), `shared settings trigger: ${token}`);
}
await click(trigger());
assert.match(input().className, /lh-input/);
assert.equal(document.activeElement, input());
assert.equal(document.querySelector('[data-testid="dictation-language-list"] button')!.textContent, "Multilingual");
await search("Polish");
assert.equal(document.querySelectorAll('[data-testid="dictation-language-list"] button').length, 1);
await key(input(), "ArrowDown");
await key(input(), "Enter");
assert.deepEqual(f.state.writes, ["pl"]);
assert.match(trigger().textContent!, /Polish/);
assert.equal(menu(), null);
assert.equal(document.activeElement, trigger());
await f.remount();
assert.match(trigger().textContent!, /Polish/);
});
test("Enter on a focused option preserves native button activation", async () => {
const f = await fixture();
await click(trigger());
const option = byTest("dictation-language-multi");
option.focus();
let prevented = false;
await act(async () => {
const event = new window.Event("keydown", { bubbles: true, cancelable: true });
Object.assign(event, { key: "Enter" });
option.dispatchEvent(event);
prevented = event.defaultPrevented;
// linkedom does not implement native keyboard activation. Follow the
// browser's default action only if the handler left it uncancelled.
if (!prevented) option.click();
});
assert.equal(prevented, false, "focused option Enter must retain its native click");
assert.deepEqual(f.state.writes, ["multi"]);
assert.equal(menu(), null);
assert.match(trigger().textContent!, /Multilingual/);
});
test("empty results cannot save; Escape and outside click close the menu without closing settings", async () => {
const f = await fixture();
await click(trigger());
await search("zzzz-no-language");
assert.match(menu()!.textContent!, /No languages match/);
await key(input(), "Enter");
assert.deepEqual(f.state.writes, []);
await key(input(), "Escape");
assert.equal(menu(), null);
assert.equal(f.state.outerEscapes, 0);
await click(trigger());
await key(trigger(), "Escape");
assert.equal(menu(), null);
assert.equal(f.state.outerEscapes, 0);
await click(trigger());
await act(async () => { document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); });
assert.equal(menu(), null);
});
test("failed saves retain the confirmed choice and the same menu can retry", async () => {
const f = await fixture();
f.state.failWrite = true;
await click(trigger());
await search("Polish");
await key(input(), "Enter");
assert.match(trigger().textContent!, /English/);
assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not save/);
f.state.failWrite = false;
await click(byTest("dictation-language-pl"));
assert.equal(menu(), null);
assert.match(trigger().textContent!, /Polish/);
assert.deepEqual(f.state.writes, ["pl", "pl"]);
});
test("a remounted picker stays disabled throughout an outstanding save", async () => {
const f = await fixture();
let complete!: (response: Response) => void;
f.state.nextWrite = new Promise((resolve) => { complete = resolve; });
await click(trigger());
await click(byTest("dictation-language-multi"));
assert.equal(trigger().disabled, true);
await f.remount();
assert.equal(trigger().disabled, true);
await click(trigger());
assert.equal(menu(), null);
f.state.record.dictation = { language: "multi" };
await act(async () => { complete(Response.json({ settings: f.state.record })); });
assert.equal(trigger().disabled, false);
assert.match(trigger().textContent!, /Multilingual/);
assert.deepEqual(f.state.writes, ["multi"]);
});
test("capture locks the row and dismisses an open language menu until capture ends", async () => {
await fixture();
await click(trigger());
await act(async () => { setDictationActive(true); });
assert.equal(trigger().disabled, true);
assert.match(byTest("row-dictation-language").className, /opacity-40/);
assert.equal(menu(), null);
await act(async () => { setDictationActive(false); });
assert.equal(trigger().disabled, false);
await click(trigger());
assert(menu());
});
for (const source of ["none", "file", "env"] as const) test(`Language is below the full key row for ${source}-owned keys, including editing`, async () => {
const f = await fixture();
f.state.keys = [{
id: "DEEPGRAM_API_KEY", info: AUTH_KEYS.find((key) => key.id === "DEEPGRAM_API_KEY")!,
present: source !== "none", masked: source === "none" ? null : "…test (32 chars)",
source, shadowedByEnv: false,
}];
const { KeysSection } = await import("../components/settings/keys-section.tsx");
await f.render(<KeysSection />);
const group = byTest("group-dictation");
const rows = group.lastElementChild!.children;
assert.equal(rows[0]!.getAttribute("data-testid"), "key-deepgram_api_key");
assert.equal(rows[1]!.getAttribute("data-testid"), "row-dictation-language");
assert.equal(rows[2]!.getAttribute("data-testid"), "dictation-setup-hint");
assert.equal(byTest("dictation-setup-hint").textContent, "Create a Deepgram API key with the Member role or higher, then add it above.");
assert.match(byTest("dictation-setup-hint").className, /text-muted-foreground/);
assert.equal(byTest("dictation-setup-hint").querySelector("a")!.href, "https://console.deepgram.com/");
assert.equal(byTest("dictation-setup-hint").querySelector("a")!.textContent, "Deepgram API key");
assert.equal(byTest("dictation-setup-hint").querySelectorAll("a").length, 1);
assert.match(rows[1]!.querySelector(".font-prose")!.className, /text-\(length:--lh-prose-size\)/);
assert.equal(trigger().disabled, false, "language is independent of key ownership");
await click(byTest("key-deepgram_api_key-row"));
if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"));
assert.ok(byTest("dictation-setup-hint"), "guidance remains visible while the key is edited");
await click(trigger());
assert(menu());
await key(input(), "Escape");
assert.equal(menu(), null);
assert.equal(f.state.outerEscapes, 0);
if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"), "first Escape closes only the menu");
});
test("the shared model menu preserves ranking, provider identity, keyboard clamping, and row content", async () => {
const f = await fixture();
const model = (id: string, provider: string): ModelCatalogEntry => ({
id, provider, name: `Model ${id}`, contextWindow: 32000, maxTokens: 4096,
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
input: ["text"], reasoning: false, supportedLevels: [],
});
const models = [model("beta", "p1"), model("alpha", "p1"), model("alpha", "p2")];
const picked: ModelCatalogEntry[] = [];
await f.render(<ModelMenuList models={models} effectiveId="alpha" effectiveProvider="p2" onPick={(m) => picked.push(m)} footer="Model footer" />);
const modelInput = document.querySelector<HTMLInputElement>('[data-testid="model-search"]')!;
const options = () => [...document.querySelectorAll<HTMLButtonElement>('[data-testid="model-list"] button')];
assert.equal(document.activeElement, modelInput);
assert.match(options()[0]!.textContent!, /p1 · alpha/);
assert.match(options()[1]!.textContent!, /p2 · alpha/);
assert.ok(!options()[0]!.className.includes("bg-muted/60"));
assert.ok(options()[1]!.className.includes("bg-muted/60"));
assert.match(options()[0]!.textContent!, /\$1\.00\/\$2\.00/);
assert.match(options()[0]!.textContent!, /32k/);
assert.match(document.body.textContent!, /Model footer/);
await key(modelInput, "Enter");
assert.equal(picked[0], models[1], "Enter without walking selects the first ranked result");
await key(modelInput, "ArrowUp");
await key(modelInput, "Enter");
assert.equal(picked[1], models[0], "ArrowUp enters at the last result");
await key(modelInput, "ArrowDown");
await key(modelInput, "ArrowDown");
await key(modelInput, "Enter");
assert.equal(picked[2], models[0], "ArrowDown clamps at the last result");
await search("p2", modelInput);
assert.equal(options().length, 1);
await key(modelInput, "Enter");
assert.equal(picked[3], models[2], "search resets the walked index and includes provider identity");
await search("no-model-matches", modelInput);
await key(modelInput, "Enter");
assert.equal(picked.length, 4);
assert.match(document.body.textContent!, /No models match/);
});
test("clearing each search restores the complete catalog order with Multilingual first", async () => {
await fixture();
await click(trigger());
const order = () => [...document.querySelectorAll('[data-testid="dictation-language-list"] button')].map((item) => item.getAttribute("data-testid")!.replace("dictation-language-", ""));
const expected = DICTATION_LANGUAGES.map((item) => item.code);
assert.deepEqual(order(), expected);
for (const query of ["en", "multi", "Polish", "zzzzz"]) {
await search(query);
await search("");
assert.deepEqual(order(), expected, `catalog order after clearing ${query}`);
}
});
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createDictationLanguageStore } from "../components/dictation-preference.ts";
test("all readers immediately receive the confirmed write without another GET", async () => {
let reads = 0;
let notifications = 0;
let failReads = false;
const store = createDictationLanguageStore(async (_url, init) => {
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: JSON.parse(init.body as string) } });
reads++;
return failReads ? Response.json({}, { status: 500 }) : Response.json({ settings: null });
}, () => { notifications++; });
assert.equal(await store.load(), "en");
const observed: string[] = [];
const off = store.subscribe(() => { observed.push(`${store.getSnapshot().language}:${store.getSnapshot().saving}`); });
failReads = true;
assert.equal(await store.save("multi"), true);
assert.equal(await store.getLanguage(), "multi");
assert.equal(reads, 1);
assert.equal(notifications, 1);
assert(observed.includes("multi:true"));
assert.equal(observed.at(-1), "multi:false");
off();
});
test("ordinary loads and repeated recordings reuse the initial confirmed value", async () => {
let reads = 0;
const store = createDictationLanguageStore(async () => {
reads++;
if (reads > 1) throw new Error("should not reread");
return Response.json({ settings: { version: 1, dictation: { language: "fr" } } });
});
assert.deepEqual(await Promise.all([store.getLanguage(), store.load()]), ["fr", "fr"]);
for (let i = 0; i < 3; i++) {
const unsubscribe = store.subscribe(() => {});
assert.equal(await store.getLanguage(), "fr");
assert.equal(await store.load(), "fr");
unsubscribe();
}
assert.equal(reads, 1);
});
test("a failed external refresh invalidates the cache until a successful retry", async () => {
let fail = false;
const store = createDictationLanguageStore(async () => fail
? Response.json({}, { status: 500 })
: Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
await store.load();
fail = true;
await assert.rejects(store.load(true), /Could not load/);
await assert.rejects(store.getLanguage(), /Could not load/);
fail = false;
assert.equal(await store.getLanguage(), "pl");
assert.equal(store.getSnapshot().error, null);
});
test("stale reads cannot overwrite a newer write and subscribers see busy before transport runs", async () => {
let stale!: (response: Response) => void;
let nextRead: Promise<Response> | undefined;
const store = createDictationLanguageStore(async (_url, init) => {
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
if (nextRead) { const response = nextRead; nextRead = undefined; return response; }
return Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
});
await store.load();
nextRead = new Promise((resolve) => { stale = resolve; });
const oldRead = store.load(true);
await Promise.resolve();
let startedDuringPublish: Promise<string> | undefined;
const off = store.subscribe(() => {
if (store.getSnapshot().saving && !startedDuringPublish) startedDuringPublish = store.getLanguage();
});
assert.equal(await store.save("multi"), true);
stale(Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
assert.equal(await oldRead, "multi");
assert.equal(await startedDuringPublish, "multi");
assert.equal(store.getSnapshot().language, "multi");
off();
});
for (const newestFirst of [false, true]) test(`overlapping reads keep the newest value, newest resolves first: ${newestFirst}`, async () => {
const responses: ((response: Response) => void)[] = [];
const store = createDictationLanguageStore(() => new Promise((resolve) => { responses.push(resolve); }));
const first = store.load();
await Promise.resolve();
const second = store.load(true);
await Promise.resolve();
const old = Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
const fresh = Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
if (newestFirst) { responses[1]!(fresh); responses[0]!(old); }
else { responses[0]!(old); responses[1]!(fresh); }
assert.deepEqual(await Promise.all([first, second]), ["multi", "multi"]);
assert.equal(await store.getLanguage(), "multi");
});
test("capture also waits for a write initiated as a pending read publishes its result", async () => {
const store = createDictationLanguageStore(async (_url, init) => Response.json({ settings: {
version: 1, dictation: { language: init?.method === "PUT" ? "multi" : "en" },
} }));
let started = false;
const off = store.subscribe(() => {
if (!started && store.getSnapshot().language === "en") {
started = true;
void store.save("multi");
}
});
assert.equal(await store.getLanguage(), "multi");
off();
});
+49
View File
@@ -19,6 +19,9 @@ import { KeyboardSection } from "../components/settings/keyboard-section.tsx";
import type { GraphSettings } from "../src/core/index.ts"; import type { GraphSettings } from "../src/core/index.ts";
import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx"; import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx";
import { KEYBOARD_ACTIONS } from "../src/core/index.ts"; import { KEYBOARD_ACTIONS } from "../src/core/index.ts";
import { DictationLanguageSetting } from "../components/dictation-language.tsx";
import { SettingsMenuTrigger } from "../components/settings/controls.tsx";
import { SubscriptionConnections } from "../components/settings/subscription-connections.tsx";
/** The full opening tag of the element carrying a testid — attribute /** The full opening tag of the element carrying a testid — attribute
* order in React output is not contractual, so assertions run on the * order in React output is not contractual, so assertions run on the
@@ -60,6 +63,52 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
}); });
}); });
describe("the shared settings dropdown trigger", () => {
it("Agent, Appearance and dictation use the same compact dark mono trigger", () => {
const surfaces = [
[renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />), "slot-model-strong"],
[renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />), "font-trigger"],
[renderToStaticMarkup(<DictationLanguageSetting />), "dictation-language"],
] as const;
for (const [html, id] of surfaces) {
const tag = tagOf(html, id);
const classes = tag.match(/class="([^"]+)"/)![1]!.split(" ");
for (const token of ["h-7", "rounded-sm", "border", "border-border", "bg-background", "px-1.5", "font-ui", "text-xs", "hover:border-muted-foreground/50"]) {
assert.ok(classes.includes(token), `${id}: ${token}`);
}
assert.ok(html.includes("▾"), `${id}: shared dropdown indicator`);
}
});
it("preserves native disabling separately from callers' existing click-gated unavailable state", () => {
const disabled = tagOf(renderToStaticMarkup(<SettingsMenuTrigger value="English" disabled data-testid="disabled-trigger" />), "disabled-trigger");
const unavailable = tagOf(renderToStaticMarkup(<SettingsMenuTrigger value="Automatic" unavailable data-testid="unavailable-trigger" />), "unavailable-trigger");
assert.ok(disabled.includes('disabled=""'));
assert.ok(!unavailable.includes('disabled=""'));
assert.ok(unavailable.includes("cursor-not-allowed"));
assert.ok(!unavailable.includes("hover:border-muted-foreground/50"));
});
});
describe("Providers settings-label typography", () => {
it("Language uses the standard sans SettingsRow label without changing its mono trigger", () => {
const html = renderToStaticMarkup(<DictationLanguageSetting />);
assert.ok(html.includes('<span class="block font-prose text-(length:--lh-prose-size)">Language</span>'));
assert.ok(tagOf(html, "dictation-language").includes("font-ui text-xs"));
});
it("only Providers subscription labels use sans; setup labels retain their compact mono styling", () => {
const settings = renderToStaticMarkup(<SubscriptionConnections />);
const setup = renderToStaticMarkup(<SubscriptionConnections setup />);
for (const label of ["ChatGPT", "GitHub Copilot", "Grok"]) {
assert.ok(settings.includes(`<span class="font-prose text-(length:--lh-prose-size) text-foreground">${label}</span>`), `${label}: settings sans label`);
assert.ok(setup.includes(`<span class="font-ui text-xs text-foreground">${label}</span>`), `${label}: unchanged setup label`);
}
assert.equal((settings.match(/>Connect<\/button>/g) ?? []).length, 3);
assert.equal((setup.match(/>Connect<\/button>/g) ?? []).length, 3);
});
});
describe("AgentSection (thin SSR)", () => { describe("AgentSection (thin SSR)", () => {
it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => { it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />); const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
+2 -2
View File
@@ -234,12 +234,12 @@ describe("parseCompactionSettingsBody (owner round 14)", () => {
}); });
describe("SETTINGS_SECTIONS (the route's section map)", () => { describe("SETTINGS_SECTIONS (the route's section map)", () => {
it("maps exactly the seven record sections", () => { it("maps exactly the eight record sections", () => {
// compaction joined (owner round 14): its own section PUT — the // compaction joined (owner round 14): its own section PUT — the
// threshold is engine behavior, not an agent-section key. // threshold is engine behavior, not an agent-section key.
assert.deepEqual( assert.deepEqual(
Object.keys(SETTINGS_SECTIONS), Object.keys(SETTINGS_SECTIONS),
["general", "chat", "agent", "appearance", "graph", "compaction", "models"], ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
); );
}); });
}); });