Finish Deepgram language settings with shared searchable picker
This commit is contained in:
@@ -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>)}
|
||||
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
Reference in New Issue
Block a user