Finish Deepgram language settings with shared searchable picker

This commit is contained in:
amosblomqvist
2026-10-27 21:51:09 +02:00
parent 88266eaaf4
commit fbafc5e53a
12 changed files with 292 additions and 170 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.
**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.
+27 -60
View File
@@ -1,16 +1,13 @@
"use client";
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { Command } from "cmdk";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import {
DICTATION_LANGUAGES, fuzzyScore, type DictationLanguage,
} from "@/src/core";
import { ChevronDownIcon } from "lucide-react";
import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core";
import { MenuShell } from "@/components/menu-shell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Hint } from "@/components/ui/hint";
import { SettingsRow } from "@/components/settings/controls";
import { SearchMenuList } from "@/components/settings/search-menu-list";
import { useDictationActive } from "@/components/dictation-state";
import { useDictationLanguage } from "@/components/dictation-preference";
@@ -30,16 +27,8 @@ function DictationLanguagePicker({ preference, disabled }: {
}) {
const { language, saving, error, load, save } = preference;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const triggerRef = useRef<HTMLButtonElement>(null);
const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—";
// Own ordering so clearing a search restores the catalog's order.
// cmdk handles keyboard/ARIA, but its DOM sorting retains old ranks.
const results = query.trim() ? DICTATION_LANGUAGES
.map((item) => ({ item, score: fuzzyScore(query, item.code, item.label) }))
.filter((item) => item.score !== null)
.sort((a, b) => b.score! - a.score!)
.map(({ item }) => item) : DICTATION_LANGUAGES;
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
useEffect(() => {
@@ -50,9 +39,7 @@ function DictationLanguagePicker({ preference, disabled }: {
if (!triggerRef.current?.contains(target)) setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
};
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
const closeOnEscape = (event: ReactKeyboardEvent) => {
if (event.key !== "Escape") return;
@@ -79,13 +66,11 @@ function DictationLanguagePicker({ preference, disabled }: {
data-testid="dictation-language"
aria-label={`Dictation language: ${label}`}
aria-expanded={open && !disabled}
aria-haspopup="listbox"
disabled={disabled || saving}
onKeyDown={(event) => { if (open) closeOnEscape(event); }}
onClick={() => {
setQuery("");
setOpen((value) => !value);
if (language === undefined || error) void load().catch(() => {});
if (language === undefined) void load().catch(() => {});
}}
className="h-8 min-w-0 max-w-[65%] px-2.5 font-normal"
>
@@ -97,47 +82,29 @@ function DictationLanguagePicker({ preference, disabled }: {
anchorRef={triggerRef}
testid="dictation-language-menu"
preferredSide="down"
menuClass="flex w-72 max-w-[calc(100vw-1rem)] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
menuClass="flex w-72 max-w-[calc(100vw-1rem)] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
{error && <div role="alert" className="mb-1.5 text-destructive">{error}</div>}
<Command
label="Dictation languages"
vimBindings={false}
shouldFilter={false}
onKeyDown={(event) => {
closeOnEscape(event);
// Keep menu navigation out of the composer's submit/shortcut
// handlers. cmdk owns selection, scrolling and ARIA state.
if (["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) event.stopPropagation();
}}
>
<Command.Input asChild disabled={saving} value={query} onValueChange={setQuery}>
<Input aria-label="Search dictation languages" placeholder="Search languages…" className="mb-1.5 h-8 bg-background px-2 text-xs md:text-xs" />
</Command.Input>
<Command.List className="max-h-72 overflow-y-auto px-1">
<Command.Empty className="px-2 py-3 text-center text-muted-foreground">No languages match.</Command.Empty>
{results.map((item) => <Command.Item
key={item.code}
value={item.code}
disabled={saving || language === undefined}
onSelect={() => { void pick(item.code); }}
asChild
>
<Button
type="button"
variant="ghost"
size="sm"
tabIndex={-1}
disabled={saving || language === undefined}
data-testid={`dictation-language-${item.code}`}
className="h-auto w-full justify-between whitespace-normal rounded-lg px-2 py-1.5 text-left text-xs font-normal text-card-foreground data-[selected=true]:bg-accent/40"
>
{item.label}
{language === item.code && <CheckIcon aria-hidden className="size-3.5" />}
</Button>
</Command.Item>)}
</Command.List>
</Command>
<div onKeyDown={(event) => {
closeOnEscape(event);
if (event.key === "Enter") { 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>}
</>;
}
+4 -1
View File
@@ -27,6 +27,7 @@ export function createDictationLanguageStore(request: typeof fetch, saved: () =>
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 () => {
@@ -118,7 +119,9 @@ function subscribe(listener: () => void) {
stopSignals = onSettingsSaved((section) => {
if (section === "dictation" && !announcingSave) void store.load(true).catch(() => {});
});
if (!store.getSnapshot().saving) 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; } };
}
+12 -12
View File
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react";
import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { Hint } from "@/components/ui/hint";
import { Button } from "@/components/ui/button";
import { deepgramListenUrl, type DictationLanguage } from "@/src/core";
import { useDictationLanguage } from "@/components/dictation-preference";
import { setDictationActive } from "@/components/dictation-state";
@@ -676,11 +675,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
if (!active) {
return (
<Hint label={error ?? "Dictate"} action={error ? undefined : "toggle-dictation"} side="top">
<Button
<Hint
// The error line truncates in the button; its tooltip reveals it.
label={error ?? "Dictate"}
action={error ? undefined : "toggle-dictation"}
side="top"
>
<button
type="button"
variant="outline"
size="xs"
onClick={() => {
if (keyless) {
clearError();
@@ -691,11 +693,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
}}
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
data-testid="dictation-toggle"
className="h-7 w-full min-w-0 gap-1.5 overflow-hidden border-border/50 bg-card/50 font-normal text-muted-foreground/70 shadow-lg backdrop-blur hover:border-border hover:bg-card/50 hover:text-muted-foreground dark:bg-card/50 dark:hover:bg-card/50"
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" />
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
</Button>
</button>
</Hint>
);
}
@@ -703,19 +705,17 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
return (
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
<Hint label="Stop dictation (Esc)" side="top">
<Button
<button
type="button"
variant="ghost"
size="xs"
onClick={toggle}
disabled={phase === "stopping"}
aria-label="Stop dictation"
data-testid="dictation-toggle"
className="h-7 gap-1.5 bg-destructive/10 text-destructive 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" ? <Loader2Icon className="size-3.5 animate-spin" /> : <MicIcon className="size-3.5" />}
Stop
</Button>
</button>
</Hint>
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
+5
View File
@@ -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,7 @@ export function KeysSection({
testid={`key-${status.id.toLowerCase()}`}
/>
))}
{label === "Dictation" && <DictationLanguageSetting />}
</SettingsGroup>)}
</>
+25 -80
View File
@@ -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<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 (
<>
<input
ref={searchRef}
data-testid="model-search"
value={query}
onChange={(e) => {
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);
}
}
}}
<SearchMenuList
items={models}
itemKey={(m) => `${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) => <>
<span className="flex items-center justify-between gap-2">
<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"}>
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<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>
</>}
/>
{!models && <div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>}
{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="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"}>
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<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>
</button>
</li>
))}
</ul>
{footer && (
<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>
</>;
}
+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.
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → General → Dictation → Language 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; subsequent read failures cannot revert a confirmed choice. External 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 composes the shared Button, Input, Hint and MenuShell with the existing `cmdk` dependency for accessible list navigation, using the app's fuzzy-search scoring.
**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.
## Testing
+17 -4
View File
@@ -53,6 +53,7 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Respo
nextWrite: undefined as Promise<Response> | undefined,
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
readFails: false,
reads: 0,
active: false,
captures: 0,
stops: 0,
@@ -72,6 +73,7 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Respo
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 });
}
@@ -101,7 +103,16 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Respo
return null;
}
let root = createRoot(document.getElementById("root")!);
const mount = async () => { await act(async () => { root.render(<Harness />); }); };
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,
@@ -170,14 +181,16 @@ test("a failed save keeps the previous language, including a shortcut pressed wh
assert.equal(Socket.all[0]!.language, "pl");
});
test("successful choices survive unmount/remount and failed reads never start capture", async () => {
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); });
await f.remount();
assert.equal(f.setting.language, "multi");
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);
+7 -2
View File
@@ -12,7 +12,7 @@ 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", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) {
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.
@@ -24,7 +24,7 @@ describe("Nova-3 dictation language contract", () => {
});
it("only language varies in the streaming URL; all tuned parameters survive", () => {
for (const language of ["en", "pl", "multi", "en-GB"] as const) {
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), {
@@ -74,4 +74,9 @@ it("the section route persists language independently and rejects unsupported va
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");
});
+82 -10
View File
@@ -3,7 +3,9 @@ import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { setDictationActive } from "../components/dictation-state.ts";
import { DICTATION_LANGUAGES, type UserSettings } from "../src/core/index.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;
@@ -19,9 +21,8 @@ async function key(element: HTMLElement, value: string) {
element.dispatchEvent(event);
});
}
async function search(value: string) {
async function search(value: string, element = input()) {
await act(async () => {
const element = input();
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
element.dispatchEvent(new window.Event("input", { bubbles: true }));
});
@@ -40,6 +41,7 @@ async function fixture(failRead = false) {
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>();
@@ -51,6 +53,7 @@ async function fixture(failRead = false) {
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);
@@ -79,7 +82,14 @@ async function fixture(failRead = false) {
const mount = async () => { await act(async () => { root.render(<DictationLanguageSetting />); }); };
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
await mount();
return { state, remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); 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; });
@@ -102,12 +112,11 @@ test("the real picker searches by language, chooses with Arrow/Enter, and restor
assert.match(trigger().textContent!, /English/);
assert.equal(trigger().getAttribute("data-variant"), "outline");
await click(trigger());
assert.equal(input().getAttribute("data-slot"), "input");
assert.equal(input().getAttribute("role"), "combobox");
assert.match(input().className, /lh-input/);
assert.equal(document.activeElement, input());
assert.equal(document.querySelector("[cmdk-item]")!.textContent, "Multilingual");
assert.equal(document.querySelector('[data-testid="dictation-language-list"] button')!.textContent, "Multilingual");
await search("Polish");
assert.equal(document.querySelectorAll("[cmdk-item]").length, 1);
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"]);
@@ -122,7 +131,7 @@ test("empty results cannot save; Escape and outside click close the menu without
const f = await fixture();
await click(trigger());
await search("zzzz-no-language");
assert.match(document.querySelector("[cmdk-empty]")!.textContent!, /No languages match/);
assert.match(menu()!.textContent!, /No languages match/);
await key(input(), "Enter");
assert.deepEqual(f.state.writes, []);
await key(input(), "Escape");
@@ -183,10 +192,73 @@ test("capture locks the row and dismisses an open language menu until capture en
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(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"));
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("[cmdk-item]")].map((item) => item.getAttribute("data-value"));
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"]) {
+17
View File
@@ -24,6 +24,23 @@ test("all readers immediately receive the confirmed write without another GET",
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