Finish Deepgram language settings with shared searchable picker
This commit is contained in:
@@ -52,6 +52,8 @@ Add your provider API key and choose your models, then start a session with what
|
|||||||
|
|
||||||
All three platforms support in-app updates.
|
All three platforms support in-app updates.
|
||||||
|
|
||||||
|
**Dictation:** Create a [Deepgram API key](https://console.deepgram.com/) with the **Member role or higher** ([required for token minting](https://developers.deepgram.com/reference/auth/tokens/grant)) and add it under **Settings → Providers → Dictation**. Choose the language below the key; **Multilingual** supports automatic recognition and mixed-language speech. A `403 Insufficient permissions` error during token minting can indicate an insufficient key role.
|
||||||
|
|
||||||
## Your keys, your data
|
## Your keys, your data
|
||||||
|
|
||||||
- **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs.
|
- **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs.
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
import { Command } from "cmdk";
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core";
|
||||||
import {
|
|
||||||
DICTATION_LANGUAGES, fuzzyScore, type DictationLanguage,
|
|
||||||
} from "@/src/core";
|
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { SettingsRow } from "@/components/settings/controls";
|
import { SettingsRow } from "@/components/settings/controls";
|
||||||
|
import { SearchMenuList } from "@/components/settings/search-menu-list";
|
||||||
import { useDictationActive } from "@/components/dictation-state";
|
import { useDictationActive } from "@/components/dictation-state";
|
||||||
import { useDictationLanguage } from "@/components/dictation-preference";
|
import { useDictationLanguage } from "@/components/dictation-preference";
|
||||||
|
|
||||||
@@ -30,16 +27,8 @@ function DictationLanguagePicker({ preference, disabled }: {
|
|||||||
}) {
|
}) {
|
||||||
const { language, saving, error, load, save } = preference;
|
const { language, saving, error, load, save } = preference;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—";
|
const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—";
|
||||||
// Own ordering so clearing a search restores the catalog's order.
|
|
||||||
// cmdk handles keyboard/ARIA, but its DOM sorting retains old ranks.
|
|
||||||
const results = query.trim() ? DICTATION_LANGUAGES
|
|
||||||
.map((item) => ({ item, score: fuzzyScore(query, item.code, item.label) }))
|
|
||||||
.filter((item) => item.score !== null)
|
|
||||||
.sort((a, b) => b.score! - a.score!)
|
|
||||||
.map(({ item }) => item) : DICTATION_LANGUAGES;
|
|
||||||
|
|
||||||
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
|
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,9 +39,7 @@ function DictationLanguagePicker({ preference, disabled }: {
|
|||||||
if (!triggerRef.current?.contains(target)) setOpen(false);
|
if (!triggerRef.current?.contains(target)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener("pointerdown", onPointerDown);
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
return () => {
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||||
document.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
const closeOnEscape = (event: ReactKeyboardEvent) => {
|
const closeOnEscape = (event: ReactKeyboardEvent) => {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
@@ -79,13 +66,11 @@ function DictationLanguagePicker({ preference, disabled }: {
|
|||||||
data-testid="dictation-language"
|
data-testid="dictation-language"
|
||||||
aria-label={`Dictation language: ${label}`}
|
aria-label={`Dictation language: ${label}`}
|
||||||
aria-expanded={open && !disabled}
|
aria-expanded={open && !disabled}
|
||||||
aria-haspopup="listbox"
|
|
||||||
disabled={disabled || saving}
|
disabled={disabled || saving}
|
||||||
onKeyDown={(event) => { if (open) closeOnEscape(event); }}
|
onKeyDown={(event) => { if (open) closeOnEscape(event); }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuery("");
|
|
||||||
setOpen((value) => !value);
|
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"
|
className="h-8 min-w-0 max-w-[65%] px-2.5 font-normal"
|
||||||
>
|
>
|
||||||
@@ -97,47 +82,29 @@ function DictationLanguagePicker({ preference, disabled }: {
|
|||||||
anchorRef={triggerRef}
|
anchorRef={triggerRef}
|
||||||
testid="dictation-language-menu"
|
testid="dictation-language-menu"
|
||||||
preferredSide="down"
|
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>}
|
<div onKeyDown={(event) => {
|
||||||
<Command
|
closeOnEscape(event);
|
||||||
label="Dictation languages"
|
if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); }
|
||||||
vimBindings={false}
|
}}>
|
||||||
shouldFilter={false}
|
{error && <div role="alert" className="mb-1.5 text-destructive">{error}</div>}
|
||||||
onKeyDown={(event) => {
|
<SearchMenuList
|
||||||
closeOnEscape(event);
|
items={DICTATION_LANGUAGES}
|
||||||
// Keep menu navigation out of the composer's submit/shortcut
|
itemKey={(item) => item.code}
|
||||||
// handlers. cmdk owns selection, scrolling and ARIA state.
|
searchTerms={(item) => [item.code, item.label]}
|
||||||
if (["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) event.stopPropagation();
|
isSelected={(item) => item.code === language}
|
||||||
}}
|
onPick={(item) => { void pick(item.code); }}
|
||||||
>
|
disabled={saving || language === undefined}
|
||||||
<Command.Input asChild disabled={saving} value={query} onValueChange={setQuery}>
|
searchTestid="dictation-language-search"
|
||||||
<Input aria-label="Search dictation languages" placeholder="Search languages…" className="mb-1.5 h-8 bg-background px-2 text-xs md:text-xs" />
|
listTestid="dictation-language-list"
|
||||||
</Command.Input>
|
optionTestid={(item) => `dictation-language-${item.code}`}
|
||||||
<Command.List className="max-h-72 overflow-y-auto px-1">
|
ariaLabel="Search dictation languages"
|
||||||
<Command.Empty className="px-2 py-3 text-center text-muted-foreground">No languages match.</Command.Empty>
|
placeholder="Search languages…"
|
||||||
{results.map((item) => <Command.Item
|
empty="No languages match."
|
||||||
key={item.code}
|
renderItem={(item) => <span className="text-card-foreground">{item.label}</span>}
|
||||||
value={item.code}
|
/>
|
||||||
disabled={saving || language === undefined}
|
</div>
|
||||||
onSelect={() => { void pick(item.code); }}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={saving || language === undefined}
|
|
||||||
data-testid={`dictation-language-${item.code}`}
|
|
||||||
className="h-auto w-full justify-between whitespace-normal rounded-lg px-2 py-1.5 text-left text-xs font-normal text-card-foreground data-[selected=true]:bg-accent/40"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
{language === item.code && <CheckIcon aria-hidden className="size-3.5" />}
|
|
||||||
</Button>
|
|
||||||
</Command.Item>)}
|
|
||||||
</Command.List>
|
|
||||||
</Command>
|
|
||||||
</MenuShell>}
|
</MenuShell>}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function createDictationLanguageStore(request: typeof fetch, saved: () =>
|
|||||||
function load(refresh = false): Promise<DictationLanguage> {
|
function load(refresh = false): Promise<DictationLanguage> {
|
||||||
if (writing) return getLanguage();
|
if (writing) return getLanguage();
|
||||||
if (reading && !refresh) return reading;
|
if (reading && !refresh) return reading;
|
||||||
|
if (!refresh && !needsRead && snapshot.language !== undefined) return Promise.resolve(snapshot.language);
|
||||||
const version = ++revision;
|
const version = ++revision;
|
||||||
needsRead = true;
|
needsRead = true;
|
||||||
reading = Promise.resolve().then(async () => {
|
reading = Promise.resolve().then(async () => {
|
||||||
@@ -118,7 +119,9 @@ function subscribe(listener: () => void) {
|
|||||||
stopSignals = onSettingsSaved((section) => {
|
stopSignals = onSettingsSaved((section) => {
|
||||||
if (section === "dictation" && !announcingSave) void store.load(true).catch(() => {});
|
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; } };
|
return () => { off(); if (--readers === 0) { stopSignals?.(); stopSignals = undefined; } };
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { deepgramListenUrl, type DictationLanguage } from "@/src/core";
|
import { deepgramListenUrl, type DictationLanguage } from "@/src/core";
|
||||||
import { useDictationLanguage } from "@/components/dictation-preference";
|
import { useDictationLanguage } from "@/components/dictation-preference";
|
||||||
import { setDictationActive } from "@/components/dictation-state";
|
import { setDictationActive } from "@/components/dictation-state";
|
||||||
@@ -676,11 +675,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return (
|
return (
|
||||||
<Hint label={error ?? "Dictate"} action={error ? undefined : "toggle-dictation"} side="top">
|
<Hint
|
||||||
<Button
|
// The error line truncates in the button; its tooltip reveals it.
|
||||||
|
label={error ?? "Dictate"}
|
||||||
|
action={error ? undefined : "toggle-dictation"}
|
||||||
|
side="top"
|
||||||
|
>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (keyless) {
|
if (keyless) {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -691,11 +693,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
}}
|
}}
|
||||||
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
||||||
data-testid="dictation-toggle"
|
data-testid="dictation-toggle"
|
||||||
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" />
|
<MicIcon className="size-3.5" />
|
||||||
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
|
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
|
||||||
</Button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -703,19 +705,17 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
||||||
<Hint label="Stop dictation (Esc)" side="top">
|
<Hint label="Stop dictation (Esc)" side="top">
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={phase === "stopping"}
|
disabled={phase === "stopping"}
|
||||||
aria-label="Stop dictation"
|
aria-label="Stop dictation"
|
||||||
data-testid="dictation-toggle"
|
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" />}
|
{phase === "stopping" ? <Loader2Icon className="size-3.5 animate-spin" /> : <MicIcon className="size-3.5" />}
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
||||||
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { SubscriptionConnections } from "./subscription-connections.tsx";
|
|||||||
import { ProviderMenuList } from "./model-menu.tsx";
|
import { ProviderMenuList } from "./model-menu.tsx";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||||
|
import { DictationLanguageSetting } from "@/components/dictation-language";
|
||||||
|
|
||||||
/** One pi-ai provider with plain API-key auth (the Platform seam —
|
/** One pi-ai provider with plain API-key auth (the Platform seam —
|
||||||
* derived from pi-ai's own env-key table; no drift possible). */
|
* derived from pi-ai's own env-key table; no drift possible). */
|
||||||
@@ -350,6 +351,9 @@ export function KeysSection({
|
|||||||
if (openRow === null && adding === null) return;
|
if (openRow === null && adding === null) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
|
// A language dropdown above this editor in the dismissal stack owns
|
||||||
|
// the first Escape; do not swallow it at document capture phase.
|
||||||
|
if (document.querySelector('[data-testid="dictation-language-menu"]')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenRow(null);
|
setOpenRow(null);
|
||||||
@@ -486,6 +490,7 @@ export function KeysSection({
|
|||||||
testid={`key-${status.id.toLowerCase()}`}
|
testid={`key-${status.id.toLowerCase()}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{label === "Dictation" && <DictationLanguageSetting />}
|
||||||
</SettingsGroup>)}
|
</SettingsGroup>)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import type { ModelCatalogEntry } from "@/src/core";
|
import type { ModelCatalogEntry } from "@/src/core";
|
||||||
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||||
import { formatTokens } from "@/components/usage-format";
|
import { formatTokens } from "@/components/usage-format";
|
||||||
|
import { SearchMenuList } from "./search-menu-list";
|
||||||
|
|
||||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||||
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
|
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
|
||||||
@@ -53,90 +54,34 @@ export function ModelMenuList({
|
|||||||
/** Quiet footer note under the list (context-specific). */
|
/** Quiet footer note under the list (context-specific). */
|
||||||
footer?: string;
|
footer?: string;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
searchRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keep the arrow-walked result visible in the scrolling list.
|
|
||||||
useEffect(() => {
|
|
||||||
if (menuIdx === null) return;
|
|
||||||
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
|
|
||||||
}, [menuIdx]);
|
|
||||||
|
|
||||||
// The filtered, ranked model list.
|
|
||||||
const results = (models ?? [])
|
|
||||||
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
|
|
||||||
.filter((r) => r.s !== null)
|
|
||||||
.sort((a, b) => b.s! - a.s! || a.m.id.localeCompare(b.m.id))
|
|
||||||
.map((r) => r.m);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<SearchMenuList
|
||||||
ref={searchRef}
|
items={models}
|
||||||
data-testid="model-search"
|
itemKey={(m) => `${m.provider}/${m.id}`}
|
||||||
value={query}
|
searchTerms={(m) => [m.provider, m.id, m.name]}
|
||||||
onChange={(e) => {
|
compare={(a, b) => a.id.localeCompare(b.id)}
|
||||||
setQuery(e.target.value);
|
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
|
||||||
setMenuIdx(null); // new query → the walk restarts on Enter/↓
|
onPick={onPick}
|
||||||
}}
|
searchTestid="model-search"
|
||||||
onKeyDown={(e) => {
|
listTestid="model-list"
|
||||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (results.length === 0) return;
|
|
||||||
setMenuIdx((i) =>
|
|
||||||
e.key === "ArrowDown"
|
|
||||||
? Math.min((i ?? -1) + 1, results.length - 1)
|
|
||||||
: Math.max((i ?? results.length) - 1, 0),
|
|
||||||
);
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
const chosen = results[menuIdx ?? 0];
|
|
||||||
if (chosen) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onPick(chosen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Search models…"
|
placeholder="Search models…"
|
||||||
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
loading="Loading models…"
|
||||||
|
empty="No models match."
|
||||||
|
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 && (
|
{footer && (
|
||||||
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
|
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -176,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority
|
|||||||
|
|
||||||
**Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
|
**Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
|
||||||
|
|
||||||
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. 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
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Respo
|
|||||||
nextWrite: undefined as Promise<Response> | undefined,
|
nextWrite: undefined as Promise<Response> | undefined,
|
||||||
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
|
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
|
||||||
readFails: false,
|
readFails: false,
|
||||||
|
reads: 0,
|
||||||
active: false,
|
active: false,
|
||||||
captures: 0,
|
captures: 0,
|
||||||
stops: 0,
|
stops: 0,
|
||||||
@@ -72,6 +73,7 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Respo
|
|||||||
fetch: async (url: string, init?: RequestInit) => {
|
fetch: async (url: string, init?: RequestInit) => {
|
||||||
if (url === "/api/stt/token") return Response.json({ token: "test-token" });
|
if (url === "/api/stt/token") return Response.json({ token: "test-token" });
|
||||||
if (url === "/api/settings") {
|
if (url === "/api/settings") {
|
||||||
|
state.reads++;
|
||||||
if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; }
|
if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; }
|
||||||
return state.readFails ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
let root = createRoot(document.getElementById("root")!);
|
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(); }); };
|
dispose = async () => { await act(async () => { root.unmount(); }); };
|
||||||
return {
|
return {
|
||||||
state,
|
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");
|
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();
|
const f = await fixture();
|
||||||
await f.mount();
|
await f.mount();
|
||||||
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
|
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
|
||||||
await f.remount();
|
const reads = f.state.reads;
|
||||||
assert.equal(f.setting.language, "multi");
|
|
||||||
f.state.readFails = true;
|
f.state.readFails = true;
|
||||||
await f.remount();
|
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(); });
|
await act(async () => { f.dictation.toggle(); });
|
||||||
assert.match(f.dictation.error!, /Could not load/);
|
assert.match(f.dictation.error!, /Could not load/);
|
||||||
assert.equal(f.state.captures, 0);
|
assert.equal(f.state.captures, 0);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ describe("Nova-3 dictation language contract", () => {
|
|||||||
it("offers monolingual languages/locales and a distinct multilingual mode", () => {
|
it("offers monolingual languages/locales and a distinct multilingual mode", () => {
|
||||||
assert.equal(DICTATION_LANGUAGES[0]?.code, "multi");
|
assert.equal(DICTATION_LANGUAGES[0]?.code, "multi");
|
||||||
assert.equal(new Set(DICTATION_LANGUAGES.map((item) => item.code)).size, DICTATION_LANGUAGES.length);
|
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);
|
assert.ok(isDictationLanguage(code), code);
|
||||||
}
|
}
|
||||||
// Xhosa and Zulu are documented for multi, not monolingual Nova-3.
|
// 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", () => {
|
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));
|
const url = new URL(deepgramListenUrl(language));
|
||||||
assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen");
|
assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen");
|
||||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
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.deepEqual(readSettings(), saved);
|
||||||
assert.equal((await put("unsupported")).status, 400);
|
assert.equal((await put("unsupported")).status, 400);
|
||||||
assert.deepEqual(readSettings(), saved);
|
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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import assert from "node:assert/strict";
|
|||||||
import { afterEach, test } from "node:test";
|
import { afterEach, test } from "node:test";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { setDictationActive } from "../components/dictation-state.ts";
|
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 dispose: (() => Promise<void>) | undefined;
|
||||||
let restore: (() => void) | undefined;
|
let restore: (() => void) | undefined;
|
||||||
@@ -19,9 +21,8 @@ async function key(element: HTMLElement, value: string) {
|
|||||||
element.dispatchEvent(event);
|
element.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function search(value: string) {
|
async function search(value: string, element = input()) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const element = input();
|
|
||||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
|
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
|
||||||
element.dispatchEvent(new window.Event("input", { bubbles: true }));
|
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,
|
record: { version: 1, dictation: { language: "en" } } as UserSettings,
|
||||||
writes: [] as string[], failWrite: false, outerEscapes: 0,
|
writes: [] as string[], failWrite: false, outerEscapes: 0,
|
||||||
failRead,
|
failRead,
|
||||||
|
keys: [] as AuthKeyStatus[],
|
||||||
nextWrite: undefined as Promise<Response> | undefined,
|
nextWrite: undefined as Promise<Response> | undefined,
|
||||||
};
|
};
|
||||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||||
@@ -51,6 +53,7 @@ async function fixture(failRead = false) {
|
|||||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
|
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
|
||||||
cancelAnimationFrame: clearTimeout,
|
cancelAnimationFrame: clearTimeout,
|
||||||
fetch: async (url: string, init?: RequestInit) => {
|
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 });
|
if (url === "/api/settings") return state.failRead ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
|
||||||
assert.equal(url, "/api/settings/dictation");
|
assert.equal(url, "/api/settings/dictation");
|
||||||
const body = JSON.parse(init!.body as string);
|
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 />); }); };
|
const mount = async () => { await act(async () => { root.render(<DictationLanguageSetting />); }); };
|
||||||
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
|
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
|
||||||
await mount();
|
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; });
|
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.match(trigger().textContent!, /English/);
|
||||||
assert.equal(trigger().getAttribute("data-variant"), "outline");
|
assert.equal(trigger().getAttribute("data-variant"), "outline");
|
||||||
await click(trigger());
|
await click(trigger());
|
||||||
assert.equal(input().getAttribute("data-slot"), "input");
|
assert.match(input().className, /lh-input/);
|
||||||
assert.equal(input().getAttribute("role"), "combobox");
|
|
||||||
assert.equal(document.activeElement, 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");
|
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(), "ArrowDown");
|
||||||
await key(input(), "Enter");
|
await key(input(), "Enter");
|
||||||
assert.deepEqual(f.state.writes, ["pl"]);
|
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();
|
const f = await fixture();
|
||||||
await click(trigger());
|
await click(trigger());
|
||||||
await search("zzzz-no-language");
|
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");
|
await key(input(), "Enter");
|
||||||
assert.deepEqual(f.state.writes, []);
|
assert.deepEqual(f.state.writes, []);
|
||||||
await key(input(), "Escape");
|
await key(input(), "Escape");
|
||||||
@@ -183,10 +192,73 @@ test("capture locks the row and dismisses an open language menu until capture en
|
|||||||
assert(menu());
|
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 () => {
|
test("clearing each search restores the complete catalog order with Multilingual first", async () => {
|
||||||
await fixture();
|
await fixture();
|
||||||
await click(trigger());
|
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);
|
const expected = DICTATION_LANGUAGES.map((item) => item.code);
|
||||||
assert.deepEqual(order(), expected);
|
assert.deepEqual(order(), expected);
|
||||||
for (const query of ["en", "multi", "Polish", "zzzzz"]) {
|
for (const query of ["en", "multi", "Polish", "zzzzz"]) {
|
||||||
|
|||||||
@@ -24,6 +24,23 @@ test("all readers immediately receive the confirmed write without another GET",
|
|||||||
off();
|
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 () => {
|
test("a failed external refresh invalidates the cache until a successful retry", async () => {
|
||||||
let fail = false;
|
let fail = false;
|
||||||
const store = createDictationLanguageStore(async () => fail
|
const store = createDictationLanguageStore(async () => fail
|
||||||
|
|||||||
Reference in New Issue
Block a user