Merge accepted local main into PR #184 working branch
This commit is contained in:
+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;
|
||||
}
|
||||
Reference in New Issue
Block a user