From fbafc5e53a565d61e4eca57afc3cea59a50210b1 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 21:51:09 +0200 Subject: [PATCH] Finish Deepgram language settings with shared searchable picker --- README.md | 2 + components/dictation-language.tsx | 87 ++++++------------- components/dictation-preference.ts | 5 +- components/dictation.tsx | 24 +++--- components/settings/keys-section.tsx | 5 ++ components/settings/model-menu.tsx | 105 ++++++----------------- components/settings/search-menu-list.tsx | 93 ++++++++++++++++++++ docs/development.md | 2 +- tests/dictation-flow.test.tsx | 21 ++++- tests/dictation-language.test.ts | 9 +- tests/dictation-picker.test.tsx | 92 +++++++++++++++++--- tests/dictation-preference.test.ts | 17 ++++ 12 files changed, 292 insertions(+), 170 deletions(-) create mode 100644 components/settings/search-menu-list.tsx 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/components/dictation-language.tsx b/components/dictation-language.tsx index ccb85cd..5f6528b 100644 --- a/components/dictation-language.tsx +++ b/components/dictation-language.tsx @@ -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(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 &&
{error}
} - { - 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(); - }} - > - - - - - No languages match. - {results.map((item) => { void pick(item.code); }} - asChild - > - - )} - - +
{ + closeOnEscape(event); + if (event.key === "Enter") { 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 index 8151a9b..38b922a 100644 --- a/components/dictation-preference.ts +++ b/components/dictation-preference.ts @@ -27,6 +27,7 @@ export function createDictationLanguageStore(request: typeof fetch, saved: () => 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 () => { @@ -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; } }; } diff --git a/components/dictation.tsx b/components/dictation.tsx index fdb78c7..96c6cbd 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -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 ( - - + ); } @@ -703,19 +705,17 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { return (
- +
diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index f7c3f4a..14680b7 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,7 @@ export function KeysSection({ testid={`key-${status.id.toLowerCase()}`} /> ))} + {label === "Dictation" && } )} 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/docs/development.md b/docs/development.md index 0acbdd5..0eee7fe 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. 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 diff --git a/tests/dictation-flow.test.tsx b/tests/dictation-flow.test.tsx index cd6d20f..0cca1a5 100644 --- a/tests/dictation-flow.test.tsx +++ b/tests/dictation-flow.test.tsx @@ -53,6 +53,7 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise | 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 { 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 { await act(async () => { root.render(); }); }; + 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, @@ -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); diff --git a/tests/dictation-language.test.ts b/tests/dictation-language.test.ts index 2b75ad6..cb4b910 100644 --- a/tests/dictation-language.test.ts +++ b/tests/dictation-language.test.ts @@ -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"); }); diff --git a/tests/dictation-picker.test.tsx b/tests/dictation-picker.test.tsx index f33d350..e351d22 100644 --- a/tests/dictation-picker.test.tsx +++ b/tests/dictation-picker.test.tsx @@ -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) | 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 | undefined, }; const previous = new Map(); @@ -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(); }); }; 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(); + 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( 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("[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"]) { diff --git a/tests/dictation-preference.test.ts b/tests/dictation-preference.test.ts index 4b9b7d4..233c276 100644 --- a/tests/dictation-preference.test.ts +++ b/tests/dictation-preference.test.ts @@ -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