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:
@@ -1,7 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Chat } from "@/components/chat";
|
import { Chat } from "@/components/chat";
|
||||||
import type { TeacherUIMessage } from "@/src/core";
|
import { slotsConfigured, type TeacherUIMessage } from "@/src/core";
|
||||||
import { getChat, isTurnRunning } from "@/src/server";
|
import { getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -15,12 +15,17 @@ export default async function ChatPage({
|
|||||||
const [{ id }, { prompt }] = await Promise.all([params, searchParams]);
|
const [{ id }, { prompt }] = await Promise.all([params, searchParams]);
|
||||||
const chat = getChat(id);
|
const chat = getChat(id);
|
||||||
if (!chat) notFound();
|
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 (
|
return (
|
||||||
<Chat
|
<Chat
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
chatId={chat.id}
|
chatId={chat.id}
|
||||||
title={chat.title}
|
title={chat.title}
|
||||||
initialMessages={chat.messages as TeacherUIMessage[]}
|
initialMessages={chat.messages as TeacherUIMessage[]}
|
||||||
|
initialConfigured={slotsConfigured(record?.models)}
|
||||||
initialPrompt={prompt}
|
initialPrompt={prompt}
|
||||||
// A teacher turn that outlived its client: the remounted view must
|
// A teacher turn that outlived its client: the remounted view must
|
||||||
// attach (busy shimmer + poll), never resurrect the stale record.
|
// attach (busy shimmer + poll), never resurrect the stale record.
|
||||||
|
|||||||
@@ -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 { ActivityIcon, BookOpenIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
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";
|
import { formatTokens, formatUsd } from "@/components/usage-format";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,7 +184,10 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
|||||||
startedAt: Date.parse(job.createdAt) || 0,
|
startedAt: Date.parse(job.createdAt) || 0,
|
||||||
cost: jobCost(job),
|
cost: jobCost(job),
|
||||||
context,
|
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,
|
model: usage?.model,
|
||||||
steps: usage?.steps,
|
steps: usage?.steps,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { CheckIcon, XIcon } from "lucide-react";
|
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
|
||||||
import { JobsContext } from "@/components/jobs-context";
|
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
|
* 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") {
|
if (!job || job.status === "running") {
|
||||||
return <DispatchActivityCard job={job} label={label} />;
|
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";
|
const failed = job.status === "error";
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }) {
|
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
|
||||||
// Elapsed from createdAt — jobs appear here only via the poll (post-mount),
|
// Elapsed from createdAt — jobs appear here only via the poll (post-mount),
|
||||||
// so no SSR/hydration mismatch.
|
// so no SSR/hydration mismatch.
|
||||||
|
|||||||
+134
-3
@@ -4,6 +4,8 @@ import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMe
|
|||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||||
import { createChatTransport } from "@/components/chat-transport";
|
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 { useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -24,7 +26,7 @@ import { JobsContext } from "@/components/jobs-context";
|
|||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import { GraphContextMarker } from "@/components/graph-context-marker";
|
import { GraphContextMarker } from "@/components/graph-context-marker";
|
||||||
import { useActivity } from "@/components/activity-dock";
|
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 { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
||||||
import { PlanMarginalia } from "@/components/plan-window";
|
import { PlanMarginalia } from "@/components/plan-window";
|
||||||
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
||||||
@@ -78,8 +80,30 @@ export const ChatActionsContext = createContext<{
|
|||||||
syncFromServer: () => Promise<void>;
|
syncFromServer: () => Promise<void>;
|
||||||
/** Send a user message into the session (demo error-fix loop, W6). */
|
/** Send a user message into the session (demo error-fix loop, W6). */
|
||||||
sendText: (text: string) => void;
|
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);
|
} | 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). */
|
/** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */
|
||||||
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
@@ -118,6 +142,7 @@ export function Chat({
|
|||||||
initialMessages,
|
initialMessages,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
initiallyInFlight,
|
initiallyInFlight,
|
||||||
|
initialConfigured = false,
|
||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -126,8 +151,12 @@ export function Chat({
|
|||||||
/** A teacher turn is running server-side but attached to no client
|
/** A teacher turn is running server-side but attached to no client
|
||||||
* (navigated away mid-turn, returned while in flight). */
|
* (navigated away mid-turn, returned while in flight). */
|
||||||
initiallyInFlight?: boolean;
|
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>({
|
useChat<TeacherUIMessage>({
|
||||||
id: chatId,
|
id: chatId,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
@@ -267,6 +296,55 @@ export function Chat({
|
|||||||
const streamingActivity = !pending ? streamingToolActivity(messages) : null;
|
const streamingActivity = !pending ? streamingToolActivity(messages) : null;
|
||||||
|
|
||||||
const [text, setText] = useState("");
|
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
|
// Voice dictation (STT wave, 2026-09-17): finals append at the end of
|
||||||
// the current text (the page owns the separator, dictate-flush style);
|
// the current text (the page owns the separator, dictate-flush style);
|
||||||
// interims ghost in the stats bar's middle slot next to the meter.
|
// interims ghost in the stats bar's middle slot next to the meter.
|
||||||
@@ -397,6 +475,7 @@ export function Chat({
|
|||||||
const sendVerdict = useCallback(
|
const sendVerdict = useCallback(
|
||||||
(accepted: boolean) => {
|
(accepted: boolean) => {
|
||||||
const note = text.trim();
|
const note = text.trim();
|
||||||
|
if (note) draftRef.current = note; // refusal flow: the note replays as text
|
||||||
setText("");
|
setText("");
|
||||||
// TWO REAL MESSAGES, ONE TURN (the custom-message scheme, user-decided
|
// 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
|
// 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 onSend = () => {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
if (!t || busy || pending) return;
|
if (!t || busy || pending) return;
|
||||||
|
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
||||||
setText("");
|
setText("");
|
||||||
void sendMessage({ text: t });
|
void sendMessage({ text: t });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }}>
|
<SessionMermaidContext.Provider value={{ openMermaid: setMermaidFocus }}>
|
||||||
<JobsContext.Provider value={jobs}>
|
<JobsContext.Provider value={jobs}>
|
||||||
{/* The session surface: the transcript column (header + scroller) and
|
{/* The session surface: the transcript column (header + scroller) and
|
||||||
@@ -967,6 +1054,20 @@ export function Chat({
|
|||||||
onReserve={setDockReserve}
|
onReserve={setDockReserve}
|
||||||
dictationSlot={<DictationBar dictation={dictation} />}
|
dictationSlot={<DictationBar dictation={dictation} />}
|
||||||
dictationCancel={dictation.cancel}
|
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={
|
||||||
<Composer
|
<Composer
|
||||||
// Stop applies only to a LOCAL stream: a server-side turn has no
|
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||||
@@ -1013,6 +1114,31 @@ export function Chat({
|
|||||||
</div>
|
</div>
|
||||||
</JobsContext.Provider>
|
</JobsContext.Provider>
|
||||||
</SessionMermaidContext.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>
|
</ChatActionsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1053,6 +1179,7 @@ function DockOverlay({
|
|||||||
onOpenPlan,
|
onOpenPlan,
|
||||||
dictationSlot,
|
dictationSlot,
|
||||||
dictationCancel,
|
dictationCancel,
|
||||||
|
setupCta,
|
||||||
}: {
|
}: {
|
||||||
pending: ReturnType<typeof findPendingToolCall>;
|
pending: ReturnType<typeof findPendingToolCall>;
|
||||||
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
|
/** 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
|
/** 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). */
|
* stats bar's; the stop belongs to the page-owned hook, threaded down). */
|
||||||
dictationCancel?: () => void;
|
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();
|
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
|
||||||
// Teach-phase mount (session reload with a pending check): the dock starts
|
// 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 className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||||
<div ref={blockRef} className="relative bg-background pb-5">
|
<div ref={blockRef} className="relative bg-background pb-5">
|
||||||
<div className={DOCK}>
|
<div className={DOCK}>
|
||||||
|
{setupCta}
|
||||||
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
<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"}>
|
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ interface SegmentInfo {
|
|||||||
/** The GET /api/models response fields this section consumes. */
|
/** The GET /api/models response fields this section consumes. */
|
||||||
interface ModelsBody {
|
interface ModelsBody {
|
||||||
models: ModelCatalogEntry[];
|
models: ModelCatalogEntry[];
|
||||||
/** The three tiers' RESOLVED pins (override → factory) — the honest
|
/** The three tiers' RESOLVED pins (BYOK: NULL per unset tier — no
|
||||||
* "what runs now" the Default rows and hints speak from. */
|
* factory floor) — machinery only (the thinking list fallback); the
|
||||||
slots: Record<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: string }>;
|
* 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.
|
/** Compose: one tool toggled, the rest of the section carried verbatim.
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
|
|||||||
interface ModelsBody {
|
interface ModelsBody {
|
||||||
models: ModelCatalogEntry[];
|
models: ModelCatalogEntry[];
|
||||||
providers: { id: string; keyPresent: boolean }[];
|
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
|
/** 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";
|
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({
|
export function SetupDialog({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -98,6 +122,11 @@ export function SetupDialog({
|
|||||||
.then((b: ModelsBody) => {
|
.then((b: ModelsBody) => {
|
||||||
setBody(b);
|
setBody(b);
|
||||||
setFailed(false);
|
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));
|
.catch(() => setFailed(true));
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -208,7 +237,17 @@ export function SetupDialog({
|
|||||||
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models;
|
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models;
|
||||||
return (
|
return (
|
||||||
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
<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>
|
<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>
|
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
||||||
<div ref={openSlot === slot ? triggerRef : undefined} className="relative">
|
<div ref={openSlot === slot ? triggerRef : undefined} className="relative">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ export function modelsNotConfigured(missing: ModelSlotName[]): ModelsNotConfigur
|
|||||||
missing,
|
missing,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The refusal error text (BYOK, 2026-09-22): job/turn surfaces compose the
|
||||||
|
* record wording around this prefix; UI consumers detect refused work by
|
||||||
|
* it (info tone, not scary-error) — the envelope's home module owns the
|
||||||
|
* string so the writer and the matcher can never drift. */
|
||||||
|
export function isModelsRefusal(error: string | undefined): boolean {
|
||||||
|
return !!error && error.startsWith(modelsNotConfigured([]).error);
|
||||||
|
}
|
||||||
/** The typed throw for in-process seams (extraction pipeline): callers
|
/** The typed throw for in-process seams (extraction pipeline): callers
|
||||||
* catch it to skip honestly (trigger) or surface the 503 envelope
|
* catch it to skip honestly (trigger) or surface the 503 envelope
|
||||||
* (routes) instead of logging a failure. */
|
* (routes) instead of logging a failure. */
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseModelsRefusal } from "../components/chat.tsx";
|
||||||
|
import { prefillFromSlots, setupStage } from "../components/setup-dialog.tsx";
|
||||||
|
import { isModelsRefusal, modelsNotConfigured } from "../src/core/settings.ts";
|
||||||
|
import type { ModelCatalogEntry, ModelSlotName } from "../src/core/index.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BYOK universal gate rendered honestly (owner, 2026-09-22): the server
|
||||||
|
* refuses ALL turns with 503 {error, code: "models_not_configured", missing}
|
||||||
|
* when a slot tier is unset. These pin the client's pure seams: the refusal
|
||||||
|
* detector (the transport throws the 503 body verbatim as the error text),
|
||||||
|
* the job-record refusal matcher, and the SetupDialog's prefill.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REFUSAL = JSON.stringify(modelsNotConfigured(["strong"]));
|
||||||
|
|
||||||
|
describe("parseModelsRefusal — the send refusal detector", () => {
|
||||||
|
it("reads the missing tiers from the envelope text", () => {
|
||||||
|
assert.deepEqual(parseModelsRefusal(REFUSAL), ["strong"]);
|
||||||
|
assert.deepEqual(parseModelsRefusal(JSON.stringify(modelsNotConfigured(["strong", "vision"]))), [
|
||||||
|
"strong",
|
||||||
|
"vision",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anything else is a real error — null, the honest banner stays", () => {
|
||||||
|
assert.equal(parseModelsRefusal(undefined), null);
|
||||||
|
assert.equal(parseModelsRefusal("Something went wrong"), null); // not JSON
|
||||||
|
assert.equal(parseModelsRefusal('{"error":"A turn is already running"}'), null); // JSON, wrong code
|
||||||
|
assert.equal(parseModelsRefusal('{"code":"models_not_configured","missing":"strong"}'), null); // missing not an array
|
||||||
|
assert.equal(parseModelsRefusal('{"code":"models_not_configured","missing":[42]}'), null); // tiers are strings
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isModelsRefusal — the job-record matcher", () => {
|
||||||
|
it("matches the runner's composed wording, not real failures", () => {
|
||||||
|
assert.equal(isModelsRefusal(`${modelsNotConfigured(["vision"]).error} (missing: vision)`), true);
|
||||||
|
assert.equal(isModelsRefusal(modelsNotConfigured(["cheap"]).error), true);
|
||||||
|
assert.equal(isModelsRefusal(undefined), false);
|
||||||
|
assert.equal(isModelsRefusal("FAILED: headless crashed"), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
|
||||||
|
const entry = (id: string, provider: string): ModelCatalogEntry => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
provider,
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
supportedLevels: ["low"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 1024,
|
||||||
|
});
|
||||||
|
const catalog = [entry("glm-5.3-flash", "z-ai"), entry("qwen3.8-flash", "qwen")];
|
||||||
|
const pins = (strong: boolean, vision: boolean) => ({
|
||||||
|
strong: strong ? { model: { provider: "z-ai", id: "glm-5.3-flash" } } : null,
|
||||||
|
cheap: null,
|
||||||
|
vision: vision ? { model: { provider: "qwen", id: "qwen3.8-flash" } } : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefills exactly the pinned tiers, matched by provider AND id", () => {
|
||||||
|
const pre = prefillFromSlots(catalog, pins(true, true));
|
||||||
|
assert.equal(pre.strong?.id, "glm-5.3-flash");
|
||||||
|
assert.equal(pre.vision?.id, "qwen3.8-flash");
|
||||||
|
assert.equal(pre.cheap, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing pinned (first-run) → nothing prefilled — all three marked needed", () => {
|
||||||
|
const pre = prefillFromSlots(catalog, pins(false, false));
|
||||||
|
assert.deepEqual(pre, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => {
|
||||||
|
const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null });
|
||||||
|
assert.deepEqual(pre, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provider collisions never cross-pollute (same id, different provider)", () => {
|
||||||
|
const both = [entry("shared-id", "openai"), entry("shared-id", "openrouter")];
|
||||||
|
const pre = prefillFromSlots(both, { strong: { model: { provider: "openrouter", id: "shared-id" } }, cheap: null, vision: null });
|
||||||
|
assert.equal(pre.strong?.provider, "openrouter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absent slots payload (older shape) prefills nothing", () => {
|
||||||
|
const pre = prefillFromSlots(catalog, undefined);
|
||||||
|
assert.deepEqual(pre, {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupStage — unchanged by the BYOK wave", () => {
|
||||||
|
it("keys first (owner ordering), pickers when a provider is keyed", () => {
|
||||||
|
assert.equal(setupStage(null), "keys");
|
||||||
|
assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys");
|
||||||
|
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,20 @@ describe("activity dock pure layer", () => {
|
|||||||
assert.equal(absent.cost, null);
|
assert.equal(absent.cost, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a models-refused job reads as info — 'needs model setup', not 'failed' (BYOK, 2026-09-22)", () => {
|
||||||
|
const refused = job({
|
||||||
|
status: "error",
|
||||||
|
activity: "failed",
|
||||||
|
error: "No model configured — set up your models first (missing: vision)",
|
||||||
|
});
|
||||||
|
const blob = toBlobs({ jobs: [refused], extractions: [] })[0]!;
|
||||||
|
assert.equal(blob.activity, "needs model setup");
|
||||||
|
// A real failure keeps the runner's honest wording.
|
||||||
|
const crashed = job({ status: "error", activity: "failed", error: "FAILED: headless crashed" });
|
||||||
|
const real = toBlobs({ jobs: [crashed], extractions: [] })[0]!;
|
||||||
|
assert.equal(real.activity, "failed");
|
||||||
|
});
|
||||||
|
|
||||||
it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
|
it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
|
||||||
const base = {
|
const base = {
|
||||||
model: "openrouter:z-ai/glm-5.3-flash",
|
model: "openrouter:z-ai/glm-5.3-flash",
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
||||||
import { QuestionDock } from "../components/question-dock.tsx";
|
import { QuestionDock } from "../components/question-dock.tsx";
|
||||||
import { ProbeDoneCard } from "../components/artifacts/probe-done-card.tsx";
|
import { ProbeDoneCard } from "../components/artifacts/probe-done-card.tsx";
|
||||||
|
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
|
||||||
|
import { JobsContext } from "../components/jobs-context.ts";
|
||||||
|
import { ChatActionsContext } from "../components/chat.tsx";
|
||||||
|
import type { JobRecord } from "../src/core/contracts.ts";
|
||||||
import { AnsweredCard } from "../components/answered-card.tsx";
|
import { AnsweredCard } from "../components/answered-card.tsx";
|
||||||
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||||
|
|
||||||
@@ -63,6 +67,54 @@ function renderDock(props: { toolName: "quiz" | "ask_user_question"; toolCallId:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
|
||||||
|
const job = (over: Partial<JobRecord>): JobRecord => ({
|
||||||
|
id: "j1",
|
||||||
|
kind: "research",
|
||||||
|
chatId: "c1",
|
||||||
|
toolCallId: "t1",
|
||||||
|
status: "error",
|
||||||
|
activity: "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const inJobs = (jobs: JobRecord[]) => (
|
||||||
|
<JobsContext.Provider value={jobs}>
|
||||||
|
<ChatActionsContext.Provider
|
||||||
|
value={{ chatId: "c1", syncFromServer: async () => {}, sendText: () => {}, openSetup: () => {} }}
|
||||||
|
>
|
||||||
|
<DispatchCard toolCallId="t1" kind="research" />
|
||||||
|
</ChatActionsContext.Provider>
|
||||||
|
</JobsContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
it("a models-refused job is INFO: quiet card, 'needs model setup', deep-link to the dialog", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
inJobs([job({ error: "No model configured — set up your models first (missing: cheap)" })]),
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('data-testid="dispatch-refused"'));
|
||||||
|
assert.ok(html.includes("needs model setup"));
|
||||||
|
assert.ok(html.includes('data-testid="dispatch-setup-link"'));
|
||||||
|
assert.ok(html.includes("Set up models"));
|
||||||
|
assert.ok(!html.includes("failed"));
|
||||||
|
assert.ok(!html.includes("text-red-500"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a real failure keeps the honest red failure", () => {
|
||||||
|
const html = renderToStaticMarkup(inJobs([job({ error: "FAILED: headless crashed" })]));
|
||||||
|
assert.ok(html.includes('data-testid="dispatch-settled"'));
|
||||||
|
assert.ok(html.includes("failed"));
|
||||||
|
assert.ok(html.includes("text-red-500"));
|
||||||
|
assert.ok(!html.includes("dispatch-refused"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a delivered job reads delivered, untouched by the wave", () => {
|
||||||
|
const html = renderToStaticMarkup(inJobs([job({ status: "done", error: undefined })]));
|
||||||
|
assert.ok(html.includes("delivered"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("render smoke tests", () => {
|
describe("render smoke tests", () => {
|
||||||
it("QuizCard renders question, options, and the automatic 'I don't know'", () => {
|
it("QuizCard renders question, options, and the automatic 'I don't know'", () => {
|
||||||
const html = renderDock({ toolName: "quiz", toolCallId: "q1", input: quizInput as never });
|
const html = renderDock({ toolName: "quiz", toolCallId: "q1", input: quizInput as never });
|
||||||
|
|||||||
Reference in New Issue
Block a user