Merge accepted local main into PR #184 working branch

This commit is contained in:
amosblomqvist
2026-10-27 18:12:09 +02:00
28 changed files with 1464 additions and 377 deletions
+8 -6
View File
@@ -10,7 +10,8 @@
* (server/settings-body.ts): a bad shape/type is a 400 carrying the * (server/settings-body.ts): a bad shape/type is a 400 carrying the
* supported list where one exists. The chat and models sections * supported list where one exists. The chat and models sections
* additionally pass the catalog gate (validateChatSettings / * additionally pass the catalog gate (validateChatSettings /
* validateModelsSettings — unknown provider/model = 400). Thinking * validateModelsSettings — unknown provider/model = 400, except unchanged
* model-slot selections may remain saved while unavailable). Thinking
* levels never reject a write (owner, interlude 2026-09-22): a pinned * levels never reject a write (owner, interlude 2026-09-22): a pinned
* level CLAMPS to the named model's nearest supported level and the * level CLAMPS to the named model's nearest supported level and the
* clamped value is stored — model swaps just work, same as the * clamped value is stored — model swaps just work, same as the
@@ -63,6 +64,8 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
if (!parsed.ok) { if (!parsed.ok) {
return Response.json({ error: parsed.error }, { status: 400 }); return Response.json({ error: parsed.error }, { status: 400 });
} }
// Compare against the same record that this whole-section write replaces.
const record = readSettingsRecord() ?? { version: 1 };
let agentHealed: AgentSettings | undefined; let agentHealed: AgentSettings | undefined;
// Section-specific cross-validation (against the catalog / the prompt // Section-specific cross-validation (against the catalog / the prompt
@@ -73,11 +76,11 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
} }
} }
// Model slots wave (2026-09-20): the models section gets the same // New/replaced slot models pass the catalog gate. Unchanged saved model
// catalog gate the chat section gets — a slot must name a real // references may survive disconnect, including thinking-preference edits,
// provider+model and a thinking level it supports. // so each unavailable slot can be repaired independently of the others.
if (section === "models") { if (section === "models") {
const verdict = validateModelsSettings(parsed.settings as ModelsSettings); const verdict = validateModelsSettings(parsed.settings as ModelsSettings, record.models);
if (!verdict.ok) { if (!verdict.ok) {
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
} }
@@ -124,7 +127,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
// Read-modify-write ONE section of the record; everything else rides // Read-modify-write ONE section of the record; everything else rides
// along untouched (other sections, unknown keys — sanitize preserves). // along untouched (other sections, unknown keys — sanitize preserves).
const record = readSettingsRecord() ?? { version: 1 };
const next = { ...record, [section]: sectionOut }; const next = { ...record, [section]: sectionOut };
writeSettingsRecord(next); writeSettingsRecord(next);
return Response.json({ settings: next }); return Response.json({ settings: next });
+68 -90
View File
@@ -5,7 +5,7 @@ import { useChatSession } from "@/components/session";
import { visibleQueuedSteers } from "@/components/session-core"; import { visibleQueuedSteers } from "@/components/session-core";
import { SetupDialog } from "@/components/setup-dialog"; import { SetupDialog } from "@/components/setup-dialog";
import { Hint } from "@/components/ui/hint"; 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 { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
import { import {
Conversation, Conversation,
@@ -26,6 +26,7 @@ import { Composer } from "@/components/composer";
import { useEnterSubmits } from "@/components/enter-submits"; import { useEnterSubmits } from "@/components/enter-submits";
import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { dockStateKey } from "@/components/dock-state-key"; import { dockStateKey } from "@/components/dock-state-key";
import { forwardDockWheel, useDockHeight } from "@/components/dock-height";
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
import { useFieldCycle } from "@/components/field-cycle"; import { useFieldCycle } from "@/components/field-cycle";
@@ -79,8 +80,6 @@ import {
} from "@/src/core"; } from "@/src/core";
import { ReadingThinkingField } from "@/components/reading-thinking"; import { ReadingThinkingField } from "@/components/reading-thinking";
import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block"; 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. */ /** One width for everything: transcript, cards, and the bottom blob share this column. */
/** /**
@@ -555,30 +554,18 @@ export function Chat({
textRef.current = v; textRef.current = v;
setText(v); setText(v);
}, []); }, []);
// The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the // Saved slot completeness is live, but the session's selected route
// refusal interception share one SetupDialog mount. `configured` is // comes from its own ordinary model control, never today's Strong pin.
// seeded server-side (slotsConfigured on the record) and flips on Save. const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
const [configured, setConfigured] = useState(initialConfigured); const catalogRef = useRef(catalog);
// The zero-keys truth (zero-keys wave): no provider key = the pill catalogRef.current = catalog;
// takes precedence over the models CTA (keys come first in the chain). const [selectedModel, setSelectedModel] = useState<string | undefined>();
// 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(() => {});
});
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
/** The last explicitly-sent draft (composer send): the /** The last explicitly-sent draft (composer send): the
* refusal flow restores it into the composer, where it waits for 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). * learner to re-send (Save never auto-sends, owner 2026-10-09).
* Auto-continuations (probe answers) never set it — their refusal * 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); const draftRef = useRef<string | null>(null);
// THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's // THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
@@ -594,8 +581,9 @@ export function Chat({
if (!error) return; if (!error) return;
const missing = parseModelsRefusal(error.message); const missing = parseModelsRefusal(error.message);
// The key-refusal twin (zero-keys wave): a pinned model whose // The key-refusal twin (zero-keys wave): a pinned model whose
// provider key is gone gets the SAME treatment — revert-draft and // provider key is gone also restores the draft. With another provider
// open setup (the dialog derives to its first-key stage on its own). // connected, repair belongs to the ordinary current-session picker;
// otherwise setup derives its exclusive provider/loading stage.
const keyRefused = parseProviderKeyRefusal(error.message); const keyRefused = parseProviderKeyRefusal(error.message);
if (!missing && !keyRefused) return; // a real error keeps the honest banner if (!missing && !keyRefused) return; // a real error keeps the honest banner
// ORDER MATTERS: clearError() flips this effect's own dep (`error`) // ORDER MATTERS: clearError() flips this effect's own dep (`error`)
@@ -615,7 +603,8 @@ export function Chat({
draftRef.current = null; draftRef.current = null;
} }
clearError(); clearError();
setSetupOpen(true); if (keyRefused && catalogRef.current?.providers.some((p) => p.keyPresent)) openModelPicker();
else setSetupOpen(true);
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
@@ -1152,6 +1141,14 @@ export function Chat({
if (busy || pending || compacting) return; if (busy || pending || compacting) return;
const t = textRef.current.trim(); const t = textRef.current.trim();
if (!t) return; 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 draftRef.current = t; // the refusal flow's preserve-and-replay seam
applyText(""); applyText("");
void send({ type: "text", text: t }); void send({ type: "text", text: t });
@@ -1616,6 +1613,7 @@ export function Chat({
compactionRunning={compacting} compactionRunning={compacting}
onSubmitThinking={submitThinking} onSubmitThinking={submitThinking}
chatId={chatId} chatId={chatId}
onModelChange={setSelectedModel}
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined} plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
onOpenPlan={() => setPlanOpen(true)} onOpenPlan={() => setPlanOpen(true)}
requiresReveal={requiresReveal} requiresReveal={requiresReveal}
@@ -1719,7 +1717,6 @@ export function Chat({
onClose={() => setSetupOpen(false)} onClose={() => setSetupOpen(false)}
onDone={() => { onDone={() => {
setSetupOpen(false); setSetupOpen(false);
setConfigured(true);
// Save only saves (owner ruling 2026-10-09): the draft went // Save only saves (owner ruling 2026-10-09): the draft went
// home into the composer at refusal time and STAYS there — // home into the composer at refusal time and STAYS there —
// the learner sends it themselves. // the learner sends it themselves.
@@ -1854,6 +1851,7 @@ function DockOverlay({
composer, composer,
onSubmitAnswer, onSubmitAnswer,
chatId, chatId,
onModelChange,
onReserve, onReserve,
plan, plan,
onOpenPlan, onOpenPlan,
@@ -1890,6 +1888,7 @@ function DockOverlay({
onSubmitAnswer: (output: QuestionAnswerData) => void; onSubmitAnswer: (output: QuestionAnswerData) => void;
/** The model control's pass-through (the stats bar hosts it). */ /** The model control's pass-through (the stats bar hosts it). */
chatId: string; chatId: string;
onModelChange: (model: string | undefined) => void;
/** Reports the dock's settled height (block + gradient) — ChatView's /** Reports the dock's settled height (block + gradient) — ChatView's
* dockReserve state feeds the transcript's spacer from it. */ * dockReserve state feeds the transcript's spacer from it. */
onReserve: (h: number) => void; onReserve: (h: number) => void;
@@ -1984,6 +1983,17 @@ function DockOverlay({
const revealedRef = useRef(revealed); const revealedRef = useRef(revealed);
revealedRef.current = revealed; revealedRef.current = revealed;
const blockRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const mode = pending
? `question:${pending.toolCallId}`
: practice
? `practice:${practice.toolCallId}`
: "chat";
// The plan marginalia's flank width (owner, plan-placement round): from // The plan marginalia's flank width (owner, plan-placement round): from
// the DOCK container's right edge to the block's right edge — the bare // the DOCK container's right edge to the block's right edge — the bare
// diagram's canvas. Tracked LIVE by a ResizeObserver on the block // diagram's canvas. Tracked LIVE by a ResizeObserver on the block
@@ -2022,28 +2032,33 @@ function DockOverlay({
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
// The reserve grows immediately, but never shrinks while a question or
// practice problem owns the dock: shrinking flow space could pin a nearby
// learner back to the bottom. Composer mode may give space back.
const reserveRef = useRef(0);
const offer = useCallback((blockH: number) => {
const next = blockH + GRADIENT_H;
if (next <= reserveRef.current + 0.5 && pendingRef.current) return;
// Stamp growth at the source, including child-only field resizes, so
// stick-to-bottom's catch-up is not mistaken for the learner leaving.
if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now();
reserveRef.current = next;
onReserve(next);
}, [onReserve]);
// The overlay must not paint over the scroller's scrollbar (owner, scroll // The overlay must not paint over the scroller's scrollbar (owner, scroll
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter: // round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
// stable both-edges), so inset the overlay's right edge by the gutter — // stable both-edges), so inset the overlay's right edge by the gutter —
// zero on macOS overlay scrollbars, the real width on classic platforms. // zero on macOS overlay scrollbars, the real width on classic platforms.
// The same measure owns the QUESTION/PROBLEM HEIGHT CAP (owner round, // Budget the whole solid dock, leaving only its question area flexible.
// 2026-10-05): the old fixed fractions (55dvh / 40vh) cut long problems // The hook measures natural, visible content before the morph/reserve
// off absurdly short — the cap is now the AVAILABLE COLUMN (the // effect below. Short windows scroll the reading/question/answer body;
// scroller already starts below the top bar) minus a 2rem breathing // the stats/model toolbar remains outside that overflow viewport.
// gap, carried as --dock-max-h for the docks' scroll areas.
const [sbInset, setSbInset] = useState(0); const [sbInset, setSbInset] = useState(0);
const [dockMaxH, setDockMaxH] = useState<number | null>(null); useDockHeight({
useEffect(() => { scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
const el = scrollRef.current; expanded, enabled: !!pending || !!practice, mode, onScrollbarInset: setSbInset, onHeight: offer,
if (!el) return; });
const measure = () => {
setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2));
setDockMaxH(Math.max(el.clientHeight - 32, 160));
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [scrollRef]);
// GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a // GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a
// live turn the transcript content grows and stick-to-bottom chases it; // live turn the transcript content grows and stick-to-bottom chases it;
@@ -2079,48 +2094,11 @@ function DockOverlay({
const root = blockRef.current; const root = blockRef.current;
const el = scrollRef.current; const el = scrollRef.current;
if (!root || !el) return; if (!root || !el) return;
const canScroll = (n: HTMLElement, dir: number) => { const onWheel = (e: WheelEvent) => forwardDockWheel(e, root, el);
if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable
return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5;
};
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native
}
e.preventDefault();
el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1);
};
root.addEventListener("wheel", onWheel, { passive: false }); root.addEventListener("wheel", onWheel, { passive: false });
return () => root.removeEventListener("wheel", onWheel); return () => root.removeEventListener("wheel", onWheel);
}, [scrollRef]); }, [scrollRef]);
// ── The reserve: the spacer's height, reported upward ──
// Grows immediately (typing, new variants). NEVER drops while a question
// is open — a shrinking reserved tail is scroll geometry and the browser
// clamp would pin a nearby learner to the bottom (the old oscillation).
// In chat mode drops apply (the composer's typing shrink is today's
// harmless in-flow behavior; nothing auto-expands there).
const reserveRef = useRef(0);
const blockRef = useRef<HTMLDivElement>(null);
const offer = (blockH: number) => {
const next = blockH + GRADIENT_H;
if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity
// Reserve GROWTH is the phantom-scroll SOURCE (flicker round,
// 2026-09-26): the spacer's height lands in this commit, the smooth
// catch-up lags it by the growth amount, and the position machine
// used to read that lag as the learner leaving — form → bar → form
// (the dictation flush, reproduced d≈152 > COLLAPSE_DIST). Stamp the
// mute right where the spacer grows: it covers the scroll events of
// THIS growth no matter how long after the field stretched (the
// field grows on its own render; the offer lands on the NEXT
// DockOverlay render — ~1s apart live). Shrinks never stamp (the
// fold's own give-back must not self-mute the re-expand).
if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now();
reserveRef.current = next;
onReserve(next);
};
// Explicit expand (bar click): the smooth scroll to the bottom takes // Explicit expand (bar click): the smooth scroll to the bottom takes
// hundreds of ms, during which the position is still legitimately inside // hundreds of ms, during which the position is still legitimately inside
// the collapse zone — the machine would instantly re-collapse the dock // the collapse zone — the machine would instantly re-collapse the dock
@@ -2222,11 +2200,6 @@ function DockOverlay({
if (b) offer(b.getBoundingClientRect().height); if (b) offer(b.getBoundingClientRect().height);
}; };
const mode = pending
? `question:${pending.toolCallId}`
: practice
? `practice:${practice.toolCallId}`
: "chat";
// The opening variant of a session appears instantly — the fade/rise is // The opening variant of a session appears instantly — the fade/rise is
// reserved for mid-session transitions. // reserved for mid-session transitions.
const initialModeRef = useRef(mode); const initialModeRef = useRef(mode);
@@ -2234,11 +2207,9 @@ function DockOverlay({
// Height morph: animate the shell from its previous height to its new one // Height morph: animate the shell from its previous height to its new one
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
// growth keeps the same stateKey and is NOT animated. // growth keeps the same stateKey and is NOT animated.
const shellRef = useRef<HTMLDivElement>(null);
// The fold's CONTENT fade (owner round, 2026-10-05): the shell height // The fold's CONTENT fade (owner round, 2026-10-05): the shell height
// morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise // morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise
// on the toggle — no remount, so the question form's state survives. // on the toggle — no remount, so the question form's state survives.
const contentRef = useRef<HTMLDivElement>(null);
const prevExpandedForFadeRef = useRef(expanded); const prevExpandedForFadeRef = useRef(expanded);
useIsoLayoutEffect(() => { useIsoLayoutEffect(() => {
if (prevExpandedForFadeRef.current === expanded) return; if (prevExpandedForFadeRef.current === expanded) return;
@@ -2286,8 +2257,10 @@ function DockOverlay({
return ( return (
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}> <div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
<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" style={{ "--dock-max-h": dockMaxH ? `${dockMaxH}px` : undefined } as React.CSSProperties}> <div ref={blockRef} className="relative bg-background pb-5">
<div className={DOCK}> <div className={DOCK}>
<div ref={viewportRef} data-dock-viewport="" className="pointer-events-auto">
<div ref={bodyRef}>
{setupCta} {setupCta}
{/* The queued-steer indicator (owner spec 2026-10-03): quiet {/* The queued-steer indicator (owner spec 2026-10-03): quiet
one-liners above the shell - right-aligned to where its corner one-liners above the shell - right-aligned to where its corner
@@ -2316,10 +2289,12 @@ function DockOverlay({
/> />
)} )}
<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">
{/* Include descendant margins (notably practice thinking's
bottom margin) in the natural content measurement. */}
<div <div
ref={contentRef} ref={contentRef}
key={mode} key={mode}
className={mode === initialModeRef.current ? undefined : "dock-variant"} className={mode === initialModeRef.current ? "flow-root" : "flow-root dock-variant"}
> >
{pending ? ( {pending ? (
<QuestionDock <QuestionDock
@@ -2355,6 +2330,8 @@ function DockOverlay({
)} )}
</div> </div>
</div> </div>
</div>
</div>
{/* The stats row: beneath the composer/question shell, not {/* The stats row: beneath the composer/question shell, not
flush to the screen bottom — static height, outside the flush to the screen bottom — static height, outside the
morphing shell so it never joins a height animation. */} morphing shell so it never joins a height animation. */}
@@ -2364,6 +2341,7 @@ function DockOverlay({
pricing={pricing} pricing={pricing}
dictationSlot={dictationSlot} dictationSlot={dictationSlot}
chatId={chatId} chatId={chatId}
onModelChange={onModelChange}
/> />
{/* The scroll-down disc (moved here from the Conversation, {/* The scroll-down disc (moved here from the Conversation,
owner refinement round 2026-09-24): anchored to the BLOCK's owner refinement round 2026-09-24): anchored to the BLOCK's
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, type RefObject } from "react";
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export interface DockHeightMeasurements {
columnHeight: number;
/** Stats/toolbar and block padding, outside the scrolling viewport. */
outsideHeight: number;
/** Everything in the viewport except the animated shell. */
beforeShellHeight: number;
shellBorderHeight: number;
/** Natural content height, not the shell's animated/clamped height. */
contentHeight: number;
/** Null when collapsed, in chat mode, or showing malformed input. */
questionHeight: number | null;
questionPaddingHeight: number;
}
export function dockHeightBudget(m: DockHeightMeasurements) {
const available = Math.max(m.columnHeight - 32, 0);
const viewportMaxHeight = Math.max(available - m.outsideHeight, 0);
const shellMaxHeight = Math.max(viewportMaxHeight - m.beforeShellHeight, 0);
const naturalHeight = m.beforeShellHeight + m.shellBorderHeight + m.contentHeight;
const questionMaxHeight = m.questionHeight === null
? null
: Math.max(viewportMaxHeight - (naturalHeight - m.questionHeight), 0);
// A cap smaller than the question's padding leaves no content viewport.
// Deliberately uncap the question and scroll the whole content instead;
// do not confuse a measured zero with an unmeasured CSS fallback.
const scrollWholeContent = questionMaxHeight === null
? naturalHeight > viewportMaxHeight
: questionMaxHeight <= m.questionPaddingHeight;
return { viewportMaxHeight, shellMaxHeight, questionMaxHeight, scrollWholeContent };
}
interface DockHeightElements {
column: HTMLElement;
block: HTMLElement;
viewport: HTMLElement;
body: HTMLElement;
shell: HTMLElement;
content: HTMLElement;
}
const pixels = (value: string) => Number.parseFloat(value) || 0;
/** Read natural geometry before writing any constraints. Cancel the shell's
* animated height out of both ancestor measurements, then substitute its
* natural content and borders. This stays valid during a WAAPI height morph
* and when the outer viewport is already capped. */
export function syncDockHeight(elements: DockHeightElements, expanded: boolean) {
const { column, block, viewport, body, shell, content } = elements;
// QuestionDock keeps a laid-out form in an invisible h-0 wrapper;
// PracticeDock removes its form. Neither hidden form is a height input.
const question = expanded ? content.querySelector<HTMLElement>("[data-dock-scroll-area]") : null;
const height = (el: HTMLElement) => el.getBoundingClientRect().height;
const shellStyle = getComputedStyle(shell);
const questionStyle = question ? getComputedStyle(question) : null;
const budget = dockHeightBudget({
columnHeight: column.clientHeight,
outsideHeight: Math.max(height(block) - height(viewport), 0),
beforeShellHeight: Math.max(height(body) - height(shell), 0),
shellBorderHeight: pixels(shellStyle.borderTopWidth) + pixels(shellStyle.borderBottomWidth),
contentHeight: height(content),
questionHeight: question ? height(question) : null,
questionPaddingHeight: questionStyle
? pixels(questionStyle.paddingTop) + pixels(questionStyle.paddingBottom)
: 0,
});
if (budget.questionMaxHeight === null) block.style.removeProperty("--dock-max-h");
else block.style.setProperty("--dock-max-h", budget.scrollWholeContent ? "none" : `${budget.questionMaxHeight}px`);
viewport.style.maxHeight = `${budget.viewportMaxHeight}px`;
// Overflow is visible in normal layouts: keep the shell's shadow intact.
// The stats/model toolbar is a sibling, never inside this overflow box.
viewport.style.overflowY = budget.scrollWholeContent ? "auto" : "visible";
// Clamp animated starting heights too, so a previous tall variant cannot
// paint above the column while morphing into a constrained layout.
shell.style.maxHeight = budget.scrollWholeContent ? "none" : `${budget.shellMaxHeight}px`;
return budget;
}
interface DockHeightRefs {
scrollRef: RefObject<HTMLElement | null>;
blockRef: RefObject<HTMLDivElement | null>;
viewportRef: RefObject<HTMLDivElement | null>;
bodyRef: RefObject<HTMLDivElement | null>;
shellRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
}
export function useDockHeight({
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
expanded, enabled, mode, onScrollbarInset, onHeight,
}: DockHeightRefs & {
expanded: boolean;
enabled: boolean;
/** Reconnect observers when the keyed content node/scroll area changes. */
mode: string;
onScrollbarInset: (inset: number) => void;
/** Settled visible block height, fed through the existing reserve policy. */
onHeight: (height: number) => void;
}) {
const measure = useCallback(() => {
const column = scrollRef.current;
const block = blockRef.current;
const viewport = viewportRef.current;
const body = bodyRef.current;
const shell = shellRef.current;
const content = contentRef.current;
if (!column || !block || !viewport || !body || !shell || !content) return;
onScrollbarInset(Math.round((column.offsetWidth - column.clientWidth) / 2));
if (enabled) syncDockHeight({ column, block, viewport, body, shell, content }, expanded);
else {
block.style.removeProperty("--dock-max-h");
viewport.style.removeProperty("max-height");
viewport.style.removeProperty("overflow-y");
shell.style.removeProperty("max-height");
}
// ResizeObserver runs even when only a child field changed and React
// did not rerender DockOverlay. Keep its transcript reserve current,
// but never report a mid-morph height.
if (shell.getAnimations().length === 0) onHeight(block.getBoundingClientRect().height);
}, [scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, expanded, enabled, onScrollbarInset, onHeight]);
// Imperative constraints land before the overlay's later layout effect
// measures its morph target/reserve, and before paint (including reveal).
useIsoLayoutEffect(measure);
useIsoLayoutEffect(() => {
const observer = new ResizeObserver(measure);
for (const el of [scrollRef.current, blockRef.current, viewportRef.current, bodyRef.current, contentRef.current]) {
if (el) observer.observe(el);
}
const question = expanded ? contentRef.current?.querySelector("[data-dock-scroll-area]") : null;
if (question) observer.observe(question);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [measure, mode, expanded, scrollRef, blockRef, viewportRef, bodyRef, contentRef]);
}
/** Overlay and transcript are siblings. Keep native scrolling in an actual
* scroll container (including the short-window viewport), then forward only
* at its edge. overflow:hidden/visible boxes cannot consume the wheel. */
export function forwardDockWheel(event: WheelEvent, root: HTMLElement, transcript: HTMLElement) {
if (event.deltaY === 0) return;
for (let n = event.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
if (!(n instanceof HTMLElement) || !/^(auto|scroll)$/.test(getComputedStyle(n).overflowY)) continue;
if (n.scrollHeight - n.clientHeight < 2) continue;
if (event.deltaY < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5) return;
}
event.preventDefault();
transcript.scrollTop += event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? transcript.clientHeight : 1);
}
+12 -15
View File
@@ -7,7 +7,7 @@ import { GraphView } from "@/components/graph-view";
import { MinimalHome } from "@/components/minimal-home"; import { MinimalHome } from "@/components/minimal-home";
import { SetupDialog } from "@/components/setup-dialog"; import { SetupDialog } from "@/components/setup-dialog";
import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
import { useFieldCycle } from "@/components/field-cycle"; import { useFieldCycle } from "@/components/field-cycle";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
@@ -41,15 +41,8 @@ export function Home({
// start until the user has picked all three slots — no defaults for // start until the user has picked all three slots — no defaults for
// 33 providers. The dock shows the CTA while unconfigured; submitting // 33 providers. The dock shows the CTA while unconfigured; submitting
// a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save // a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
// stores the slots and the queued message sends). // stores slots only; the learner sends the message themselves).
const [configured, setConfigured] = useState(initialConfigured); const { catalog, configured, zeroKeys } = useModelSetup(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).
const zeroKeys = useZeroKeys();
// The strong slot's keyless truth (pre-send key guards, owner interlude
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
// provider has none — the same skip-everything treatment on send.
const strongKeyless = useStrongKeyless();
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the // Voice dictation (STT wave, 2026-09-17): finals append at the end of the
// topic draft; the strip lives in the stats bar's middle slot. // topic draft; the strip lives in the stats bar's middle slot.
@@ -123,13 +116,18 @@ export function Home({
// models case guards BEFORE creation; the key cases must too — a // models case guards BEFORE creation; the key cases must too — a
// refused first send would otherwise leave an orphan error-shell // refused first send would otherwise leave an orphan error-shell
// chat in the sidebar (the 503 lands only after POST /api/chats // chat in the sidebar (the 503 lands only after POST /api/chats
// already created the record). Same skip-everything-and-open-setup // already created the record). Zero providers opens connection setup;
// treatment; the server's 503 gate remains the belt for /chat/[id] // an unavailable selection with another provider opens its ordinary
// sessions. // picker. The server's admission gate remains the belt.
if (zeroKeys || strongKeyless) { if (zeroKeys) {
setSetupOpen(true); setSetupOpen(true);
return; return;
} }
const strong = catalog?.slots.strong?.model;
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
openModelPicker();
return; // Preserve the draft and never create an orphan session.
}
setBusy(true); setBusy(true);
try { try {
// The map context (owner, 2026-10-04): picks in EITHER mode ride // The map context (owner, 2026-10-04): picks in EITHER mode ride
@@ -326,7 +324,6 @@ export function Home({
open={setupOpen} open={setupOpen}
onClose={() => setSetupOpen(false)} onClose={() => setSetupOpen(false)}
onDone={() => { onDone={() => {
setConfigured(true);
setSetupOpen(false); setSetupOpen(false);
// Save only saves: the draft waits in the composer. // Save only saves: the draft waits in the composer.
}} }}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { ModelCatalogResponse } from "@/src/core";
import { onSettingsSaved } from "@/components/settings-signal";
// Shared stale-while-revalidate catalog. Metadata refreshes retain the last
// good snapshot; connection changes clear selectable models immediately.
let catalogCache: ModelCatalogResponse | undefined;
let catalogError = false;
let catalogInflight: Promise<void> | undefined;
let catalogGeneration = 0;
let stopCatalogSignals: (() => void) | undefined;
const catalogReaders = new Set<() => void>();
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
function fetchCatalog(revalidate = false): void {
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
if (catalogInflight) return;
const generation = catalogGeneration;
catalogError = false;
publishCatalog();
catalogInflight = fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
.then((c) => {
if (generation === catalogGeneration) { catalogCache = c; catalogError = false; publishCatalog(); }
})
.catch(() => {
if (generation === catalogGeneration) { catalogError = true; publishCatalog(); }
})
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
}
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; failed: boolean; refresh: () => void } {
const [snapshot, setSnapshot] = useState(() => ({ catalog: catalogCache, failed: catalogError }));
useEffect(() => {
const first = catalogReaders.size === 0;
const read = () => setSnapshot({ catalog: catalogCache, failed: catalogError });
catalogReaders.add(read);
if (first) {
stopCatalogSignals = onSettingsSaved((section) => {
if (section === "keys") {
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
fetchCatalog();
} else if (section === "models") fetchCatalog(true);
});
fetchCatalog(true);
} else fetchCatalog();
read();
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
}, []);
const refresh = useCallback(() => fetchCatalog(true), []);
return { ...snapshot, refresh };
}
+19 -59
View File
@@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
import { composeSlotModel } from "@/components/settings/agent-section"; import { composeSlotModel } from "@/components/settings/agent-section";
import { MenuShell } from "@/components/menu-shell"; import { MenuShell } from "@/components/menu-shell";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { useModelCatalog } from "@/components/model-catalog";
export { useModelCatalog } from "@/components/model-catalog";
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
/** GET /api/models is cached per page load for instant menu opens, but
* the federation is LIVE per call — keys saved mid-session widen the
* catalog immediately server-side. ModelControl therefore re-fetches on
* every model-menu open (stale-while-revalidate: the cached list shows
* instantly, the fresh one swaps in while the menu is open). */
let catalogCache: ModelCatalogResponse | undefined;
let catalogInflight: Promise<void> | undefined;
let catalogGeneration = 0;
let stopCatalogSignals: (() => void) | undefined;
const catalogReaders = new Set<() => void>();
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
function fetchCatalog(revalidate = false): void {
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
if (catalogInflight) return;
const generation = catalogGeneration;
catalogInflight = fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
.then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } })
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
}
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
const [catalog, setCatalog] = useState(catalogCache);
useEffect(() => {
const first = catalogReaders.size === 0;
const read = () => setCatalog(catalogCache);
catalogReaders.add(read);
if (first) {
stopCatalogSignals = onSettingsSaved((section) => {
if (section === "keys") {
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
publishCatalog(); fetchCatalog();
} else if (section === "models") fetchCatalog(true);
});
fetchCatalog(true);
} else fetchCatalog();
read();
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
}, []);
const refresh = useCallback(() => fetchCatalog(true), []);
return { catalog, refresh };
}
/** provider:id → contextWindow over the cached catalog — the client-side /** provider:id → contextWindow over the cached catalog — the client-side
* twin of the server's catalogContextLimits join (context-meter * twin of the server's catalogContextLimits join (context-meter
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit * amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
@@ -84,7 +43,9 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) { export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
const { catalog, refresh } = useModelCatalog(); const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState<ModelsSettings | undefined>(); const [models, setModels] = useState<ModelsSettings | undefined>();
const [session, setSession] = useState<ChatSettings | undefined>(); // A successful read with no settings is a legacy session; undefined
// means no session record is known yet, not permission to inherit Strong.
const [session, setSession] = useState<{ settings?: ChatSettings }>();
const [open, setOpen] = useState<"model" | "thinking" | null>(null); const [open, setOpen] = useState<"model" | "thinking" | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -99,8 +60,8 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
const readRecord = useCallback(() => { const readRecord = useCallback(() => {
if (chatId) { if (chatId) {
fetch(`/api/chats/${chatId}`) fetch(`/api/chats/${chatId}`)
.then((r) => r.json() as Promise<{ settings?: ChatSettings }>) .then((r) => { if (!r.ok) throw new Error(`session read failed: ${r.status}`); return r.json() as Promise<{ settings?: ChatSettings }>; })
.then((body) => setSession(body.settings)) .then((body) => setSession({ settings: body.settings }))
.catch(() => { .catch(() => {
/* same read-only posture as a models-section failure */ /* same read-only posture as a models-section failure */
}); });
@@ -124,15 +85,17 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
}); });
// Dual-bound truth: chat mode displays the session's own record, // Dual-bound truth: chat mode displays the session's own record,
// falling back to the strong slot for legacy unstamped sessions — // falling back to Strong only for a successfully read legacy unstamped
// EXACTLY what resolveConversationSpec runs. Draft mode is the strong // session — EXACTLY what resolveConversationSpec runs. An unresolved or
// slot itself. // failed record read must not report a provisional global preference as
// this session's identity (including to the Send guard). Draft mode is
// the strong slot itself.
const strong = models?.strong; const strong = models?.strong;
const override: ChatSettings | undefined = chatId const override: ChatSettings | undefined = chatId
? { ? session ? {
model: session?.model ?? strong?.model, model: session.settings?.model ?? strong?.model,
thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel, thinkingLevel: session.settings?.thinkingLevel ?? strong?.thinkingLevel,
} } : undefined
: strong; : strong;
const effectiveId = override?.model?.id; const effectiveId = override?.model?.id;
const effectiveProvider = override?.model?.provider; const effectiveProvider = override?.model?.provider;
@@ -236,7 +199,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
return; return;
} }
const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null; const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null;
setSession(stored?.settings ?? next); setSession({ settings: stored?.settings ?? next });
setOpen(null); setOpen(null);
} catch { } catch {
setError("Could not reach the server — try again."); setError("Could not reach the server — try again.");
@@ -315,10 +278,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
return ( return (
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control"> <div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
<Hint <Hint
// Trimmed (owner, subtlety round 2026-09-25): "Model selector" label={effectiveId ? `${effectiveProvider ? `${effectiveProvider}:` : ""}${effectiveId}` : "Model selector"}
// — the long sentence was way too much. ⌘K opens this same
// menu (registry, override-aware).
label="Model selector"
action="open-model-picker" action="open-model-picker"
side="bottom" side="bottom"
> >
+12 -14
View File
@@ -8,7 +8,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio
import { useFieldCycle } from "@/components/field-cycle"; import { useFieldCycle } from "@/components/field-cycle";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
import { SetupDialog } from "@/components/setup-dialog"; import { SetupDialog } from "@/components/setup-dialog";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
import { StatsBar } from "@/components/stats-bar"; import { StatsBar } from "@/components/stats-bar";
import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { ChainTableOverlay } from "@/components/chain-table.tsx";
import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
@@ -275,14 +275,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
// 33 providers, chats cannot start. The draft stays in the textarea; // 33 providers, chats cannot start. The draft stays in the textarea;
// Save stores the slots and never sends (owner ruling 2026-10-09) — // Save stores the slots and never sends (owner ruling 2026-10-09) —
// the learner sends the draft themselves. // the learner sends the draft themselves.
const [configured, setConfigured] = useState(initialConfigured); const { catalog, configured, zeroKeys } = useModelSetup(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).
const zeroKeys = useZeroKeys();
// The strong slot's keyless truth (pre-send key guards, owner interlude
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
// provider has none — the same skip-everything treatment on send.
const strongKeyless = useStrongKeyless();
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
const send = useCallback(() => { const send = useCallback(() => {
@@ -295,12 +288,18 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
// The pre-send KEY guards (owner interlude, no-orphan-chats): guard // The pre-send KEY guards (owner interlude, no-orphan-chats): guard
// BEFORE startDraftSession creates the record — a refused first // BEFORE startDraftSession creates the record — a refused first
// send would otherwise leave an orphan error-shell chat in the // send would otherwise leave an orphan error-shell chat in the
// sidebar. Same skip-everything-and-open-setup treatment; the // sidebar. Zero providers opens connection setup; an unavailable
// server's 503 gate remains the belt for /chat/[id] sessions. // selection with another provider opens the ordinary Strong picker.
if (zeroKeys || strongKeyless) { // The server's admission gate remains the belt.
if (zeroKeys) {
setSetupOpen(true); setSetupOpen(true);
return; return;
} }
const strong = catalog?.slots.strong?.model;
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
openModelPicker();
return; // The learner replaces it explicitly, then sends again.
}
setBusy(true); setBusy(true);
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined) void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
.then((target) => { .then((target) => {
@@ -308,7 +307,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
else setBusy(false); else setBusy(false);
}) })
.catch(() => setBusy(false)); .catch(() => setBusy(false));
}, [busy, router, configured, zeroKeys, strongKeyless]); }, [busy, router, configured, zeroKeys, catalog]);
return ( return (
<> <>
@@ -369,7 +368,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
open={setupOpen} open={setupOpen}
onClose={() => setSetupOpen(false)} onClose={() => setSetupOpen(false)}
onDone={() => { onDone={() => {
setConfigured(true);
setSetupOpen(false); setSetupOpen(false);
// Save only saves (owner ruling 2026-10-09): the draft // Save only saves (owner ruling 2026-10-09): the draft
// waits in the composer; the learner sends it. // waits in the composer; the learner sends it.
+1 -1
View File
@@ -105,7 +105,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
cards' label row — owner round, 2026-10-05) + the statement, cards' label row — owner round, 2026-10-05) + the statement,
markdown + LaTeX rendered, capped by the measured column markdown + LaTeX rendered, capped by the measured column
(--dock-max-h; the old fixed 40vh cut long problems short). */} (--dock-max-h; the old fixed 40vh cut long problems short). */}
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2"> <div data-dock-scroll-area="" className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div> <div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]"> <div className="lh-prose text-[15px]">
<ToolContentResponse>{input.statement}</ToolContentResponse> <ToolContentResponse>{input.statement}</ToolContentResponse>
+1 -1
View File
@@ -564,7 +564,7 @@ function QuestionDockForm({
{/* Question area — scrolls internally if very tall; the bottom row stays put. {/* Question area — scrolls internally if very tall; the bottom row stays put.
Capped by the measured column (--dock-max-h; the old fixed 55dvh cut Capped by the measured column (--dock-max-h; the old fixed 55dvh cut
long questions short — owner round, 2026-10-05). */} long questions short — owner round, 2026-10-05). */}
<div data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3"> <div data-dock-scroll-area="" data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{toolName === "probe" {toolName === "probe"
? "Probe" ? "Probe"
+13 -39
View File
@@ -55,6 +55,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { MenuShell } from "@/components/menu-shell"; import { MenuShell } from "@/components/menu-shell";
import { useSettingsSaved } from "@/components/settings-signal"; import { useSettingsSaved } from "@/components/settings-signal";
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default"; import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
import { useModelCatalog } from "@/components/model-catalog";
import { Hint } from "@/components/ui/hint";
import type React from "react"; import type React from "react";
import { import {
COMPACTION_THRESHOLD_FLOOR, COMPACTION_THRESHOLD_FLOOR,
@@ -63,7 +65,6 @@ import {
type AgentSettings, type AgentSettings,
type CompactionSettings, type CompactionSettings,
type GraphSettings, type GraphSettings,
type ModelCatalogEntry,
type ModelSlotName, type ModelSlotName,
type ModelsSettings, type ModelsSettings,
} from "@/src/core"; } from "@/src/core";
@@ -101,15 +102,6 @@ interface SegmentInfo {
shared: boolean; shared: boolean;
} }
/** The GET /api/models response fields this section consumes. */
interface ModelsBody {
models: ModelCatalogEntry[];
/** 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. /** Compose: one tool toggled, the rest of the section carried verbatim.
* Pure — exported for tests. */ * Pure — exported for tests. */
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings { export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
@@ -234,8 +226,10 @@ function MenuTrigger({
onClick, onClick,
widthClass, widthClass,
disabled, disabled,
hint,
}: { }: {
value: string; value: string;
hint?: string;
/** What the trigger shows when there is no override ("Default"). */ /** What the trigger shows when there is no override ("Default"). */
emptyLabel: string; emptyLabel: string;
ariaLabel: string; ariaLabel: string;
@@ -250,7 +244,7 @@ function MenuTrigger({
/** The no-key search state grays the whole row; the trigger follows. */ /** The no-key search state grays the whole row; the trigger follows. */
disabled?: boolean; disabled?: boolean;
}) { }) {
return ( const button = (
<button <button
type="button" type="button"
aria-label={ariaLabel} aria-label={ariaLabel}
@@ -269,6 +263,7 @@ function MenuTrigger({
</span> </span>
</button> </button>
); );
return hint ? <Hint label={hint}>{button}</Hint> : button;
} }
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk /* The section's dropdowns all mount the shared MenuShell (fix wave chunk
@@ -303,10 +298,9 @@ export function AgentSection({
/** Writes the whole compaction section (replace-PUT discipline). */ /** Writes the whole compaction section (replace-PUT discipline). */
onCompactionChange: (next: CompactionSettings) => void; onCompactionChange: (next: CompactionSettings) => void;
}) { }) {
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null); const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog();
/** The three tiers' resolved pins — GET /api/models `slots`. The rows' const catalog = body?.models;
* Default hints and thinking lists resolve from it. */ const slots = body?.slots;
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
/** The live search state (/api/search) — the row's reason line. */ /** The live search state (/api/search) — the row's reason line. */
const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null); const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null);
/** Which search engines have a key (/api/keys) — the no-keys-anywhere /** Which search engines have a key (/api/keys) — the no-keys-anywhere
@@ -341,28 +335,9 @@ export function AgentSection({
.catch(() => {}); .catch(() => {});
}, []); }, []);
// The catalog (federated: keyed providers only), the resolved slots, // The shared catalog seeds the first frame and refreshes per mount and
// the prompt segments, and the search engines' key presence — one // keys/models signal. Settings props still own saved selections/levels.
// fetch each per section mount. The models read ALSO re-runs on the
// cross-surface signal (fix wave: another surface's write re-reads
// into the slot rows live).
const modelsRequest = useRef(0);
const modelsMounted = useRef(false);
const readModels = useCallback((connectionChanged = false) => {
const request = ++modelsRequest.current;
if (connectionChanged) setCatalog(null);
fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((body: ModelsBody) => {
if (!modelsMounted.current || request !== modelsRequest.current) return;
setCatalog(body.models);
setSlots(body.slots);
})
.catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); });
}, []);
useSettingsSaved((section) => { useSettingsSaved((section) => {
if (section === "models" || section === "keys") readModels(section === "keys");
// THE state-echo root cause: the prompt switches write through the // THE state-echo root cause: the prompt switches write through the
// agent section, but this view (the toggles' truth) never re-read — // agent section, but this view (the toggles' truth) never re-read —
// the settings-saved law (every reader re-reads) was missing this // the settings-saved law (every reader re-reads) was missing this
@@ -371,8 +346,6 @@ export function AgentSection({
}); });
useEffect(() => { useEffect(() => {
modelsMounted.current = true;
readModels();
fetch("/api/settings/prompts") fetch("/api/settings/prompts")
.then((r) => r.json()) .then((r) => r.json())
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
@@ -388,7 +361,6 @@ export function AgentSection({
setEngineKeys(present); setEngineKeys(present);
}) })
.catch(() => {}); .catch(() => {});
return () => { modelsMounted.current = false; modelsRequest.current++; };
}, []); }, []);
// The reason line re-reads /api/search AFTER an engine write lands — // The reason line re-reads /api/search AFTER an engine write lands —
@@ -615,6 +587,7 @@ export function AgentSection({
return ( return (
<> <>
<SettingsGroup label="Models" testid="group-models"> <SettingsGroup label="Models" testid="group-models">
{catalogFailed && <p role="alert" className="text-xs text-red-400">Model catalog could not be loaded. Check the connection and retry. <button type="button" className="underline" onClick={refreshCatalog}>Retry</button></p>}
{MODEL_SLOTS.map((slot) => { {MODEL_SLOTS.map((slot) => {
const override = models?.[slot]; const override = models?.[slot];
const modelId = override?.model?.id ?? ""; const modelId = override?.model?.id ?? "";
@@ -652,6 +625,7 @@ export function AgentSection({
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>} {slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
<MenuTrigger <MenuTrigger
value={modelId ? prettyModel(modelId) : ""} value={modelId ? prettyModel(modelId) : ""}
hint={modelId ? `${provider}:${modelId}` : undefined}
emptyLabel="—" emptyLabel="—"
ariaLabel={`${SLOT_META[slot].label} model`} ariaLabel={`${SLOT_META[slot].label} model`}
testid={`slot-model-${slot}`} testid={`slot-model-${slot}`}
+9 -1
View File
@@ -96,6 +96,14 @@ export function classifyKeys(
}; };
} }
/** A key row names its credential, not whichever provider sorts first.
* Pi exposes Zen and Go separately but both read OPENCODE_API_KEY. */
export function providerKeyLabel(id: string, providers: readonly ProviderKeyInfo[] | undefined): string | undefined {
const matches = providers?.filter((p) => p.envVar === id);
if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)";
return matches?.[0]?.label;
}
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an /** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* environment-owned value — the row itself is read-only then). Pure. */ * environment-owned value — the row itself is read-only then). Pure. */
export function keyStatusLine(status: AuthKeyStatus): string { export function keyStatusLine(status: AuthKeyStatus): string {
@@ -417,7 +425,7 @@ export function KeysSection({
{providerRows.map((status) => ( {providerRows.map((status) => (
<KeyTableRow <KeyTableRow
key={status.id} key={status.id}
label={providers?.find((p) => p.envVar === status.id)?.label ?? status.info?.label ?? status.id} label={providerKeyLabel(status.id, providers) ?? status.info?.label ?? status.id}
docsUrl={status.info?.docsUrl} docsUrl={status.info?.docsUrl}
status={status} status={status}
expanded={openRow === status.id} expanded={openRow === status.id}
+30 -2
View File
@@ -15,9 +15,10 @@
* its own save), the same re-read pattern chat.tsx uses for "models". * its own save), the same re-read pattern chat.tsx uses for "models".
*/ */
import type { AuthKeyStatus } from "@/src/core"; import { useEffect, useState } from "react";
import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core";
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
import { useModelCatalog } from "@/components/model-picker"; import { useModelCatalog } from "@/components/model-catalog";
import type { ModelCatalogResponse } from "@/src/core"; import type { ModelCatalogResponse } from "@/src/core";
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses /** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
@@ -32,6 +33,33 @@ export function hasProviderKey(
return classifyKeys(statuses, providers).providerRows.some((s) => s.present); return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
} }
/** Completeness is saved identity, not current provider availability. */
export function savedSlotsConfigured(slots: ModelCatalogResponse["slots"] | undefined): boolean {
return MODEL_SLOTS.every((slot) => !!slots?.[slot]?.model);
}
/** Keep the last known completeness during connection invalidation, but
* follow ordinary slot repairs as soon as their shared snapshot arrives. */
export function useModelSetup(initialConfigured: boolean) {
const { catalog } = useModelCatalog();
const [lastConfigured, setLastConfigured] = useState(initialConfigured);
const configured = catalog ? savedSlotsConfigured(catalog.slots) : lastConfigured;
useEffect(() => { if (catalog) setLastConfigured(savedSlotsConfigured(catalog.slots)); }, [catalog]);
return { catalog, configured, zeroKeys: catalog ? !catalog.providers.some((p) => p.keyPresent) : false };
}
/** A known unavailable route needs an explicit replacement, never an
* automatic provider/model substitution. Unknown metadata defers to the
* server's admission gate instead of declaring a route disconnected. */
export function selectedModelUnavailable(catalog: ModelCatalogResponse | undefined, selected: string | undefined): boolean {
return !!catalog && !!selected && !catalog.models.some((m) => `${m.provider}:${m.id}` === selected);
}
/** Reuse the ordinary control (also opened by the existing keyboard action). */
export function openModelPicker(): void {
window.dispatchEvent(new CustomEvent("lh:kbd-open-model-picker"));
}
/** The zero-keys truth for the calling page: TRUE only after a /** The zero-keys truth for the calling page: TRUE only after a
* successful read says no provider key exists (pending and failed * successful read says no provider key exists (pending and failed
* reads render nothing — the pill must never flash on a gap, and a * reads render nothing — the pill must never flash on a gap, and a
+121 -97
View File
@@ -13,7 +13,8 @@
* STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models — * STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models —
* there's no point presenting the model picker before the user has set a * there's no point presenting the model picker before the user has set a
* key because there are no models to pick then"): * key because there are no models to pick then"):
* Stage 1 — no keyed provider: a SELF-CONTAINED first-key screen * Unknown catalog — neutral loading (or error/Retry), never a key screen.
* Stage 1 — no keyed provider or saved selection: a SELF-CONTAINED first-key screen
* (zero-keys wave, owner 2026-09-25): provider picker + one key * (zero-keys wave, owner 2026-09-25): provider picker + one key
* input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED — * input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
* saving dispatches the "keys" signal, the fetch effect re-runs on * saving dispatches the "keys" signal, the fetch effect re-runs on
@@ -23,6 +24,9 @@
* Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog * Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
* (keyed providers only), the vision slot filtered to image-capable * (keyed providers only), the vision slot filtered to image-capable
* models, in the app's shared dropdown language (ModelMenuList). * models, in the app's shared dropdown language (ModelMenuList).
* Saved disconnected selections and edits are retained internally in
* the exclusive provider stage. Connecting with all slots already saved
* closes setup; otherwise the pickers require only the missing slots.
* All three required (owner: no skip, no defaults); Save PUTs the * All three required (owner: no skip, no defaults); Save PUTs the
* models section and reports onDone(models). * models section and reports onDone(models).
* *
@@ -41,6 +45,8 @@ import { prettyModel } from "@/components/usage-format";
import { ModelMenuList } from "@/components/settings/model-menu"; import { ModelMenuList } from "@/components/settings/model-menu";
import { MenuShell } from "@/components/menu-shell"; import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { useModelCatalog } from "@/components/model-catalog";
import { savedSlotsConfigured } from "@/components/setup-cta";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { type ProviderKeyInfo } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu"; import { ProviderMenuList } from "@/components/settings/model-menu";
@@ -73,27 +79,26 @@ interface ModelsBody {
* (owner's dependency ordering). Exported for tests. */ * (owner's dependency ordering). Exported for tests. */
export function setupStage( export function setupStage(
providers: { id: string; keyPresent: boolean }[] | null, providers: { id: string; keyPresent: boolean }[] | null,
): "keys" | "pickers" { ): "loading" | "keys" | "pickers" {
if (providers === null) return "keys"; // fetch failed → the honest fallback if (providers === null) return "loading";
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 type ModelSelection = Pick<ModelCatalogEntry, "provider" | "id">;
* pick — matched in the FEDERATED catalog (a lapsed key's pin does not
* prefill); unpinned tiers stay empty and carry the "needed" mark. /** Saved identity is independent of selectable metadata. A disconnected
* First-run = nothing pinned → all three empty + marked. Exported pure * pin still prefills; only the menu is restricted to the live catalog. */
* for tests. */
export function prefillFromSlots( export function prefillFromSlots(
models: ModelCatalogEntry[], models: ModelCatalogEntry[],
slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined, slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
): Partial<Record<ModelSlotName, ModelCatalogEntry>> { ): Partial<Record<ModelSlotName, ModelSelection>> {
const pre: Partial<Record<ModelSlotName, ModelCatalogEntry>> = {}; const pre: Partial<Record<ModelSlotName, ModelSelection>> = {};
for (const slot of SLOT_ORDER) { for (const slot of SLOT_ORDER) {
const pin = slots?.[slot]; const pin = slots?.[slot];
const entry = pin?.model const entry = pin?.model
? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id) ? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
: undefined; : undefined;
if (entry) pre[slot] = entry; if (pin?.model) pre[slot] = entry ?? pin.model;
} }
return pre; return pre;
} }
@@ -217,24 +222,38 @@ export function SetupKeysBody({
); );
} }
export function SetupDialog({ function prefillLevels(body: ModelsBody | undefined): Partial<Record<ModelSlotName, ThinkingLevel>> {
open, const levels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
onClose, for (const slot of SLOT_ORDER) {
/** Saved: the models section landed; the caller marks configured (the const pin = body?.slots?.[slot];
* draft stays put — save never sends). */ const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id);
onDone, if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel;
}: { else if (entry) levels[slot] = prefillLevelFromPin(entry, pin);
}
return levels;
}
interface SetupDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
/** Saving never sends the caller's draft. */
onDone: (models: ModelsSettings) => void; onDone: (models: ModelsSettings) => void;
}) { }
export function SetupDialog(props: SetupDialogProps) {
// An open owns its draft. Mount-time cache seeding avoids a key-screen
// first frame, and reopening cannot reuse a previous open's edits.
return props.open ? <OpenSetupDialog {...props} /> : null;
}
function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
const backdropDismiss = useBackdropDismiss(onClose); const backdropDismiss = useBackdropDismiss(onClose);
const [body, setBody] = useState<ModelsBody | null>(null); const { catalog, failed, refresh } = useModelCatalog();
const [failed, setFailed] = useState(false); const providerStageSeen = useRef(false);
const [picked, setPicked] = useState<Partial<Record<ModelSlotName, ModelCatalogEntry>>>({}); const connectionCloseRequested = useRef(false);
/** Per-slot thinking level (chunk 2): populated from the ordering on const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots));
* every fresh model pick, editable — an explicit pick always wins. */ const [levels, setLevels] = useState(() => prefillLevels(catalog));
const [levels, setLevels] = useState<Partial<Record<ModelSlotName, ThinkingLevel>>>({}); const editedSlots = useRef(new Set<ModelSlotName>());
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null); const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null);
@@ -249,64 +268,65 @@ export function SetupDialog({
const [keyError, setKeyError] = useState<string | null>(null); const [keyError, setKeyError] = useState<string | null>(null);
const [keyMenuOpen, setKeyMenuOpen] = useState(false); const [keyMenuOpen, setKeyMenuOpen] = useState(false);
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor. // Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
// Declared with the other hooks: the early `if (!open) return null` sits // Stage 2's slot menus anchor by anchorTestid instead — see the slot
// below them all. (Stage 2's slot menus anchor by anchorTestid instead — // MenuShell below for why a freshly attached ref cannot work there.
// see the slot MenuShell below for why a ref cannot work there.)
const keyTriggerRef = useRef<HTMLDivElement>(null); const keyTriggerRef = useRef<HTMLDivElement>(null);
// The keys signal (zero-keys wave): keys-section saves/deletes AND // The keys signal (zero-keys wave): keys-section saves/deletes AND
// THIS dialog's own first-key save dispatch it; the fetch effect below // THIS dialog's own first-key save dispatch it; the fetch effect below
// re-runs on it, so saving a key flips the stage to the pickers with // re-runs on it, so saving a key widens the catalog. Incomplete saved
// the widened catalog — no restart, no Settings round-trip. // slots advance to pickers; complete saved slots just close setup.
const [keysTick, setKeysTick] = useState(0); const [keysTick, setKeysTick] = useState(0);
useSettingsSaved((section) => { useSettingsSaved((section) => {
if (section === "keys") setKeysTick((t) => t + 1); if (section === "keys") setKeysTick((t) => t + 1);
}); });
// Fresh /api/models per open AND per keys change — a key saved a
// moment ago (stage 1's own screen) widens the pickers immediately.
useEffect(() => { useEffect(() => {
if (!open) return;
let cancelled = false; let cancelled = false;
setBody(null);
setPicked({});
setLevels({});
setError(null);
fetch("/api/keys") fetch("/api/keys")
.then((r) => r.json()) .then((r) => r.json())
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); }) .then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
.catch(() => { if (!cancelled) setKeyProviders([]); }); .catch(() => { if (!cancelled) setKeyProviders([]); });
fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((b: ModelsBody) => {
if (cancelled) return;
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.
const pre = prefillFromSlots(b.models, b.slots);
setPicked(pre);
// Levels prefill with the pins (chunk 2): stored level if still
// supported, else the ordering's level for the pinned model.
const preLevels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
for (const slot of SLOT_ORDER) {
const entry = pre[slot];
if (entry) preLevels[slot] = prefillLevelFromPin(entry, b.slots?.[slot]);
}
setLevels(preLevels);
})
.catch(() => { if (!cancelled) setFailed(true); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [open, keysTick]); }, [keysTick]);
useEffect(() => {
if (!catalog) return;
const pre = prefillFromSlots(catalog.models, catalog.slots);
const preLevels = prefillLevels(catalog);
// Fresh metadata is always used, but a response cannot reset an
// edited slot, including a level chosen while the request was pending.
setPicked((prev) => {
const next = { ...prev };
for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) {
if (pre[slot]) next[slot] = pre[slot]; else delete next[slot];
}
return next;
});
setLevels((prev) => {
const next = { ...prev };
for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) {
if (preLevels[slot] !== undefined) next[slot] = preLevels[slot]; else delete next[slot];
}
return next;
});
}, [catalog]);
const stage = setupStage(catalog?.providers ?? null);
const closeAfterConnection = stage === "pickers" && providerStageSeen.current && savedSlotsConfigured(catalog?.slots);
useEffect(() => {
if (stage === "keys") providerStageSeen.current = true;
if (closeAfterConnection && !connectionCloseRequested.current) {
connectionCloseRequested.current = true;
onClose(); // Connection recovery is not a model-settings save.
}
}, [stage, closeAfterConnection, onClose]);
// Esc + outside-click — layered dismissal, closable WITHOUT // Esc + outside-click — layered dismissal, closable WITHOUT
// configuring (browsing stays possible; the gate is the send // configuring (browsing stays possible; the gate is the send
// interception, not this dialog). An open slot menu eats Esc first // interception, not this dialog). An open slot menu eats Esc first
// (and any click outside it); only a bare Esc closes the dialog. // (and any click outside it); only a bare Esc closes the dialog.
useEffect(() => { useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
// THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at
@@ -341,17 +361,18 @@ export function SetupDialog({
document.removeEventListener("keydown", onKey); document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("pointerdown", onPointerDown);
}; };
}, [open, onClose, openMenu, keyMenuOpen]); }, [onClose, openMenu, keyMenuOpen]);
if (!open) return null; // Do not briefly paint model pickers between successful connection and
// closing an already-configured setup. No saved model/thinking writes.
if (closeAfterConnection) return null;
const stage = failed ? "keys" : setupStage(body ? body.providers : null);
const allPicked = SLOT_ORDER.every((s) => picked[s]); const allPicked = SLOT_ORDER.every((s) => picked[s]);
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and /** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
* CLEAN the local screen — the signal re-fetch widens the catalog and * CLEAN the local key field. The fresh catalog decides whether to
* the stage flips to the pickers on its own; the dialog stays mounted * close recovery or advance to the missing model slots. Shape-gate
* the whole way. Shape-gate 400s keep their user-facing line inline. */ * 400s keep their user-facing line inline. */
async function saveKey() { async function saveKey() {
if (!keyProvider || !keyValue.trim() || keySaving) return; if (!keyProvider || !keyValue.trim() || keySaving) return;
setKeySaving(true); setKeySaving(true);
@@ -398,6 +419,20 @@ export function SetupDialog({
onDone(models); onDone(models);
} }
const keysBody = <SetupKeysBody
providers={keyProviders}
provider={keyProvider}
value={keyValue}
saving={keySaving}
error={keyError}
menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef}
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
onValue={setKeyValue}
onSave={() => void saveKey()}
/>;
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80" className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
@@ -427,32 +462,15 @@ export function SetupDialog({
</Hint> </Hint>
</header> </header>
{stage === "keys" ? ( {failed && (
<div className="flex flex-col px-6 py-5"> <p role="alert" data-testid="setup-catalog-error" className="px-6 py-3 text-sm text-foreground">
{failed ? ( Model catalog could not be loaded. Check the connection and retry. <button type="button" data-testid="setup-retry" className="underline" onClick={refresh}>Retry</button>
<p className="text-sm text-foreground">
Model catalog could not be loaded. Check the connection and retry.
</p> </p>
) : (
<SetupKeysBody
providers={keyProviders}
provider={keyProvider}
value={keyValue}
saving={keySaving}
error={keyError}
menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef}
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
onPick={(p) => {
setKeyProvider(p);
setKeyMenuOpen(false);
setKeyError(null);
}}
onValue={setKeyValue}
onSave={() => void saveKey()}
/>
)} )}
</div> {stage === "loading" ? (
!failed && <p data-testid="setup-stage-loading" role="status" className="px-6 py-5 text-xs text-muted-foreground">Loading models…</p>
) : stage === "keys" ? (
<div className="flex flex-col px-6 py-5">{keysBody}</div>
) : ( ) : (
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers"> <div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -461,14 +479,16 @@ export function SetupDialog({
{SLOT_ORDER.map((slot) => { {SLOT_ORDER.map((slot) => {
const entry = picked[slot]; const entry = picked[slot];
const level = levels[slot]; const level = levels[slot];
const levelsForEntry = entry?.supportedLevels ?? []; const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider);
const catalog = // Unavailable metadata cannot erase or clamp a saved preference.
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models; const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []);
const available = catalog?.models ?? (failed ? [] : undefined);
const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available;
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="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] && ( {!catalog?.slots?.[slot]?.model && !picked[slot] && (
<span <span
data-testid={`setup-needed-${slot}`} 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" className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
@@ -481,6 +501,7 @@ export function SetupDialog({
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>} {slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Hint label={entry ? `${entry.provider}:${entry.id}` : "Pick a model…"}>
<button <button
type="button" type="button"
aria-label={`${SLOT_META[slot].label} model`} aria-label={`${SLOT_META[slot].label} model`}
@@ -494,6 +515,7 @@ export function SetupDialog({
▾ ▾
</span> </span>
</button> </button>
</Hint>
{/* Anchor by TESTID, not a ref (the settings-section pattern): {/* Anchor by TESTID, not a ref (the settings-section pattern):
React attaches a host element's ref AFTER its descendants' React attaches a host element's ref AFTER its descendants'
layout effects run, so a ref freshly attached in this same layout effects run, so a ref freshly attached in this same
@@ -512,10 +534,11 @@ export function SetupDialog({
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl" menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
> >
<ModelMenuList <ModelMenuList
models={catalog} models={slotCatalog}
effectiveId={entry?.id} effectiveId={entry?.id}
effectiveProvider={entry?.provider} effectiveProvider={entry?.provider}
onPick={(m) => { onPick={(m) => {
editedSlots.current.add(slot);
setPicked((prev) => ({ ...prev, [slot]: m })); setPicked((prev) => ({ ...prev, [slot]: m }));
// Auto-populate (chunk 2, owner: "populate // Auto-populate (chunk 2, owner: "populate
// everywhere"): no explicit level → the // everywhere"): no explicit level → the
@@ -527,7 +550,7 @@ export function SetupDialog({
})); }));
setOpenMenu(null); setOpenMenu(null);
}} }}
footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined} footer={slotCatalog && slotCatalog.length === 0 && slot === "vision" ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
/> />
</MenuShell> </MenuShell>
)} )}
@@ -563,6 +586,7 @@ export function SetupDialog({
levels={levelsForEntry.map(String)} levels={levelsForEntry.map(String)}
effectiveLevel={level} effectiveLevel={level}
onPick={(lv) => { onPick={(lv) => {
editedSlots.current.add(slot);
setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel })); setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel }));
setOpenMenu(null); setOpenMenu(null);
}} }}
+9 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core"; import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format"; import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
@@ -34,6 +34,7 @@ export function StatsBar({
dictationSlot, dictationSlot,
chatId, chatId,
leadingSlot, leadingSlot,
onModelChange,
}: { }: {
turns: TurnUsage[]; turns: TurnUsage[];
contextTokens?: number | null; contextTokens?: number | null;
@@ -43,6 +44,8 @@ export function StatsBar({
* record; absent on the draft surfaces, where it binds the strong * record; absent on the draft surfaces, where it binds the strong
* slot (the new-session default). */ * slot (the new-session default). */
chatId?: string; chatId?: string;
/** The same effective identity the visible model control owns. */
onModelChange?: (model: string | undefined) => void;
/** The dictation strip (STT wave, 2026-09-17): the row's middle is dead /** The dictation strip (STT wave, 2026-09-17): the row's middle is dead
* space between the session data and the model control — the mic * space between the session data and the model control — the mic
* button lives there, and while recording the meter unfurls to fill * button lives there, and while recording the meter unfurls to fill
@@ -55,6 +58,10 @@ export function StatsBar({
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>(); const [selectedModel, setSelectedModel] = useState<string | undefined>();
const modelChanged = useCallback((model: string | undefined) => {
setSelectedModel(model);
onModelChange?.(model);
}, [onModelChange]);
// Same dropdown discipline as the model control (owner, df4433f treatment, // Same dropdown discipline as the model control (owner, df4433f treatment,
// stats round 1): close on any pointerdown outside the bar or on Escape. // stats round 1): close on any pointerdown outside the bar or on Escape.
const barRef = useRef<HTMLDivElement>(null); const barRef = useRef<HTMLDivElement>(null);
@@ -192,7 +199,7 @@ export function StatsBar({
{/* The model + thinking control — one controller for the conversation {/* The model + thinking control — one controller for the conversation
agents (prober/teacher); the record's `turns` above still folds agents (prober/teacher); the record's `turns` above still folds
the ACTUAL models billed, regardless of what the picker says. */} the ACTUAL models billed, regardless of what the picker says. */}
<ModelControl chatId={chatId} onModelChange={setSelectedModel} /> <ModelControl chatId={chatId} onModelChange={modelChanged} />
</div> </div>
); );
} }
+5 -19
View File
@@ -29,25 +29,11 @@ export function splitQualified(id: string): { provider: string | null; id: strin
return { provider: id.slice(0, colon), id: id.slice(colon + 1) }; return { provider: id.slice(0, colon), id: id.slice(colon + 1) };
} }
/** /** Model-only labels omit the vendor path, never versions, dates or variants.
* "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the * Only usage callers pass `qualified`: a bare catalog id's colon belongs to
* name KEPT EXACTLY as its provider writes it (dashes stay, no * the model (e.g. :free), not a provider. Usage retains its provider suffix. */
* auto-caps — owner rounds 4-5, 2026-10-05); a trailing all-digit
* segment (OpenRouter's date suffix, "qwen3.8-max-0902") is dropped.
* Quiet-chip friendly.
*
* With `qualified` (usage stamps are "provider:id", model-slots wave):
* the provider rides as a quiet suffix — "glm-5.3-flash · openrouter" —
* because with federation the same model via two providers is genuinely
* different bookkeeping. Bare catalog ids are unchanged.
*/
export function prettyModel(id: string, qualified = false): string { export function prettyModel(id: string, qualified = false): string {
const { provider, id: bare } = splitQualified(id); const { provider, id: bare } = qualified ? splitQualified(id) : { provider: null, id };
const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare; const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
const parts = short.split("-"); return provider ? `${short} · ${provider}` : short;
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
// No auto-capitalization, dashes KEPT (owner rounds 4-5, 2026-10-05):
// model names render exactly as their providers write them.
const pretty = trimmed.join("-");
return qualified && provider ? `${pretty} · ${provider}` : pretty;
} }
+11
View File
@@ -24,6 +24,17 @@ prompt, plan continuity, persistence, threshold, scheduling, and authorization
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
calls. A cut that drops no active history does not launch a summarizer. calls. A cut that drops no active history does not launch a summarizer.
When the active cutoff is applied, conversation requests carry the latest summary
once, followed by the retained transcript without older compaction markers. When
a subsequent compaction supplies that prior summary separately, it includes it
once, plus the newly superseded transcript without summary markers. All markers
and original messages remain stored. The existing full-history fallback for an
unresolved cutoff is unchanged, including its summary text.
A provider response stopped at its output limit (`length`) is an incomplete
summary, not a successful checkpoint. Compaction fails without changing the
existing history or prior summary; a later attempt can retry normally.
## Conversation meter ## Conversation meter
The numerator is the latest qualifying completed conversation response's native The numerator is the latest qualifying completed conversation response's native
+3 -3
View File
@@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails)
}; };
} }
/** The ACTIVE compaction marker: the newest one in the record (chained /** The ACTIVE compaction marker: the newest one with a retained-message
* compactions — the last one rules; older markers fall inside the * id. Its summary supersedes older markers even when their stored
* dropped span and never render). */ * positions remain inside its retained tail. */
export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null { export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null {
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!; const m = messages[i]!;
+7 -7
View File
@@ -563,8 +563,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
// THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker // THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker
// (the newest — chained compactions rule last) names the first kept // (the newest — chained compactions rule last) names the first kept
// message; everything before it is replaced by the marker's content // message; everything before it is replaced by the marker's content
// (header + summary) rendered ONCE, up top. The marker itself is then // (header + summary) rendered ONCE, up top. All compaction markers
// skipped in place — it must never render twice. A cut naming a // in the retained tail are then skipped, including older summaries
// whose stored positions still lie after the cut. A cut naming a
// missing id degrades honestly (everything renders — never throw). // missing id degrades honestly (everything renders — never throw).
const lhMessages = messages as readonly LhMessage[]; const lhMessages = messages as readonly LhMessage[];
const active = activeCompactionOf(lhMessages); const active = activeCompactionOf(lhMessages);
@@ -605,11 +606,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
// UI-only markers: plan blocks, and problem dismissals (the // UI-only markers: plan blocks, and problem dismissals (the
// synthesized conclude pair carries the model's face). // synthesized conclude pair carries the model's face).
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue; if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
// The ACTIVE compaction marker already rendered as the head — // The head supersedes every compaction marker, not only its own
// never twice. (Stale markers before the cut are unreachable; a // stored position. Without a resolved head, preserve the existing
// marker whose cut could not resolve has no head and renders as // full-history fallback, including the summary text.
// its user text — honest degradation.) if (marker.customType === "compaction" && head !== null) continue;
if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue;
out.push({ out.push({
role: "user", role: "user",
content: [{ type: "text", text: marker.content }], content: [{ type: "text", text: marker.content }],
+15 -2
View File
@@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
totalTokens: 0, totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}; };
const { text } = await runSubagent([], { const { text, newMessages } = await runSubagent([], {
// The engine override is always present (the chat's own conversation // The engine override is always present (the chat's own conversation
// engine — owner ruling), so the role label never resolves an // engine — owner ruling), so the role label never resolves an
// engine; "researcher" is the least-wrong existing ModelRole label // engine; "researcher" is the least-wrong existing ModelRole label
@@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
usage = u; usage = u;
}, },
}); });
// A provider-declared incomplete response is not a usable checkpoint,
// even when it contains nonempty text. Match Pi's compaction failure
// semantics: leave the existing history and prior summary untouched.
if (newMessages.some((message) => message.role === "assistant" && message.stopReason === "length")) {
throw new Error("Compaction summary hit the output limit and is incomplete");
}
return { summary: text, usage }; return { summary: text, usage };
} }
@@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
spanStart = prevKept; spanStart = prevKept;
priorSummary = previous.content; priorSummary = previous.content;
} }
const spanText = serializeForSummary(messages.slice(spanStart, cut)); const span = messages.slice(spanStart, cut);
// The separately supplied prior summary replaces all summary markers
// in this span. Without a resolved prior boundary, retain the existing
// legacy fallback rather than silently discarding summary-only context.
const summaryMessages = priorSummary
? span.filter((m) => m.role !== "custom" || m.customType !== "compaction")
: span;
const spanText = serializeForSummary(summaryMessages);
const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText; const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText;
const prompt = buildCompactionPrompt({ const prompt = buildCompactionPrompt({
+10 -4
View File
@@ -245,10 +245,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation
return { ok: true }; return { ok: true };
} }
export function validateModelsSettings(settings: ModelsSettings): SettingsValidation { /** Validate introduced/replaced model selections, not unchanged saved pins.
* A disconnected subscription loses its available catalog, but its stored
* selections must not prevent repairing another slot or editing thinking.
* Only the SAME slot's provider+id can pass through; runtime admission stays
* separate. Callers without a stored section retain strict catalog checking. */
export function validateModelsSettings(settings: ModelsSettings, stored?: ModelsSettings): SettingsValidation {
for (const name of MODEL_SLOTS) { for (const name of MODEL_SLOTS) {
const slot = settings[name]; const slot = settings[name];
if (!slot) continue; if (!slot) continue;
const previous = stored?.[name]?.model;
if (slot.model && previous && slot.model.provider === previous.provider && slot.model.id === previous.id) continue;
const verdict = validateSlotSpec(name, slot); const verdict = validateSlotSpec(name, slot);
if (!verdict.ok) return verdict; if (!verdict.ok) return verdict;
} }
@@ -280,9 +287,8 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
// runtime — and the clamped value is what lands in the record. The // runtime — and the clamped value is what lands in the record. The
// server is the truth: the record shows the clamped level after write. // server is the truth: the record shows the clamped level after write.
/** Clamp one level against one model (identity when the model is unknown /** Clamp one level against one model. Preserve the preference when metadata
* or the level already supported — the existence gate rejects unknown * is unavailable for an unchanged saved pin; runtime clamps after reconnect. */
* models separately, so this only ever sees real ones in practice). */
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel { function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
const model = connectionModel(provider, id); const model = connectionModel(provider, id);
return model ? clampThinkingLevel(model, level) : level; return model ? clampThinkingLevel(model, level) : level;
+4 -4
View File
@@ -123,9 +123,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
assert.deepEqual(pre, {}); assert.deepEqual(pre, {});
}); });
it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => { it("a disconnected saved identity still prefills without selectable metadata", () => {
const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null }); const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null });
assert.deepEqual(pre, {}); assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } });
}); });
it("provider collisions never cross-pollute (same id, different provider)", () => { it("provider collisions never cross-pollute (same id, different provider)", () => {
@@ -158,9 +158,9 @@ describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", (
}); });
}); });
describe("setupStage — unchanged by the BYOK wave", () => { describe("setupStage — unknown is not keyless", () => {
it("keys first (owner ordering), pickers when a provider is keyed", () => { it("keys first (owner ordering), pickers when a provider is keyed", () => {
assert.equal(setupStage(null), "keys"); assert.equal(setupStage(null), "loading");
assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys"); assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys");
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers"); assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
}); });
+111 -1
View File
@@ -27,12 +27,14 @@ const {
ensureCompacted, ensureCompacted,
kickCompaction, kickCompaction,
awaitCompaction, awaitCompaction,
compactionInFlight,
effectiveThresholdTokens, effectiveThresholdTokens,
projectedContextTokens, projectedContextTokens,
} = await import("../src/server/compaction.ts"); } = await import("../src/server/compaction.ts");
import type { CompactionDeps } from "../src/server/compaction.ts"; import type { CompactionDeps } from "../src/server/compaction.ts";
import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai";
const { subscribe } = await import("../src/core/events.ts"); const { subscribe } = await import("../src/core/events.ts");
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts"); const { activeCompactionOf, newCompactionMarker, newUserMessage, measuredContextTokens, toLlmMessages, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
import type { LhMessage } from "../src/core/index.ts"; import type { LhMessage } from "../src/core/index.ts";
after(() => { after(() => {
@@ -167,12 +169,120 @@ describe("the compaction service", () => {
await ensureCompacted(deps, chatId); await ensureCompacted(deps, chatId);
assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span"); assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span");
assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text"); assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text");
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once");
const record = chatStore.getChat(chatId)!; const record = chatStore.getChat(chatId)!;
const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction"); const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction");
assert.equal(markers.length, 2, "both markers persist (history is never rewritten)"); assert.equal(markers.length, 2, "both markers persist (history is never rewritten)");
assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules"); assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules");
}); });
it("ten successive compactions keep one summary on the wire and preserve the complete record", async () => {
const chat = chatStore.createChat("repeated-cycle fixture");
chatStore.saveMessages(chat.id, Array.from({ length: 20 }, (_, i) => big(`exchange-${i}`, 8)));
let calls = 0;
const deps: CompactionDeps = {
...depsOf(chat.id, 150_000),
summarize: async (prompt) => {
assert.equal(prompt.split(COMPACTED_HISTORY_HEADER).length - 1, calls === 0 ? 0 : 1);
if (calls > 0) assert.ok(prompt.includes(`SUMMARY-${calls}.`), "only the immediately prior summary rides forward");
return {
summary: `SUMMARY-${++calls}.`,
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
};
},
};
for (let cycle = 1; cycle <= 10; cycle++) {
const messages = [...chatStore.getChat(chat.id)!.messages];
if (cycle > 1) messages.push(big(`growth-${cycle}`, 8));
// Controlled provider readings trigger compaction while the visible
// retained tail advances slowly enough to contain older markers.
messages.push({ id: `usage-${cycle}`, role: "assistant", content: [{ type: "text", text: "measured response" }], stopReason: "stop", usage: { input: 150_001, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 150_002 }, timestamp: cycle } as LhMessage);
chatStore.saveMessages(chat.id, messages);
await ensureCompacted(deps, chat.id);
assert.equal(calls, cycle);
const record = chatStore.getChat(chat.id)!;
assert.deepEqual(record.messages.slice(0, -1), messages, "every original message and marker remains stored");
assert.equal(record.messages.filter((m) => m.role === "custom" && m.customType === "compaction").length, cycle);
const wire = JSON.stringify(toLlmMessages(record.messages));
assert.equal(wire.split(COMPACTED_HISTORY_HEADER).length - 1, 1, `cycle ${cycle}: one summary on the wire`);
assert.ok(wire.includes(`SUMMARY-${cycle}.`));
assert.equal(measuredContextTokens(record.messages), null, "committing a marker invalidates pre-compaction readings");
}
});
it("a resolved prior summary replaces all summary markers in the newly superseded span", async () => {
const chat = chatStore.createChat("summary span fixture");
const kept = big("previously retained");
const details = { firstKeptMessageId: kept.id, tokensBefore: 150_000, at: "fixture" };
const steer: LhMessage = { id: "steer", role: "custom", customType: "subagent-steer", content: "RESEARCH DELIVERY", display: true, details: {}, timestamp: 1 };
const messages = [big("discarded"), kept, newCompactionMarker("SUPERSEDED SUMMARY", details), steer, newCompactionMarker("LATEST SUMMARY", details), big("growth"), big("recent")];
chatStore.saveMessages(chat.id, messages);
let seenPrompt = "";
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
seenPrompt = prompt;
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
} }, chat.id);
assert.ok(seenPrompt.includes("previously retained"));
assert.ok(seenPrompt.includes("RESEARCH DELIVERY"), "non-compaction context markers still ride the span");
assert.ok(!seenPrompt.includes("SUPERSEDED SUMMARY"));
assert.equal(seenPrompt.split("LATEST SUMMARY").length - 1, 1);
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
});
it("an unresolved cutoff preserves legacy summary text in the next compaction input", async () => {
const chat = chatStore.createChat("legacy summary fixture");
const legacy = newCompactionMarker("LEGACY-ONLY INFORMATION", { firstKeptMessageId: "missing", tokensBefore: 150_000, at: "fixture" });
const messages = [big("old"), legacy, big("recent"), newUserMessage("latest")];
chatStore.saveMessages(chat.id, messages);
let seenPrompt = "";
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
seenPrompt = prompt;
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
} }, chat.id);
assert.equal(seenPrompt.split("LEGACY-ONLY INFORMATION").length - 1, 1);
assert.ok(!seenPrompt.includes("prior compaction summary"), "no separate prior summary exists for an unresolved cutoff");
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
});
it("the production summarizer rejects a length-stopped checkpoint and a complete retry can commit", async () => {
const chatId = overThresholdChat("incomplete summary fixture");
await ensureCompacted(depsOf(chatId, 40_000), chatId);
chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, big("growth 1"), big("growth 2")]);
const faux = createFauxCore({});
const { summarize: _injected, ...base } = depsOf(chatId, 40_000);
const deps: CompactionDeps = { ...base, engine: { model: faux.getModel(), streamFn: faux.streamSimple } };
const before = chatStore.getChat(chatId)!;
assert.ok(activeCompactionOf(before.messages), "the failed replacement must preserve an existing checkpoint too");
const events: string[] = [];
const off = subscribe((event) => {
const { chatId: id, running } = event as { chatId?: string; running?: boolean };
if (event.type === "compaction-changed" && id === chatId) events.push(`${running}`);
});
// Exercise the actual zero-tool summarizer with an offline provider;
// NODE_ENV=test otherwise deliberately skips non-injected summaries.
const env: Record<string, string | undefined> = process.env;
const originalNodeEnv = env.NODE_ENV;
env.NODE_ENV = "development";
try {
faux.setResponses([fauxAssistantMessage("INCOMPLETE SUMMARY", { stopReason: "length" })]);
await ensureCompacted(deps, chatId);
assert.deepEqual(chatStore.getChat(chatId), before, "an explicitly unfinished summary must not become a checkpoint");
assert.equal(compactionInFlight(chatId), false, "the failed run releases its gate");
assert.deepEqual(events, ["true", "false"]);
faux.setResponses([fauxAssistantMessage("COMPLETE SUMMARY")]);
await ensureCompacted(deps, chatId);
const record = chatStore.getChat(chatId)!;
assert.ok(activeCompactionOf(record.messages)!.content.includes("COMPLETE SUMMARY"));
assert.deepEqual(record.messages.slice(0, -1), before.messages);
assert.ok(!JSON.stringify(record.messages).includes("INCOMPLETE SUMMARY"));
} finally {
if (originalNodeEnv === undefined) delete env.NODE_ENV;
else env.NODE_ENV = originalNodeEnv;
off();
}
});
it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => { it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => {
assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000); assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000);
assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384); assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384);
+44
View File
@@ -256,6 +256,22 @@ describe("toLlmMessages honors the cut", () => {
assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once"); assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once");
}); });
it("omits nine superseded summaries inside the retained tail without changing stored history", () => {
const kept = newUserMessage("retained exchange");
const details = { firstKeptMessageId: kept.id, tokensBefore: 200_000, at: "fixture" };
const old = Array.from({ length: 9 }, (_, i) => newCompactionMarker(`SUPERSEDED-${i}`, details));
const fresh = newCompactionMarker("LATEST SUMMARY", details);
const messages = [newUserMessage("discarded"), kept, ...old, newUserMessage("latest reply"), fresh];
const original = JSON.stringify(messages);
const out = toLlmMessages(messages);
const flat = JSON.stringify(out);
assert.equal(out.length, 3, "latest summary + retained exchange + latest reply");
assert.ok(flat.includes("LATEST SUMMARY"));
assert.ok(!flat.includes("SUPERSEDED-"));
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
assert.equal(JSON.stringify(messages), original, "projection never rewrites the record");
});
it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => { it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => {
const kept = [assistantWithCall("a1", "call_1")]; const kept = [assistantWithCall("a1", "call_1")];
const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" }); const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" });
@@ -270,6 +286,22 @@ describe("toLlmMessages honors the cut", () => {
assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)"); assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)");
}); });
it("an unresolved newest cutoff preserves the full-history fallback, including summary text", () => {
const kept = newUserMessage("retained exchange");
const old = newCompactionMarker("OLD SUMMARY", { firstKeptMessageId: kept.id, tokensBefore: 1, at: "fixture" });
const fresh = newCompactionMarker("LATEST SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 2, at: "fixture" });
const messages = [newUserMessage("earliest exchange"), kept, old, newUserMessage("latest reply"), fresh];
const original = JSON.stringify(messages);
const out = toLlmMessages(messages);
assert.equal(out.length, messages.length);
assert.equal(out[0]!.content, "earliest exchange", "do not silently apply the older cutoff");
const flat = JSON.stringify(out);
assert.ok(flat.includes("OLD SUMMARY"));
assert.ok(flat.includes("LATEST SUMMARY"));
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 2);
assert.equal(JSON.stringify(messages), original);
});
it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => { it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => {
const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" }); const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" });
const m1 = newUserMessage("x"); const m1 = newUserMessage("x");
@@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => {
assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize"); assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize");
}); });
it("preserves legacy summary text when the caller has no separately supplied prior summary", () => {
const marker = newCompactionMarker("LEGACY SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 1, at: "fixture" });
assert.ok(serializeForSummary([marker]).includes("LEGACY SUMMARY"));
});
it("the prompt demands the four sections in order and carries the plan tree verbatim", () => { it("the prompt demands the four sections in order and carries the plan tree verbatim", () => {
const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" }); const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" });
const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map( const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map(
@@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
const record = (): LhMessage[] => [ const record = (): LhMessage[] => [
{ id: "old", role: "user", content: "dropped history", timestamp: 1 }, { id: "old", role: "user", content: "dropped history", timestamp: 1 },
{ id: "kept", role: "user", content: "kept tail", timestamp: 2 }, { id: "kept", role: "user", content: "kept tail", timestamp: 2 },
newCompactionMarker("SUPERSEDED SUMMARY", {
firstKeptMessageId: "kept",
tokensBefore: 98,
at: new Date(0).toISOString(),
}),
newCompactionMarker("SUMMARY", { newCompactionMarker("SUMMARY", {
firstKeptMessageId: "kept", firstKeptMessageId: "kept",
tokensBefore: 99, tokensBefore: 99,
@@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
: ((m.content as { text?: string }[])[0]?.text ?? ""); : ((m.content as { text?: string }[])[0]?.text ?? "");
assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail")); assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail"));
assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history")); assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history"));
assert.ok(!JSON.stringify(out).includes("SUPERSEDED SUMMARY"));
assert.equal(JSON.stringify(out).split(COMPACTED_HISTORY_HEADER).length - 1, 1);
}); });
it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => { it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => {
+259
View File
@@ -0,0 +1,259 @@
import React, { act, useLayoutEffect, useRef, useState } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { QuestionDock } from "../components/question-dock.tsx";
import { PracticeDock } from "../components/practice-dock.tsx";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
const observers: TestObserver[] = [];
class TestObserver {
elements = new Set<Element>();
disconnected = false;
constructor(readonly callback: () => void) { observers.push(this); }
observe(el: Element) { this.elements.add(el); }
disconnect() { this.disconnected = true; this.elements.clear(); }
}
Object.assign(globalThis, {
ResizeObserver: TestObserver,
getComputedStyle: (el: HTMLElement) => ({
borderTopWidth: el.dataset.part === "shell" ? "1px" : "0px",
borderBottomWidth: el.dataset.part === "shell" ? "1px" : "0px",
paddingTop: el.hasAttribute("data-dock-scroll-area") ? "12px" : "0px",
paddingBottom: "0px",
overflowY: el.style.overflowY || (el.classList.contains("overflow-y-auto") ? "auto" : "visible"),
}),
});
// Import after installing window so the hook uses its browser layout effect.
const { dockHeightBudget, syncDockHeight, useDockHeight, forwardDockWheel } = await import("../components/dock-height.ts");
const normal = {
columnHeight: 700, outsideHeight: 60, beforeShellHeight: 0,
shellBorderHeight: 2, contentHeight: 800, questionHeight: 600,
questionPaddingHeight: 12,
};
describe("dockHeightBudget — whole dock, not just its question", () => {
it("subtracts answer controls, toolbar, borders and breathing gap", () => {
assert.deepEqual(dockHeightBudget(normal), {
viewportMaxHeight: 608, shellMaxHeight: 608, questionMaxHeight: 406, scrollWholeContent: false,
});
});
it("accounts for growing answer fields and the separate reading draft", () => {
const result = dockHeightBudget({ ...normal, beforeShellHeight: 170, contentHeight: 880 });
assert.equal(result.questionMaxHeight, 156);
assert.equal(result.shellMaxHeight, 438);
});
it("preserves a computed zero and deliberately selects whole-content scrolling", () => {
const result = dockHeightBudget({ ...normal, columnHeight: 292 });
assert.equal(result.questionMaxHeight, 0);
assert.equal(result.scrollWholeContent, true);
});
it("switches before padding would leave a zero-sized content viewport, without an invented minimum", () => {
assert.equal(dockHeightBudget({ ...normal, columnHeight: 306 }).scrollWholeContent, true);
assert.equal(dockHeightBudget({ ...normal, columnHeight: 307 }).scrollWholeContent, false);
});
it("never creates negative caps, even if the toolbar exceeds the column", () => {
const result = dockHeightBudget({ ...normal, columnHeight: 20 });
assert.equal(result.viewportMaxHeight, 0);
assert.equal(result.shellMaxHeight, 0);
assert.equal(result.questionMaxHeight, 0);
assert.equal(result.scrollWholeContent, true);
});
it("collapsed bars use their visible height, and a tall reading draft can still scroll", () => {
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42 }).scrollWholeContent, false);
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42, beforeShellHeight: 650 }).scrollWholeContent, true);
});
});
// These mocked dimensions test the DOM/lifecycle seam, not real CSS layout.
// Live-browser checks separately exercise actual field sizing and WAAPI.
const sizes: Record<string, number> = {};
let animating = false;
dom.window.HTMLElement.prototype.getAnimations = () => animating ? [{} as Animation] : [];
const resetSizes = () => Object.assign(sizes, { column: 700, block: 862, viewport: 802, body: 802, shell: 802, content: 800, question: 600 });
const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
dom.window.HTMLElement.prototype.getBoundingClientRect = function () {
const part = this.hasAttribute("data-dock-scroll-area") ? "question" : this.dataset.part;
if (part && part in sizes) return { height: sizes[part]!, width: 700, x: 0, y: 0, top: 0, left: 0, bottom: sizes[part]!, right: 700, toJSON() {} };
return originalRect.call(this);
};
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get() { return this.dataset.part === "column" ? sizes.column : Number(this.dataset.clientHeight ?? 0); } });
Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get() { return this.dataset.part === "column" ? 680 : 700; } });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get() { return 700; } });
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", { configurable: true, get() { return Number(this.dataset.scrollHeight ?? 0); } });
const ignoreHeight = () => {};
function Fixture({ expanded = true, practice = false, enabled = true, inspected, onHeight = ignoreHeight }: {
expanded?: boolean; practice?: boolean; enabled?: boolean; inspected?: (block: HTMLDivElement) => void;
onHeight?: (height: number) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const blockRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [inset, setInset] = useState(0);
useDockHeight({ scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
expanded, enabled, mode: practice ? "practice:p1" : "question:q1", onScrollbarInset: setInset, onHeight });
// Like DockOverlay's later morph/reserve layout effect: constraints must
// already be applied in this same commit, not an after-paint correction.
useLayoutEffect(() => { if (blockRef.current) inspected?.(blockRef.current); });
return <><div ref={scrollRef} data-part="column" /><div ref={blockRef} data-part="block" data-inset={inset}>
<div ref={viewportRef} data-part="viewport"><div ref={bodyRef} data-part="body">
<div ref={shellRef} data-part="shell"><div ref={contentRef} data-part="content" key={practice ? "practice" : "question"}>
{practice ? <PracticeDock problemCallId="p1" input={{ statement: "Long practice problem" }} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
: <QuestionDock toolName="probe" toolCallId="q1" input={{ question: "Long teaching question", options: [{ label: "A" }, { label: "B" }], correctAnswer: "A", explanation: "A is correct." }} expanded={expanded} dictationClaim={false} onExpand={() => {}} onSubmit={() => {}} />}
</div></div>
</div></div><div data-testid="toolbar" />
</div></>;
}
async function withFixture(run: (host: HTMLElement, render: (props?: React.ComponentProps<typeof Fixture>) => Promise<void>) => Promise<void>) {
resetSizes();
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const render = async (props = {}) => { await act(async () => root.render(<Fixture {...props} />)); };
try { await run(host, render); }
finally { await act(async () => root.unmount()); host.remove(); }
}
const part = (host: HTMLElement, name: string) => host.querySelector<HTMLElement>(`[data-part="${name}"]`)!;
describe("measured question/practice DOM and observer lifecycle", () => {
it("applies constraints before the morph layout effect and handles classic scrollbar insets", async () => {
await withFixture(async (host, render) => {
await render({ inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
assert.equal(part(host, "block").dataset.inset, "10");
assert.equal(part(host, "viewport").style.overflowY, "visible");
assert.ok(!part(host, "viewport").contains(host.querySelector('[data-testid="toolbar"]')));
});
});
it("excludes the animated/clamped shell height rather than treating it as answer chrome", async () => {
await withFixture(async (host, render) => {
await render();
Object.assign(sizes, { block: 160, viewport: 100, body: 100, shell: 100 });
await act(async () => observers.at(-1)!.callback());
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
});
});
it("ignores the still-mounted hidden teaching form and sizes it synchronously on reveal", async () => {
await withFixture(async (host, render) => {
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42, question: 2000 });
await render({ expanded: false });
assert.ok(host.querySelector('[data-dock-scroll-area]'), "teaching form remains mounted");
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
assert.equal(part(host, "viewport").style.overflowY, "visible");
resetSizes();
await render({ expanded: true, inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
});
});
it("reconnects to practice's newly mounted question after collapsing and revealing", async () => {
await withFixture(async (host, render) => {
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42 });
await render({ practice: true, expanded: false });
const collapsedObserver = observers.at(-1)!;
assert.equal(host.querySelector('[data-dock-scroll-area]'), null);
resetSizes();
await render({ practice: true, expanded: true });
assert.equal(collapsedObserver.disconnected, true);
assert.ok(observers.at(-1)!.elements.has(host.querySelector('[data-dock-scroll-area]')!));
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
});
});
it("intentionally uncaps the question for a short window, then restores the measured cap on growth", async () => {
await withFixture(async (host, render) => {
sizes.column = 292;
await render();
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "none");
assert.equal(part(host, "viewport").style.overflowY, "auto");
assert.equal(part(host, "viewport").style.maxHeight, "200px");
assert.equal(part(host, "shell").style.maxHeight, "none");
sizes.column = 700;
await act(async () => observers.at(-1)!.callback());
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
assert.equal(part(host, "viewport").style.overflowY, "visible");
});
});
it("removes constraints in composer mode and disconnects observers on unmount", async () => {
let last: TestObserver;
await withFixture(async (host, render) => {
await render();
await render({ enabled: false });
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
assert.equal(part(host, "shell").style.maxHeight || "", "");
last = observers.at(-1)!;
});
assert.equal(last!.disconnected, true);
});
it("reports child-only resize heights to the reserve, but never mid-morph values", async () => {
await withFixture(async (_host, render) => {
const heights: number[] = [];
await render({ onHeight: h => heights.push(h) });
assert.equal(heights.at(-1), 862);
animating = true;
try {
sizes.block = 900;
await act(async () => observers.at(-1)!.callback());
assert.equal(heights.at(-1), 862, "animated block height was not reserved");
} finally { animating = false; }
await act(async () => observers.at(-1)!.callback());
assert.equal(heights.at(-1), 900, "settled child resize reaches the reserve without a parent render");
});
});
it("reconnects to a new keyed mode even when expansion is unchanged", async () => {
await withFixture(async (host, render) => {
await render();
const previous = observers.at(-1)!;
const oldContent = part(host, "content");
await render({ practice: true });
assert.equal(previous.disconnected, true);
assert.notEqual(part(host, "content"), oldContent);
assert.ok(observers.at(-1)!.elements.has(part(host, "content")));
});
});
it("keeps the DOM synchronizer deterministic when observed again with the same dimensions", async () => {
await withFixture(async (host, render) => {
await render();
const elements = { column: part(host, "column"), block: part(host, "block"), viewport: part(host, "viewport"),
body: part(host, "body"), shell: part(host, "shell"), content: part(host, "content") };
assert.deepEqual(syncDockHeight(elements, true), syncDockHeight(elements, true));
});
});
});
describe("wheel routing — question, short-window content, then transcript", () => {
it("keeps native wheel input in the question and then the whole-content viewport", () => {
const root = document.createElement("div");
root.innerHTML = '<div data-viewport style="overflow-y:auto"><div data-question style="overflow-y:auto"><span>text</span></div></div>';
const viewport = root.firstElementChild as HTMLElement;
const question = viewport.firstElementChild as HTMLElement;
const transcript = document.createElement("div"); transcript.scrollTop = 100;
for (const el of [viewport, question]) { el.dataset.scrollHeight = "400"; el.dataset.clientHeight = "100"; el.scrollTop = 50; }
const wheel = (deltaY: number, deltaMode = 0) => {
let prevented = false;
forwardDockWheel({ target: question.firstElementChild, deltaY, deltaMode, preventDefault() { prevented = true; } } as unknown as WheelEvent, root, transcript);
return prevented;
};
assert.equal(wheel(-20), false, "question can scroll up");
question.scrollTop = 0;
assert.equal(wheel(-20), false, "whole content can scroll up when question is at its edge");
viewport.scrollTop = 0;
assert.equal(wheel(-20), true);
assert.equal(transcript.scrollTop, 80);
question.scrollTop = viewport.scrollTop = 300;
assert.equal(wheel(1, 1), true);
assert.equal(transcript.scrollTop, 96, "line-mode wheel is normalized");
viewport.style.overflowY = "visible"; viewport.scrollTop = 50;
assert.equal(wheel(-20), false, "question still consumes upward movement");
question.style.overflowY = "hidden";
assert.equal(wheel(-20), true, "hidden and visible boxes are not scroll containers");
assert.equal(wheel(0), false);
});
});
+298
View File
@@ -0,0 +1,298 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { SetupDialog } from "../components/setup-dialog.tsx";
import { AgentSection } from "../components/settings/agent-section.tsx";
import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx";
import { useModelCatalog } from "../components/model-catalog.ts";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import { useModelSetup, savedSlotsConfigured, selectedModelUnavailable, openModelPicker } from "../components/setup-cta.tsx";
import { StatsBar } from "../components/stats-bar.tsx";
import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
cancelAnimationFrame: (id: number) => clearTimeout(id),
});
Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const originalFetch = globalThis.fetch;
let root: Root | undefined;
let host: HTMLDivElement | undefined;
let requests: ReturnType<typeof deferred<Response>>[] = [];
let writes: ModelsSettings[] = [];
let snapshot: ReturnType<typeof useModelCatalog>;
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
const provider = { id: "fixture", envVar: "FIXTURE_KEY", label: "Fixture" };
const a: ModelCatalogEntry = { id: "vendor/model:free", name: "Model free", provider: provider.id, input: ["text", "image"], reasoning: true, supportedLevels: ["off", "low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
const b: ModelCatalogEntry = { ...a, id: "claude-opus-5-5", name: "Claude Opus 5.5", supportedLevels: ["off", "medium"] };
function catalog(connected = true): ModelCatalogResponse {
return {
models: connected ? [a, b] : [], defaults: null,
rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null },
slots: { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, cheap: { model: { provider: a.provider, id: a.id }, thinkingLevel: "high" }, vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } },
providers: [{ ...provider, keyPresent: connected }],
};
}
function Reader() { snapshot = useModelCatalog(); return null; }
function setup(onClose = () => {}, onDone = (_models: ModelsSettings) => {}) { return <SetupDialog open onClose={onClose} onDone={onDone} />; }
function installFetch() {
requests = []; writes = [];
globalThis.fetch = async (input, init) => {
const path = String(input);
if (path === "/api/models") { const request = deferred<Response>(); requests.push(request); return request.promise; }
if (path === "/api/settings/models") { writes.push(JSON.parse(String(init?.body))); return Response.json({ settings: { models: writes.at(-1) } }); }
if (path === "/api/keys") return Response.json({ providers: [provider], keys: [] });
if (path === "/api/settings/prompts") return Response.json({ prober: [], teacher: [] });
if (path === "/api/search") return Response.json({ available: false, engine: null, reason: "fixture" });
throw new Error(`Unexpected request ${path}`);
};
}
async function render(children: React.ReactNode) {
if (!root) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); }
await act(async () => root!.render(children));
}
async function answer(index: number, body: ModelCatalogResponse | null) { await act(async () => requests[index]!.resolve(body ? Response.json(body) : new Response("unavailable", { status: 503 }))); }
function element(testid: string) { const e = document.querySelector<HTMLElement>(`[data-testid="${testid}"]`); assert.ok(e, testid); return e; }
async function click(testid: string) { await act(async () => element(testid).click()); }
function levels() { return [...document.querySelectorAll('[data-testid^="thinking-option-"]')].map((e) => e.textContent); }
async function warm(body = catalog()) { await render(<Reader />); await answer(0, body); }
afterEach(async () => {
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
globalThis.fetch = originalFetch;
});
test("cold setup is loading, not keyless; failure/Retry does not invent a key stage", async () => {
installFetch(); await render(setup());
assert.ok(element("setup-stage-loading"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
await answer(0, null);
assert.ok(element("setup-catalog-error"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
await click("setup-retry"); assert.ok(element("setup-stage-loading"));
const empty = catalog(false); empty.slots = { strong: null, cheap: null, vision: null };
await answer(1, empty); assert.ok(element("setup-stage-keys"));
});
test("cached setup is immediately usable; fresh metadata/untouched slots update without wiping model or thinking edits; failed refresh still allows exact-ID Save", async () => {
installFetch(); await warm();
await render(<><Reader />{setup()}</>);
assert.equal(requests.length, 2);
assert.ok(element("setup-stage-pickers"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5");
await click("setup-thinking-strong"); await click("thinking-option-medium");
const fresh = catalog(); fresh.slots.cheap = { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" };
fresh.models[1] = { ...b, supportedLevels: ["off", "medium", "high"] };
await answer(1, fresh);
assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾");
assert.equal(element("setup-trigger-cheap").textContent, "claude-opus-5-5▾");
await click("setup-thinking-strong"); assert.deepEqual(levels(), ["off", "medium✓", "high"]);
await click("setup-thinking-strong");
await act(async () => snapshot.refresh()); await answer(2, null);
assert.ok(element("setup-catalog-error")); assert.equal(element("setup-save").hasAttribute("disabled"), false);
await click("setup-save");
assert.deepEqual(writes[0], {
strong: { model: { provider: b.provider, id: b.id }, thinkingLevel: "medium" },
cheap: { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" },
vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" },
});
});
test("zero providers always means exclusive connection stage; reconnect with all saved slots only closes, even if every selection remains unavailable", async () => {
installFetch(); await warm();
let closed = 0, completed = 0;
await render(<><Reader />{setup(() => { closed++; }, () => { completed++; })}</>); await answer(1, catalog());
await click("setup-trigger-strong");
await act(async () => notifySettingsSaved("keys"));
assert.ok(element("setup-stage-loading"));
assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null);
await answer(2, catalog(false));
assert.ok(element("setup-stage-keys"));
assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null);
assert.equal(document.querySelector('[data-testid="setup-add-provider"]'), null);
assert.equal(document.querySelector('[data-testid="setup-save"]'), null);
await act(async () => notifySettingsSaved("keys"));
const other = catalog(false); other.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
await answer(3, other);
assert.equal(closed, 1); assert.equal(completed, 0); assert.deepEqual(writes, []);
assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null, "no intermediate picker frame after reconnect");
});
test("cold saved disconnected pins still enter the provider stage, without any model-selector frame", async () => {
installFetch(); await warm();
await act(async () => notifySettingsSaved("keys"));
const stages: Array<string | undefined> = [];
function Probe() {
const { catalog } = useModelCatalog();
React.useLayoutEffect(() => { stages.push(document.querySelector<HTMLElement>('[data-testid^="setup-stage-"]')?.dataset.testid); }, [catalog]);
return null;
}
await render(<><Reader />{setup()}<Probe /></>);
await answer(1, catalog(false));
assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-keys"]);
});
for (const count of [0, 1, 2]) test(`connecting with ${count} saved slots only requires the missing slots; stale identities/thinking survive`, async () => {
installFetch(); const before = catalog(false);
if (count < 1) before.slots.strong = null;
if (count < 2) before.slots.cheap = null;
before.slots.vision = null;
if (before.slots.strong) before.slots.strong.thinkingLevel = "xhigh";
await warm(before);
let closed = 0; await render(<><Reader />{setup(() => { closed++; })}</>); await answer(1, before);
assert.ok(element("setup-stage-keys"));
const connected = { ...before, models: [{ ...b, provider: "other" }], providers: [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }] };
await act(async () => notifySettingsSaved("keys")); await answer(2, connected);
assert.ok(element("setup-stage-pickers")); assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); assert.equal(closed, 0);
for (const slot of ["strong", "cheap", "vision"] as const) {
if (before.slots[slot]) continue;
await click(`setup-trigger-${slot}`); await click("model-option-claude-opus-5-5");
}
if (count > 0) assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾");
await click("setup-save");
for (const slot of ["strong", "cheap", "vision"] as const) {
if (before.slots[slot]) assert.deepEqual(writes[0]?.[slot], before.slots[slot]);
else assert.equal(writes[0]?.[slot]?.model?.provider, "other");
}
});
test("a partial setup's model/thinking edits survive exclusive provider recovery, but reopening discards the abandoned draft", async () => {
installFetch(); const before = catalog(); before.slots.cheap = null; before.slots.vision = null;
await warm(before); await render(<><Reader />{setup()}</>); await answer(1, before);
await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5");
await click("setup-thinking-strong"); await click("thinking-option-medium");
await act(async () => notifySettingsSaved("keys")); await answer(2, { ...before, models: [], providers: [] });
assert.ok(element("setup-stage-keys")); assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null);
await act(async () => notifySettingsSaved("keys")); await answer(3, before);
assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾");
await render(<Reader />); await render(<><Reader />{setup()}</>);
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); await answer(4, before);
});
test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => {
installFetch(); await warm();
const models: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } };
let changed: ModelsSettings | undefined;
await render(<><Reader /><AgentSection agent={undefined} models={models} graph={undefined} compaction={undefined} onSectionChange={() => {}} onModelsChange={(next) => { changed = next; }} onCompactionChange={() => {}} /></>);
await click("slot-thinking-strong"); assert.deepEqual(levels(), ["off", "low✓", "high"]);
assert.equal(element("slot-model-strong").textContent, "model:free▾");
await answer(1, catalog());
await act(async () => notifySettingsSaved("keys")); await answer(2, catalog(false));
assert.equal(element("slot-thinking-strong").textContent, "low▾");
await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id);
});
test("saved completeness follows ordinary repairs and survives temporary connection invalidation", async () => {
installFetch(); let state: ReturnType<typeof useModelSetup>;
function StateReader() { state = useModelSetup(false); return null; }
await render(<StateReader />); await answer(0, catalog());
assert.equal(state!.configured, true);
await act(async () => notifySettingsSaved("keys")); assert.equal(state!.configured, true);
const partial = catalog(false); partial.slots.vision = null;
await answer(1, partial); assert.equal(state!.configured, false); assert.equal(state!.zeroKeys, true);
await act(async () => notifySettingsSaved("models")); await answer(2, catalog(false));
assert.equal(state!.configured, true); assert.equal(state!.zeroKeys, true, "saved is not connected");
});
test("blocked session recovery opens its ordinary picker, never changes Strong or substitutes a model", async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
body.slots.strong = { model: { provider: "other", id: b.id }, thinkingLevel: "medium" };
const globals: ModelsSettings = { strong: body.slots.strong };
const session = { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" };
const requestsOnly = globalThis.fetch; const sessionWrites: unknown[] = [];
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return Response.json({ settings: session });
if (input === "/api/chats/fixture/settings") { const next = JSON.parse(String(init?.body)); sessionWrites.push(next); return Response.json({ settings: next }); }
return requestsOnly(input, init);
};
await warm(body); let selected: string | undefined;
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { selected = id; }} /></>);
assert.equal(selected, `${a.provider}:${a.id}`, "current session does not inherit a different Strong pin");
assert.equal(selectedModelUnavailable(body, selected), true);
assert.deepEqual(sessionWrites, []);
await act(async () => openModelPicker());
assert.ok(element("model-menu")); assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null);
assert.equal(document.querySelector('[data-testid="model-option-vendor_model_free"]'), null);
await click("model-option-claude-opus-5-5");
assert.deepEqual(sessionWrites, [{ model: { provider: "other", id: b.id }, thinkingLevel: "xhigh" }]);
assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong });
});
for (const result of ["override", "legacy", "http-failure", "network-failure"] as const) test(`known catalog never enforces provisional Strong on a pending session; session read ${result}`, async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
// Global Strong is unavailable, but this existing session owns a valid
// model. Resolve global settings first while keeping its record pending.
const globals: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" } };
let resolveSession!: (response: Response) => void;
let rejectSession!: (reason: Error) => void;
const sessionRead = new Promise<Response>((resolve, reject) => { resolveSession = resolve; rejectSession = reject; });
const requestsOnly = globalThis.fetch;
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return sessionRead;
return requestsOnly(input, init);
};
await warm(body); const identities: Array<string | undefined> = [];
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { identities.push(id); }} /></>);
assert.ok(snapshot.catalog, "catalog is already known, not the deferred unknown-catalog case");
assert.equal(element("stats-model").textContent, "—▾");
assert.equal(identities.at(-1), undefined);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), false, "pending session cannot block Send using global Strong");
await act(async () => {
if (result === "network-failure") rejectSession(new Error("fixture connection failure"));
else if (result === "http-failure") resolveSession(Response.json({ error: "fixture unavailable" }, { status: 503 }));
else resolveSession(Response.json(result === "legacy" ? {} : { settings: { model: { provider: available.provider, id: available.id }, thinkingLevel: "medium" } }));
});
const expected = result === "legacy" ? `${a.provider}:${a.id}` : result === "override" ? `other:${b.id}` : undefined;
assert.equal(identities.at(-1), expected);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), result === "legacy", "only a confirmed legacy record may inherit the unavailable Strong pin");
assert.equal(document.querySelector('[data-testid="model-menu"]'), null);
assert.deepEqual(writes, []);
});
test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => {
const body = catalog(false);
assert.equal(savedSlotsConfigured(body.slots), true);
assert.equal(selectedModelUnavailable(body, `${a.provider}:${a.id}`), true);
assert.equal(selectedModelUnavailable(undefined, `${a.provider}:${a.id}`), false);
assert.equal(selectedModelUnavailable(body, undefined), false);
body.models = [b]; body.providers = [{ ...provider, keyPresent: true }];
assert.equal(selectedModelUnavailable(body, `${b.provider}:${b.id}`), false);
body.slots.cheap = null; assert.equal(savedSlotsConfigured(body.slots), false);
});
test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => {
installFetch(); await warm();
await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh());
const fresh = catalog(); fresh.models = [b];
await answer(2, fresh); await answer(1, null);
assert.equal(snapshot.failed, false); assert.equal(snapshot.catalog?.models[0]?.id, b.id);
await act(async () => snapshot.refresh()); await answer(3, null);
assert.equal(snapshot.failed, true); assert.equal(snapshot.catalog?.models[0]?.id, b.id);
await act(async () => snapshot.refresh()); assert.equal(snapshot.failed, false);
await answer(4, fresh);
});
test("OpenCode's saved shared credential names both Pi providers without changing addable-provider semantics", () => {
const providers = [{ id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" }, { id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" }];
assert.equal(providerKeyLabel("OPENCODE_API_KEY", providers), "OpenCode (Zen / Go)");
assert.equal(providerKeyLabel("OPENCODE_API_KEY", [...providers].reverse()), "OpenCode (Zen / Go)");
assert.equal(providerKeyLabel("FIXTURE_KEY", [provider]), "Fixture");
assert.equal(classifyKeys([{ id: "OPENCODE_API_KEY", info: null, present: true, masked: "…fake", source: "file", shadowedByEnv: false }], providers).addable.length, 0);
});
+165
View File
@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, beforeEach, test } from "node:test";
import type { ModelsSettings, ModelSlotName } from "../src/core/settings.ts";
import type { NativeSubscriptionSnapshot } from "../src/core/contracts/subscriptions.ts";
// Every app import follows isolation. No installed credentials, ambient keys,
// OAuth, or provider requests: subscription state comes from synthetic IPC.
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-models-repair-"));
process.env.LH_DATA_DIR = directory;
process.env.LH_AUTH_FILE = path.join(directory, "auth.json");
process.env.LH_IGNORE_ENV_KEYS = "1";
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
process.env.LH_DESKTOP_SERVER = "1";
process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: true });
const { piModels, piEngineForSpec } = await import("../src/agent/models.ts");
const { refreshSubscriptionModels } = await import("../src/agent/subscription-bridge.ts");
const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts");
const { readSettings: readSettingsRecord, writeSettings: writeSettingsRecord } = await import("../src/store/settings.ts");
const { PUT } = await import("../app/api/settings/[section]/route.ts");
const { clampThinkingLevel, normalizeContext } = await import("@earendil-works/pi-ai");
const native = piModels().getModels("github-copilot")[0]!;
const copilot = { provider: "github-copilot-subscription", id: native.id };
const replacement = { provider: "openrouter", id: "z-ai/glm-5.3-flash" };
const slots: ModelSlotName[] = ["strong", "cheap", "vision"];
const initial: ModelsSettings = Object.fromEntries(slots.map((slot) => [slot, { model: copilot, thinkingLevel: "high" }]));
let connected = false, revision = 0, leases = 0;
function snapshot(): NativeSubscriptionSnapshot {
return {
statuses: [{ providerId: "github-copilot", available: true, connected, pending: false, allowBackgroundUse: true, connectionId: connected ? "fixture-connection" : null, epoch: revision, authorityRevision: revision, revision }],
models: { "github-copilot": connected ? [native] : [] },
};
}
Object.defineProperty(process, "send", { configurable: true, value: (message: { id: string; op: string }, callback?: () => void) => {
if (message.op !== "snapshot") { leases++; throw new Error("No credential lease expected"); }
queueMicrotask(() => (process.emit as (event: string, value: unknown) => boolean)("message", { channel: "lh:subscriptions", id: message.id, op: message.op, ok: true, value: snapshot() }));
callback?.(); return true;
} });
async function connection(value: boolean) { connected = value; revision++; await refreshSubscriptionModels(); }
async function put(body: unknown) {
return PUT(new Request("http://localhost/api/settings/models", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), { params: Promise.resolve({ section: "models" }) });
}
beforeEach(async () => {
await connection(false);
writeSettingsRecord({ version: 1, models: initial, general: { autoUpdate: false }, graph: { rendering: false } });
});
after(() => {
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
for (const [key, value] of [["LH_DESKTOP_SERVER", marker], ["LH_DESKTOP_API_SECRET", secret]]) {
if (value === undefined) delete process.env[key!]; else process.env[key!] = value;
}
fs.rmSync(directory, { recursive: true, force: true });
});
for (const slot of slots) test(`${slot} can be replaced while the other disconnected slots remain saved`, async () => {
const response = await put({ ...initial, [slot]: { model: replacement, thinkingLevel: "xhigh" } });
assert.equal(response.status, 200, JSON.stringify(await response.clone().json()));
const stored = readSettingsRecord()!;
assert.deepEqual(stored.models?.[slot], { model: replacement, thinkingLevel: "max" });
for (const other of slots.filter((name) => name !== slot)) assert.deepEqual(stored.models?.[other], initial[other]);
assert.deepEqual(stored.general, { autoUpdate: false });
assert.deepEqual(stored.graph, { rendering: false });
assert.deepEqual((await response.json()).settings, stored);
assert(!modelCatalog().models.some((model) => model.provider === copilot.provider), "saving stale pins must not make them available choices");
});
test("all three slots can be repaired successively without reconnecting", async () => {
for (const slot of slots) {
const response = await put({ ...readSettingsRecord()!.models, [slot]: { model: replacement } });
assert.equal(response.status, 200);
}
assert.deepEqual(readSettingsRecord()!.models, Object.fromEntries(slots.map((slot) => [slot, { model: replacement }])));
});
test("thinking preferences on unchanged disconnected models can be changed, removed, and restored", async () => {
for (const thinkingLevel of ["max", undefined, "minimal"] as const) {
const strong = { model: copilot, ...(thinkingLevel ? { thinkingLevel } : {}) };
const response = await put({ ...initial, strong });
assert.equal(response.status, 200);
assert.deepEqual(readSettingsRecord()!.models?.strong, strong);
}
await connection(true);
const engine = piEngineForSpec({ ...copilot, thinkingLevel: "minimal" }, false);
assert.equal(engine.level, clampThinkingLevel(native, "minimal"), "reconnected runtime clamps the saved preference");
assert.equal((await put(readSettingsRecord()!.models)).status, 200);
assert.equal(readSettingsRecord()!.models?.strong?.thinkingLevel, engine.level, "known metadata still clamps on write");
});
test("introduced or replaced unknown models still reject, without writing any section", async () => {
for (const model of [
{ provider: copilot.provider, id: "fixture-not-in-catalog" },
{ provider: "xai-subscription", id: copilot.id },
{ provider: "fixture-unknown-provider", id: copilot.id },
{ provider: replacement.provider, id: "fixture-not-in-catalog" },
]) {
const before = readSettingsRecord();
assert.equal((await put({ ...initial, strong: { model } })).status, 400);
assert.deepEqual(readSettingsRecord(), before);
}
writeSettingsRecord({ version: 1, models: { strong: initial.strong } });
assert.equal((await put({ strong: initial.strong, cheap: initial.strong })).status, 400, "a pin saved in one slot cannot authorize another slot");
assert.equal((await put(initial)).status, 400, "missing slots cannot introduce disconnected models");
});
test("a first models write cannot introduce disconnected selections", async () => {
fs.unlinkSync(path.join(directory, "settings.json"));
assert.equal((await put(initial)).status, 400);
assert.equal(readSettingsRecord(), null);
});
test("a replaced stale selection cannot be reintroduced without availability", async () => {
assert.equal((await put({ ...initial, strong: { model: replacement } })).status, 200);
assert.equal((await put(initial)).status, 400);
assert.deepEqual(readSettingsRecord()!.models?.strong?.model, replacement);
});
test("slot removal still replaces the whole section, including clearing all slots", async () => {
assert.equal((await put({ cheap: initial.cheap })).status, 200);
assert.deepEqual(readSettingsRecord()!.models, { cheap: initial.cheap });
assert.equal((await put({})).status, 200);
assert.deepEqual(readSettingsRecord()!.models, {});
});
test("unchanged model identity does not bypass body shape validation", async () => {
for (const body of [
{ ...initial, strong: { model: copilot, thinkingLevel: 7 } },
{ ...initial, strong: { thinkingLevel: "high" } },
{ ...initial, strong: { model: { provider: copilot.provider } } },
]) assert.equal((await put(body)).status, 400);
assert.deepEqual(readSettingsRecord()!.models, initial);
});
test("standalone catalog validation stays strict without a stored section", () => {
assert.equal(validateModelsSettings(initial).ok, false);
assert.equal(validateModelsSettings({ strong: { model: replacement } }).ok, true);
});
test("retained selections remain unavailable at runtime, with no API-key fallback or credential lease", async () => {
await connection(true);
const engine = piEngineForSpec(copilot, false);
await connection(false);
assert.equal((await put({ ...initial, cheap: { model: replacement } })).status, 200);
assert.throws(() => piEngineForSpec(copilot, false), /catalog has no model/);
let requests = 0;
const before = leases;
const key = process.env.COPILOT_GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "fixture-must-not-be-used";
try {
const result = await engine.streamFn(engine.model, normalizeContext({ messages: [] }), { fetch: async () => { requests++; throw new Error("Must not dispatch"); } }).result();
assert.equal(result.stopReason, "error");
assert.match(result.errorMessage ?? "", /No API-key fallback was used/);
assert.equal(requests, 0);
assert.equal(leases, before);
} finally {
if (key === undefined) delete process.env.COPILOT_GITHUB_TOKEN; else process.env.COPILOT_GITHUB_TOKEN = key;
}
});
+12 -4
View File
@@ -89,9 +89,15 @@ describe("stats bar", () => {
assert.match(empty, /data-testid="stats-context">\?</); assert.match(empty, /data-testid="stats-context">\?</);
}); });
it("prettyModel strips vendor + date suffix, keeps dashes", () => { it("prettyModel strips only the vendor path, preserving versions, dates and variants", () => {
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5) assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max"); assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max-0902");
assert.equal(prettyModel("google/gemma-4-27b-it:free"), "gemma-4-27b-it:free");
assert.equal(prettyModel("anthropic/claude-opus-5:batch"), "claude-opus-5:batch");
assert.equal(prettyModel("claude-opus-5"), "claude-opus-5");
assert.equal(prettyModel("claude-opus-5-5"), "claude-opus-5-5");
assert.equal(prettyModel("model:free"), "model:free");
assert.equal(prettyModel("model:variant:free"), "model:variant:free");
}); });
it("provider-qualified stamps (usage contract, model-slots wave)", () => { it("provider-qualified stamps (usage contract, model-slots wave)", () => {
@@ -99,8 +105,10 @@ describe("stats bar", () => {
// the same model via two providers different bookkeeping // the same model via two providers different bookkeeping
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter"); assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter");
assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai"); assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai");
// unqualified on a qualified stamp: provider silently dropped (old shape) assert.equal(prettyModel("openrouter:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter");
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash"); assert.equal(prettyModel("opencode:claude-opus-5-5", true), "claude-opus-5-5 · opencode");
// Bare ids are never parsed as usage stamps, even with a colon.
assert.equal(prettyModel("model:free"), "model:free");
// qualified on a bare id: no suffix invented // qualified on a bare id: no suffix invented
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash"); assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash");
}); });
+1 -1
View File
@@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", (
}); });
it("setupStage: keys before models (owner's dependency ordering)", () => { it("setupStage: keys before models (owner's dependency ordering)", () => {
assert.equal(setupStage(null), "keys", "fetch failed → the honest fallback"); assert.equal(setupStage(null), "loading", "unknown catalog must not claim keys are missing");
assert.equal(setupStage([]), "keys", "no providers = keys"); assert.equal(setupStage([]), "keys", "no providers = keys");
assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless"); assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless");
assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers"); assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers");