Restore exclusive provider-first setup recovery

This commit is contained in:
amosblomqvist
2026-10-27 16:12:13 +02:00
parent c1ef6d6822
commit 419dcf1ba2
7 changed files with 222 additions and 97 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