feat: refine language controls and add per-tier ChatGPT Fast mode

This commit is contained in:
2026-10-25 21:19:48 +03:00
parent f8ce0f21ef
commit 8c564946ef
17 changed files with 488 additions and 37 deletions
+61 -11
View File
@@ -1,8 +1,9 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core";
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
import { MenuShell } from "./menu-shell";
type I18nValue = {
language: Language;
@@ -78,19 +79,68 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
const { general, t, saveLanguage, sttLanguage, dictating } = useI18n();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const menuId = useId();
const locked = disabled || saving || (field === "sttLanguage" && dictating);
const menuOpen = open && !locked;
const selected = general[field] ?? "auto";
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
const testid = `language-select-${field}${compact ? "-compact" : ""}`;
const options = [
{ value: "auto", label: compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default") },
{ value: "en", label: "English" },
{ value: "ru", label: "Русский" },
] as const;
useEffect(() => { if (locked) setOpen(false); }, [locked]);
useEffect(() => {
if (!menuOpen) return;
const outside = (event: PointerEvent) => {
const target = event.target as Node;
if (!trigger.current?.contains(target) && !document.getElementById(menuId)?.contains(target)) setOpen(false);
};
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
setOpen(false);
trigger.current?.focus();
};
document.addEventListener("pointerdown", outside);
// Claim Escape before the settings dialog or dictation's outer layer.
document.addEventListener("keydown", escape, true);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape, true);
};
}, [menuOpen, menuId]);
const pick = (value: "auto" | Language) => {
if (locked) return;
setOpen(false);
trigger.current?.focus();
if (value === selected) return;
setSaving(true); setError(null);
void saveLanguage(field, value === "auto" ? undefined : value).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
};
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex flex-col items-end gap-1"}>
<select aria-label={label} title={label} value={general[field] ?? "auto"} disabled={disabled || saving || (field === "sttLanguage" && dictating)}
className="h-7 max-w-full rounded-md border border-border bg-background px-1.5 text-xs text-foreground disabled:opacity-50"
onChange={(event) => {
const value = event.currentTarget.value;
setSaving(true); setError(null);
void saveLanguage(field, value === "auto" ? undefined : value as Language).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
<button ref={trigger} type="button" aria-label={label} title={label} aria-haspopup="menu" aria-expanded={menuOpen} aria-controls={menuOpen ? menuId : undefined} disabled={locked} data-testid={testid}
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "w-24" : "w-36"}`}
onClick={() => setOpen(!menuOpen)}
onKeyDown={(event) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault(); event.stopPropagation(); setOpen(true);
}}>
<option value="auto">{compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default")}</option>
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
<span className="min-w-0 flex-1 truncate text-left">{options.find((option) => option.value === selected)?.label}</span>
<span aria-hidden className="text-muted-foreground">▾</span>
</button>
{menuOpen && <MenuShell anchorRef={trigger} testid={`${testid}-menu`} preferredSide={compact ? "up" : "down"} menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl">
<div id={menuId} role="menu" aria-label={label} className="flex flex-col gap-0.5">
{options.map((option) => <button key={option.value} type="button" role="menuitemradio" aria-checked={selected === option.value} onClick={() => pick(option.value)}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${selected === option.value ? "bg-muted/60 text-card-foreground" : "text-card-foreground"}`}>
<span>{option.label}</span>{selected === option.value && <span aria-hidden>✓</span>}
</button>)}
</div>
</MenuShell>}
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
</span>;
}
+45 -8
View File
@@ -72,6 +72,8 @@ import {
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { ZapIcon } from "lucide-react";
import { Hint } from "@/components/ui/hint";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
@@ -152,13 +154,22 @@ export function composeSlotModel(
delete next[slot];
return next;
}
const model = provider && id ? { provider, id } : prev?.model;
next[slot] = {
...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}),
...(model ? { model } : {}),
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
...(model?.provider === "chatgpt" && prev?.fastMode !== undefined ? { fastMode: prev.fastMode } : {}),
};
return next;
}
/** Speed belongs to the tier, independently of its reasoning budget. */
export function composeSlotFast(models: ModelsSettings | undefined, slot: ModelSlotName, enabled: boolean): ModelsSettings {
const current = models?.[slot];
if (current?.model?.provider !== "chatgpt") return { ...models };
return { ...models, [slot]: { ...current, fastMode: enabled } };
}
/** Compose: the search-engine preference. null = the explicit pin is
* REMOVED (auto-activation takes over — the schema's resting state).
* Pure — exported for tests. */
@@ -258,11 +269,12 @@ function MenuTrigger({
type="button"
aria-label={ariaLabel}
aria-expanded={open}
disabled={disabled}
data-testid={testid}
onClick={() => {
if (!disabled) onClick();
}}
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
} ${widthClass}`}
>
@@ -302,12 +314,21 @@ export function AgentSection({
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
onSectionChange: (next: AgentSettings) => void;
/** Writes the composed models section (slots — replace-PUT discipline). */
onModelsChange: (next: ModelsSettings) => void;
onModelsChange: (next: ModelsSettings) => void | Promise<void>;
/** Writes the whole compaction section (replace-PUT discipline). */
onCompactionChange: (next: CompactionSettings) => void;
}) {
const { t } = useI18n();
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
const [savingModels, setSavingModels] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const updateModels = async (next: ModelsSettings) => {
if (savingModels) return;
setSavingModels(true); setModelsError(null);
try { await onModelsChange(next); }
catch { setModelsError("Could not save model settings"); }
finally { setSavingModels(false); }
};
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
* Default hints and thinking lists resolve from it. */
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
@@ -617,6 +638,7 @@ export function AgentSection({
return (
<>
<SettingsGroup label={t("Models")} testid="group-models">
{modelsError && <p role="alert" className="text-xs text-destructive">{t(modelsError)}</p>}
{MODEL_SLOTS.map((slot) => {
const override = models?.[slot];
const modelId = override?.model?.id ?? "";
@@ -644,12 +666,13 @@ export function AgentSection({
label={SLOT_META[slot].label}
hint={SLOT_META[slot].roster}
testid={`slot-row-${slot}`}
className="flex-wrap gap-y-2"
>
{/* The anchor for the outside-click contract is the
span holding BOTH triggers of the open menu. */}
<span
ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative flex items-center gap-2"
className="relative ml-auto flex max-w-full flex-wrap items-center justify-end gap-2"
>
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
<MenuTrigger
@@ -660,6 +683,7 @@ export function AgentSection({
open={thisMenuOpen === "model"}
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
widthClass="min-w-20"
disabled={savingModels}
/>
<MenuTrigger
// Legacy display (chunk 2 item 4): no stored level + a known
@@ -672,7 +696,19 @@ export function AgentSection({
open={thisMenuOpen === "thinking"}
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
widthClass="w-20"
disabled={savingModels}
/>
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
<span className="inline-flex">
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
</button>
</span>
</Hint>
{thisMenuOpen === "model" && (
<MenuShell
preferredSide="down"
@@ -689,7 +725,7 @@ export function AgentSection({
// fresh pick writes a level — the stored one rides as-is
// (the write clamps server-side), else the ordering's level.
const next = populateLevel(thinking || undefined, entry.supportedLevels);
onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, next));
void updateModels(composeSlotModel(models, slot, entry.provider, entry.id, next));
setOpenMenu(null);
}}
footer={
@@ -713,7 +749,7 @@ export function AgentSection({
// the same one the trigger shows.
effectiveLevel={thinking || (chosen ? defaultOrderLevel(levels) : undefined)}
onPick={(level) => {
onModelsChange(composeSlotModel(models, slot, provider, modelId, level));
void updateModels(composeSlotModel(models, slot, provider, modelId, level));
setOpenMenu(null);
}}
/>
@@ -762,11 +798,12 @@ export function AgentSection({
label={t("Quizzing")}
hint={t("How both tutors check your understanding")}
testid="quiz-mechanics-row"
className="flex-wrap gap-y-2"
>
<div
role="radiogroup"
aria-label={t("Quizzing mechanics")}
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
className="ml-auto flex max-w-full shrink-0 rounded-lg border border-border/60 bg-muted/30 p-0.5"
>
{(["choice", "free"] as const).map((m) => {
const active = m === "free" ? free : !free;
@@ -779,7 +816,7 @@ export function AgentSection({
data-testid={`quiz-mechanics-${m}`}
onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
className={cn(
"h-8 rounded-md px-3 text-sm font-medium transition-colors",
"min-h-8 min-w-0 rounded-md px-3 py-1 text-sm leading-snug font-medium break-words transition-colors",
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
+4 -2
View File
@@ -65,6 +65,7 @@ export function SettingsRow({
children,
testid,
dimmed,
className,
}: {
label: string;
hint?: string;
@@ -73,10 +74,11 @@ export function SettingsRow({
/** Dependency graying, graph-view style: the whole row dims when the
* control is inert (parent off, no file yet, …). */
dimmed?: boolean;
className?: string;
}) {
const { t } = useI18n();
return (
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}>
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40", className)}>
<span className="min-w-0">
<span className="block font-prose text-(length:--lh-prose-size)">{t(label)}</span>
{hint && <span className="block text-xs text-muted-foreground/70">{t(hint)}</span>}
@@ -237,4 +239,4 @@ export function CommitInput({
className="h-7 w-40 rounded-sm border border-border bg-background px-2 font-ui text-xs text-foreground outline-none placeholder:text-muted-foreground/60"
/>
);
}
}
+1 -1
View File
@@ -399,7 +399,7 @@ export function SettingsDialogBody({
graph={record.graph}
compaction={record.compaction}
onModelsChange={(next: ModelsSettings) =>
void write("models", next)
write("models", next)
}
onSectionChange={(next: AgentSettings) =>
void write("agent", next)