Merge reviewed provider-first setup recovery and session-identity correction

This commit is contained in:
amosblomqvist
2026-10-27 16:35:06 +02:00
8 changed files with 270 additions and 108 deletions
+25 -26
View File
@@ -5,7 +5,7 @@ import { useChatSession } from "@/components/session";
import { visibleQueuedSteers } from "@/components/session-core";
import { SetupDialog } from "@/components/setup-dialog";
import { Hint } from "@/components/ui/hint";
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
import {
Conversation,
@@ -78,8 +78,6 @@ import {
} from "@/src/core";
import { ReadingThinkingField } from "@/components/reading-thinking";
import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block";
import { slotsConfigured, type ModelsSettings } from "@/src/core";
import { useSettingsSaved } from "@/components/settings-signal";
/** One width for everything: transcript, cards, and the bottom blob share this column. */
/**
@@ -555,30 +553,18 @@ export function Chat({
textRef.current = v;
setText(v);
}, []);
// The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the
// refusal interception share one SetupDialog mount. `configured` is
// seeded server-side (slotsConfigured on the record) and flips on Save.
const [configured, setConfigured] = useState(initialConfigured);
// The zero-keys truth (zero-keys wave): no provider key = the pill
// takes precedence over the models CTA (keys come first in the chain).
// Reads once on mount, re-reads on the "keys" signal.
const zeroKeys = useZeroKeys();
// Cross-surface freshness (fix wave): another surface's models write —
// setup save, composer pick, a settings write — re-reads the record so
// the setup CTA appears/disappears live (it used to lag until reload).
useSettingsSaved((section) => {
if (section !== "models") return;
fetch("/api/settings")
.then((r) => r.json() as Promise<{ settings?: { models?: ModelsSettings } | null } | null>)
.then((body) => setConfigured(slotsConfigured(body?.settings?.models)))
.catch(() => {});
});
// Saved slot completeness is live, but the session's selected route
// comes from its own ordinary model control, never today's Strong pin.
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
const catalogRef = useRef(catalog);
catalogRef.current = catalog;
const [selectedModel, setSelectedModel] = useState<string | undefined>();
const [setupOpen, setSetupOpen] = useState(false);
/** The last explicitly-sent draft (composer send): the
* refusal flow restores it into the composer, where it waits for the
* learner to re-send (Save never auto-sends, owner 2026-10-09).
* Auto-continuations (probe answers) never set it — their refusal
* opens the dialog with no replay. */
* opens recovery with no replay. */
const draftRef = useRef<string | null>(null);
// THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
@@ -594,8 +580,9 @@ export function Chat({
if (!error) return;
const missing = parseModelsRefusal(error.message);
// The key-refusal twin (zero-keys wave): a pinned model whose
// provider key is gone gets the SAME treatment — revert-draft and
// open setup (the dialog derives to its first-key stage on its own).
// provider key is gone also restores the draft. With another provider
// connected, repair belongs to the ordinary current-session picker;
// otherwise setup derives its exclusive provider/loading stage.
const keyRefused = parseProviderKeyRefusal(error.message);
if (!missing && !keyRefused) return; // a real error keeps the honest banner
// ORDER MATTERS: clearError() flips this effect's own dep (`error`)
@@ -615,7 +602,8 @@ export function Chat({
draftRef.current = null;
}
clearError();
setSetupOpen(true);
if (keyRefused && catalogRef.current?.providers.some((p) => p.keyPresent)) openModelPicker();
else setSetupOpen(true);
})();
return () => {
cancelled = true;
@@ -1146,6 +1134,14 @@ export function Chat({
if (busy || pending || compacting) return;
const t = textRef.current.trim();
if (!t) return;
if (zeroKeys) {
setSetupOpen(true);
return;
}
if (selectedModelUnavailable(catalog, selectedModel)) {
openModelPicker();
return; // No optimistic send and no change to the learner's draft.
}
draftRef.current = t; // the refusal flow's preserve-and-replay seam
applyText("");
void send({ type: "text", text: t });
@@ -1605,6 +1601,7 @@ export function Chat({
compactionRunning={compacting}
onSubmitThinking={submitThinking}
chatId={chatId}
onModelChange={setSelectedModel}
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
onOpenPlan={() => setPlanOpen(true)}
requiresReveal={requiresReveal}
@@ -1708,7 +1705,6 @@ export function Chat({
onClose={() => setSetupOpen(false)}
onDone={() => {
setSetupOpen(false);
setConfigured(true);
// Save only saves (owner ruling 2026-10-09): the draft went
// home into the composer at refusal time and STAYS there —
// the learner sends it themselves.
@@ -1843,6 +1839,7 @@ function DockOverlay({
composer,
onSubmitAnswer,
chatId,
onModelChange,
onReserve,
plan,
onOpenPlan,
@@ -1879,6 +1876,7 @@ function DockOverlay({
onSubmitAnswer: (output: QuestionAnswerData) => void;
/** The model control's pass-through (the stats bar hosts it). */
chatId: string;
onModelChange: (model: string | undefined) => void;
/** Reports the dock's settled height (block + gradient) — ChatView's
* dockReserve state feeds the transcript's spacer from it. */
onReserve: (h: number) => void;
@@ -2331,6 +2329,7 @@ function DockOverlay({
pricing={pricing}
dictationSlot={dictationSlot}
chatId={chatId}
onModelChange={onModelChange}
/>
{/* The scroll-down disc (moved here from the Conversation,
owner refinement round 2026-09-24): anchored to the BLOCK's
+12 -15
View File
@@ -7,7 +7,7 @@ import { GraphView } from "@/components/graph-view";
import { MinimalHome } from "@/components/minimal-home";
import { SetupDialog } from "@/components/setup-dialog";
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
import { useFieldCycle } from "@/components/field-cycle";
import { SidebarToggle } from "@/components/sidebar";
@@ -41,15 +41,8 @@ export function Home({
// start until the user has picked all three slots — no defaults for
// 33 providers. The dock shows the CTA while unconfigured; submitting
// a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
// stores the slots and the queued message sends).
const [configured, setConfigured] = useState(initialConfigured);
// The zero-keys truth (zero-keys wave): no provider key = the pill
// takes precedence over the models CTA (keys come first in the chain).
const zeroKeys = useZeroKeys();
// The strong slot's keyless truth (pre-send key guards, owner interlude
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
// provider has none — the same skip-everything treatment on send.
const strongKeyless = useStrongKeyless();
// stores slots only; the learner sends the message themselves).
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
const [setupOpen, setSetupOpen] = useState(false);
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
// topic draft; the strip lives in the stats bar's middle slot.
@@ -123,13 +116,18 @@ export function Home({
// models case guards BEFORE creation; the key cases must too — a
// refused first send would otherwise leave an orphan error-shell
// chat in the sidebar (the 503 lands only after POST /api/chats
// already created the record). Same skip-everything-and-open-setup
// treatment; the server's 503 gate remains the belt for /chat/[id]
// sessions.
if (zeroKeys || strongKeyless) {
// already created the record). Zero providers opens connection setup;
// an unavailable selection with another provider opens its ordinary
// picker. The server's admission gate remains the belt.
if (zeroKeys) {
setSetupOpen(true);
return;
}
const strong = catalog?.slots.strong?.model;
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
openModelPicker();
return; // Preserve the draft and never create an orphan session.
}
setBusy(true);
try {
// The map context (owner, 2026-10-04): picks in EITHER mode ride
@@ -326,7 +324,6 @@ export function Home({
open={setupOpen}
onClose={() => setSetupOpen(false)}
onDone={() => {
setConfigured(true);
setSetupOpen(false);
// Save only saves: the draft waits in the composer.
}}
+15 -11
View File
@@ -43,7 +43,9 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState<ModelsSettings | undefined>();
const [session, setSession] = useState<ChatSettings | undefined>();
// A successful read with no settings is a legacy session; undefined
// means no session record is known yet, not permission to inherit Strong.
const [session, setSession] = useState<{ settings?: ChatSettings }>();
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -58,8 +60,8 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
const readRecord = useCallback(() => {
if (chatId) {
fetch(`/api/chats/${chatId}`)
.then((r) => r.json() as Promise<{ settings?: ChatSettings }>)
.then((body) => setSession(body.settings))
.then((r) => { if (!r.ok) throw new Error(`session read failed: ${r.status}`); return r.json() as Promise<{ settings?: ChatSettings }>; })
.then((body) => setSession({ settings: body.settings }))
.catch(() => {
/* same read-only posture as a models-section failure */
});
@@ -83,15 +85,17 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
});
// Dual-bound truth: chat mode displays the session's own record,
// falling back to the strong slot for legacy unstamped sessions —
// EXACTLY what resolveConversationSpec runs. Draft mode is the strong
// slot itself.
// falling back to Strong only for a successfully read legacy unstamped
// session — EXACTLY what resolveConversationSpec runs. An unresolved or
// failed record read must not report a provisional global preference as
// this session's identity (including to the Send guard). Draft mode is
// the strong slot itself.
const strong = models?.strong;
const override: ChatSettings | undefined = chatId
? {
model: session?.model ?? strong?.model,
thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel,
}
? session ? {
model: session.settings?.model ?? strong?.model,
thinkingLevel: session.settings?.thinkingLevel ?? strong?.thinkingLevel,
} : undefined
: strong;
const effectiveId = override?.model?.id;
const effectiveProvider = override?.model?.provider;
@@ -195,7 +199,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
return;
}
const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null;
setSession(stored?.settings ?? next);
setSession({ settings: stored?.settings ?? next });
setOpen(null);
} catch {
setError("Could not reach the server — try again.");
+12 -14
View File
@@ -8,7 +8,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio
import { useFieldCycle } from "@/components/field-cycle";
import { SidebarToggle } from "@/components/sidebar";
import { SetupDialog } from "@/components/setup-dialog";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
import { StatsBar } from "@/components/stats-bar";
import { ChainTableOverlay } from "@/components/chain-table.tsx";
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
@@ -275,14 +275,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
// 33 providers, chats cannot start. The draft stays in the textarea;
// Save stores the slots and never sends (owner ruling 2026-10-09) —
// the learner sends the draft themselves.
const [configured, setConfigured] = useState(initialConfigured);
// The zero-keys truth (zero-keys wave): no provider key = the pill
// takes precedence over the models CTA (keys come first in the chain).
const zeroKeys = useZeroKeys();
// The strong slot's keyless truth (pre-send key guards, owner interlude
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
// provider has none — the same skip-everything treatment on send.
const strongKeyless = useStrongKeyless();
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
const [setupOpen, setSetupOpen] = useState(false);
const send = useCallback(() => {
@@ -295,12 +288,18 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
// The pre-send KEY guards (owner interlude, no-orphan-chats): guard
// BEFORE startDraftSession creates the record — a refused first
// send would otherwise leave an orphan error-shell chat in the
// sidebar. Same skip-everything-and-open-setup treatment; the
// server's 503 gate remains the belt for /chat/[id] sessions.
if (zeroKeys || strongKeyless) {
// sidebar. Zero providers opens connection setup; an unavailable
// selection with another provider opens the ordinary Strong picker.
// The server's admission gate remains the belt.
if (zeroKeys) {
setSetupOpen(true);
return;
}
const strong = catalog?.slots.strong?.model;
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
openModelPicker();
return; // The learner replaces it explicitly, then sends again.
}
setBusy(true);
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
.then((target) => {
@@ -308,7 +307,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
else setBusy(false);
})
.catch(() => setBusy(false));
}, [busy, router, configured, zeroKeys, strongKeyless]);
}, [busy, router, configured, zeroKeys, catalog]);
return (
<>
@@ -369,7 +368,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
open={setupOpen}
onClose={() => setSetupOpen(false)}
onDone={() => {
setConfigured(true);
setSetupOpen(false);
// Save only saves (owner ruling 2026-10-09): the draft
// waits in the composer; the learner sends it.
+30 -2
View File
@@ -15,9 +15,10 @@
* its own save), the same re-read pattern chat.tsx uses for "models".
*/
import type { AuthKeyStatus } from "@/src/core";
import { useEffect, useState } from "react";
import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core";
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
import { useModelCatalog } from "@/components/model-picker";
import { useModelCatalog } from "@/components/model-catalog";
import type { ModelCatalogResponse } from "@/src/core";
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
@@ -32,6 +33,33 @@ export function hasProviderKey(
return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
}
/** Completeness is saved identity, not current provider availability. */
export function savedSlotsConfigured(slots: ModelCatalogResponse["slots"] | undefined): boolean {
return MODEL_SLOTS.every((slot) => !!slots?.[slot]?.model);
}
/** Keep the last known completeness during connection invalidation, but
* follow ordinary slot repairs as soon as their shared snapshot arrives. */
export function useModelSetup(initialConfigured: boolean) {
const { catalog } = useModelCatalog();
const [lastConfigured, setLastConfigured] = useState(initialConfigured);
const configured = catalog ? savedSlotsConfigured(catalog.slots) : lastConfigured;
useEffect(() => { if (catalog) setLastConfigured(savedSlotsConfigured(catalog.slots)); }, [catalog]);
return { catalog, configured, zeroKeys: catalog ? !catalog.providers.some((p) => p.keyPresent) : false };
}
/** A known unavailable route needs an explicit replacement, never an
* automatic provider/model substitution. Unknown metadata defers to the
* server's admission gate instead of declaring a route disconnected. */
export function selectedModelUnavailable(catalog: ModelCatalogResponse | undefined, selected: string | undefined): boolean {
return !!catalog && !!selected && !catalog.models.some((m) => `${m.provider}:${m.id}` === selected);
}
/** Reuse the ordinary control (also opened by the existing keyboard action). */
export function openModelPicker(): void {
window.dispatchEvent(new CustomEvent("lh:kbd-open-model-picker"));
}
/** The zero-keys truth for the calling page: TRUE only after a
* successful read says no provider key exists (pending and failed
* reads render nothing — the pill must never flash on a gap, and a
+26 -19
View File
@@ -24,8 +24,9 @@
* Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
* (keyed providers only), the vision slot filtered to image-capable
* models, in the app's shared dropdown language (ModelMenuList).
* Saved disconnected selections stay visible; Add provider opens the
* connection body when no provider remains. Refreshes preserve edits.
* Saved disconnected selections and edits are retained internally in
* the exclusive provider stage. Connecting with all slots already saved
* closes setup; otherwise the pickers require only the missing slots.
* All three required (owner: no skip, no defaults); Save PUTs the
* models section and reports onDone(models).
*
@@ -45,6 +46,7 @@ import { ModelMenuList } from "@/components/settings/model-menu";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { useModelCatalog } from "@/components/model-catalog";
import { savedSlotsConfigured } from "@/components/setup-cta";
import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu";
@@ -247,12 +249,11 @@ export function SetupDialog(props: SetupDialogProps) {
function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
const backdropDismiss = useBackdropDismiss(onClose);
const { catalog, failed, refresh } = useModelCatalog();
const [lastBody, setLastBody] = useState<ModelsBody | undefined>(catalog);
const body = catalog ?? lastBody;
const providerStageSeen = useRef(false);
const connectionCloseRequested = useRef(false);
const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots));
const [levels, setLevels] = useState(() => prefillLevels(catalog));
const editedSlots = useRef(new Set<ModelSlotName>());
const [showKeys, setShowKeys] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null);
@@ -273,8 +274,8 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
// The keys signal (zero-keys wave): keys-section saves/deletes AND
// THIS dialog's own first-key save dispatch it; the fetch effect below
// re-runs on it, so saving a key flips the stage to the pickers with
// the widened catalog — no restart, no Settings round-trip.
// re-runs on it, so saving a key widens the catalog. Incomplete saved
// slots advance to pickers; complete saved slots just close setup.
const [keysTick, setKeysTick] = useState(0);
useSettingsSaved((section) => {
if (section === "keys") setKeysTick((t) => t + 1);
@@ -291,7 +292,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
useEffect(() => {
if (!catalog) return;
setLastBody(catalog);
const pre = prefillFromSlots(catalog.models, catalog.slots);
const preLevels = prefillLevels(catalog);
// Fresh metadata is always used, but a response cannot reset an
@@ -310,9 +310,18 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
}
return next;
});
if (catalog.providers.some((p) => p.keyPresent)) setShowKeys(false);
}, [catalog]);
const stage = setupStage(catalog?.providers ?? null);
const closeAfterConnection = stage === "pickers" && providerStageSeen.current && savedSlotsConfigured(catalog?.slots);
useEffect(() => {
if (stage === "keys") providerStageSeen.current = true;
if (closeAfterConnection && !connectionCloseRequested.current) {
connectionCloseRequested.current = true;
onClose(); // Connection recovery is not a model-settings save.
}
}, [stage, closeAfterConnection, onClose]);
// Esc + outside-click — layered dismissal, closable WITHOUT
// configuring (browsing stays possible; the gate is the send
// interception, not this dialog). An open slot menu eats Esc first
@@ -354,14 +363,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
};
}, [onClose, openMenu, keyMenuOpen]);
const hasSelections = Object.keys(picked).length > 0 || SLOT_ORDER.some((slot) => body?.slots?.[slot]?.model);
const stage = hasSelections ? "pickers" : setupStage(body ? body.providers : null);
// Do not briefly paint model pickers between successful connection and
// closing an already-configured setup. No saved model/thinking writes.
if (closeAfterConnection) return null;
const allPicked = SLOT_ORDER.every((s) => picked[s]);
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
* CLEAN the local screen — the signal re-fetch widens the catalog and
* the stage flips to the pickers on its own; the dialog stays mounted
* the whole way. Shape-gate 400s keep their user-facing line inline. */
* CLEAN the local key field. The fresh catalog decides whether to
* close recovery or advance to the missing model slots. Shape-gate
* 400s keep their user-facing line inline. */
async function saveKey() {
if (!keyProvider || !keyValue.trim() || keySaving) return;
setKeySaving(true);
@@ -465,10 +476,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
<p className="text-xs text-muted-foreground">
Pick the model for each tier. All three are required before chats can start.
</p>
{!body?.providers.some((p) => p.keyPresent) && <>
<button type="button" data-testid="setup-add-provider" aria-expanded={showKeys} className="self-start text-xs underline" onClick={() => setShowKeys((v) => !v)}>Add provider</button>
{showKeys && keysBody}
</>}
{SLOT_ORDER.map((slot) => {
const entry = picked[slot];
const level = levels[slot];
@@ -481,7 +488,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5">
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
{!body?.slots?.[slot]?.model && !picked[slot] && (
{!catalog?.slots?.[slot]?.model && !picked[slot] && (
<span
data-testid={`setup-needed-${slot}`}
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
+9 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
@@ -34,6 +34,7 @@ export function StatsBar({
dictationSlot,
chatId,
leadingSlot,
onModelChange,
}: {
turns: TurnUsage[];
contextTokens?: number | null;
@@ -43,6 +44,8 @@ export function StatsBar({
* record; absent on the draft surfaces, where it binds the strong
* slot (the new-session default). */
chatId?: string;
/** The same effective identity the visible model control owns. */
onModelChange?: (model: string | undefined) => void;
/** The dictation strip (STT wave, 2026-09-17): the row's middle is dead
* space between the session data and the model control — the mic
* button lives there, and while recording the meter unfurls to fill
@@ -55,6 +58,10 @@ export function StatsBar({
}) {
const [open, setOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>();
const modelChanged = useCallback((model: string | undefined) => {
setSelectedModel(model);
onModelChange?.(model);
}, [onModelChange]);
// Same dropdown discipline as the model control (owner, df4433f treatment,
// stats round 1): close on any pointerdown outside the bar or on Escape.
const barRef = useRef<HTMLDivElement>(null);
@@ -192,7 +199,7 @@ export function StatsBar({
{/* The model + thinking control — one controller for the conversation
agents (prober/teacher); the record's `turns` above still folds
the ACTUAL models billed, regardless of what the picker says. */}
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
<ModelControl chatId={chatId} onModelChange={modelChanged} />
</div>
);
}
+141 -19
View File
@@ -9,6 +9,8 @@ import { AgentSection } from "../components/settings/agent-section.tsx";
import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx";
import { useModelCatalog } from "../components/model-catalog.ts";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import { useModelSetup, savedSlotsConfigured, selectedModelUnavailable, openModelPicker } from "../components/setup-cta.tsx";
import { StatsBar } from "../components/stats-bar.tsx";
import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
@@ -40,7 +42,7 @@ function catalog(connected = true): ModelCatalogResponse {
};
}
function Reader() { snapshot = useModelCatalog(); return null; }
function setup() { return <SetupDialog open onClose={() => {}} onDone={() => {}} />; }
function setup(onClose = () => {}, onDone = (_models: ModelsSettings) => {}) { return <SetupDialog open onClose={onClose} onDone={onDone} />; }
function installFetch() {
requests = []; writes = [];
globalThis.fetch = async (input, init) => {
@@ -107,22 +109,27 @@ test("cached setup is immediately usable; fresh metadata/untouched slots update
});
});
test("disconnect invalidates choices, not saved selections or thinking; all-disconnected setup offers Add provider", async () => {
installFetch(); await warm(); await render(<><Reader />{setup()}</>); await answer(1, catalog());
test("zero providers always means exclusive connection stage; reconnect with all saved slots only closes, even if every selection remains unavailable", async () => {
installFetch(); await warm();
let closed = 0, completed = 0;
await render(<><Reader />{setup(() => { closed++; }, () => { completed++; })}</>); await answer(1, catalog());
await click("setup-trigger-strong");
assert.ok(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'));
await act(async () => notifySettingsSaved("keys"));
assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null, "stale choices disappear before the fresh request resolves");
assert.ok(element("setup-stage-loading"));
assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null);
await answer(2, catalog(false));
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾");
assert.ok(element("setup-add-provider")); await click("setup-add-provider"); assert.ok(element("setup-stage-keys"));
await click("setup-thinking-strong"); await click("thinking-option-xhigh");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾");
await click("setup-save"); assert.equal(writes[0]?.strong?.thinkingLevel, "xhigh"); assert.equal(writes[0]?.strong?.model?.id, a.id);
assert.ok(element("setup-stage-keys"));
assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null);
assert.equal(document.querySelector('[data-testid="setup-add-provider"]'), null);
assert.equal(document.querySelector('[data-testid="setup-save"]'), null);
await act(async () => notifySettingsSaved("keys"));
const other = catalog(false); other.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
await answer(3, other);
assert.equal(closed, 1); assert.equal(completed, 0); assert.deepEqual(writes, []);
assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null, "no intermediate picker frame after reconnect");
});
test("cold reconnect checks saved pins before choosing the key stage, and reopening gets a new draft", async () => {
test("cold saved disconnected pins still enter the provider stage, without any model-selector frame", async () => {
installFetch(); await warm();
await act(async () => notifySettingsSaved("keys"));
const stages: Array<string | undefined> = [];
@@ -132,14 +139,47 @@ test("cold reconnect checks saved pins before choosing the key stage, and reopen
return null;
}
await render(<><Reader />{setup()}<Probe /></>);
assert.ok(element("setup-stage-loading"));
await answer(1, catalog(false));
assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-pickers"]);
await click("setup-thinking-strong"); await click("thinking-option-xhigh");
await render(<Reader />);
await render(<><Reader />{setup()}</>);
assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾", "a reopened draft uses saved state, not the abandoned edit");
await answer(2, catalog(false));
assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-keys"]);
});
for (const count of [0, 1, 2]) test(`connecting with ${count} saved slots only requires the missing slots; stale identities/thinking survive`, async () => {
installFetch(); const before = catalog(false);
if (count < 1) before.slots.strong = null;
if (count < 2) before.slots.cheap = null;
before.slots.vision = null;
if (before.slots.strong) before.slots.strong.thinkingLevel = "xhigh";
await warm(before);
let closed = 0; await render(<><Reader />{setup(() => { closed++; })}</>); await answer(1, before);
assert.ok(element("setup-stage-keys"));
const connected = { ...before, models: [{ ...b, provider: "other" }], providers: [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }] };
await act(async () => notifySettingsSaved("keys")); await answer(2, connected);
assert.ok(element("setup-stage-pickers")); assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); assert.equal(closed, 0);
for (const slot of ["strong", "cheap", "vision"] as const) {
if (before.slots[slot]) continue;
await click(`setup-trigger-${slot}`); await click("model-option-claude-opus-5-5");
}
if (count > 0) assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾");
await click("setup-save");
for (const slot of ["strong", "cheap", "vision"] as const) {
if (before.slots[slot]) assert.deepEqual(writes[0]?.[slot], before.slots[slot]);
else assert.equal(writes[0]?.[slot]?.model?.provider, "other");
}
});
test("a partial setup's model/thinking edits survive exclusive provider recovery, but reopening discards the abandoned draft", async () => {
installFetch(); const before = catalog(); before.slots.cheap = null; before.slots.vision = null;
await warm(before); await render(<><Reader />{setup()}</>); await answer(1, before);
await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5");
await click("setup-thinking-strong"); await click("thinking-option-medium");
await act(async () => notifySettingsSaved("keys")); await answer(2, { ...before, models: [], providers: [] });
assert.ok(element("setup-stage-keys")); assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null);
await act(async () => notifySettingsSaved("keys")); await answer(3, before);
assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾");
await render(<Reader />); await render(<><Reader />{setup()}</>);
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); await answer(4, before);
});
test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => {
@@ -155,6 +195,88 @@ test("Agent opens with cached thinking metadata; refresh and disconnect never cl
await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id);
});
test("saved completeness follows ordinary repairs and survives temporary connection invalidation", async () => {
installFetch(); let state: ReturnType<typeof useModelSetup>;
function StateReader() { state = useModelSetup(false); return null; }
await render(<StateReader />); await answer(0, catalog());
assert.equal(state!.configured, true);
await act(async () => notifySettingsSaved("keys")); assert.equal(state!.configured, true);
const partial = catalog(false); partial.slots.vision = null;
await answer(1, partial); assert.equal(state!.configured, false); assert.equal(state!.zeroKeys, true);
await act(async () => notifySettingsSaved("models")); await answer(2, catalog(false));
assert.equal(state!.configured, true); assert.equal(state!.zeroKeys, true, "saved is not connected");
});
test("blocked session recovery opens its ordinary picker, never changes Strong or substitutes a model", async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
body.slots.strong = { model: { provider: "other", id: b.id }, thinkingLevel: "medium" };
const globals: ModelsSettings = { strong: body.slots.strong };
const session = { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" };
const requestsOnly = globalThis.fetch; const sessionWrites: unknown[] = [];
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return Response.json({ settings: session });
if (input === "/api/chats/fixture/settings") { const next = JSON.parse(String(init?.body)); sessionWrites.push(next); return Response.json({ settings: next }); }
return requestsOnly(input, init);
};
await warm(body); let selected: string | undefined;
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { selected = id; }} /></>);
assert.equal(selected, `${a.provider}:${a.id}`, "current session does not inherit a different Strong pin");
assert.equal(selectedModelUnavailable(body, selected), true);
assert.deepEqual(sessionWrites, []);
await act(async () => openModelPicker());
assert.ok(element("model-menu")); assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null);
assert.equal(document.querySelector('[data-testid="model-option-vendor_model_free"]'), null);
await click("model-option-claude-opus-5-5");
assert.deepEqual(sessionWrites, [{ model: { provider: "other", id: b.id }, thinkingLevel: "xhigh" }]);
assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong });
});
for (const result of ["override", "legacy", "http-failure", "network-failure"] as const) test(`known catalog never enforces provisional Strong on a pending session; session read ${result}`, async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
// Global Strong is unavailable, but this existing session owns a valid
// model. Resolve global settings first while keeping its record pending.
const globals: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" } };
let resolveSession!: (response: Response) => void;
let rejectSession!: (reason: Error) => void;
const sessionRead = new Promise<Response>((resolve, reject) => { resolveSession = resolve; rejectSession = reject; });
const requestsOnly = globalThis.fetch;
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return sessionRead;
return requestsOnly(input, init);
};
await warm(body); const identities: Array<string | undefined> = [];
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { identities.push(id); }} /></>);
assert.ok(snapshot.catalog, "catalog is already known, not the deferred unknown-catalog case");
assert.equal(element("stats-model").textContent, "—▾");
assert.equal(identities.at(-1), undefined);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), false, "pending session cannot block Send using global Strong");
await act(async () => {
if (result === "network-failure") rejectSession(new Error("fixture connection failure"));
else if (result === "http-failure") resolveSession(Response.json({ error: "fixture unavailable" }, { status: 503 }));
else resolveSession(Response.json(result === "legacy" ? {} : { settings: { model: { provider: available.provider, id: available.id }, thinkingLevel: "medium" } }));
});
const expected = result === "legacy" ? `${a.provider}:${a.id}` : result === "override" ? `other:${b.id}` : undefined;
assert.equal(identities.at(-1), expected);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), result === "legacy", "only a confirmed legacy record may inherit the unavailable Strong pin");
assert.equal(document.querySelector('[data-testid="model-menu"]'), null);
assert.deepEqual(writes, []);
});
test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => {
const body = catalog(false);
assert.equal(savedSlotsConfigured(body.slots), true);
assert.equal(selectedModelUnavailable(body, `${a.provider}:${a.id}`), true);
assert.equal(selectedModelUnavailable(undefined, `${a.provider}:${a.id}`), false);
assert.equal(selectedModelUnavailable(body, undefined), false);
body.models = [b]; body.providers = [{ ...provider, keyPresent: true }];
assert.equal(selectedModelUnavailable(body, `${b.provider}:${b.id}`), false);
body.slots.cheap = null; assert.equal(savedSlotsConfigured(body.slots), false);
});
test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => {
installFetch(); await warm();
await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh());