diff --git a/README.md b/README.md index a9ff694..0aa7706 100644 --- a/README.md +++ b/README.md @@ -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. +**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 and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs. diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts index a51da1c..2c955cf 100644 --- a/app/api/settings/[section]/route.ts +++ b/app/api/settings/[section]/route.ts @@ -5,7 +5,7 @@ * discipline, same as the per-chat and defaults routes); sections are * 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 * (server/settings-body.ts): a bad shape/type is a 400 carrying the * supported list where one exists. The chat and models sections diff --git a/components/dictation-language.tsx b/components/dictation-language.tsx new file mode 100644 index 0000000..94a9c38 --- /dev/null +++ b/components/dictation-language.tsx @@ -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; + +export function DictationLanguageSetting() { + const preference = useDictationLanguage(); + const active = useDictationActive(); + return + + ; +} + +function DictationLanguagePicker({ preference, disabled }: { + preference: LanguagePreference; + disabled: boolean; +}) { + const { language, saving, error, load, save } = preference; + const [open, setOpen] = useState(false); + const triggerRef = useRef(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 <> + + { if (open) closeOnEscape(event); }} + onClick={() => { + setOpen((value) => !value); + if (language === undefined) void load().catch(() => {}); + }} + className="min-w-20 max-w-[65%]" + /> + + {open && !disabled && +
{ + 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 &&
{error}
} + 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) => {item.label}} + /> +
+
} + ; +} diff --git a/components/dictation-preference.ts b/components/dictation-preference.ts new file mode 100644 index 0000000..38b922a --- /dev/null +++ b/components/dictation-preference.ts @@ -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 | null = null; + let writing: Promise | null = null; + let revision = 0; + let needsRead = true; + const listeners = new Set<() => void>(); + const publish = (patch: Partial) => { + snapshot = { ...snapshot, ...patch }; + for (const listener of listeners) listener(); + }; + + function load(refresh = false): Promise { + 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 { + // 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 { + 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 }; +} diff --git a/components/dictation-state.ts b/components/dictation-state.ts new file mode 100644 index 0000000..8f290b1 --- /dev/null +++ b/components/dictation-state.ts @@ -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); +} diff --git a/components/dictation.tsx b/components/dictation.tsx index ee34b77..96c6cbd 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -4,6 +4,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2Icon, MicIcon } from "lucide-react"; import { useSettingsDialog } from "@/components/settings/settings-dialog"; import { Hint } from "@/components/ui/hint"; +import { deepgramListenUrl, type DictationLanguage } from "@/src/core"; +import { useDictationLanguage } from "@/components/dictation-preference"; +import { setDictationActive } from "@/components/dictation-state"; +export { isDictationActive } from "@/components/dictation-state"; /** * Voice dictation — the cloud engine (STT wave 1, 2026-09-17). @@ -86,15 +90,7 @@ import { Hint } from "@/components/ui/hint"; * endpointing=300, no interims. Silence streams too (the worklet never * pauses), so the socket never idles — no KeepAlive needed. */ -const DG_URL = - "wss://api.deepgram.com/v1/listen" + - "?model=nova-3" + - "&encoding=linear16" + - "&sample_rate=16000" + - "&channels=1" + - "&smart_format=true" + - "&punctuate=true" + - "&endpointing=300"; +// URL construction lives in the browser-safe core; only language varies. /** Meter tuning — the owner's tested values, see the file header. */ const METER_FLOOR_DB = -50; @@ -204,16 +200,6 @@ export function rmsToMeter(rms: number): number { */ let questionTarget: ((text: string) => void) | null = null; -/* Is a dictation run live on this page? The Esc chain (keyboard wave A, - * components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the - * stop handler can't report back through a CustomEvent. Set at start(), - * cleared in cleanup() (every terminal path runs through cleanup). */ -let dictationActiveFlag = false; - -export function isDictationActive(): boolean { - return dictationActiveFlag; -} - /* The finisher registry: submit paths FAR from the hook's owner (the * pending question's dock) need finish() without a prop bridge. Same * module-level pattern as questionTarget — one window, one instance, @@ -285,6 +271,7 @@ export type Dictation = { * question waits. */ export function useDictation({ append }: { append: (text: string) => void }): Dictation { + const { getLanguage } = useDictationLanguage(); const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); /** 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. */ - const cleanup = useCallback(() => { + const cleanup = useCallback((warmNext = true) => { genRef.current++; - dictationActiveFlag = false; + setDictationActive(false); flush(); // the deferred transcript appears when the run ends (owner round 2) if (safetyRef.current) { clearTimeout(safetyRef.current); @@ -357,16 +344,16 @@ export function useDictation({ append }: { append: (text: string) => void }): Di for (const w of waitersRef.current.splice(0)) w(); // Pre-mint for the next run (mitigation D) — best-effort; a keyless // page just doesn't warm. - warmTokens.refresh(); + if (warmNext) warmTokens.refresh(); }, []); /** Open the Deepgram socket and wire its messages. `gen` guards staleness. */ const openSocket = useCallback( - (gen: number, token: string) => { + (gen: number, token: string, language: DictationLanguage) => { // Minted JWTs authenticate via the "bearer" subprotocol — ["token", // jwt] is rejected at the handshake (verified live; "token" is for // raw API keys only, as in the owner's pi dictate extension). - const ws = new WebSocket(DG_URL, ["bearer", token]); + const ws = new WebSocket(deepgramListenUrl(language), ["bearer", token]); wsRef.current = ws; ws.addEventListener("open", () => { @@ -408,7 +395,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di reconnectsRef.current++; mintFreshToken() // always FRESH — a stale warm token is the likely culprit .then((t) => { - if (gen === genRef.current) openSocket(gen, t); + if (gen === genRef.current) openSocket(gen, t, language); }) .catch(() => { if (gen === genRef.current) { @@ -426,13 +413,23 @@ export function useDictation({ append }: { append: (text: string) => void }): Di ); const start = useCallback(async () => { - dictationActiveFlag = true; + setDictationActive(true); setError(null); setPhase("connecting"); reconnectsRef.current = 0; bufferRef.current = []; // fresh run, fresh buffer (round 2) pendingRef.current = []; // fresh run, fresh queue (mitigation A) const gen = ++genRef.current; + let language: DictationLanguage; + try { + language = await getLanguage(); + } catch (e) { + if (gen !== genRef.current) return; + setError(e instanceof Error ? e.message : "Dictation language unavailable"); + cleanup(); + return; + } + if (gen !== genRef.current) return; // Mitigation D: the warm token (pre-minted at mount / after the last // run) resolves ~instantly; a cold/keyless mint surfaces below. The @@ -509,8 +506,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di if (gen !== genRef.current) return; // Mitigation B: the handshake fires the moment the graph is live, // overlapping any remaining token wait — mic-gated, never before it. - openSocket(gen, token); - }, [openSocket, cleanup]); + openSocket(gen, token, language); + }, [openSocket, cleanup, getLanguage]); const stop = useCallback(() => { const gen = genRef.current; @@ -586,7 +583,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di // attached. useEffect(() => { warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless - }, []); + return () => { + // Navigation cancels capture: invalidate every pending await before + // releasing resources, and never append into a different page's input. + bufferRef.current = []; + cleanup(false); + }; + }, [cleanup]); useEffect(() => { window.addEventListener("lh:kbd-toggle-dictation", toggle); return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle); @@ -630,7 +633,6 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { * error keeps the old behavior (click = retry, which clears). */ const keyless = error === DICTATION_KEYLESS; const [levels, setLevels] = useState(() => new Array(METER_CELLS).fill(0)); - const recording = phase === "recording" || phase === "stopping"; /** Adaptive cell count (owner round 2): derived from the meter's * measured width — narrow rows show fewer bars instead of * overflowing under neighbors. */ @@ -674,10 +676,7 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { if (!active) { return ( + 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" + > {error ?? "Dictate"} @@ -707,32 +704,20 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { return (
- + - {/* 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). */}
{levels.slice(-cellCount).map((v, i) => ( void): v const saved = useRef(handler); saved.current = handler; useEffect(() => onSettingsSaved((section) => saved.current(section)), []); -} \ No newline at end of file +} diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 7c79b64..33f5512 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -71,7 +71,7 @@ import { import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { prettyModel } from "@/components/usage-format"; 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 { ChatGPTBackgroundAccess } from "./chatgpt-connection"; import { NativeSubscriptionBackgroundAccess } from "./subscription-connections"; @@ -245,23 +245,17 @@ function MenuTrigger({ disabled?: boolean; }) { const button = ( - + unavailable={disabled} + className={widthClass} + /> ); return hint ? {button} : button; } diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index 30d3af9..d75ee47 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -41,7 +41,7 @@ import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; import { useAppearance } from "./appearance.tsx"; 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 = * this). Exported for tests. */ @@ -210,29 +210,22 @@ export function AppearanceSection({ testid="row-font" > - {/* The MenuTrigger look (agent-section's collapsed - select — it lives unexported there, so the - classes ride inline; the searchable machine is - the shared part). The tooltip rides the CLOSED - button ONLY (owner ruling 2026-09-26: never while + {/* The shared Agent/Appearance collapsed-menu trigger. + The tooltip rides the CLOSED button ONLY + (owner ruling 2026-09-26: never while the dropdown is out — it popped over the open menu when the wrap covered the whole anchor span). */} {(() => { const triggerButton = ( - + className="min-w-20" + /> ); return fontMenuOpen ? ( triggerButton diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx index f9d2d69..498ccf9 100644 --- a/components/settings/chatgpt-connection.tsx +++ b/components/settings/chatgpt-connection.tsx @@ -328,12 +328,12 @@ function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; } /** 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 active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); const accounts = ui.status.accounts.filter((a) => a.connected); return
- active ? ui.manage(active.id) : ui.connect()}> + active ? ui.manage(active.id) : ui.connect()}> {active && } diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx index 395d604..5a92319 100644 --- a/components/settings/connection-controls.tsx +++ b/components/settings/connection-controls.tsx @@ -4,9 +4,9 @@ import { createPortal } from "react-dom"; import { useBackdropDismiss } from "@/components/backdrop-dismiss"; 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
- {label} + {label} {children}
; diff --git a/components/settings/controls.tsx b/components/settings/controls.tsx index 4aaa385..1715be1 100644 --- a/components/settings/controls.tsx +++ b/components/settings/controls.tsx @@ -9,7 +9,7 @@ * 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"; /** 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, "value" | "children"> & { + value: string; + unavailable?: boolean; +}) { + return ; +} + /** The on/off switch — a plain button in the switch role. */ export function Toggle({ checked, diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index f7c3f4a..198e93d 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -30,6 +30,7 @@ import { SubscriptionConnections } from "./subscription-connections.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; +import { DictationLanguageSetting } from "@/components/dictation-language"; /** One pi-ai provider with plain API-key auth (the Platform seam — * 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; const onKeyDown = (e: KeyboardEvent) => { 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.stopPropagation(); setOpenRow(null); @@ -486,6 +490,17 @@ export function KeysSection({ testid={`key-${status.id.toLowerCase()}`} /> ))} + {label === "Dictation" && <> + +

+ Create a Deepgram API key with the Member role or higher, then add it above. +

+ } )} diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 19697b9..792ae23 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -23,6 +23,7 @@ import { useEffect, useRef, useState } from "react"; import type { ModelCatalogEntry } from "@/src/core"; import { fuzzyScore, subscriptionLabel } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; +import { SearchMenuList } from "./search-menu-list"; function priceLabel(cost: ModelCatalogEntry["cost"]): string { const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; @@ -53,90 +54,34 @@ export function ModelMenuList({ /** Quiet footer note under the list (context-specific). */ footer?: string; }) { - const [query, setQuery] = useState(""); - const [menuIdx, setMenuIdx] = useState(null); - const searchRef = useRef(null); - const listRef = useRef(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 ( <> - { - setQuery(e.target.value); - setMenuIdx(null); // new query → the walk restarts on Enter/↓ - }} - onKeyDown={(e) => { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - 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); - } - } - }} + `${m.provider}/${m.id}`} + searchTerms={(m) => [m.provider, m.id, m.name]} + compare={(a, b) => a.id.localeCompare(b.id)} + isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)} + onPick={onPick} + searchTestid="model-search" + listTestid="model-list" + optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`} 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." + renderItem={(m) => <> + + {m.name} + + {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} + + + + {subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id} + {formatTokens(m.contextWindow)} + + } /> - {!models &&
Loading models…
} - {models && results.length === 0 && ( -
No models match.
- )} -
    - {results.map((m, i) => ( -
  • - -
  • - ))} -
{footer && (
{footer}
)} diff --git a/components/settings/search-menu-list.tsx b/components/settings/search-menu-list.tsx new file mode 100644 index 0000000..08a4fa6 --- /dev/null +++ b/components/settings/search-menu-list.tsx @@ -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({ + 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(null); + const searchRef = useRef(null); + const listRef = useRef(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 <> + { 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 &&
{loading}
} + {items && results.length === 0 &&
{empty}
} +
    + {results.map((item, i) =>
  • + +
  • )} +
+ ; +} diff --git a/components/settings/subscription-connections.tsx b/components/settings/subscription-connections.tsx index 6c095d1..80f3870 100644 --- a/components/settings/subscription-connections.tsx +++ b/components/settings/subscription-connections.tsx @@ -119,16 +119,19 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea ; } -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 status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId); return
- s.pending)} onAction={() => ui.open(providerId)} /> + s.pending)} onAction={() => ui.open(providerId)} /> {status.error &&

{status.error}

}
; } export function SubscriptionConnections({ setup = false }: { setup?: boolean }) { - return
{NATIVE_SUBSCRIPTIONS.map((p) => )}
; + // 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
{NATIVE_SUBSCRIPTIONS.map((p) => )}
; } export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) { const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider); diff --git a/docs/development.md b/docs/development.md index 4c81a84..f775f61 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. -**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 @@ -196,4 +196,4 @@ The teaching methodology comes from an Obsidian-based POC (its `knowledge graph Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`. -**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand. \ No newline at end of file +**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand. diff --git a/src/core/dictation.ts b/src/core/dictation.ts new file mode 100644 index 0000000..45d6826 --- /dev/null +++ b/src/core/dictation.ts @@ -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)}`; +} diff --git a/src/core/index.ts b/src/core/index.ts index 77de4ed..bae8b33 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -59,6 +59,7 @@ export * from "./fuzzy.ts"; // the user-settings record's shape/sanitizer (pure) and its fully-resolved // view. Browser-safe — the Wave-4 panel imports these directly. export * from "./settings.ts"; +export * from "./dictation.ts"; // 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 // two type-only things from it: the ChatDeleteMode union (the delete diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index ce4b6aa..1a1cbf9 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -35,6 +35,7 @@ import { type BackgroundModelRole, type FactorySettings, type GeneralSettings, + type DictationSettings, type CompactionSettings, type GraphSettings, type ModelSlot, @@ -69,6 +70,7 @@ export type EffectiveAppearance = FactorySettings["appearance"]; /** The concrete view: every knob resolved, no absent fields. */ export interface EffectiveSettings { general: Required; + dictation: Required; agent: { models: Record; /** Delta map as stored — consumers resolve absent key → enabled. */ @@ -176,6 +178,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings general: { autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate, }, + dictation: { + language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language, + }, agent: { models, tools: agent.tools ?? {}, diff --git a/src/core/settings.ts b/src/core/settings.ts index ce80c65..f7966f1 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts"; import type { ModelRole } from "../../config.ts"; import type { ChatSettings } from "./contracts/chat.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 * changes (a field renamed/meaning changed); pure additions never bump. */ @@ -235,6 +236,11 @@ export interface GeneralSettings { autoUpdate?: boolean; } +/** One app-wide dictation preference, shared by every composer. */ +export interface DictationSettings { + language?: DictationLanguage; +} + /** Agent settings — everything about the non-conversation machinery. */ export interface AgentSettings { /** Background-role model/thinking overrides (absent role = config pin). */ @@ -361,6 +367,7 @@ export interface CompactionSettings { export interface UserSettings { version: number; general?: GeneralSettings; + dictation?: DictationSettings; agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; @@ -378,6 +385,7 @@ export interface UserSettings { export interface FactorySettings { version: number; general: Required; + dictation: Required; appearance: { mode: "dark" | "light"; background: string | null; @@ -397,6 +405,7 @@ export interface FactorySettings { export const FACTORY_SETTINGS: FactorySettings = { version: CURRENT_SETTINGS_VERSION, general: { autoUpdate: true }, + dictation: { language: "en" }, appearance: { mode: "dark", 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)) { out.general = sanitizeGeneral(r.general as Record); } + if (r.dictation !== undefined) { + const raw = r.dictation; + out.dictation = raw !== null && typeof raw === "object" && !Array.isArray(raw) && + isDictationLanguage((raw as Record).language) + ? { language: (raw as DictationSettings).language } + : {}; + } if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { out.agent = sanitizeAgent(r.agent as Record); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 2cf1050..9b1a173 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -19,12 +19,14 @@ import type { BackgroundModelRole, CompactionSettings, GeneralSettings, + DictationSettings, GraphSettings, ModelsSettings, RoleModelOverride, } 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 { isDictationLanguage } from "../core/dictation.ts"; /** Parsed-or-rejected — `ok: false` carries the 400's error message. */ export type SettingsBodyResult = @@ -375,6 +377,15 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult { + 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 * carries the special prompts sub-resource). */ export const SETTINGS_SECTIONS = { @@ -385,6 +396,7 @@ export const SETTINGS_SECTIONS = { graph: parseGraphSettingsBody, compaction: parseCompactionSettingsBody, models: parseModelsSettingsBody, + dictation: parseDictationSettingsBody, } as const; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; diff --git a/tests/dictation-flow.test.tsx b/tests/dictation-flow.test.tsx new file mode 100644 index 0000000..0cca1a5 --- /dev/null +++ b/tests/dictation-flow.test.tsx @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((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; +let restore: (() => void) | undefined; +let dispose: (() => Promise) | undefined; + +async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise) { + const dom = parseHTML("
"); + const state = { + record: { version: 1, dictation: { language } } as UserSettings, + nextRead: delayedRead, + nextWrite: undefined as Promise | 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(); + 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; + 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(); }); + // 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(); + 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(); + f.state.nextWrite = write.promise; + let saved!: Promise; + 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(); + f.state.nextWrite = write.promise; + let saved!: Promise; + 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(); + 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(); + 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(); + 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(); + f.state.nextWrite = write.promise; + let first!: Promise; + 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(); + 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); +}); diff --git a/tests/dictation-language.test.ts b/tests/dictation-language.test.ts new file mode 100644 index 0000000..cb4b910 --- /dev/null +++ b/tests/dictation-language.test.ts @@ -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"); +}); diff --git a/tests/dictation-picker.test.tsx b/tests/dictation-picker.test.tsx new file mode 100644 index 0000000..97cb1d6 --- /dev/null +++ b/tests/dictation-picker.test.tsx @@ -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) | undefined; +let restore: (() => void) | undefined; +const byTest = (id: string) => document.querySelector(`[data-testid="${id}"]`)!; +const trigger = () => byTest("dictation-language"); +const input = () => document.querySelector('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("
"); + 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 | undefined, + }; + const previous = new Map(); + 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)[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(); }); }; + 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(); + 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( picked.push(m)} footer="Model footer" />); + const modelInput = document.querySelector('[data-testid="model-search"]')!; + const options = () => [...document.querySelectorAll('[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}`); + } +}); diff --git a/tests/dictation-preference.test.ts b/tests/dictation-preference.test.ts new file mode 100644 index 0000000..233c276 --- /dev/null +++ b/tests/dictation-preference.test.ts @@ -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 | 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 | 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(); +}); diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index 6c21490..370bb12 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -19,6 +19,9 @@ import { KeyboardSection } from "../components/settings/keyboard-section.tsx"; import type { GraphSettings } from "../src/core/index.ts"; import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx"; 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 * 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( {}} onModelsChange={() => {}} />), "slot-model-strong"], + [renderToStaticMarkup( {}} />), "font-trigger"], + [renderToStaticMarkup(), "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(), "disabled-trigger"); + const unavailable = tagOf(renderToStaticMarkup(), "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(); + assert.ok(html.includes('Language')); + 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(); + const setup = renderToStaticMarkup(); + for (const label of ["ChatGPT", "GitHub Copilot", "Grok"]) { + assert.ok(settings.includes(`${label}`), `${label}: settings sans label`); + assert.ok(setup.includes(`${label}`), `${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)", () => { it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => { const html = renderToStaticMarkup( {}} onModelsChange={() => {}} />); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index e543d45..16c0ac9 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -234,12 +234,12 @@ describe("parseCompactionSettingsBody (owner round 14)", () => { }); 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 // threshold is engine behavior, not an agent-section key. assert.deepEqual( Object.keys(SETTINGS_SECTIONS), - ["general", "chat", "agent", "appearance", "graph", "compaction", "models"], + ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"], ); }); });