Merge accepted local main into PR #184 working branch
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
|
||||
* supported list where one exists. The chat and models sections
|
||||
* 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
|
||||
* level CLAMPS to the named model's nearest supported level and 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) {
|
||||
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;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
// Model slots wave (2026-09-20): the models section gets the same
|
||||
// catalog gate the chat section gets — a slot must name a real
|
||||
// provider+model and a thinking level it supports.
|
||||
// New/replaced slot models pass the catalog gate. Unchanged saved model
|
||||
// references may survive disconnect, including thinking-preference edits,
|
||||
// so each unavailable slot can be repaired independently of the others.
|
||||
if (section === "models") {
|
||||
const verdict = validateModelsSettings(parsed.settings as ModelsSettings);
|
||||
const verdict = validateModelsSettings(parsed.settings as ModelsSettings, record.models);
|
||||
if (!verdict.ok) {
|
||||
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
|
||||
// along untouched (other sections, unknown keys — sanitize preserves).
|
||||
const record = readSettingsRecord() ?? { version: 1 };
|
||||
const next = { ...record, [section]: sectionOut };
|
||||
writeSettingsRecord(next);
|
||||
return Response.json({ settings: next });
|
||||
|
||||
+71
-93
@@ -5,7 +5,7 @@ import { useChatSession } from "@/components/session";
|
||||
import { visibleQueuedSteers } from "@/components/session-core";
|
||||
import { SetupDialog } from "@/components/setup-dialog";
|
||||
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 {
|
||||
Conversation,
|
||||
@@ -26,6 +26,7 @@ import { Composer } from "@/components/composer";
|
||||
import { useEnterSubmits } from "@/components/enter-submits";
|
||||
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
||||
import { dockStateKey } from "@/components/dock-state-key";
|
||||
import { forwardDockWheel, useDockHeight } from "@/components/dock-height";
|
||||
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
||||
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
||||
import { useFieldCycle } from "@/components/field-cycle";
|
||||
@@ -79,8 +80,6 @@ import {
|
||||
} from "@/src/core";
|
||||
import { ReadingThinkingField } from "@/components/reading-thinking";
|
||||
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. */
|
||||
/**
|
||||
@@ -555,30 +554,18 @@ export function Chat({
|
||||
textRef.current = v;
|
||||
setText(v);
|
||||
}, []);
|
||||
// The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the
|
||||
// refusal interception share one SetupDialog mount. `configured` is
|
||||
// seeded server-side (slotsConfigured on the record) and flips on Save.
|
||||
const [configured, setConfigured] = useState(initialConfigured);
|
||||
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
||||
// takes precedence over the models CTA (keys come first in the chain).
|
||||
// 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(() => {});
|
||||
});
|
||||
// Saved slot completeness is live, but the session's selected route
|
||||
// comes from its own ordinary model control, never today's Strong pin.
|
||||
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||
const catalogRef = useRef(catalog);
|
||||
catalogRef.current = catalog;
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
/** The last explicitly-sent draft (composer send): 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).
|
||||
* 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);
|
||||
|
||||
// THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
|
||||
@@ -594,8 +581,9 @@ export function Chat({
|
||||
if (!error) return;
|
||||
const missing = parseModelsRefusal(error.message);
|
||||
// The key-refusal twin (zero-keys wave): a pinned model whose
|
||||
// provider key is gone gets the SAME treatment — revert-draft and
|
||||
// open setup (the dialog derives to its first-key stage on its own).
|
||||
// provider key is gone also restores the draft. With another provider
|
||||
// connected, repair belongs to the ordinary current-session picker;
|
||||
// otherwise setup derives its exclusive provider/loading stage.
|
||||
const keyRefused = parseProviderKeyRefusal(error.message);
|
||||
if (!missing && !keyRefused) return; // a real error keeps the honest banner
|
||||
// ORDER MATTERS: clearError() flips this effect's own dep (`error`)
|
||||
@@ -615,7 +603,8 @@ export function Chat({
|
||||
draftRef.current = null;
|
||||
}
|
||||
clearError();
|
||||
setSetupOpen(true);
|
||||
if (keyRefused && catalogRef.current?.providers.some((p) => p.keyPresent)) openModelPicker();
|
||||
else setSetupOpen(true);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -1152,6 +1141,14 @@ export function Chat({
|
||||
if (busy || pending || compacting) return;
|
||||
const t = textRef.current.trim();
|
||||
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
|
||||
applyText("");
|
||||
void send({ type: "text", text: t });
|
||||
@@ -1616,6 +1613,7 @@ export function Chat({
|
||||
compactionRunning={compacting}
|
||||
onSubmitThinking={submitThinking}
|
||||
chatId={chatId}
|
||||
onModelChange={setSelectedModel}
|
||||
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
||||
onOpenPlan={() => setPlanOpen(true)}
|
||||
requiresReveal={requiresReveal}
|
||||
@@ -1719,7 +1717,6 @@ export function Chat({
|
||||
onClose={() => setSetupOpen(false)}
|
||||
onDone={() => {
|
||||
setSetupOpen(false);
|
||||
setConfigured(true);
|
||||
// Save only saves (owner ruling 2026-10-09): the draft went
|
||||
// home into the composer at refusal time and STAYS there —
|
||||
// the learner sends it themselves.
|
||||
@@ -1854,6 +1851,7 @@ function DockOverlay({
|
||||
composer,
|
||||
onSubmitAnswer,
|
||||
chatId,
|
||||
onModelChange,
|
||||
onReserve,
|
||||
plan,
|
||||
onOpenPlan,
|
||||
@@ -1890,6 +1888,7 @@ function DockOverlay({
|
||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||
/** The model control's pass-through (the stats bar hosts it). */
|
||||
chatId: string;
|
||||
onModelChange: (model: string | undefined) => void;
|
||||
/** Reports the dock's settled height (block + gradient) — ChatView's
|
||||
* dockReserve state feeds the transcript's spacer from it. */
|
||||
onReserve: (h: number) => void;
|
||||
@@ -1984,6 +1983,17 @@ function DockOverlay({
|
||||
const revealedRef = useRef(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 DOCK container's right edge to the block's right edge — the bare
|
||||
// diagram's canvas. Tracked LIVE by a ResizeObserver on the block
|
||||
@@ -2022,28 +2032,33 @@ function DockOverlay({
|
||||
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
|
||||
// round 2): the scroller reserves symmetric gutters (scrollbar-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.
|
||||
// The same measure owns the QUESTION/PROBLEM HEIGHT CAP (owner round,
|
||||
// 2026-10-05): the old fixed fractions (55dvh / 40vh) cut long problems
|
||||
// off absurdly short — the cap is now the AVAILABLE COLUMN (the
|
||||
// scroller already starts below the top bar) minus a 2rem breathing
|
||||
// gap, carried as --dock-max-h for the docks' scroll areas.
|
||||
// Budget the whole solid dock, leaving only its question area flexible.
|
||||
// The hook measures natural, visible content before the morph/reserve
|
||||
// effect below. Short windows scroll the reading/question/answer body;
|
||||
// the stats/model toolbar remains outside that overflow viewport.
|
||||
const [sbInset, setSbInset] = useState(0);
|
||||
const [dockMaxH, setDockMaxH] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
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]);
|
||||
useDockHeight({
|
||||
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
|
||||
expanded, enabled: !!pending || !!practice, mode, onScrollbarInset: setSbInset, onHeight: offer,
|
||||
});
|
||||
|
||||
// GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a
|
||||
// live turn the transcript content grows and stick-to-bottom chases it;
|
||||
@@ -2079,48 +2094,11 @@ function DockOverlay({
|
||||
const root = blockRef.current;
|
||||
const el = scrollRef.current;
|
||||
if (!root || !el) return;
|
||||
const canScroll = (n: HTMLElement, dir: number) => {
|
||||
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);
|
||||
};
|
||||
const onWheel = (e: WheelEvent) => forwardDockWheel(e, root, el);
|
||||
root.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => root.removeEventListener("wheel", onWheel);
|
||||
}, [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
|
||||
// hundreds of ms, during which the position is still legitimately inside
|
||||
// the collapse zone — the machine would instantly re-collapse the dock
|
||||
@@ -2222,11 +2200,6 @@ function DockOverlay({
|
||||
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
|
||||
// reserved for mid-session transitions.
|
||||
const initialModeRef = useRef(mode);
|
||||
@@ -2234,11 +2207,9 @@ function DockOverlay({
|
||||
// Height morph: animate the shell from its previous height to its new one
|
||||
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
||||
// 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
|
||||
// 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.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const prevExpandedForFadeRef = useRef(expanded);
|
||||
useIsoLayoutEffect(() => {
|
||||
if (prevExpandedForFadeRef.current === expanded) return;
|
||||
@@ -2286,8 +2257,10 @@ function DockOverlay({
|
||||
return (
|
||||
<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 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 ref={viewportRef} data-dock-viewport="" className="pointer-events-auto">
|
||||
<div ref={bodyRef}>
|
||||
{setupCta}
|
||||
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
|
||||
one-liners above the shell - right-aligned to where its corner
|
||||
@@ -2316,11 +2289,13 @@ function DockOverlay({
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
ref={contentRef}
|
||||
key={mode}
|
||||
className={mode === initialModeRef.current ? undefined : "dock-variant"}
|
||||
>
|
||||
ref={contentRef}
|
||||
key={mode}
|
||||
className={mode === initialModeRef.current ? "flow-root" : "flow-root dock-variant"}
|
||||
>
|
||||
{pending ? (
|
||||
<QuestionDock
|
||||
toolName={pending.tool as "quiz" | "probe" | "ask_user_question" | "explain" | "recall"}
|
||||
@@ -2355,6 +2330,8 @@ function DockOverlay({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* The stats row: beneath the composer/question shell, not
|
||||
flush to the screen bottom — static height, outside the
|
||||
morphing shell so it never joins a height animation. */}
|
||||
@@ -2364,6 +2341,7 @@ function DockOverlay({
|
||||
pricing={pricing}
|
||||
dictationSlot={dictationSlot}
|
||||
chatId={chatId}
|
||||
onModelChange={onModelChange}
|
||||
/>
|
||||
{/* The scroll-down disc (moved here from the Conversation,
|
||||
owner refinement round 2026-09-24): anchored to the BLOCK's
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@ import { GraphView } from "@/components/graph-view";
|
||||
import { MinimalHome } from "@/components/minimal-home";
|
||||
import { SetupDialog } from "@/components/setup-dialog";
|
||||
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 { useFieldCycle } from "@/components/field-cycle";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
@@ -41,15 +41,8 @@ export function Home({
|
||||
// start until the user has picked all three slots — no defaults for
|
||||
// 33 providers. The dock shows the CTA while unconfigured; submitting
|
||||
// a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
|
||||
// stores the slots and the queued message sends).
|
||||
const [configured, setConfigured] = useState(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();
|
||||
// stores slots only; the learner sends the message themselves).
|
||||
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
// 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.
|
||||
@@ -123,13 +116,18 @@ export function Home({
|
||||
// models case guards BEFORE creation; the key cases must too — a
|
||||
// refused first send would otherwise leave an orphan error-shell
|
||||
// chat in the sidebar (the 503 lands only after POST /api/chats
|
||||
// already created the record). Same skip-everything-and-open-setup
|
||||
// treatment; the server's 503 gate remains the belt for /chat/[id]
|
||||
// sessions.
|
||||
if (zeroKeys || strongKeyless) {
|
||||
// already created the record). Zero providers opens connection setup;
|
||||
// an unavailable selection with another provider opens its ordinary
|
||||
// picker. The server's admission gate remains the belt.
|
||||
if (zeroKeys) {
|
||||
setSetupOpen(true);
|
||||
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);
|
||||
try {
|
||||
// The map context (owner, 2026-10-04): picks in EITHER mode ride
|
||||
@@ -326,7 +324,6 @@ export function Home({
|
||||
open={setupOpen}
|
||||
onClose={() => setSetupOpen(false)}
|
||||
onDone={() => {
|
||||
setConfigured(true);
|
||||
setSetupOpen(false);
|
||||
// Save only saves: the draft waits in the composer.
|
||||
}}
|
||||
|
||||
@@ -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
@@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
|
||||
import { composeSlotModel } from "@/components/settings/agent-section";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
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";
|
||||
|
||||
/** 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
|
||||
* twin of the server's catalogContextLimits join (context-meter
|
||||
* 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 }) {
|
||||
const { catalog, refresh } = useModelCatalog();
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -99,8 +60,8 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
||||
const readRecord = useCallback(() => {
|
||||
if (chatId) {
|
||||
fetch(`/api/chats/${chatId}`)
|
||||
.then((r) => r.json() as Promise<{ settings?: ChatSettings }>)
|
||||
.then((body) => setSession(body.settings))
|
||||
.then((r) => { if (!r.ok) throw new Error(`session read failed: ${r.status}`); return r.json() as Promise<{ settings?: ChatSettings }>; })
|
||||
.then((body) => setSession({ settings: body.settings }))
|
||||
.catch(() => {
|
||||
/* 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,
|
||||
// falling back to the strong slot for legacy unstamped sessions —
|
||||
// EXACTLY what resolveConversationSpec runs. Draft mode is the strong
|
||||
// slot itself.
|
||||
// falling back to Strong only for a successfully read legacy unstamped
|
||||
// session — EXACTLY what resolveConversationSpec runs. An unresolved or
|
||||
// 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 override: ChatSettings | undefined = chatId
|
||||
? {
|
||||
model: session?.model ?? strong?.model,
|
||||
thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel,
|
||||
}
|
||||
? session ? {
|
||||
model: session.settings?.model ?? strong?.model,
|
||||
thinkingLevel: session.settings?.thinkingLevel ?? strong?.thinkingLevel,
|
||||
} : undefined
|
||||
: strong;
|
||||
const effectiveId = override?.model?.id;
|
||||
const effectiveProvider = override?.model?.provider;
|
||||
@@ -236,7 +199,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
||||
return;
|
||||
}
|
||||
const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null;
|
||||
setSession(stored?.settings ?? next);
|
||||
setSession({ settings: stored?.settings ?? next });
|
||||
setOpen(null);
|
||||
} catch {
|
||||
setError("Could not reach the server — try again.");
|
||||
@@ -315,10 +278,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
|
||||
<Hint
|
||||
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
|
||||
// — the long sentence was way too much. ⌘K opens this same
|
||||
// menu (registry, override-aware).
|
||||
label="Model selector"
|
||||
label={effectiveId ? `${effectiveProvider ? `${effectiveProvider}:` : ""}${effectiveId}` : "Model selector"}
|
||||
action="open-model-picker"
|
||||
side="bottom"
|
||||
>
|
||||
|
||||
+12
-14
@@ -8,7 +8,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio
|
||||
import { useFieldCycle } from "@/components/field-cycle";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
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 { ChainTableOverlay } from "@/components/chain-table.tsx";
|
||||
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;
|
||||
// Save stores the slots and never sends (owner ruling 2026-10-09) —
|
||||
// the learner sends the draft themselves.
|
||||
const [configured, setConfigured] = useState(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 { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
|
||||
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
|
||||
// BEFORE startDraftSession creates the record — a refused first
|
||||
// send would otherwise leave an orphan error-shell chat in the
|
||||
// sidebar. Same skip-everything-and-open-setup treatment; the
|
||||
// server's 503 gate remains the belt for /chat/[id] sessions.
|
||||
if (zeroKeys || strongKeyless) {
|
||||
// sidebar. Zero providers opens connection setup; an unavailable
|
||||
// selection with another provider opens the ordinary Strong picker.
|
||||
// The server's admission gate remains the belt.
|
||||
if (zeroKeys) {
|
||||
setSetupOpen(true);
|
||||
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);
|
||||
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
|
||||
.then((target) => {
|
||||
@@ -308,7 +307,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
||||
else setBusy(false);
|
||||
})
|
||||
.catch(() => setBusy(false));
|
||||
}, [busy, router, configured, zeroKeys, strongKeyless]);
|
||||
}, [busy, router, configured, zeroKeys, catalog]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -369,7 +368,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
||||
open={setupOpen}
|
||||
onClose={() => setSetupOpen(false)}
|
||||
onDone={() => {
|
||||
setConfigured(true);
|
||||
setSetupOpen(false);
|
||||
// Save only saves (owner ruling 2026-10-09): the draft
|
||||
// waits in the composer; the learner sends it.
|
||||
|
||||
@@ -105,7 +105,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
||||
cards' label row — owner round, 2026-10-05) + the statement,
|
||||
markdown + LaTeX rendered, capped by the measured column
|
||||
(--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="lh-prose text-[15px]">
|
||||
<ToolContentResponse>{input.statement}</ToolContentResponse>
|
||||
|
||||
@@ -564,7 +564,7 @@ function QuestionDockForm({
|
||||
{/* 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
|
||||
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">
|
||||
{toolName === "probe"
|
||||
? "Probe"
|
||||
|
||||
@@ -55,6 +55,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { useSettingsSaved } from "@/components/settings-signal";
|
||||
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 {
|
||||
COMPACTION_THRESHOLD_FLOOR,
|
||||
@@ -63,7 +65,6 @@ import {
|
||||
type AgentSettings,
|
||||
type CompactionSettings,
|
||||
type GraphSettings,
|
||||
type ModelCatalogEntry,
|
||||
type ModelSlotName,
|
||||
type ModelsSettings,
|
||||
} from "@/src/core";
|
||||
@@ -101,15 +102,6 @@ interface SegmentInfo {
|
||||
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.
|
||||
* Pure — exported for tests. */
|
||||
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
||||
@@ -234,8 +226,10 @@ function MenuTrigger({
|
||||
onClick,
|
||||
widthClass,
|
||||
disabled,
|
||||
hint,
|
||||
}: {
|
||||
value: string;
|
||||
hint?: string;
|
||||
/** What the trigger shows when there is no override ("Default"). */
|
||||
emptyLabel: string;
|
||||
ariaLabel: string;
|
||||
@@ -250,7 +244,7 @@ function MenuTrigger({
|
||||
/** The no-key search state grays the whole row; the trigger follows. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
@@ -269,6 +263,7 @@ function MenuTrigger({
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
return hint ? <Hint label={hint}>{button}</Hint> : button;
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
onCompactionChange: (next: CompactionSettings) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
||||
* Default hints and thinking lists resolve from it. */
|
||||
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
||||
const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog();
|
||||
const catalog = body?.models;
|
||||
const slots = body?.slots;
|
||||
/** The live search state (/api/search) — the row's reason line. */
|
||||
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
|
||||
@@ -341,28 +335,9 @@ export function AgentSection({
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// The catalog (federated: keyed providers only), the resolved slots,
|
||||
// the prompt segments, and the search engines' key presence — one
|
||||
// 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); });
|
||||
}, []);
|
||||
|
||||
// The shared catalog seeds the first frame and refreshes per mount and
|
||||
// keys/models signal. Settings props still own saved selections/levels.
|
||||
useSettingsSaved((section) => {
|
||||
if (section === "models" || section === "keys") readModels(section === "keys");
|
||||
// THE state-echo root cause: the prompt switches write through the
|
||||
// agent section, but this view (the toggles' truth) never re-read —
|
||||
// the settings-saved law (every reader re-reads) was missing this
|
||||
@@ -371,8 +346,6 @@ export function AgentSection({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
modelsMounted.current = true;
|
||||
readModels();
|
||||
fetch("/api/settings/prompts")
|
||||
.then((r) => r.json())
|
||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
||||
@@ -388,7 +361,6 @@ export function AgentSection({
|
||||
setEngineKeys(present);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { modelsMounted.current = false; modelsRequest.current++; };
|
||||
}, []);
|
||||
|
||||
// The reason line re-reads /api/search AFTER an engine write lands —
|
||||
@@ -615,6 +587,7 @@ export function AgentSection({
|
||||
return (
|
||||
<>
|
||||
<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) => {
|
||||
const override = models?.[slot];
|
||||
const modelId = override?.model?.id ?? "";
|
||||
@@ -652,6 +625,7 @@ export function AgentSection({
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
hint={modelId ? `${provider}:${modelId}` : undefined}
|
||||
emptyLabel="—"
|
||||
ariaLabel={`${SLOT_META[slot].label} model`}
|
||||
testid={`slot-model-${slot}`}
|
||||
|
||||
@@ -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
|
||||
* environment-owned value — the row itself is read-only then). Pure. */
|
||||
export function keyStatusLine(status: AuthKeyStatus): string {
|
||||
@@ -417,7 +425,7 @@ export function KeysSection({
|
||||
{providerRows.map((status) => (
|
||||
<KeyTableRow
|
||||
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}
|
||||
status={status}
|
||||
expanded={openRow === status.id}
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
* 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 { useModelCatalog } from "@/components/model-picker";
|
||||
import { useModelCatalog } from "@/components/model-catalog";
|
||||
import type { ModelCatalogResponse } from "@/src/core";
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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
|
||||
* successful read says no provider key exists (pending and failed
|
||||
* reads render nothing — the pill must never flash on a gap, and a
|
||||
|
||||
+123
-99
@@ -13,7 +13,8 @@
|
||||
* 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
|
||||
* 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
|
||||
* input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
|
||||
* 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
|
||||
* (keyed providers only), the vision slot filtered to image-capable
|
||||
* 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
|
||||
* models section and reports onDone(models).
|
||||
*
|
||||
@@ -41,6 +45,8 @@ import { prettyModel } from "@/components/usage-format";
|
||||
import { ModelMenuList } from "@/components/settings/model-menu";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
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 { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
||||
@@ -73,27 +79,26 @@ interface ModelsBody {
|
||||
* (owner's dependency ordering). Exported for tests. */
|
||||
export function setupStage(
|
||||
providers: { id: string; keyPresent: boolean }[] | null,
|
||||
): "keys" | "pickers" {
|
||||
if (providers === null) return "keys"; // fetch failed → the honest fallback
|
||||
): "loading" | "keys" | "pickers" {
|
||||
if (providers === null) return "loading";
|
||||
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
|
||||
}
|
||||
|
||||
/** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its
|
||||
* pick — matched in the FEDERATED catalog (a lapsed key's pin does not
|
||||
* prefill); unpinned tiers stay empty and carry the "needed" mark.
|
||||
* First-run = nothing pinned → all three empty + marked. Exported pure
|
||||
* for tests. */
|
||||
type ModelSelection = Pick<ModelCatalogEntry, "provider" | "id">;
|
||||
|
||||
/** Saved identity is independent of selectable metadata. A disconnected
|
||||
* pin still prefills; only the menu is restricted to the live catalog. */
|
||||
export function prefillFromSlots(
|
||||
models: ModelCatalogEntry[],
|
||||
slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
|
||||
): Partial<Record<ModelSlotName, ModelCatalogEntry>> {
|
||||
const pre: Partial<Record<ModelSlotName, ModelCatalogEntry>> = {};
|
||||
): Partial<Record<ModelSlotName, ModelSelection>> {
|
||||
const pre: Partial<Record<ModelSlotName, ModelSelection>> = {};
|
||||
for (const slot of SLOT_ORDER) {
|
||||
const pin = slots?.[slot];
|
||||
const entry = pin?.model
|
||||
? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
|
||||
: undefined;
|
||||
if (entry) pre[slot] = entry;
|
||||
if (pin?.model) pre[slot] = entry ?? pin.model;
|
||||
}
|
||||
return pre;
|
||||
}
|
||||
@@ -217,24 +222,38 @@ export function SetupKeysBody({
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupDialog({
|
||||
open,
|
||||
onClose,
|
||||
/** Saved: the models section landed; the caller marks configured (the
|
||||
* draft stays put — save never sends). */
|
||||
onDone,
|
||||
}: {
|
||||
function prefillLevels(body: ModelsBody | undefined): Partial<Record<ModelSlotName, ThinkingLevel>> {
|
||||
const levels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
|
||||
for (const slot of SLOT_ORDER) {
|
||||
const pin = body?.slots?.[slot];
|
||||
const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id);
|
||||
if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel;
|
||||
else if (entry) levels[slot] = prefillLevelFromPin(entry, pin);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
interface SetupDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Saving never sends the caller's draft. */
|
||||
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 [body, setBody] = useState<ModelsBody | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [picked, setPicked] = useState<Partial<Record<ModelSlotName, ModelCatalogEntry>>>({});
|
||||
/** Per-slot thinking level (chunk 2): populated from the ordering on
|
||||
* every fresh model pick, editable — an explicit pick always wins. */
|
||||
const [levels, setLevels] = useState<Partial<Record<ModelSlotName, ThinkingLevel>>>({});
|
||||
const { catalog, failed, refresh } = useModelCatalog();
|
||||
const providerStageSeen = useRef(false);
|
||||
const connectionCloseRequested = useRef(false);
|
||||
const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots));
|
||||
const [levels, setLevels] = useState(() => prefillLevels(catalog));
|
||||
const editedSlots = useRef(new Set<ModelSlotName>());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | 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 [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
||||
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
|
||||
// Declared with the other hooks: the early `if (!open) return null` sits
|
||||
// below them all. (Stage 2's slot menus anchor by anchorTestid instead —
|
||||
// see the slot MenuShell below for why a ref cannot work there.)
|
||||
// Stage 2's slot menus anchor by anchorTestid instead — see the slot
|
||||
// MenuShell below for why a freshly attached ref cannot work there.
|
||||
const keyTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The keys signal (zero-keys wave): keys-section saves/deletes AND
|
||||
// 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
|
||||
// the widened catalog — no restart, no Settings round-trip.
|
||||
// re-runs on it, so saving a key widens the catalog. Incomplete saved
|
||||
// slots advance to pickers; complete saved slots just close setup.
|
||||
const [keysTick, setKeysTick] = useState(0);
|
||||
useSettingsSaved((section) => {
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setBody(null);
|
||||
setPicked({});
|
||||
setLevels({});
|
||||
setError(null);
|
||||
fetch("/api/keys")
|
||||
.then((r) => r.json())
|
||||
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
|
||||
.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; };
|
||||
}, [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
|
||||
// configuring (browsing stays possible; the gate is the send
|
||||
// interception, not this dialog). An open slot menu eats Esc first
|
||||
// (and any click outside it); only a bare Esc closes the dialog.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// 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("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]);
|
||||
|
||||
/** 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
|
||||
* the stage flips to the pickers on its own; the dialog stays mounted
|
||||
* the whole way. Shape-gate 400s keep their user-facing line inline. */
|
||||
* CLEAN the local key field. The fresh catalog decides whether to
|
||||
* close recovery or advance to the missing model slots. Shape-gate
|
||||
* 400s keep their user-facing line inline. */
|
||||
async function saveKey() {
|
||||
if (!keyProvider || !keyValue.trim() || keySaving) return;
|
||||
setKeySaving(true);
|
||||
@@ -398,6 +419,20 @@ export function SetupDialog({
|
||||
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(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||
@@ -427,32 +462,15 @@ export function SetupDialog({
|
||||
</Hint>
|
||||
</header>
|
||||
|
||||
{stage === "keys" ? (
|
||||
<div className="flex flex-col px-6 py-5">
|
||||
{failed ? (
|
||||
<p className="text-sm text-foreground">
|
||||
Model catalog could not be loaded. Check the connection and retry.
|
||||
</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>
|
||||
{failed && (
|
||||
<p role="alert" data-testid="setup-catalog-error" className="px-6 py-3 text-sm text-foreground">
|
||||
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>
|
||||
)}
|
||||
{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">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -461,14 +479,16 @@ export function SetupDialog({
|
||||
{SLOT_ORDER.map((slot) => {
|
||||
const entry = picked[slot];
|
||||
const level = levels[slot];
|
||||
const levelsForEntry = entry?.supportedLevels ?? [];
|
||||
const catalog =
|
||||
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models;
|
||||
const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider);
|
||||
// Unavailable metadata cannot erase or clamp a saved preference.
|
||||
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 (
|
||||
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
|
||||
{!body?.slots?.[slot]?.model && !picked[slot] && (
|
||||
{!catalog?.slots?.[slot]?.model && !picked[slot] && (
|
||||
<span
|
||||
data-testid={`setup-needed-${slot}`}
|
||||
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
|
||||
@@ -481,6 +501,7 @@ export function SetupDialog({
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Hint label={entry ? `${entry.provider}:${entry.id}` : "Pick a model…"}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${SLOT_META[slot].label} model`}
|
||||
@@ -494,6 +515,7 @@ export function SetupDialog({
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
</Hint>
|
||||
{/* Anchor by TESTID, not a ref (the settings-section pattern):
|
||||
React attaches a host element's ref AFTER its descendants'
|
||||
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"
|
||||
>
|
||||
<ModelMenuList
|
||||
models={catalog}
|
||||
models={slotCatalog}
|
||||
effectiveId={entry?.id}
|
||||
effectiveProvider={entry?.provider}
|
||||
onPick={(m) => {
|
||||
editedSlots.current.add(slot);
|
||||
setPicked((prev) => ({ ...prev, [slot]: m }));
|
||||
// Auto-populate (chunk 2, owner: "populate
|
||||
// everywhere"): no explicit level → the
|
||||
@@ -527,7 +550,7 @@ export function SetupDialog({
|
||||
}));
|
||||
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>
|
||||
)}
|
||||
@@ -563,6 +586,7 @@ export function SetupDialog({
|
||||
levels={levelsForEntry.map(String)}
|
||||
effectiveLevel={level}
|
||||
onPick={(lv) => {
|
||||
editedSlots.current.add(slot);
|
||||
setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel }));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||
@@ -34,6 +34,7 @@ export function StatsBar({
|
||||
dictationSlot,
|
||||
chatId,
|
||||
leadingSlot,
|
||||
onModelChange,
|
||||
}: {
|
||||
turns: TurnUsage[];
|
||||
contextTokens?: number | null;
|
||||
@@ -43,6 +44,8 @@ export function StatsBar({
|
||||
* record; absent on the draft surfaces, where it binds the strong
|
||||
* slot (the new-session default). */
|
||||
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
|
||||
* space between the session data and the model control — the mic
|
||||
* button lives there, and while recording the meter unfurls to fill
|
||||
@@ -55,6 +58,10 @@ export function StatsBar({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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,
|
||||
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
@@ -192,7 +199,7 @@ export function StatsBar({
|
||||
{/* The model + thinking control — one controller for the conversation
|
||||
agents (prober/teacher); the record's `turns` above still folds
|
||||
the ACTUAL models billed, regardless of what the picker says. */}
|
||||
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
|
||||
<ModelControl chatId={chatId} onModelChange={modelChanged} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
/**
|
||||
* "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the
|
||||
* name KEPT EXACTLY as its provider writes it (dashes stay, no
|
||||
* 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.
|
||||
*/
|
||||
/** Model-only labels omit the vendor path, never versions, dates or variants.
|
||||
* Only usage callers pass `qualified`: a bare catalog id's colon belongs to
|
||||
* the model (e.g. :free), not a provider. Usage retains its provider suffix. */
|
||||
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 parts = short.split("-");
|
||||
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;
|
||||
return provider ? `${short} · ${provider}` : short;
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The numerator is the latest qualifying completed conversation response's native
|
||||
|
||||
@@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails)
|
||||
};
|
||||
}
|
||||
|
||||
/** The ACTIVE compaction marker: the newest one in the record (chained
|
||||
* compactions — the last one rules; older markers fall inside the
|
||||
* dropped span and never render). */
|
||||
/** The ACTIVE compaction marker: the newest one with a retained-message
|
||||
* id. Its summary supersedes older markers even when their stored
|
||||
* positions remain inside its retained tail. */
|
||||
export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
|
||||
@@ -563,8 +563,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
// THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker
|
||||
// (the newest — chained compactions rule last) names the first kept
|
||||
// message; everything before it is replaced by the marker's content
|
||||
// (header + summary) rendered ONCE, up top. The marker itself is then
|
||||
// skipped in place — it must never render twice. A cut naming a
|
||||
// (header + summary) rendered ONCE, up top. All compaction markers
|
||||
// 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).
|
||||
const lhMessages = messages as readonly LhMessage[];
|
||||
const active = activeCompactionOf(lhMessages);
|
||||
@@ -605,11 +606,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
// UI-only markers: plan blocks, and problem dismissals (the
|
||||
// synthesized conclude pair carries the model's face).
|
||||
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
|
||||
// The ACTIVE compaction marker already rendered as the head —
|
||||
// never twice. (Stale markers before the cut are unreachable; a
|
||||
// marker whose cut could not resolve has no head and renders as
|
||||
// its user text — honest degradation.)
|
||||
if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue;
|
||||
// The head supersedes every compaction marker, not only its own
|
||||
// stored position. Without a resolved head, preserve the existing
|
||||
// full-history fallback, including the summary text.
|
||||
if (marker.customType === "compaction" && head !== null) continue;
|
||||
out.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: marker.content }],
|
||||
|
||||
@@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
|
||||
totalTokens: 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
|
||||
// engine — owner ruling), so the role label never resolves an
|
||||
// engine; "researcher" is the least-wrong existing ModelRole label
|
||||
@@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
||||
spanStart = prevKept;
|
||||
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 prompt = buildCompactionPrompt({
|
||||
|
||||
@@ -245,10 +245,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation
|
||||
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) {
|
||||
const slot = settings[name];
|
||||
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);
|
||||
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
|
||||
// server is the truth: the record shows the clamped level after write.
|
||||
|
||||
/** Clamp one level against one model (identity when the model is unknown
|
||||
* or the level already supported — the existence gate rejects unknown
|
||||
* models separately, so this only ever sees real ones in practice). */
|
||||
/** Clamp one level against one model. Preserve the preference when metadata
|
||||
* is unavailable for an unchanged saved pin; runtime clamps after reconnect. */
|
||||
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
|
||||
const model = connectionModel(provider, id);
|
||||
return model ? clampThinkingLevel(model, level) : level;
|
||||
|
||||
@@ -123,9 +123,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
|
||||
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 });
|
||||
assert.deepEqual(pre, {});
|
||||
assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
assert.equal(setupStage(null), "keys");
|
||||
assert.equal(setupStage(null), "loading");
|
||||
assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys");
|
||||
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
|
||||
});
|
||||
|
||||
@@ -27,12 +27,14 @@ const {
|
||||
ensureCompacted,
|
||||
kickCompaction,
|
||||
awaitCompaction,
|
||||
compactionInFlight,
|
||||
effectiveThresholdTokens,
|
||||
projectedContextTokens,
|
||||
} = await import("../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 { 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";
|
||||
|
||||
after(() => {
|
||||
@@ -167,12 +169,120 @@ describe("the compaction service", () => {
|
||||
await ensureCompacted(deps, chatId);
|
||||
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.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once");
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
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.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", () => {
|
||||
assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000);
|
||||
assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const kept = [assistantWithCall("a1", "call_1")];
|
||||
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)");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" });
|
||||
const m1 = newUserMessage("x");
|
||||
@@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => {
|
||||
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", () => {
|
||||
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(
|
||||
@@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
|
||||
const record = (): LhMessage[] => [
|
||||
{ id: "old", role: "user", content: "dropped history", timestamp: 1 },
|
||||
{ id: "kept", role: "user", content: "kept tail", timestamp: 2 },
|
||||
newCompactionMarker("SUPERSEDED SUMMARY", {
|
||||
firstKeptMessageId: "kept",
|
||||
tokensBefore: 98,
|
||||
at: new Date(0).toISOString(),
|
||||
}),
|
||||
newCompactionMarker("SUMMARY", {
|
||||
firstKeptMessageId: "kept",
|
||||
tokensBefore: 99,
|
||||
@@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
|
||||
: ((m.content as { text?: string }[])[0]?.text ?? "");
|
||||
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(!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)", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -89,9 +89,15 @@ describe("stats bar", () => {
|
||||
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("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)", () => {
|
||||
@@ -99,8 +105,10 @@ describe("stats bar", () => {
|
||||
// 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("openai:gpt-5.2", true), "gpt-5.2 · openai");
|
||||
// unqualified on a qualified stamp: provider silently dropped (old shape)
|
||||
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash");
|
||||
assert.equal(prettyModel("openrouter:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter");
|
||||
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
|
||||
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash");
|
||||
});
|
||||
|
||||
@@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", (
|
||||
});
|
||||
|
||||
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([{ 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");
|
||||
|
||||
Reference in New Issue
Block a user