BYOK universal gate rendered honestly: mid-chat setup flow, refusal-aware cards

- 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
This commit is contained in:
amosblomqvist
2026-09-22 18:12:57 +03:00
parent bb7804bd7f
commit 5ab52f07f6
10 changed files with 397 additions and 13 deletions
+5 -2
View File
@@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { ActivityIcon, BookOpenIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { CONTEXT_LIMITS, jobCost } from "@/src/core";
import { CONTEXT_LIMITS, isModelsRefusal, jobCost } from "@/src/core";
import { formatTokens, formatUsd } from "@/components/usage-format";
/**
@@ -184,7 +184,10 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
startedAt: Date.parse(job.createdAt) || 0,
cost: jobCost(job),
context,
activity: job.activity ?? "",
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
// the honest line the runner left ("failed") reads as setup
// needed, matching the dispatch card's quiet tone.
activity: isModelsRefusal(job.error) ? "needs model setup" : (job.activity ?? ""),
model: usage?.model,
steps: usage?.steps,
};
+34 -2
View File
@@ -1,9 +1,10 @@
"use client";
import { useContext } from "react";
import { CheckIcon, XIcon } from "lucide-react";
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
import { JobsContext } from "@/components/jobs-context";
import type { JobKind, JobRecord } from "@/src/core";
import { ChatActionsContext } from "@/components/chat";
import { isModelsRefusal, type JobKind, type JobRecord } from "@/src/core";
/**
* The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call
@@ -35,6 +36,12 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
if (!job || job.status === "running") {
return <DispatchActivityCard job={job} label={label} />;
}
// 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 <DispatchRefusedCard label={label} />;
}
const failed = job.status === "error";
return (
<div
@@ -48,6 +55,31 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
);
}
/** The refused card: quiet info tone — nothing red, nothing "failed". */
function DispatchRefusedCard({ label }: { label: string }) {
const ctx = useContext(ChatActionsContext);
return (
<div
className="flex items-center gap-2 text-xs text-muted-foreground"
data-testid="dispatch-refused"
>
<SettingsIcon className="size-3.5" />
<span className="font-medium uppercase tracking-wide">{label}</span>
<span>needs model setup</span>
{ctx && (
<button
type="button"
data-testid="dispatch-setup-link"
onClick={ctx.openSetup}
className="ml-1 rounded-sm border border-border bg-card px-2 py-0.5 font-ui normal-case tracking-normal text-foreground transition-colors hover:border-muted-foreground/50"
>
Set up models
</button>
)}
</div>
);
}
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.
+134 -3
View File
@@ -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<void>;
/** 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<TeacherUIMessage>({
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<string | null>(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 (
<ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}>
<ChatActionsContext.Provider
value={{
chatId,
syncFromServer,
sendText: (t: string) => void sendMessage({ text: t }),
openSetup: () => setSetupOpen(true),
}}
>
<SessionMermaidContext.Provider value={{ openMermaid: setMermaidFocus }}>
<JobsContext.Provider value={jobs}>
{/* The session surface: the transcript column (header + scroller) and
@@ -967,6 +1054,20 @@ export function Chat({
onReserve={setDockReserve}
dictationSlot={<DictationBar dictation={dictation} />}
dictationCancel={dictation.cancel}
setupCta={
!configured && (
<div className="pointer-events-auto mb-2 flex justify-center">
<button
type="button"
data-testid="setup-cta"
onClick={() => setSetupOpen(true)}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
Set up your models
</button>
</div>
)
}
composer={
<Composer
// Stop applies only to a LOCAL stream: a server-side turn has no
@@ -1013,6 +1114,31 @@ export function Chat({
</div>
</JobsContext.Provider>
</SessionMermaidContext.Provider>
{/* 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). */}
<SetupDialog
open={setupOpen}
onClose={() => 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");
}}
/>
</ChatActionsContext.Provider>
);
}
@@ -1053,6 +1179,7 @@ function DockOverlay({
onOpenPlan,
dictationSlot,
dictationCancel,
setupCta,
}: {
pending: ReturnType<typeof findPendingToolCall>;
/** 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({
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div ref={blockRef} className="relative bg-background pb-5">
<div className={DOCK}>
{setupCta}
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
{pending ? (
+4 -3
View File
@@ -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<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: string }>;
/** 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<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: string } | null>;
}
/** Compose: one tool toggled, the rest of the section carried verbatim.
+40 -1
View File
@@ -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<ModelSlotName, { model: { provider: string; id: string } } | null>;
}
/** 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<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
): Partial<Record<ModelSlotName, ModelCatalogEntry>> {
const pre: Partial<Record<ModelSlotName, ModelCatalogEntry>> = {};
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 (
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
<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] && (
<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"
>
needed
</span>
)}
</span>
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
<div ref={openSlot === slot ? triggerRef : undefined} className="relative">
<button