Merge reviewed provider-first setup recovery and session-identity correction
This commit is contained in:
+25
-26
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user