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
+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>
</>;
}