From 5ab52f07f6991c8ece833d3687ff61702e58cb3e Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 22 Sep 2026 18:12:57 +0300 Subject: [PATCH] BYOK universal gate rendered honestly: mid-chat setup flow, refusal-aware cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - existing sessions: a send that lands the 503 models_not_configured envelope renders as the SETUP FLOW, never the error banner — the record refetch drops the refused orphan (the gate refuses before the write-ahead persist), the draft goes home to the composer, and Save auto-replays it (owner ruling); parseModelsRefusal reads the envelope from the transport's verbatim error text (exported pure, tested) - persistent 'Set up your models' CTA above the chat composer while slotsConfigured is false (server-read seed via app/chat/[id]/page.tsx) — legacy per-chat overrides may still send with the pill present; the server gate stays the truth - SetupDialog prefill + 'needed' marks (owner ruling): pinned tiers open with their pick (matched in the federated catalog — lapsed keys don't prefill), unpinned tiers carry the mark; Save still requires all three; prefillFromSlots exported pure, tested - refused jobs read as INFO: dispatch card shows a quiet 'needs model setup' line + 'Set up models' deep-link (ChatActionsContext.openSetup); dock blobs' activity reads 'needs model setup' instead of 'failed'; real failures keep the red tone; isModelsRefusal added to the envelope's home module - agent-section ModelsBody.slots type honest to the nullable contract --- app/chat/[id]/page.tsx | 9 +- components/activity-dock.tsx | 7 +- components/artifacts/dispatch-card.tsx | 36 ++++++- components/chat.tsx | 137 ++++++++++++++++++++++++- components/settings/agent-section.tsx | 7 +- components/setup-dialog.tsx | 41 +++++++- src/core/settings.ts | 8 ++ tests/byok-refusal.test.ts | 99 ++++++++++++++++++ tests/render-activity-dock.test.tsx | 14 +++ tests/render-cards.test.tsx | 52 ++++++++++ 10 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 tests/byok-refusal.test.ts diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 627dc7a..5ee1d70 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import { Chat } from "@/components/chat"; -import type { TeacherUIMessage } from "@/src/core"; -import { getChat, isTurnRunning } from "@/src/server"; +import { slotsConfigured, type TeacherUIMessage } from "@/src/core"; +import { getChat, isTurnRunning, readSettingsRecord } from "@/src/server"; export const dynamic = "force-dynamic"; @@ -15,12 +15,17 @@ export default async function ChatPage({ const [{ id }, { prompt }] = await Promise.all([params, searchParams]); const chat = getChat(id); if (!chat) notFound(); + // The persistent setup CTA's seed (BYOK, owner 2026-09-22): slotsConfigured + // over the settings record, server-read — the same gate home/new read. The + // server's 503 stays the truth; this only decides whether the pill rides. + const record = readSettingsRecord(); return ( ; } + // BYOK (owner, 2026-09-22): a job refused for missing models is INFO, + // not scary-error — gray glyph + "needs model setup" + the deep-link + // into the SetupDialog (the same flow the send interception uses). + if (isModelsRefusal(job.error)) { + return ; + } const failed = job.status === "error"; return (
+ + {label} + needs model setup + {ctx && ( + + )} +
+ ); +} + 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 (
- {SLOT_META[slot].label} + + {SLOT_META[slot].label} + {!body?.slots?.[slot]?.model && !picked[slot] && ( + + needed + + )} + {SLOT_META[slot].roster}