+ );
+}
+
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
// Elapsed from createdAt — jobs appear here only via the poll (post-mount),
// so no SSR/hydration mismatch.
diff --git a/components/chat.tsx b/components/chat.tsx
index 849cffe..531a619 100644
--- a/components/chat.tsx
+++ b/components/chat.tsx
@@ -4,6 +4,8 @@ import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMe
import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { createChatTransport } from "@/components/chat-transport";
+import { SetupDialog } from "@/components/setup-dialog";
+import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
Conversation,
@@ -24,7 +26,7 @@ import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar";
import { GraphContextMarker } from "@/components/graph-context-marker";
import { useActivity } from "@/components/activity-dock";
-import { mergeAdopted, type ChatSettings } from "@/src/core";
+import { mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
@@ -78,8 +80,30 @@ export const ChatActionsContext = createContext<{
syncFromServer: () => Promise;
/** Send a user message into the session (demo error-fix loop, W6). */
sendText: (text: string) => void;
+ /** Opens the model setup dialog (BYOK, 2026-09-22): refused job cards
+ * deep-link into the same SetupDialog the send interception uses. */
+ openSetup: () => void;
} | null>(null);
+/** The BYOK refusal envelope from the transport's error text: the
+ * DefaultChatTransport throws the 503 body VERBATIM as the error message,
+ * so the machine-readable {code, missing} shape survives to here. Real
+ * errors (any other text) read as null and keep the honest banner.
+ * Exported pure for tests. */
+export function parseModelsRefusal(message: string | undefined): ModelSlotName[] | null {
+ if (!message) return null;
+ try {
+ const body = JSON.parse(message) as { code?: unknown; missing?: unknown };
+ if (body.code === "models_not_configured" && Array.isArray(body.missing)
+ && body.missing.every((s) => typeof s === "string")) {
+ return body.missing as ModelSlotName[];
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
/** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -118,6 +142,7 @@ export function Chat({
initialMessages,
initialPrompt,
initiallyInFlight,
+ initialConfigured = false,
}: {
chatId: string;
title: string;
@@ -126,8 +151,12 @@ export function Chat({
/** A teacher turn is running server-side but attached to no client
* (navigated away mid-turn, returned while in flight). */
initiallyInFlight?: boolean;
+ /** slotsConfigured(server record) — the persistent setup CTA keys on
+ * it (BYOK, owner 2026-09-22). The server gate stays the truth; this
+ * only decides whether the pill rides the composer. */
+ initialConfigured?: boolean;
}) {
- const { messages, sendMessage, addToolOutput, setMessages, status, error, stop } =
+ const { messages, sendMessage, addToolOutput, setMessages, status, error, stop, clearError } =
useChat({
id: chatId,
messages: initialMessages,
@@ -267,6 +296,55 @@ export function Chat({
const streamingActivity = !pending ? streamingToolActivity(messages) : null;
const [text, setText] = useState("");
+ // 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);
+ const [setupOpen, setSetupOpen] = useState(false);
+ const { open: openSettings } = useSettingsDialog();
+ /** The last explicitly-sent draft (composer send, verdict note): the
+ * refusal flow restores it into the composer and Save replays it.
+ * Auto-continuations (probe answers) never set it — their refusal
+ * opens the dialog with no replay. */
+ const draftRef = useRef(null);
+
+ // THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
+ // 503 models_not_configured envelope renders as the SETUP FLOW, never
+ // the error banner. The gate refuses BEFORE the write-ahead persist,
+ // so the server record is the truth minus the refused send — the
+ // optimistic message is an orphan that goes HOME: refetch the record,
+ // drop the orphan, put the draft back in the composer, open the
+ // dialog (the prefilled tiers carry their picks, the unpinned ones the
+ // "needed" marks). Esc keeps the draft; Save replays it.
+ useEffect(() => {
+ if (!error) return;
+ const missing = parseModelsRefusal(error.message);
+ if (!missing) return; // a real error keeps the honest banner
+ // ORDER MATTERS: clearError() flips this effect's own dep (`error`)
+ // and would cancel the in-flight work below — so the banner dismiss,
+ // the draft restore, and the dialog open all land TOGETHER after the
+ // record refetch (the honest truth minus the refused send).
+ let cancelled = false;
+ void (async () => {
+ const res = await fetch(`/api/chats/${encodeURIComponent(chatId)}`);
+ if (!cancelled && res.ok) {
+ const record = (await res.json().catch(() => null)) as { messages?: TeacherUIMessage[] } | null;
+ if (Array.isArray(record?.messages)) setMessages(record.messages);
+ }
+ if (cancelled) return;
+ if (draftRef.current !== null) {
+ setText(draftRef.current);
+ draftRef.current = null;
+ }
+ clearError();
+ setSetupOpen(true);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ // (setMessages/clearError are stable per useChat instance — the
+ // dep pair [error, chatId] is what the flow actually keys on.)
+ }, [error, chatId]);
// Voice dictation (STT wave, 2026-09-17): finals append at the end of
// the current text (the page owns the separator, dictate-flush style);
// interims ghost in the stats bar's middle slot next to the meter.
@@ -397,6 +475,7 @@ export function Chat({
const sendVerdict = useCallback(
(accepted: boolean) => {
const note = text.trim();
+ if (note) draftRef.current = note; // refusal flow: the note replays as text
setText("");
// TWO REAL MESSAGES, ONE TURN (the custom-message scheme, user-decided
// 2026-09-12): a typed note is the learner's own user message, and the
@@ -709,12 +788,20 @@ export function Chat({
const onSend = () => {
const t = text.trim();
if (!t || busy || pending) return;
+ draftRef.current = t; // the refusal flow's preserve-and-replay seam
setText("");
void sendMessage({ text: t });
};
return (
- void sendMessage({ text: t }) }}>
+ void sendMessage({ text: t }),
+ openSetup: () => setSetupOpen(true),
+ }}
+ >
{/* The session surface: the transcript column (header + scroller) and
@@ -967,6 +1054,20 @@ export function Chat({
onReserve={setDockReserve}
dictationSlot={}
dictationCancel={dictation.cancel}
+ setupCta={
+ !configured && (
+
+
+
+ )
+ }
composer={
+ {/* The BYOK setup flow (owner, 2026-09-22): one mount serves the
+ persistent CTA, the refused-send interception, and the job cards'
+ deep-link. Save replays the preserved draft automatically; Esc
+ keeps it in the composer (the dialog is closable by design — the
+ gate is the server, not this modal). */}
+ setSetupOpen(false)}
+ onDone={() => {
+ setSetupOpen(false);
+ setConfigured(true);
+ // Draft-preserving replay (owner ruling): the draft went home
+ // into the composer at refusal time; Save sends it onward.
+ const t = text.trim();
+ if (t) {
+ setText("");
+ draftRef.current = t;
+ void sendMessage({ text: t });
+ }
+ }}
+ onOpenKeys={() => {
+ setSetupOpen(false);
+ openSettings("keys");
+ }}
+ />
);
}
@@ -1053,6 +1179,7 @@ function DockOverlay({
onOpenPlan,
dictationSlot,
dictationCancel,
+ setupCta,
}: {
pending: ReturnType;
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
@@ -1079,6 +1206,9 @@ function DockOverlay({
/** Esc in a pending question's field stops dictation (the strip is the
* stats bar's; the stop belongs to the page-owned hook, threaded down). */
dictationCancel?: () => void;
+ /** The persistent setup CTA (BYOK, owner 2026-09-22): the same pill
+ * home and /chat/new carry, above the composer while slots are unset. */
+ setupCta?: React.ReactNode;
}) {
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
// Teach-phase mount (session reload with a pending check): the dock starts
@@ -1317,6 +1447,7 @@ function DockOverlay({
+ {setupCta}
{pending ? (
diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx
index d3a5fb3..68dd1b9 100644
--- a/components/settings/agent-section.tsx
+++ b/components/settings/agent-section.tsx
@@ -85,9 +85,10 @@ interface SegmentInfo {
/** The GET /api/models response fields this section consumes. */
interface ModelsBody {
models: ModelCatalogEntry[];
- /** The three tiers' RESOLVED pins (override → factory) — the honest
- * "what runs now" the Default rows and hints speak from. */
- slots: Record;
+ /** The three tiers' RESOLVED pins (BYOK: NULL per unset tier — no
+ * factory floor) — machinery only (the thinking list fallback); the
+ * rows' displays read the record override, never a phantom pin. */
+ slots: Record;
}
/** Compose: one tool toggled, the rest of the section carried verbatim.
diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx
index 51fff94..546fff8 100644
--- a/components/setup-dialog.tsx
+++ b/components/setup-dialog.tsx
@@ -50,6 +50,10 @@ const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
interface ModelsBody {
models: ModelCatalogEntry[];
providers: { id: string; keyPresent: boolean }[];
+ /** The three tiers' RESOLVED pins (BYOK: NULL per unset tier) — the
+ * PREFILL + "needed" marks (owner, 2026-09-22): pinned tiers open
+ * with their pick, unpinned tiers carry the mark. */
+ slots?: Record;
}
/** Pure: which stage the flow opens at — no keyed provider = keys first
@@ -61,6 +65,26 @@ export function setupStage(
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
}
+/** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its
+ * pick — matched in the FEDERATED catalog (a lapsed key's pin does not
+ * prefill); unpinned tiers stay empty and carry the "needed" mark.
+ * First-run = nothing pinned → all three empty + marked. Exported pure
+ * for tests. */
+export function prefillFromSlots(
+ models: ModelCatalogEntry[],
+ slots: Record | undefined,
+): Partial> {
+ const pre: Partial> = {};
+ for (const slot of SLOT_ORDER) {
+ const pin = slots?.[slot];
+ const entry = pin?.model
+ ? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
+ : undefined;
+ if (entry) pre[slot] = entry;
+ }
+ return pre;
+}
+
export function SetupDialog({
open,
onClose,
@@ -98,6 +122,11 @@ export function SetupDialog({
.then((b: ModelsBody) => {
setBody(b);
setFailed(false);
+ // Prefill (owner, 2026-09-22): each pinned tier opens with its
+ // pick (matched in the FEDERATED catalog — a lapsed key's pin
+ // does not prefill); unpinned tiers stay empty and carry the
+ // "needed" mark. First-run = nothing pinned → all three marked.
+ setPicked(prefillFromSlots(b.models, b.slots));
})
.catch(() => setFailed(true));
}, [open]);
@@ -208,7 +237,17 @@ export function SetupDialog({
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models;
return (