feat: add Russian localization and independent STT languages
CI / Typecheck, tests, lint (push) Canceled after 0s
CI / Standalone (mac-arm64) (push) Canceled after 0s
CI / Standalone (linux-x64) (push) Canceled after 0s
CI / Standalone (windows-x64) (push) Canceled after 0s

This commit is contained in:
2026-10-25 17:18:44 +03:00
parent b277ee3e0f
commit a50ca4b867
93 changed files with 3261 additions and 904 deletions
+30 -23
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The model menu (extracted 2026-09-20 from model-picker.tsx, owner ask:
* the agent settings' role selects use the EXACT same dropdown as the
@@ -53,6 +55,7 @@ export function ModelMenuList({
/** Quiet footer note under the list (context-specific). */
footer?: string;
}) {
const { t, language } = useI18n();
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -104,12 +107,12 @@ export function ModelMenuList({
}
}
}}
placeholder="Search models…"
placeholder={t("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"
/>
{!models && <div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>}
{!models && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading models…")}</div>}
{models && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
<div className="px-2 py-3 text-center text-muted-foreground">{t("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) => (
@@ -125,13 +128,13 @@ export function ModelMenuList({
>
<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"}>
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? t("Subscription connection") : t("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 className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow, language)}</span>
</span>
</button>
</li>
@@ -160,22 +163,23 @@ export function ThinkingMenuList({
empty?: string;
footer?: string;
}) {
const { t } = useI18n();
return (
<>
{levels.length === 0 ? (
<div className="px-2 py-3 text-center text-muted-foreground">{empty ?? "No thinking levels."}</div>
<div className="px-2 py-3 text-center text-muted-foreground">{empty ?? t("No thinking levels.")}</div>
) : (
levels.map((level) => (
<button
key={level}
key={t(level)}
type="button"
data-testid={`thinking-option-${level}`}
data-testid={`thinking-option-${t(level)}`}
onClick={() => onPick(level)}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
}`}
>
<span>{level}</span>
<span>{t(level)}</span>
{level === effectiveLevel && <span aria-hidden>✓</span>}
</button>
))
@@ -199,6 +203,7 @@ export function ProviderMenuList({
providers: { id: string; envVar: string; label: string }[];
onPick: (provider: { id: string; envVar: string; label: string }) => void;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -248,11 +253,11 @@ export function ProviderMenuList({
}
}
}}
placeholder="Search providers…"
placeholder={t("Search providers…")}
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"
/>
{results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No providers match.</div>
<div className="px-2 py-3 text-center text-muted-foreground">{t("No providers match.")}</div>
)}
<ul ref={listRef} data-testid="provider-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((p, i) => (
@@ -283,7 +288,7 @@ export function ProviderMenuList({
* with the default first and a searched list drops it honestly — Enter
* can never pick a row that isn't on screen. Pure — exported for
* tests. */
export function fontMenuResults(query: string, families: string[]): Array<string | null> {
export function fontMenuResults(query: string, families: string[], defaultFamily = "Source Sans 3"): Array<string | null> {
const scored = families
.map((f) => ({ f, s: fuzzyScore(query, f) }))
.filter((r) => r.s !== null)
@@ -291,7 +296,7 @@ export function fontMenuResults(query: string, families: string[]): Array<string
.map((r) => r.f);
// The default row's searchable identity: the factory family + the
// word "default" (the suffix it renders with).
const defaultScore = fuzzyScore(query, "Source Sans 3", "default");
const defaultScore = fuzzyScore(query, defaultFamily, "default");
if (query.trim() === "" || defaultScore !== null) return [null, ...scored];
return scored;
}
@@ -300,10 +305,10 @@ export function fontMenuResults(query: string, families: string[]): Array<string
* query that matches NO family becomes the "Use '<query>'" custom pick —
* a matching family is the better pick, so the action row only exists
* when there is nothing else to pick. Exported for tests. */
export function customFamilyFor(query: string, families: string[]): string | null {
export function customFamilyFor(query: string, families: string[], defaultFamily = "Source Sans 3"): string | null {
const trimmed = query.trim();
if (trimmed === "") return null;
return fontMenuResults(query, families).length === 0 ? trimmed : null;
return fontMenuResults(query, families, defaultFamily).length === 0 ? trimmed : null;
}
/** The reading-font dropdown body — ONE search field (owner ask
@@ -329,6 +334,7 @@ export function FontMenuList({
/** Quiet footer note under the list. */
footer?: string;
}) {
const { t, language } = useI18n();
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -343,10 +349,11 @@ export function FontMenuList({
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
const results = fontMenuResults(query, families ?? []);
const defaultFamily = language === "ru" ? "Noto Sans" : "Source Sans 3";
const results = fontMenuResults(query, families ?? [], defaultFamily);
// The custom-family action row: only when a typed query matches
// nothing (a matching family is the better pick). Enter picks it.
const customFamily = customFamilyFor(query, families ?? []);
const customFamily = customFamilyFor(query, families ?? [], defaultFamily);
const walkable: Array<string | null> = customFamily !== null ? ["__custom__"] : results;
return (
@@ -378,10 +385,10 @@ export function FontMenuList({
}
}
}}
placeholder="Search fonts…"
placeholder={t("Search fonts…")}
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"
/>
{!families && <div className="px-2 py-3 text-center text-muted-foreground">Loading fonts…</div>}
{!families && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading fonts…")}</div>}
<ul ref={listRef} data-testid="font-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{families &&
results.map((family, i) => {
@@ -398,7 +405,7 @@ export function FontMenuList({
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${effective && i !== menuIdx ? " bg-muted/60" : ""}`}
>
<span className="truncate text-card-foreground">{family ?? "Source Sans 3"}</span>
<span className="truncate text-card-foreground">{family ?? (language === "ru" ? "Noto Sans" : "Source Sans 3")}</span>
<span className="shrink-0 text-muted-foreground">{isDefault ? "default" : effective && <span aria-hidden>✓</span>}</span>
</button>
</li>
@@ -415,8 +422,8 @@ export function FontMenuList({
menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}`}
>
<span className="truncate text-card-foreground">Use “{customFamily}”</span>
<span className="shrink-0 text-muted-foreground">custom</span>
<span className="truncate text-card-foreground">{t("Use “")}{customFamily}”</span>
<span className="shrink-0 text-muted-foreground">{t("custom")}</span>
</button>
</li>
)}