fix(coach): bring back the in-context provider and model switch

The Coach overlay had become a read-only line with a link to Settings >
AI, which took away switching the model mid-conversation. The provider
and model selects are back; they write through the same routes and the
same stored preference as Settings > AI, so there is still one setting,
and the link to Settings > AI stays below them.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:59:25 +02:00
1 parent 9cfc72fe1f
commit 0229b69981
12 files changed
+425 -73

No files matched your search

+2 -4
View File
@@ -807,14 +807,12 @@ test.describe("Coach page frame", () => {
const popover = page.locator('[data-slot="coach-settings-popover"]');
await expect(popover).toBeVisible();
await expect(popover).toHaveAttribute("role", "dialog");
// The model is chosen in Settings > AI; the overlay shows what is in
// effect and links there.
await expect(
popover.locator('[data-slot="coach-quick-provider"]'),
).toHaveAttribute("data-value", "local");
).toHaveValue("local");
await expect(
popover.locator('[data-slot="coach-quick-model"]'),
).toHaveAttribute("data-value", "qwen2.5");
).toHaveValue("qwen2.5");
await expect(
popover.locator('[data-slot="coach-sources-window"]'),
).toBeVisible();
+5 -1
View File
@@ -3275,7 +3275,11 @@
"noProvider": "Kein Anbieter verfügbar",
"allSettings": "Alle KI-Einstellungen",
"operatorCodex": "ChatGPT (vom Betreiber bereitgestellt)",
"changeModel": "In Einstellungen > KI ändern"
"changeModel": "In Einstellungen > KI ändern",
"saving": "Wird gespeichert…",
"saveFailed": "Die Änderung ließ sich nicht speichern.",
"providerNotActive": "{provider} konnte nicht übernehmen: Es fehlt eine funktionierende Anmeldung oder ein Schlüssel. Richte das in den KI-Einstellungen ein.",
"operatorManaged": "Diese Verbindung verwaltet der Betreiber deines Servers. Sobald einer deiner eigenen Anbieter eingerichtet ist, antwortet er zuerst."
},
"answer": {
"scrollToBottom": "Zur neuesten Nachricht",
+5 -1
View File
@@ -3275,7 +3275,11 @@
"noProvider": "No provider available",
"allSettings": "All AI settings",
"operatorCodex": "ChatGPT (shared by the operator)",
"changeModel": "Change in Settings > AI"
"changeModel": "Change in Settings > AI",
"saving": "Saving…",
"saveFailed": "That change could not be saved.",
"providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in AI settings.",
"operatorManaged": "Your server's operator manages this connection. Once one of your own providers is set up, it answers first."
},
"answer": {
"scrollToBottom": "Jump to the latest message",
+5 -1
View File
@@ -3275,7 +3275,11 @@
"noProvider": "Ningún proveedor disponible",
"allSettings": "Todos los ajustes de IA",
"operatorCodex": "ChatGPT (compartido por el operador)",
"changeModel": "Cambiar en Ajustes > IA"
"changeModel": "Cambiar en Ajustes > IA",
"saving": "Guardando…",
"saveFailed": "No se pudo guardar el cambio.",
"providerNotActive": "{provider} no pudo encargarse: no tiene un inicio de sesión ni una clave que funcionen. Configúralo en los ajustes de IA.",
"operatorManaged": "El operador de tu servidor gestiona esta conexión. Cuando configures uno de tus propios proveedores, responderá primero."
},
"answer": {
"scrollToBottom": "Ir al último mensaje",
+5 -1
View File
@@ -3275,7 +3275,11 @@
"noProvider": "Aucun fournisseur disponible",
"allSettings": "Tous les paramètres IA",
"operatorCodex": "ChatGPT (partagé par l'opérateur)",
"changeModel": "Modifier dans Réglages > IA"
"changeModel": "Modifier dans Réglages > IA",
"saving": "Enregistrement…",
"saveFailed": "Impossible d’enregistrer cette modification.",
"providerNotActive": "{provider} n'a pas pu prendre le relais : aucune connexion ni clé valide. Configurez-le dans les réglages IA.",
"operatorManaged": "L'opérateur de votre serveur gère cette connexion. Dès qu'un de vos propres fournisseurs est configuré, c'est lui qui répond en premier."
},
"answer": {
"scrollToBottom": "Aller au dernier message",
+5 -1
View File
@@ -3275,7 +3275,11 @@
"noProvider": "Nessun fornitore disponibile",
"allSettings": "Tutte le impostazioni IA",
"operatorCodex": "ChatGPT (condiviso dall'operatore)",
"changeModel": "Cambia in Impostazioni > IA"
"changeModel": "Cambia in Impostazioni > IA",
"saving": "Salvataggio…",
"saveFailed": "Impossibile salvare la modifica.",
"providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle impostazioni IA.",
"operatorManaged": "Questa connessione è gestita dall'operatore del tuo server. Quando configuri uno dei tuoi fornitori, risponde per primo."
},
"answer": {
"scrollToBottom": "Vai all'ultimo messaggio",
+5 -1
View File
@@ -3275,7 +3275,11 @@
"noProvider": "사용할 수 있는 제공자가 없어요",
"allSettings": "모든 AI 설정",
"operatorCodex": "ChatGPT (운영자 공유)",
"changeModel": "설정 > AI에서 변경"
"changeModel": "설정 > AI에서 변경",
"saving": "저장 중…",
"saveFailed": "변경 사항을 저장하지 못했어요.",
"providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.",
"operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다."
},
"answer": {
"scrollToBottom": "최신 메시지로 이동",
+5 -1
View File
@@ -3275,7 +3275,11 @@
"noProvider": "Geen provider beschikbaar",
"allSettings": "Alle AI-instellingen",
"operatorCodex": "ChatGPT (gedeeld door de serverbeheerder)",
"changeModel": "Wijzigen in Instellingen > AI"
"changeModel": "Wijzigen in Instellingen > AI",
"saving": "Opslaan…",
"saveFailed": "Die wijziging kon niet worden opgeslagen.",
"providerNotActive": "{provider} kon het niet overnemen: er is geen werkende aanmelding of sleutel. Stel deze provider in bij de AI-instellingen.",
"operatorManaged": "De beheerder van je server beheert deze verbinding. Zodra een van je eigen providers is ingesteld, antwoordt die als eerste."
},
"answer": {
"scrollToBottom": "Naar het nieuwste bericht",
+5 -1
View File
@@ -3275,7 +3275,11 @@
"noProvider": "Brak dostępnego dostawcy",
"allSettings": "Wszystkie ustawienia AI",
"operatorCodex": "ChatGPT (udostępniony przez operatora)",
"changeModel": "Zmień w Ustawieniach > AI"
"changeModel": "Zmień w Ustawieniach > AI",
"saving": "Zapisywanie…",
"saveFailed": "Nie udało się zapisać zmiany.",
"providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach AI.",
"operatorManaged": "Tym połączeniem zarządza operator Twojego serwera. Gdy skonfigurujesz własnego dostawcę, to on odpowie jako pierwszy."
},
"answer": {
"scrollToBottom": "Przejdź do najnowszej wiadomości",
@@ -1,8 +1,24 @@
import { describe, expect, it } from "vitest";
import type { UserAIProvider } from "@/components/settings/ai/shared";
import type {
ProviderChainData,
UserAIProvider,
} from "@/components/settings/ai/shared";
import { modelControlFor } from "../coach-model-picker";
import {
chainWithFirst,
modelControlFor,
selectableProviders,
switchDidNotTake,
} from "../coach-model-picker";
const CHAIN: ProviderChainData["configuredChain"] = [
{ providerType: "codex", enabled: true, available: false },
{ providerType: "openai", enabled: true, available: true },
{ providerType: "anthropic", enabled: false, available: true },
{ providerType: "local", enabled: true, available: true },
{ providerType: "admin-openai", enabled: true, available: true },
];
function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
return {
@@ -24,6 +40,55 @@ function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
};
}
describe("chainWithFirst", () => {
it("moves the chosen provider to the front and keeps the rest in order", () => {
expect(chainWithFirst(CHAIN, "local")).toEqual([
{ providerType: "local", priority: 1, enabled: true },
{ providerType: "codex", priority: 2, enabled: true },
{ providerType: "openai", priority: 3, enabled: true },
{ providerType: "anthropic", priority: 4, enabled: false },
{ providerType: "admin-openai", priority: 5, enabled: true },
]);
});
it("never sends a reasoning setting, so the server keeps the stored one", () => {
for (const entry of chainWithFirst(CHAIN, "openai")) {
expect(Object.keys(entry).sort()).toEqual([
"enabled",
"priority",
"providerType",
]);
}
});
});
describe("selectableProviders", () => {
it("offers only enabled providers that can answer", () => {
expect(selectableProviders(CHAIN)).toEqual([
"openai",
"local",
"admin-openai",
]);
});
});
describe("switchDidNotTake", () => {
it("is quiet when the chosen provider leads the chain the server resolves", () => {
expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe(
false,
);
});
it("speaks up when the server still answers with another provider", () => {
// A provider without a working key was saved to the front of the chain;
// the resolver skipped it and the select would jump back silently.
expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe(
true,
);
expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true);
});
});
describe("modelControlFor", () => {
it("offers the presets for the provider whose key the person saved", () => {
const control = modelControlFor("openai", userProvider());
@@ -70,32 +70,43 @@ describe("<CoachSettingsBody>", () => {
expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings</);
});
it("shows the provider and model in effect and links to the one place they are chosen", () => {
it("offers only the providers that can answer, with the active one selected", () => {
const html = render(<CoachSettingsBody />);
expect(html).toMatch(
/data-slot="coach-quick-provider"[^>]*data-value="local"/,
);
expect(html).toMatch(
/data-slot="coach-quick-model"[^>]*data-value="qwen2.5"/,
);
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toBeDefined();
expect(select).toContain('value="local"');
expect(select).toContain('value="admin-openai"');
expect(select).not.toContain('value="codex"');
// The same preference Settings > AI writes; the link there stays.
expect(html).toContain('href="/settings/ai#ai-provider"');
// v1.44 — no second copy of the selects here.
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-provider"/);
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-model"/);
expect(html).toContain('id="coach-quick-reasoning"');
// Keys and base URLs are not here.
expect(html).not.toContain("http://localhost:11434/v1");
expect(html).not.toMatch(/type="password"/);
});
it("names the operator's central Codex when it answers", () => {
it("shows the operator's central Codex as the active provider when it answers", () => {
// `admin-codex` is never a chain entry, so no option matched it and the
// select showed its first option as the one answering.
const html = render(<CoachSettingsBody />, {
...CHAIN,
activeProvider: "admin-codex" as ProviderChainData["activeProvider"],
});
expect(html).toMatch(
/data-slot="coach-quick-provider"[^>]*data-value="admin-codex"[^>]*>ChatGPT \(shared by the operator\)</,
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toMatch(
/<option value="admin-codex" disabled="" selected="">ChatGPT \(shared by the operator\)<\/option>/,
);
expect(select).not.toMatch(/value="local"[^>]*selected/);
expect(html).toContain('data-slot="coach-quick-provider-operator"');
});
it("shows the model and reasoning controls the active provider owns", () => {
const html = render(<CoachSettingsBody />);
expect(html).toContain('data-slot="coach-quick-model"');
expect(html).toContain('id="coach-quick-reasoning"');
// Keys, base URLs and the chain order are not here.
expect(html).not.toContain("http://localhost:11434/v1");
expect(html).not.toMatch(/type="password"/);
});
it("locks the reasoning select while a change is saving", async () => {
@@ -1,9 +1,15 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ChevronRight, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { InlineAction } from "@/components/ui/inline-action";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
ANTHROPIC_MODEL_PRESETS,
LOCAL_MODEL_PRESETS,
@@ -13,23 +19,89 @@ import {
type ProviderType,
type UserAIProvider,
} from "@/components/settings/ai/shared";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet } from "@/lib/api/api-fetch";
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* Which provider answers the Coach and which model it uses, shown in the
* Coach's settings overlay with a link to where it is chosen.
* The quick model choice in the Coach's settings overlay: which provider
* answers and which model it uses. How hard the Coach thinks is the Coach's
* own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
* reasoning select stays in Settings → AI for everything else.
*
* The model is chosen in one place, Settings > AI. Until v1.44 this overlay
* carried a second copy of the provider and model selects, writing through
* the same routes; two controls for one setting meant a change in one place
* surprised the person in the other. The overlay now reads the same two
* queries and says what is in effect.
* It is a shortcut onto the same settings Settings → AI writes, never a second
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
* fallback chain stay there; this reads the same two queries and writes
* through the same two routes:
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
* to the front and every other entry in its current order. The entries'
* reasoning settings are not sent, so the server keeps what it stored.
* - Model: `PATCH /api/user/ai-provider`, only where a person
* owns the setting (their own key's provider, or their gateway). The
* operator's shared provider and Codex show their state read-only.
* Every write invalidates the AI input keys, so the capability on
* `/api/auth/me` comes back from the server rather than being guessed here.
*
* v1.44 briefly replaced the selects with a read-only line; switching the
* model mid-conversation is how people use it, so the selects are back, with
* the link to Settings > AI kept below them for everything else.
*/
/** The configured chain with `provider` first; everything else keeps order. */
export function chainWithFirst(
chain: ProviderChainData["configuredChain"],
provider: ProviderType,
): { providerType: ProviderType; priority: number; enabled: boolean }[] {
const rest = chain.filter((entry) => entry.providerType !== provider);
const ordered = [
{ providerType: provider, enabled: true },
...rest.map((entry) => ({
providerType: entry.providerType,
enabled: entry.enabled,
})),
];
return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 }));
}
/** The providers a person can switch to: enabled and able to answer. */
export function selectableProviders(
chain: ProviderChainData["configuredChain"],
): ProviderType[] {
return chain
.filter((entry) => entry.enabled && entry.available)
.map((entry) => entry.providerType);
}
/**
* True when a saved switch did not take: the chain the server resolves after
* the save is not led by the provider the person chose. A provider whose key
* stops working between the read and the save lands here.
*/
export function switchDidNotTake(
chosen: ProviderType,
fresh: Pick<ProviderChainData, "activeProvider">,
): boolean {
return fresh.activeProvider !== chosen;
}
/**
* The provider the server answers with when the select has no option for it:
* the operator's central Codex (never a chain entry), or an entry that answers
* without being offered. Shown as a disabled option so the select tells the
* truth instead of showing its first option as active.
*/
export function activeOutsideOptions(
active: string | null,
options: readonly string[],
): string | null {
return active !== null && !options.includes(active) ? active : null;
}
export type ModelControl =
| { kind: "preset"; presets: readonly string[]; value: string | null }
| { kind: "gateway"; value: string | null }
@@ -64,6 +136,7 @@ export function modelControlFor(
export function CoachModelPicker() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const chainQuery = useQuery({
queryKey: queryKeys.insightsProviderChain(),
@@ -74,6 +147,59 @@ export function CoachModelPicker() {
queryFn: () => apiGet<UserAIProvider>("/api/user/ai-provider"),
});
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const onSaved = () => {
setError(null);
void invalidateKeys(queryClient, aiInputDependentKeys);
};
const onFailed = (e: unknown) =>
setError(
e instanceof Error && e.message
? e.message
: t("insights.coach.frame.saveFailed"),
);
const switchProvider = useMutation({
mutationFn: async (provider: ProviderType) => {
setNotice(null);
await apiPut("/api/insights/provider-chain", {
chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
});
// Read back what the server now resolves, so a switch that did not
// take is said rather than shown as the select jumping back.
return apiGet<ProviderChainData>("/api/insights/provider-chain");
},
onSuccess: (fresh, provider) => {
queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh);
onSaved();
if (switchDidNotTake(provider, fresh)) {
setNotice(
t("insights.coach.frame.providerNotActive", {
provider: t(`settings.ai.providerChain.types.${provider}`),
}),
);
}
},
onError: onFailed,
});
const patchProvider = useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiPatch("/api/user/ai-provider", body),
onSuccess: onSaved,
onError: onFailed,
});
const chain = chainQuery.data;
const userProvider = providerQuery.data;
const active = chain?.activeProvider ?? null;
const options = chain ? selectableProviders(chain.configuredChain) : [];
const model = modelControlFor(active, userProvider);
const busy = switchProvider.isPending || patchProvider.isPending;
// The server may answer with `admin-codex`, which the UI's type omits.
const unlisted = activeOutsideOptions(active as string | null, options);
if (chainQuery.isError || providerQuery.isError) {
return (
<QueryErrorRow
@@ -88,8 +214,6 @@ export function CoachModelPicker() {
);
}
const chain = chainQuery.data;
const userProvider = providerQuery.data;
if (!chain || !userProvider) {
return (
<p
@@ -105,37 +229,112 @@ export function CoachModelPicker() {
);
}
// The server may answer with `admin-codex`, which the UI's type omits.
const active = (chain.activeProvider ?? null) as string | null;
const model = modelControlFor(chain.activeProvider ?? null, userProvider);
const providerLabel =
active === null
? t("insights.coach.frame.noProvider")
: active === "admin-codex"
? t("insights.coach.frame.operatorCodex")
: t(`settings.ai.providerChain.types.${active}`);
const modelLabel =
model.kind === "managed"
? t("insights.coach.frame.modelManaged")
: (model.value ?? t("settings.ai.modelOptionDefault"));
return (
<div data-slot="coach-model-picker" className="flex flex-col gap-3">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">
<div data-slot="coach-model-picker" className="flex flex-col gap-4">
<div>
<Label htmlFor="coach-quick-provider" noColon>
{t("insights.coach.frame.provider")}
</dt>
<dd data-slot="coach-quick-provider" data-value={active ?? ""}>
{providerLabel}
</dd>
<dt className="text-muted-foreground">{t("settings.ai.modelLabel")}</dt>
<dd
data-slot="coach-quick-model"
data-value={model.kind === "managed" ? "" : (model.value ?? "")}
</Label>
<NativeSelect
id="coach-quick-provider"
data-slot="coach-quick-provider"
className="mt-1"
value={active ?? ""}
disabled={busy || options.length === 0}
onChange={(e) => {
const next = e.target.value as ProviderType;
if (next && next !== active) switchProvider.mutate(next);
}}
>
{modelLabel}
</dd>
</dl>
{active === null ? (
<option value="" disabled>
{t("insights.coach.frame.noProvider")}
</option>
) : null}
{unlisted ? (
<option value={unlisted} disabled>
{unlisted === "admin-codex"
? t("insights.coach.frame.operatorCodex")
: t(`settings.ai.providerChain.types.${unlisted}`)}
</option>
) : null}
{options.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.providerChain.types.${p}`)}
</option>
))}
</NativeSelect>
{unlisted === "admin-codex" ? (
<p
data-slot="coach-quick-provider-operator"
className="text-muted-foreground mt-1 text-xs"
>
{t("insights.coach.frame.operatorManaged")}
</p>
) : null}
</div>
{model.kind === "preset" ? (
<div>
<Label htmlFor="coach-quick-model" noColon>
{t("settings.ai.modelLabel")}
</Label>
<NativeSelect
id="coach-quick-model"
data-slot="coach-quick-model"
className="mt-1"
value={model.value ?? ""}
disabled={busy}
onChange={(e) =>
patchProvider.mutate({ model: e.target.value || null })
}
>
<option value="">{t("settings.ai.modelOptionDefault")}</option>
{model.value && !model.presets.includes(model.value) ? (
<option value={model.value}>{model.value}</option>
) : null}
{model.presets.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</NativeSelect>
</div>
) : model.kind === "gateway" ? (
<GatewayModelField
key={model.value ?? ""}
value={model.value}
disabled={busy}
onSave={(next) => patchProvider.mutate({ compatModel: next })}
/>
) : (
<p
data-slot="coach-quick-model-managed"
className="text-muted-foreground text-xs"
>
{t("insights.coach.frame.modelManaged")}
</p>
)}
{busy ? (
<p role="status" className="text-muted-foreground text-xs">
{t("insights.coach.frame.saving")}
</p>
) : null}
{notice ? (
<p
role="status"
data-slot="coach-quick-provider-notice"
className="text-muted-foreground text-xs"
>
{notice}
</p>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
<p>
<InlineAction asChild tone="muted">
<Link href="/settings/ai#ai-provider" data-slot="coach-quick-change">
@@ -147,3 +346,50 @@ export function CoachModelPicker() {
</div>
);
}
/** A gateway names its own models, so its model is free text with a save. */
function GatewayModelField({
value,
disabled,
onSave,
}: {
value: string | null;
disabled: boolean;
onSave: (next: string | null) => void;
}) {
const { t } = useTranslations();
const [draft, setDraft] = useState(value ?? "");
const changed = draft.trim() !== (value ?? "");
return (
<form
className="flex flex-col gap-1"
onSubmit={(e) => {
e.preventDefault();
if (changed) onSave(draft.trim() || null);
}}
>
<Label htmlFor="coach-quick-gateway-model" noColon>
{t("settings.ai.modelLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
id="coach-quick-gateway-model"
data-slot="coach-quick-gateway-model"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={disabled}
className="min-w-0 flex-1"
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={disabled || !changed}
className="min-h-11 sm:min-h-9"
>
{t("settings.ai.saveCta")}
</Button>
</div>
</form>
);
}