Merge accepted local main into PR #154 working branch

This commit is contained in:
amosblomqvist
2026-10-27 17:13:15 +02:00
129 changed files with 6921 additions and 1690 deletions
+11 -1
View File
@@ -34,7 +34,7 @@ import {
type PluginConfig,
} from "streamdown";
import { mdComponents } from "@/components/md-blocks";
import { transformMathDelimiters } from "@/src/core";
import { repairMathForDisplay, transformMathDelimiters } from "@/src/core";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: LhViewMessage["role"];
@@ -388,6 +388,16 @@ export const MessageResponse = memo(
MessageResponse.displayName = "MessageResponse";
/** Recovery is a tool-text presentation concern, not a shape/answer-data
* transform. Ordinary assistant/user prose keeps MessageResponse unchanged. */
export function ToolContentResponse({ children, ...props }: MessageResponseProps) {
return (
<MessageResponse {...props}>
{typeof children === "string" ? repairMathForDisplay(children) : children}
</MessageResponse>
);
}
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
+13 -12
View File
@@ -5,9 +5,9 @@ import { ConfidenceRail } from "@/components/confidence-rail";
import { GlyphMark } from "@/components/ui/glyph-mark";
import { ThinkingBlock } from "@/components/thinking-block";
import { cn } from "@/lib/utils";
import { MessageResponse } from "@/components/ai-elements/message";
import { ToolContentResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core";
import { parseAskInput, parseQuizInput } from "@/src/core";
import { answerForPresentation, parseAskInput, parseQuizInput } from "@/src/core";
/**
* Static rendering of an answered question (tool part in `output-available`).
@@ -19,7 +19,8 @@ import { parseAskInput, parseQuizInput } from "@/src/core";
* client — the card reveals his answer, the committed correct answer, and
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
*/
export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) {
export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionAnswerData; input?: unknown }) {
const answer = answerForPresentation(savedAnswer, input);
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
if (answer.dismissed) {
return (
@@ -86,13 +87,13 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
))}
</div>
<div className="mb-3 font-medium">
<MessageResponse>{answer.questionText}</MessageResponse>
<ToolContentResponse>{answer.questionText}</ToolContentResponse>
</div>
{parsedDetails && (
// The details line, same paint as the pending dock (owner,
// 2026-10-12): full markdown, LaTeX included.
<div className="mb-3 text-sm text-muted-foreground">
<MessageResponse>{parsedDetails}</MessageResponse>
<ToolContentResponse>{parsedDetails}</ToolContentResponse>
</div>
)}
@@ -131,14 +132,14 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
</span>
)}
<span className={cn(!sel && "text-muted-foreground")}>
<MessageResponse>{opt.label}</MessageResponse>
<ToolContentResponse>{opt.label}</ToolContentResponse>
{descByIndex.get(opt.index) && (
// Ask-option subtext (owner, 2026-10-12): same markdown path
// as the pending dock — LaTeX included. Quiz options carry
// none (owner ruling 2026-09-26); the map stays empty for them.
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
<ToolContentResponse className="lh-prose block text-xs text-muted-foreground">
{descByIndex.get(opt.index)}
</MessageResponse>
</ToolContentResponse>
)}
</span>
</div>
@@ -211,9 +212,9 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
{whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
<span className="font-medium text-muted-foreground">Why: </span>
{/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */}
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
{/* Keep the first paragraph beside the label, not every paragraph:
later paragraphs must retain their recovered document structure. */}
<ToolContentResponse className="inline [&>p:first-child]:inline [&>p:first-child]:m-0">{answer.explanation}</ToolContentResponse>
</div>
)}
</div>
@@ -225,7 +226,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
{isExplain && answer.committed && (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">The answer: </span>
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
<ToolContentResponse className="inline [&>p:first-child]:inline [&>p:first-child]:m-0">{answer.committed}</ToolContentResponse>
</div>
)}
</div>
+2 -2
View File
@@ -15,7 +15,7 @@ import { BrailleSpinner } from "@/components/braille-spinner";
*
* job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet)
* running → live activity card (activity line + elapsed)
* done/error → quiet one-liner ("delivered" / "failed") — the artifact
* done/error → quiet one-liner ("finished" / "failed") — the artifact
* itself arrives in the steer message right below, so the
* box must not duplicate it.
*/
@@ -54,7 +54,7 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
>
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
<span className="font-medium uppercase tracking-wide">{label}</span>
<span>{failed ? "failed" : "delivered"}</span>
<span>{failed ? "failed" : "finished"}</span>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
/** Two display-only repairs for inputs the old HTML embedding accepted.
* Do not rewrite stored artifacts/downloads or attempt general XML repair.
* Comments, CDATA and processing instructions are copied verbatim. */
export function svgImageSource(svg: string): string {
let foundRoot = false;
return svg.replace(
/<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<\?[\s\S]*?(?:\?>|$)|<\/?[a-zA-Z](?:[^<>"']|"[^"]*"|'[^']*')*>|&nbsp;/g,
(token) => {
if (token.startsWith("<!--") || token.startsWith("<![CDATA[") || token.startsWith("<?")) return token;
if (!foundRoot && /^<svg(?=[\s/>])/i.test(token)) {
foundRoot = true;
// Consume complete attribute values, so text mentioning xmlns
// inside a different attribute cannot masquerade as a declaration.
const attrs = token.matchAll(/\s([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g);
const hasNamespace = Array.from(attrs).some((attr) => attr[1] === "xmlns");
if (!hasNamespace) token = token.replace(/^<svg/i, '$& xmlns="http://www.w3.org/2000/svg"');
}
return token.replace(/&nbsp;/g, "&#160;");
},
);
}
/** Keep generated SVG in an image document, outside the app's CSS and ID scope.
* This is a display boundary, not a sanitizer for the original SVG download. */
export function SvgImage({
svg,
className,
width,
}: {
svg: string;
className?: string;
width?: number;
}) {
// The source is already an in-memory vector image; no Next image optimization.
// eslint-disable-next-line @next/next/no-img-element
return <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgImageSource(svg))}`} alt="Figure" className={className} width={width} draggable={false} />;
}
+12 -18
View File
@@ -8,6 +8,7 @@ import { Toggle } from "@/components/settings/controls";
import { useSessionMermaid } from "@/components/session-overlay";
import { downloadFile } from "@/components/artifacts/download";
import { DispatchCard } from "@/components/artifacts/dispatch-card";
import { SvgImage } from "@/components/artifacts/svg-image";
import type { ArtifactPartProps, VisualResult } from "@/src/core";
/**
@@ -16,22 +17,10 @@ import type { ArtifactPartProps, VisualResult } from "@/src/core";
* the chat view's jobs feed). The SVG itself renders from the steer message
* (sub-agent result delivery) via VisualOutputCard.
*
* The SVG is model-authored for the learner's own machine (their own teacher
* wrote it, verified by looking) — dangerouslySetInnerHTML is acceptable
* here, with a cheap hygiene pass (sanitizeSvg: strip <script> blocks, on*
* handlers, and javascript:/data:text/html URLs) since it lands in the
* app's DOM.
* Both display modes use an SVG image document: generated markup and its
* styles never join the app's DOM. Downloads still contain the original SVG.
*/
/** Cheap SVG hygiene — not a full sanitizer, just removing the obvious exec vectors. */
export function sanitizeSvg(svg: string): string {
return svg
.replace(/<script[\s\S]*?<\/script\s*>/gi, "")
.replace(/<script[^>]*\/?>/gi, "")
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(/(href|xlink:href)\s*=\s*(?:"\s*(?:javascript|data:text\/html)[^"]*"|'\s*(?:javascript|data:text\/html)[^']*'|(?:javascript|data:text\/html)[^\s>]*)/gi, "");
}
/** The svg root's DECLARED width in px, when there is one — the natural
* size the card displays at instead of stretching to full width (owner,
* 2026-10-07). %, em, "auto", or a missing attribute → null (the caller
@@ -122,13 +111,18 @@ export function VisualOutputCard({
<div
className={
natural !== null
? "w-fit max-w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:max-w-full"
: "w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:w-full"
? "w-fit max-w-full overflow-hidden rounded-lg bg-white"
: "w-full overflow-hidden rounded-lg bg-white"
}
style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(result.svg) }}
data-testid="visual-svg"
/>
>
<SvgImage
svg={result.svg}
width={natural ?? undefined}
className={natural !== null ? "block h-auto max-w-full" : "block h-auto w-full"}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
<div className="flex items-center gap-1.5">
+103 -113
View File
@@ -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,
@@ -27,6 +27,7 @@ import { useEnterSubmits } from "@/components/enter-submits";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { dockStateKey } from "@/components/dock-state-key";
import { clearOpeningPrompt, readOpeningPrompt } from "@/components/opening-prompt";
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";
@@ -39,7 +40,6 @@ import {
messageAnchorId,
textPartMessageId,
kbdEventForAction as kbdEventForChat,
needsContinuation,
type ChatInput,
type ChatSettings,
type LhMessage,
@@ -58,7 +58,7 @@ import { PracticeDock } from "@/components/practice-dock";
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
import { TopBar } from "@/components/top-bar";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, QuestionFormDraft, TurnUsage, SessionPricing } from "@/src/core";
import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
import {
@@ -79,8 +79,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. */
/**
@@ -199,6 +197,7 @@ export function Chat({
chatId,
title,
initialMessages,
initialError,
initialPrompt,
initiallyInFlight,
initiallyCompacting = false,
@@ -208,6 +207,8 @@ export function Chat({
chatId: string;
title: string;
initialMessages: LhMessage[];
/** Memory-only settlement notice supplied on reopening. */
initialError?: string;
/** The session kind (practice sessions, owner 2026-10-04): practice
* renders no phase chip (no probe→teach arc applies) and a Practice
* tag rides the header instead. */
@@ -237,8 +238,8 @@ export function Chat({
// (core/view.ts projectView); sends are ONE explicit input event each
// ({chatId, input} — the stash indirection died with the SDK), and the
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
useChatSession({ chatId, initialMessages, initiallyInFlight });
const { messages, nativeMessages, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
useChatSession({ chatId, initialMessages, initialError, initiallyInFlight });
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
const scrollContextRef = useRef<StickToBottomContext | null>(null);
@@ -447,9 +448,7 @@ export function Chat({
useEffect(() => {
if (mountContinuation.current) return;
mountContinuation.current = true;
if (!turnInFlight && status === "ready" && needsContinuation(initialMessages)) {
void send({ type: "continue" });
}
if (!turnInFlight) continueIfOwed(initialMessages);
// mount-only by design (turnInFlight read at initial value)
}, []);
@@ -545,10 +544,12 @@ export function Chat({
}, []);
const pendingQuestionId = pending?.toolCallId ?? null;
useEffect(() => {
// A new pending question never inherits the previous one's draft.
readingThinkingRef.current = "";
setReadingThinking("");
}, [pendingQuestionId]);
// Different calls never share a draft; a failed submission of THIS
// call restores the reading channel alongside the form's snapshot.
const reading = pendingQuestionId ? questionDraft(pendingQuestionId)?.readingThinking ?? "" : "";
readingThinkingRef.current = reading;
setReadingThinking(reading);
}, [pendingQuestionId, questionDraft]);
// The phase at every message (one linear fold): the transcript draws a
// color-coded dotted divider at each boundary; the header chip reads the
@@ -565,30 +566,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
@@ -604,8 +593,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`)
@@ -625,7 +615,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;
@@ -730,20 +721,20 @@ export function Chat({
const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing; turnError?: string };
if (Array.isArray(record.turns)) setTurns(record.turns);
setPricing(record.pricing);
if (!Array.isArray(record.messages)) return;
setMessages(record.messages);
setMessages(record.messages, record.turnError);
// The continuation seam (contracts.ts protocol §4): a job result
// landing in the record leaves the agent owing a continuation — the
// record ENDS on un-consumed results, the dock owns no question, and
// nothing runs. The native fold (needsContinuation) is the old SDK
// lastAssistantMessageIsCompleteWithToolCalls check.
if (status === "ready" && needsContinuation(record.messages)) {
void send({ type: "continue" });
}
}, [chatId, setMessages, send, status]);
// Current model truth also guards retained/live failures: canonical
// sync must never automatically replace a reply that failed to save.
continueIfOwed(record.messages);
}, [chatId, setMessages, continueIfOwed]);
// The settled terminal's other half (stream-reattach wave): the
// transport resolved turn-settled — no turn running, the record is
@@ -918,8 +909,8 @@ export function Chat({
// (endTurn emits from onFinish before the response terminates, so the
// SSE frame reliably beats [DONE]) — the handler below cannot sync
// there, it flags instead, and THIS flip consumes the flag: the record
// is guaranteed durable (endTurn fires only after persistTurn, the S2
// ordering law), so replacing the view onto it absorbs the tail's
// reflects durable truth (a failed settlement retains its notice,
// not its unsaved reply), so replacing the view absorbs the tail's
// streamed c_-id copies into base under their server ids. Without
// this the tail kept those copies forever and the next send's
// steer-adoption window rendered them a second time. The wake handoff
@@ -1156,6 +1147,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 });
@@ -1609,10 +1608,13 @@ export function Chat({
fold) and NEVER overlaps the composer. */}
<DockOverlay
pending={pending}
initialQuestionDraft={pending ? questionDraft(pending.toolCallId)?.form : undefined}
onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.toolCallId, { form }); }}
practice={activeProblem}
compactionRunning={compacting}
onSubmitThinking={submitThinking}
chatId={chatId}
onModelChange={setSelectedModel}
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
onOpenPlan={() => setPlanOpen(true)}
requiresReveal={requiresReveal}
@@ -1676,8 +1678,10 @@ export function Chat({
// ride the same callback (QuestionAnswerData.dismissed). The
// reading-thinking draft rides along (owner 2026-10-03) — the
// check answer is its only carrier.
saveQuestionDraft(pending.toolCallId, { readingThinking: readingThinkingRef.current });
const reading = readingThinkingRef.current.trim() || undefined;
clearReadingThinking();
// The pending-id effect clears this only when the question
// actually leaves. An offline preflight keeps the draft visible.
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output);
}}
/>
@@ -1714,7 +1718,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.
@@ -1835,6 +1838,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
function DockOverlay({
pending,
initialQuestionDraft,
onSaveQuestionDraft,
practice,
compactionRunning,
onSubmitThinking,
@@ -1848,6 +1853,7 @@ function DockOverlay({
composer,
onSubmitAnswer,
chatId,
onModelChange,
onReserve,
plan,
onOpenPlan,
@@ -1857,6 +1863,8 @@ function DockOverlay({
queuedSteers,
}: {
pending: ReturnType<typeof findPendingToolCall>;
initialQuestionDraft?: QuestionFormDraft;
onSaveQuestionDraft: (draft: QuestionFormDraft) => void;
/** Compaction running for this chat (round 14) — the docks' submit
* affordance gate (the server's registry is the real one). */
compactionRunning: boolean;
@@ -1882,6 +1890,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;
@@ -1976,6 +1985,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
@@ -2014,28 +2034,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;
@@ -2071,48 +2096,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
@@ -2214,11 +2202,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);
@@ -2226,11 +2209,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;
@@ -2278,8 +2259,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
@@ -2308,11 +2291,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"}
@@ -2323,6 +2308,8 @@ function DockOverlay({
phase={phase}
onExpand={expandAndReveal}
onSubmit={onSubmitAnswer}
initialDraft={initialQuestionDraft}
onSaveDraft={onSaveQuestionDraft}
onEscape={dictationCancel}
dictationClaim={!revealPending}
compactionRunning={compactionRunning}
@@ -2345,6 +2332,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. */}
@@ -2354,6 +2343,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
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, type RefObject } from "react";
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export interface DockHeightMeasurements {
columnHeight: number;
/** Stats/toolbar and block padding, outside the scrolling viewport. */
outsideHeight: number;
/** Everything in the viewport except the animated shell. */
beforeShellHeight: number;
shellBorderHeight: number;
/** Natural content height, not the shell's animated/clamped height. */
contentHeight: number;
/** Null when collapsed, in chat mode, or showing malformed input. */
questionHeight: number | null;
questionPaddingHeight: number;
}
export function dockHeightBudget(m: DockHeightMeasurements) {
const available = Math.max(m.columnHeight - 32, 0);
const viewportMaxHeight = Math.max(available - m.outsideHeight, 0);
const shellMaxHeight = Math.max(viewportMaxHeight - m.beforeShellHeight, 0);
const naturalHeight = m.beforeShellHeight + m.shellBorderHeight + m.contentHeight;
const questionMaxHeight = m.questionHeight === null
? null
: Math.max(viewportMaxHeight - (naturalHeight - m.questionHeight), 0);
// A cap smaller than the question's padding leaves no content viewport.
// Deliberately uncap the question and scroll the whole content instead;
// do not confuse a measured zero with an unmeasured CSS fallback.
const scrollWholeContent = questionMaxHeight === null
? naturalHeight > viewportMaxHeight
: questionMaxHeight <= m.questionPaddingHeight;
return { viewportMaxHeight, shellMaxHeight, questionMaxHeight, scrollWholeContent };
}
interface DockHeightElements {
column: HTMLElement;
block: HTMLElement;
viewport: HTMLElement;
body: HTMLElement;
shell: HTMLElement;
content: HTMLElement;
}
const pixels = (value: string) => Number.parseFloat(value) || 0;
/** Read natural geometry before writing any constraints. Cancel the shell's
* animated height out of both ancestor measurements, then substitute its
* natural content and borders. This stays valid during a WAAPI height morph
* and when the outer viewport is already capped. */
export function syncDockHeight(elements: DockHeightElements, expanded: boolean) {
const { column, block, viewport, body, shell, content } = elements;
// QuestionDock keeps a laid-out form in an invisible h-0 wrapper;
// PracticeDock removes its form. Neither hidden form is a height input.
const question = expanded ? content.querySelector<HTMLElement>("[data-dock-scroll-area]") : null;
const height = (el: HTMLElement) => el.getBoundingClientRect().height;
const shellStyle = getComputedStyle(shell);
const questionStyle = question ? getComputedStyle(question) : null;
const budget = dockHeightBudget({
columnHeight: column.clientHeight,
outsideHeight: Math.max(height(block) - height(viewport), 0),
beforeShellHeight: Math.max(height(body) - height(shell), 0),
shellBorderHeight: pixels(shellStyle.borderTopWidth) + pixels(shellStyle.borderBottomWidth),
contentHeight: height(content),
questionHeight: question ? height(question) : null,
questionPaddingHeight: questionStyle
? pixels(questionStyle.paddingTop) + pixels(questionStyle.paddingBottom)
: 0,
});
if (budget.questionMaxHeight === null) block.style.removeProperty("--dock-max-h");
else block.style.setProperty("--dock-max-h", budget.scrollWholeContent ? "none" : `${budget.questionMaxHeight}px`);
viewport.style.maxHeight = `${budget.viewportMaxHeight}px`;
// Overflow is visible in normal layouts: keep the shell's shadow intact.
// The stats/model toolbar is a sibling, never inside this overflow box.
viewport.style.overflowY = budget.scrollWholeContent ? "auto" : "visible";
// Clamp animated starting heights too, so a previous tall variant cannot
// paint above the column while morphing into a constrained layout.
shell.style.maxHeight = budget.scrollWholeContent ? "none" : `${budget.shellMaxHeight}px`;
return budget;
}
interface DockHeightRefs {
scrollRef: RefObject<HTMLElement | null>;
blockRef: RefObject<HTMLDivElement | null>;
viewportRef: RefObject<HTMLDivElement | null>;
bodyRef: RefObject<HTMLDivElement | null>;
shellRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
}
export function useDockHeight({
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
expanded, enabled, mode, onScrollbarInset, onHeight,
}: DockHeightRefs & {
expanded: boolean;
enabled: boolean;
/** Reconnect observers when the keyed content node/scroll area changes. */
mode: string;
onScrollbarInset: (inset: number) => void;
/** Settled visible block height, fed through the existing reserve policy. */
onHeight: (height: number) => void;
}) {
const measure = useCallback(() => {
const column = scrollRef.current;
const block = blockRef.current;
const viewport = viewportRef.current;
const body = bodyRef.current;
const shell = shellRef.current;
const content = contentRef.current;
if (!column || !block || !viewport || !body || !shell || !content) return;
onScrollbarInset(Math.round((column.offsetWidth - column.clientWidth) / 2));
if (enabled) syncDockHeight({ column, block, viewport, body, shell, content }, expanded);
else {
block.style.removeProperty("--dock-max-h");
viewport.style.removeProperty("max-height");
viewport.style.removeProperty("overflow-y");
shell.style.removeProperty("max-height");
}
// ResizeObserver runs even when only a child field changed and React
// did not rerender DockOverlay. Keep its transcript reserve current,
// but never report a mid-morph height.
if (shell.getAnimations().length === 0) onHeight(block.getBoundingClientRect().height);
}, [scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, expanded, enabled, onScrollbarInset, onHeight]);
// Imperative constraints land before the overlay's later layout effect
// measures its morph target/reserve, and before paint (including reveal).
useIsoLayoutEffect(measure);
useIsoLayoutEffect(() => {
const observer = new ResizeObserver(measure);
for (const el of [scrollRef.current, blockRef.current, viewportRef.current, bodyRef.current, contentRef.current]) {
if (el) observer.observe(el);
}
const question = expanded ? contentRef.current?.querySelector("[data-dock-scroll-area]") : null;
if (question) observer.observe(question);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [measure, mode, expanded, scrollRef, blockRef, viewportRef, bodyRef, contentRef]);
}
/** Overlay and transcript are siblings. Keep native scrolling in an actual
* scroll container (including the short-window viewport), then forward only
* at its edge. overflow:hidden/visible boxes cannot consume the wheel. */
export function forwardDockWheel(event: WheelEvent, root: HTMLElement, transcript: HTMLElement) {
if (event.deltaY === 0) return;
for (let n = event.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
if (!(n instanceof HTMLElement) || !/^(auto|scroll)$/.test(getComputedStyle(n).overflowY)) continue;
if (n.scrollHeight - n.clientHeight < 2) continue;
if (event.deltaY < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5) return;
}
event.preventDefault();
transcript.scrollTop += event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? transcript.clientHeight : 1);
}
+12 -15
View File
@@ -7,7 +7,7 @@ import { GraphView } from "@/components/graph-view";
import { MinimalHome } from "@/components/minimal-home";
import { 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";
@@ -42,15 +42,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.
@@ -124,13 +117,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
@@ -330,7 +328,6 @@ export function Home({
open={setupOpen}
onClose={() => setSetupOpen(false)}
onDone={() => {
setConfigured(true);
setSetupOpen(false);
// Save only saves: the draft waits in the composer.
}}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { ModelCatalogResponse } from "@/src/core";
import { onSettingsSaved } from "@/components/settings-signal";
// Shared stale-while-revalidate catalog. Metadata refreshes retain the last
// good snapshot; connection changes clear selectable models immediately.
let catalogCache: ModelCatalogResponse | undefined;
let catalogError = false;
let catalogInflight: Promise<void> | undefined;
let catalogGeneration = 0;
let stopCatalogSignals: (() => void) | undefined;
const catalogReaders = new Set<() => void>();
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
function fetchCatalog(revalidate = false): void {
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
if (catalogInflight) return;
const generation = catalogGeneration;
catalogError = false;
publishCatalog();
catalogInflight = fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
.then((c) => {
if (generation === catalogGeneration) { catalogCache = c; catalogError = false; publishCatalog(); }
})
.catch(() => {
if (generation === catalogGeneration) { catalogError = true; publishCatalog(); }
})
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
}
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; failed: boolean; refresh: () => void } {
const [snapshot, setSnapshot] = useState(() => ({ catalog: catalogCache, failed: catalogError }));
useEffect(() => {
const first = catalogReaders.size === 0;
const read = () => setSnapshot({ catalog: catalogCache, failed: catalogError });
catalogReaders.add(read);
if (first) {
stopCatalogSignals = onSettingsSaved((section) => {
if (section === "keys") {
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
fetchCatalog();
} else if (section === "models") fetchCatalog(true);
});
fetchCatalog(true);
} else fetchCatalog();
read();
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
}, []);
const refresh = useCallback(() => fetchCatalog(true), []);
return { ...snapshot, refresh };
}
+19 -59
View File
@@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
import { composeSlotModel } from "@/components/settings/agent-section";
import { 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
View File
@@ -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";
@@ -279,14 +279,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(() => {
@@ -299,12 +292,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) => {
@@ -312,7 +311,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 (
<>
@@ -373,7 +372,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.
+5 -5
View File
@@ -1,8 +1,8 @@
"use client";
import { MessageResponse } from "@/components/ai-elements/message";
import { ToolContentResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core";
import type { ArtifactPartProps } from "@/src/core";
/**
* The practice transcript cards (practice sessions, owner design
@@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
<ToolContentResponse>{statement}</ToolContentResponse>
</div>
</div>
);
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
(owner round 3): the same quiet 50% register as quiz explanations. */}
{verdict.length > 0 && (
<div className="text-sm text-foreground/50">
<MessageResponse>{repairMathForDisplay(verdict)}</MessageResponse>
<ToolContentResponse>{verdict}</ToolContentResponse>
</div>
)}
{/* Then the divider, then the labeled model solution (Slice G: the
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
<div>
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
<ToolContentResponse>{reveal}</ToolContentResponse>
</div>
</div>
</>
+3 -4
View File
@@ -2,12 +2,11 @@
import { useCallback, useRef, useState } from "react";
import { ChevronRightIcon } from "lucide-react";
import { MessageResponse } from "@/components/ai-elements/message";
import { ToolContentResponse } from "@/components/ai-elements/message";
import { Button } from "@/components/ui/button";
import { joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits";
import { cn } from "@/lib/utils";
import { repairMathForDisplay } from "@/src/core";
/**
* The practice dock (practice sessions, owner design 2026-10-04): an
@@ -106,10 +105,10 @@ 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]">
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
<ToolContentResponse>{input.statement}</ToolContentResponse>
</div>
</div>
+35 -15
View File
@@ -5,10 +5,11 @@ import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits";
import { MessageResponse } from "@/components/ai-elements/message";
import { ToolContentResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import {
buildAnswer,
repairMathForDisplay,
normalizeQuizType,
CONFIDENCE_STOPS,
DEFAULT_CONFIDENCE_INDEX,
@@ -28,6 +29,7 @@ import {
type DockSelection,
type ExplainToolInput,
type QuestionAnswerData,
type QuestionFormDraft,
type QuestionData,
type QuestionMode,
type QuizToolInput,
@@ -71,6 +73,9 @@ export interface QuestionDockProps {
reveal?: boolean;
onExpand: () => void;
onSubmit: (output: QuestionAnswerData) => void;
/** Restore only this call's unconfirmed submission after recovery. */
initialDraft?: QuestionFormDraft;
onSaveDraft?: (draft: QuestionFormDraft) => void;
/** Escape in a question field stops dictation (STT wave) — threaded
* from the page that owns the dictation hook. */
onEscape?: () => void;
@@ -100,6 +105,8 @@ export function QuestionDock({
reveal = false,
onExpand,
onSubmit,
initialDraft,
onSaveDraft,
onEscape,
phase = "probe",
dictationClaim = true,
@@ -155,6 +162,8 @@ export function QuestionDock({
phase={phase}
active={expanded}
onSubmit={onSubmit}
initialDraft={initialDraft}
onSaveDraft={onSaveDraft}
onEscape={onEscape}
dictationClaim={dictationClaim}
compactionRunning={compactionRunning}
@@ -166,7 +175,7 @@ export function QuestionDock({
/** A plain-text teaser of a question for the collapsed bar: unwrap math, flatten common LaTeX. */
function teaserText(q: string): string {
return q
return repairMathForDisplay(q)
.replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math
.replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math
.replace(/\$\$?([^$]*)\$\$?/g, "$1") // standard $...$/$$...$$ math
@@ -250,6 +259,8 @@ function QuestionDockForm({
phase = "probe",
active,
onSubmit,
initialDraft,
onSaveDraft,
onEscape,
dictationClaim = true,
compactionRunning = false,
@@ -311,22 +322,22 @@ function QuestionDockForm({
// single-select displaces, multi-select coexists — picking the ask
// "Other…" row in a multi-select must never clear already-picked
// options, and picking an option must never clear a typed custom answer.
const [sel, setSel] = useState<DockSelection>(emptyDockSelection());
const [sel, setSel] = useState<DockSelection>(() => initialDraft?.selection ?? emptyDockSelection());
// The learner's transfer channel — thinking, never "a note".
const [thinking, setThinking] = useState("");
const [other, setOther] = useState("");
const [text, setText] = useState("");
const [thinking, setThinking] = useState(initialDraft?.thinking ?? "");
const [other, setOther] = useState(initialDraft?.other ?? "");
const [text, setText] = useState(initialDraft?.text ?? "");
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
* round, owner 2026-10-01): the run's flush lands via setState, which
* a closure waiting through `await finishDictation()` cannot observe
* in time — submit reads these, kept current at every write site. */
const thinkingTextRef = useRef("");
const answerTextRef = useRef("");
const thinkingTextRef = useRef(initialDraft?.thinking ?? "");
const answerTextRef = useRef(initialDraft?.text ?? "");
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
// resting at 50 — a real coin flip by definition, untouched or
// deliberate (the same number, the same meaning). The dock unmounts
// between questions, so each question starts fresh at 50.
const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX);
const [confIdx, setConfIdx] = useState<number>(initialDraft?.confidenceIndex ?? DEFAULT_CONFIDENCE_INDEX);
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
// The PRIMARY dictation input (owner, STT round 1): while a question
@@ -390,6 +401,15 @@ function QuestionDockForm({
// "untouched"); buildAnswer drops it on "I don't know".
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
};
// Snapshot before optimistic submission unmounts the form. Ref values
// include the dictation finals that just landed, unlike stale closures.
onSaveDraft?.({
selection: { ...sel, selected: new Set(sel.selected) },
thinking: thinkingTextRef.current,
other,
text: answerTextRef.current,
confidenceIndex: confIdx,
});
onSubmit(buildAnswer(qd, req, toolName));
};
@@ -544,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"
@@ -560,11 +580,11 @@ function QuestionDockForm({
{qd.mode === "multi-select" && " · select all that apply"}
</div>
<div className="mb-3 font-medium">
<MessageResponse>{qd.question}</MessageResponse>
<ToolContentResponse>{qd.question}</ToolContentResponse>
</div>
{qd.context && (
<div className="mb-3 text-sm text-muted-foreground">
<MessageResponse>{qd.context}</MessageResponse>
<ToolContentResponse>{qd.context}</ToolContentResponse>
</div>
)}
@@ -600,7 +620,7 @@ function QuestionDockForm({
)}
</span>
<span>
<MessageResponse>{opt.label}</MessageResponse>
<ToolContentResponse>{opt.label}</ToolContentResponse>
{kind !== "quiz" && opt.description && (
// Option subtext renders in Source Sans 3 (owner's mockup);
// the "I don't know" copy stays JetBrains Mono. QUIZ options render
@@ -609,9 +629,9 @@ function QuestionDockForm({
// records must not leak either. Same MessageResponse path as the
// label (owner, 2026-10-12): descriptions are full markdown —
// LaTeX included — not plain text.
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
<ToolContentResponse className="lh-prose block text-xs text-muted-foreground">
{opt.description}
</MessageResponse>
</ToolContentResponse>
)}
</span>
</button>
+3
View File
@@ -116,6 +116,9 @@ export function wireSource(source: NotifySource, hub: ReturnType<typeof createNo
const EventsContext = createContext<ReturnType<typeof createNotifyHub> | null>(null);
/** Transport adapters may also be mounted in isolation with an injected bridge. */
export function useNotifyHub() { return useContext(EventsContext); }
export function ServerEventsProvider({ children, connect }: { children: ReactNode; connect?: () => NotifySource }) {
const hub = useMemo(() => createNotifyHub(), []);
useEffect(() => wireSource((connect ?? openEventsChannel)(), hub), [hub, connect]);
+173 -28
View File
@@ -17,6 +17,8 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import {
needsContinuation,
danglingQuestionOf,
answeredCallIds,
measuredContextTokens,
newAnswerResult,
newNextMessage,
@@ -30,6 +32,7 @@ import {
type LhViewMessage,
type LiveExecution,
type QuestionAnswerData,
type QuestionDraft,
} from "../src/core";
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
@@ -78,13 +81,25 @@ async function turnInFlight(model: SessionModel): Promise<boolean | null> {
}
}
/** One wire event off the SSE body: parsed JSON, or the [DONE] terminal. */
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
const STREAM_DROPPED = "The connection dropped before the turn completed.";
/** Mark transport failures without confusing JSON/application bugs with drops. */
class StreamReadError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause), { cause });
}
}
/** Wire events; return true ONLY for the explicit completion marker. */
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>, boolean> {
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) return;
let chunk: ReadableStreamReadResult<Uint8Array>;
try { chunk = await reader.read(); }
catch (err) { throw new StreamReadError(err); }
const { done, value } = chunk;
if (done) return false;
buf += decoder.decode(value, { stream: true });
for (;;) {
const end = buf.indexOf("\n\n");
@@ -94,7 +109,7 @@ async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): Asyn
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
if (!dataLine) continue;
const data = dataLine.slice("data: ".length);
if (data === "[DONE]") return;
if (data === "[DONE]") return true;
yield JSON.parse(data) as Record<string, unknown>;
}
}
@@ -196,6 +211,8 @@ export function visibleQueuedSteers(
export interface SessionModelOptions extends SessionModelDeps {
chatId: string;
initialMessages: LhMessage[];
/** Retained, memory-only server failure when reopening a session. */
initialError?: string;
/** Fires on every state change (the hook re-renders). */
onChange?: () => void;
/** The reattach settled terminal (turn-settled): no turn running, the
@@ -220,6 +237,9 @@ export class SessionModel {
// at every full sync; the write-ahead absorbs the optimistic entries.
private base: LhMessage[];
private tail: LhMessage[] = [];
private questionDrafts = new Map<string, QuestionDraft>();
/** Only learner answers submitted through send(), never silent dismissals. */
private unconfirmedAnswers = new Map<string, string>();
// The live turn's execution layer (view model mid-turn states):
private executions = new Map<string, LiveExecution>();
private streamingIds = new Set<string>();
@@ -229,6 +249,8 @@ export class SessionModel {
private statusValue: SessionStatus = "ready";
/** Request/loop failures are transient; provider failures derive from messages. */
private errorValue: { message: string } | null = null;
/** Dismissing a banner must not authorize an automatic replacement turn. */
private automaticContinuationBlocked = false;
/** A completed failure from THIS consume, not the previous attempt's record. */
private liveFailureId: string | null = null;
// The single live consume (POST response or reattach stream): a newer
@@ -240,6 +262,8 @@ export class SessionModel {
constructor(opts: SessionModelOptions) {
this.chatId = opts.chatId;
this.base = opts.initialMessages;
this.errorValue = opts.initialError === undefined ? null : { message: opts.initialError };
this.automaticContinuationBlocked = opts.initialError !== undefined;
// BOUND: the browser's native fetch is a Window method — storing it
// unbound makes every call throw "Illegal invocation" in the browser
// (Node's undici doesn't care, so the tests never saw it). Always go
@@ -300,6 +324,7 @@ export class SessionModel {
private setError(message: string | null): void {
this.errorValue = message === null ? null : { message };
if (message !== null) this.automaticContinuationBlocked = true;
this.onChange?.();
}
@@ -346,6 +371,12 @@ export class SessionModel {
}
const agent = event as unknown as AgentEvent;
switch (agent.type) {
case "agent_start":
// Positive evidence of a new/replayed server-owned turn. Any
// later failure in that replay blocks continuation again.
this.automaticContinuationBlocked = false;
if (this.errorValue !== null) this.setError(null);
return false;
case "message_start":
case "message_update":
case "message_end": {
@@ -445,18 +476,28 @@ export class SessionModel {
/** Consume one SSE body to its [DONE] terminal. The turn-settled
* terminal (stream-route only) fires the settle hook and ends. */
async consumeStream(res: Response, controller = this.consumeAbort): Promise<void> {
async consumeStream(res: Response, controller = this.consumeAbort, allowRecovery = true): Promise<void> {
if (!res.body) throw new Error("The turn stream body is empty.");
const reader = res.body.getReader();
let sawEvent = false;
let sawDone = false;
try {
for await (const event of sseEvents(reader)) {
const events = sseEvents(reader);
for (;;) {
let next: IteratorResult<Record<string, unknown>, boolean>;
try { next = await events.next(); }
catch (err) {
if (err instanceof StreamReadError) break;
throw err;
}
if (this.consumeAbort !== controller) return;
if (next.done) { sawDone = next.value; break; }
const event = next.value;
if (event.type === "turn-settled") {
// The raced-settle terminal: no turn to attach to — the
// record is current; the settle hook refetches.
await reader.cancel().catch(() => {});
if (this.consumeAbort === controller) this.onReattachSettled?.();
sawDone = true;
this.onReattachSettled?.();
return;
}
const acted = this.applyWireEvent(event);
@@ -466,14 +507,32 @@ export class SessionModel {
}
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
// A cancelled consume may finish after its replacement has entered
// Thinking. Only the current owner can settle shared state.
if (this.consumeAbort === controller) {
this.streamingIds = new Set();
this.executions = new Map();
if (this.statusValue !== "error") this.setStatus("ready");
if (sawDone && this.statusValue !== "error") this.setStatus("ready");
}
}
if (this.consumeAbort !== controller || sawDone) return;
if (!allowRecovery) throw new StreamReadError(new Error(STREAM_DROPPED));
// One read-only recovery: refresh the canonical base, then replay the
// SAME server-owned turn. Never resend the learner's input.
await this.resumeStream(true, false);
}
private async fetchView(url: string, signal: AbortSignal, recoveringDrop: boolean): Promise<Response> {
try {
const res = await this.fetch(url, { signal });
if (recoveringDrop && !res.ok) throw new Error((await res.text()) || STREAM_DROPPED);
return res;
} catch (err) {
if (recoveringDrop && !signal.aborted) throw new StreamReadError(err);
throw err;
}
}
/**
@@ -489,7 +548,7 @@ export class SessionModel {
* lands on top. The stream then replays the turn from its first event
* through the SAME consume path.
*/
async resumeStream(refetchRecordFirst = true): Promise<void> {
async resumeStream(refetchRecordFirst = true, allowRecovery = true): Promise<void> {
this.consumeAbort?.abort();
const controller = new AbortController();
this.consumeAbort = controller;
@@ -519,16 +578,27 @@ export class SessionModel {
this.setStatus("submitted");
try {
if (refetchRecordFirst) {
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
signal: controller.signal,
});
const recordRes = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}`, controller.signal, !allowRecovery);
if (this.consumeAbort !== controller) return;
if (recordRes.ok) {
const record = (await recordRes.json()) as { messages?: LhMessage[] };
let text: string;
try { text = await recordRes.text(); }
catch (err) {
if (!allowRecovery && !controller.signal.aborted) throw new StreamReadError(err);
throw err;
}
// Parse outside the transport catch: malformed JSON is a
// contract bug, not a connection-drop diagnostic.
const record = JSON.parse(text) as { messages?: LhMessage[]; turnError?: string };
if (this.consumeAbort !== controller) return;
if (Array.isArray(record.messages)) {
this.mutate(() => {
this.base = record.messages!;
if (record.turnError !== undefined) {
this.errorValue = { message: record.turnError };
this.automaticContinuationBlocked = true;
}
this.forgetResolvedDrafts(record.messages!);
this.incompleteUsageIds.clear();
this.tail = [];
});
@@ -543,15 +613,19 @@ export class SessionModel {
});
}
}
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
signal: controller.signal,
});
const res = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, controller.signal, !allowRecovery);
if (this.consumeAbort !== controller) return;
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
await this.consumeStream(res, controller);
await this.consumeStream(res, controller, allowRecovery);
} catch (err) {
if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won
this.setError((err as Error).message);
if (this.consumeAbort !== controller || controller.signal.aborted) return; // intentionally cancelled
// A failed record read can leave the optimistic answer in the tail.
// Restore its Submit affordance; the next attempt reconciles first.
this.mutate(() => {
this.tail = this.tail.filter(m => !this.unconfirmedAnswers.has(m.id));
this.unconfirmedAnswers.clear();
});
this.setError(!allowRecovery && err instanceof StreamReadError ? STREAM_DROPPED : (err as Error).message);
this.setStatus("error");
}
}
@@ -563,12 +637,42 @@ export class SessionModel {
* server's 409 + identity retry covers the wake-race window).
*/
async send(input: ChatInput): Promise<boolean> {
if (!this.readyRef) return false;
const recovering = this.statusValue === "error";
if (!this.readyRef && !recovering) return false;
this.consumeAbort?.abort();
const controller = new AbortController();
this.consumeAbort = controller;
this.setError(null);
this.setStatus("submitted");
if (recovering && input.type === "answer") {
try {
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal });
if (this.consumeAbort !== controller) return false;
if (!res.ok) throw new Error((await res.text()) || "An error occurred.");
const record = await res.json() as { messages?: LhMessage[]; turnInFlight?: boolean; turnError?: string };
if (this.consumeAbort !== controller) return false;
// No verified record/liveness: retain the draft and never guess
// whether a lost acknowledgement meant the input was accepted.
if (!Array.isArray(record.messages) || typeof record.turnInFlight !== "boolean") throw new Error("An error occurred.");
this.setMessages(record.messages, record.turnError);
if (record.turnInFlight) {
await this.resumeStream(false);
return false;
}
if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) {
// The call already resolved, or no longer owns the dock. Adopt
// truth, never POST that answer a second time.
this.setStatus("ready");
return false;
}
} catch (err) {
if (this.consumeAbort !== controller || controller.signal.aborted) return false;
this.setError((err as Error).message);
this.setStatus("error");
return false;
}
}
if (input.type !== "continue") this.automaticContinuationBlocked = false;
// The optimistic appends — exactly the messages the server's
// write-ahead will persist (inputAppends), with LOCAL ids so
// adoption can tell them from the record's absorbed copies.
@@ -585,7 +689,9 @@ export class SessionModel {
optimistic.push({ ...newNextMessage(), id: localId() });
} else if (input.type === "answer") {
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
const result = { ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() };
optimistic.push(result);
this.unconfirmedAnswers.set(result.id, input.toolCallId);
}
if (optimistic.length > 0) this.mutate(() => {
this.tail = [...this.tail, ...optimistic];
@@ -606,9 +712,7 @@ export class SessionModel {
// connection is Diet A's whole job.
const res = await this.postInputEvent(input);
if (res.status === 409) {
this.setError("A turn is already running — try again in a moment.");
this.setStatus("error");
return false;
throw new Error("A turn is already running — try again in a moment.");
}
if (!res.ok) {
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
@@ -632,7 +736,17 @@ export class SessionModel {
await this.resumeStream(true);
return true;
} catch (err) {
if ((err as Error).name === "AbortError") return accepted; // stop() or a newer consume won
if (this.consumeAbort !== controller || controller.signal.aborted) return accepted;
// Restore the question when acceptance failed or is unknown. Remove
// only THIS optimistic answer, never saved copies/newer live content.
// Other input kinds retain their existing optimistic presentation.
if (input.type === "answer") {
const ids = new Set(optimistic.map(m => m.id));
this.mutate(() => {
this.tail = this.tail.filter(m => !ids.has(m.id));
for (const id of ids) this.unconfirmedAnswers.delete(id);
});
}
this.setError((err as Error).message);
this.setStatus("error");
// true only once the POST ack landed. A throw before that (the
@@ -747,10 +861,32 @@ export class SessionModel {
this.setStatus("ready");
}
/** Submission snapshots outlive the dock, not the session mount. */
questionDraft(toolCallId: string): QuestionDraft | undefined {
return this.questionDrafts.get(toolCallId);
}
saveQuestionDraft(toolCallId: string, draft: Partial<QuestionDraft>): void {
this.questionDrafts.set(toolCallId, { ...this.questionDrafts.get(toolCallId), ...draft });
}
private forgetResolvedDrafts(record: readonly LhMessage[]): void {
const answered = answeredCallIds(record);
for (const id of answered) this.questionDrafts.delete(id);
for (const [id, callId] of this.unconfirmedAnswers) {
if (answered.has(callId)) this.unconfirmedAnswers.delete(id);
}
}
/** Full snapshot swap (the idle settle sync — server canon). */
setMessages(record: LhMessage[]): void {
setMessages(record: LhMessage[], turnError?: string): void {
this.mutate(() => {
this.base = record;
if (turnError !== undefined) {
this.errorValue = { message: turnError };
this.automaticContinuationBlocked = true;
}
this.forgetResolvedDrafts(record);
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null; // the tail (and its slot) is gone
@@ -770,10 +906,19 @@ export class SessionModel {
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
this.mutate(() => {
this.base = record;
this.forgetResolvedDrafts(record);
this.tail = this.tail.filter((m) => !absorbed(m, record));
});
}
/** Automatic mount/sync continuation is never permission to retry a
* failed turn. Read current model truth, not a stale React closure. */
async continueIfOwed(record: readonly LhMessage[]): Promise<void> {
if (this.statusValue === "ready" && !this.automaticContinuationBlocked && this.getState().error === null && needsContinuation(record)) {
await this.send({ type: "continue" });
}
}
/** The mount/sync continuation fold (native-messages.ts). */
static needsContinuation = needsContinuation;
+19 -16
View File
@@ -7,6 +7,7 @@ import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/comp
import { renderMermaid } from "@/components/mermaid-render";
import { MermaidError } from "@/components/mermaid-error";
import { Hint } from "@/components/ui/hint";
import { SvgImage } from "@/components/artifacts/svg-image";
/**
* SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen"
@@ -269,22 +270,24 @@ export function MermaidOverlay({
: "transform 180ms ease-out, opacity 160ms ease-out",
}}
>
{/* data-lh-overlay-chart: globals.css pins the svg to the
wrapper's width (kills mermaid's inline max-width cap)
so the wrapper IS the natural-size box the fit math
centers. FIGURE MODE: a white stage (the figure's
backing law — models draw for a white page) with the
caller's invert filter riding it, exactly like the
in-card holder. */}
<div
data-lh-overlay-chart
className={cn(
"[&_svg]:h-auto [&_svg]:w-full",
rawSvg !== undefined && "bg-white",
)}
style={rawSvg !== undefined && inverted ? { filter: "invert(1) hue-rotate(180deg)" } : undefined}
dangerouslySetInnerHTML={{ __html: svg }}
/>
{/* Figures share the card's image boundary and white backing.
Mermaid keeps its inline renderer; globals.css removes
its max-width cap so it fills the natural-size box. */}
{rawSvg !== undefined ? (
<div
data-lh-overlay-chart
className="bg-white"
style={inverted ? { filter: "invert(1) hue-rotate(180deg)" } : undefined}
>
<SvgImage svg={svg} className="block h-auto w-full" />
</div>
) : (
<div
data-lh-overlay-chart
className="[&_svg]:h-auto [&_svg]:w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
+11 -3
View File
@@ -22,16 +22,18 @@ export interface UseChatSessionOptions {
chatId: string;
/** The native record seed (server-rendered page or a full sync). */
initialMessages: LhMessage[];
/** Transient settlement failure retained by the server, not the transcript. */
initialError?: string;
/** Reattach on mount: a turn is running server-side with no live view. */
initiallyInFlight?: boolean;
}
export function useChatSession({ chatId, initialMessages, initiallyInFlight }: UseChatSessionOptions) {
export function useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }: UseChatSessionOptions) {
// The model is per-mount (chatId-keyed component); onChange forces the
// re-render. The ref pattern keeps it stable across renders.
const modelRef = useRef<SessionModel | null>(null);
if (modelRef.current === null) {
modelRef.current = new SessionModel({ chatId, initialMessages });
modelRef.current = new SessionModel({ chatId, initialMessages, initialError });
}
const model = modelRef.current;
// The render VERSION — bumped by every model change, so the state memo
@@ -101,6 +103,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
);
const send = useCallback((input: ChatInput) => model.send(input), [model]);
const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]);
const steer = useCallback((text: string) => void model.steer(text), [model]);
const submitAnswer = useCallback(
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
@@ -115,7 +118,9 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
(problemCallId: string) => void model.dismissProblem(problemCallId),
[model],
);
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
const questionDraft = useCallback((toolCallId: string) => model.questionDraft(toolCallId), [model]);
const saveQuestionDraft = useCallback((toolCallId: string, draft: Parameters<SessionModel["saveQuestionDraft"]>[1]) => model.saveQuestionDraft(toolCallId, draft), [model]);
const setMessages = useCallback((record: LhMessage[], turnError?: string) => model.setMessages(record, turnError), [model]);
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
const stop = useCallback(() => model.stop(), [model]);
const clearError = useCallback(() => model.clearError(), [model]);
@@ -129,10 +134,13 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
error: state.error,
steerQueue: state.steerQueue,
send,
continueIfOwed,
steer,
submitAnswer,
dismissQuestion,
dismissProblem,
questionDraft,
saveQuestionDraft,
setMessages,
adoptRecord,
stop,
+13 -39
View File
@@ -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}`}
+12 -11
View File
@@ -5,8 +5,10 @@ import type { ChatGPTStatus } from "@/src/core";
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
import { Button } from "@/components/ui/button";
import { Toggle } from "./controls";
import { useChatGPTBridge } from "./subscription-client";
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
export function chatGPTNotice(web = false): string { return web ? `${CHATGPT_NOTICE.replace("on this device", "on the machine running LearnHarness")} Complete ChatGPT sign-in in a browser on that same machine.` : CHATGPT_NOTICE; }
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
@@ -41,6 +43,8 @@ const Context = createContext({
/** UI lifetime spans setup's keys → models transition. Only existing safe controls
* are used; account IDs remain control values, never displayed account labels. */
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const bridge = useChatGPTBridge();
const web = typeof window !== "undefined" && bridge !== window.lh?.chatgpt;
const [status, setStatus] = useState<ChatGPTStatus>(empty);
const [dialog, setDialog] = useState<Dialog | null>(null);
const [busy, setBusy] = useState(false);
@@ -79,14 +83,14 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
operation.current = "change";
const request = ++statusSequence.current;
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
const result: unknown = await bridge!.consent(attempt.background);
if (!owns(attempt)) return;
if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; }
replyFailure = result.error;
let saved: ChatGPTStatus | null;
if (request !== statusSequence.current) {
const refresh = ++statusSequence.current;
const value: unknown = await window.lh!.chatgpt!.status();
const value: unknown = await bridge!.status();
if (!owns(attempt)) return;
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
} else saved = publishStatus(result);
@@ -120,7 +124,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
};
useEffect(() => {
mounted.current = true;
const bridge = window.lh?.chatgpt;
const refresh = async () => {
const request = ++statusSequence.current;
try {
@@ -131,7 +134,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
void refresh();
const off = bridge?.onChange(() => void refresh());
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
}, []);
}, [bridge]);
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined;
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
@@ -161,13 +164,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
// Keep cancellation serialized even after login's pending IPC reply.
operation.current = "cancel"; setBusy(true);
const request = ++statusSequence.current;
void window.lh?.chatgpt?.cancel().then(async (value: unknown) => {
void bridge?.cancel().then(async (value: unknown) => {
if (!mounted.current || generation.current !== intent) return;
if (!isStatus(value)) { setError(replyError(value)); return; }
let next: ChatGPTStatus | null;
if (request !== statusSequence.current) {
const refresh = ++statusSequence.current;
const current: unknown = await window.lh!.chatgpt!.status();
const current: unknown = await bridge!.status();
if (!mounted.current || generation.current !== intent) return;
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
} else next = publishStatus(value);
@@ -195,7 +198,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
let next: ChatGPTStatus | null;
if (request !== statusSequence.current) {
const refresh = ++statusSequence.current;
const current: unknown = await window.lh!.chatgpt!.status();
const current: unknown = await bridge!.status();
if (!mounted.current || intent !== generation.current) return;
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
} else next = publishStatus(value);
@@ -214,7 +217,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
}
async function login() {
const bridge = window.lh?.chatgpt;
const owner = dialogRef.current;
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
@@ -241,7 +243,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
// No finally: pending OAuth outlives this IPC promise. Only its status
// completion, failure, or explicit cancellation releases the attempt.
}
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
const context = {
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
connect: openConnect,
@@ -262,7 +263,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
<p className="mt-3 text-xs text-muted-foreground">{web ? CHATGPT_DISCONNECT_COPY.body.replace("from this device", "from the machine running LearnHarness") : CHATGPT_DISCONNECT_COPY.body}</p>
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
@@ -277,7 +278,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
</select>
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
</div>}
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{chatGPTNotice(web)}</p>
</> : null}
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
+1 -1
View File
@@ -63,7 +63,7 @@ export function SettingsRow({
dimmed,
}: {
label: string;
hint?: string;
hint?: ReactNode;
children: React.ReactNode;
testid?: string;
/** Dependency graying, graph-view style: the whole row dims when the
+9 -1
View File
@@ -96,6 +96,14 @@ export function classifyKeys(
};
}
/** A key row names its credential, not whichever provider sorts first.
* Pi exposes Zen and Go separately but both read OPENCODE_API_KEY. */
export function providerKeyLabel(id: string, providers: readonly ProviderKeyInfo[] | undefined): string | undefined {
const matches = providers?.filter((p) => p.envVar === id);
if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)";
return matches?.[0]?.label;
}
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* 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}
+9 -18
View File
@@ -40,7 +40,8 @@ import {
type UserSettings,
} from "@/src/core";
import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { SettingsSection } from "./controls.tsx";
import { UpdateSettings } from "./update-settings.tsx";
import { AgentSection } from "./agent-section.tsx";
import type { ModelsSettings } from "@/src/core";
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell";
@@ -75,10 +76,6 @@ const DialogContext = createContext<{ open: (section?: string, row?: string) =>
/** AppShell-level mount: the sidebar row and the mod+, chord call this.
* `open(section)` lands the dialog on a specific section (the first-run
* setup flow deep-links to Keys when no provider key exists yet). */
/** The auto-update row's hint (tooltip wave): honest user language — no
* stream vocabulary. Exported for the copy pin (tooltip-mechanism test). */
export const AUTO_UPDATE_HINT = "Automatic updates arrive in an upcoming release";
export function SettingsDialogProvider({
children,
}: {
@@ -92,6 +89,7 @@ export function SettingsDialogProvider({
setLandOnRow(row ?? null);
setShow(true);
}, []);
useEffect(() => window.lh?.onOpenSettings?.((section) => open(section)), [open]);
return (
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
{children}
@@ -362,19 +360,12 @@ export function SettingsDialogBody({
title="General"
testid="section-general"
>
<SettingsRow
label="Auto-update"
hint={AUTO_UPDATE_HINT}
testid="row-auto-update"
>
<Toggle
checked={record.general?.autoUpdate ?? true}
label="auto update"
onChange={(on) =>
void write("general", { autoUpdate: on })
}
/>
</SettingsRow>
<UpdateSettings
autoUpdate={record.general?.autoUpdate ?? true}
onAutoUpdateChange={(on) =>
void write("general", { ...record.general, autoUpdate: on })
}
/>
</SettingsSection>
)}
{active === "keys" && (
@@ -0,0 +1,77 @@
"use client";
import { useMemo } from "react";
import type { ChatGPTControlBridge, ChatGPTStatus, NativeSubscriptionControlBridge, NativeSubscriptionProviderId, NativeSubscriptionStatus } from "@/src/core";
import { useNotifyHub, type createNotifyHub } from "@/components/server-events";
type Hub = ReturnType<typeof createNotifyHub> | null;
type Reply<T> = { value: T; browserAction?: string; error?: string };
const PATH = "/api/subscriptions";
const headers = { "Content-Type": "application/json", "x-learnharness-subscriptions": "1" };
async function status() {
const response = await fetch(PATH, { headers, cache: "no-store" });
if (!response.ok) throw new Error("Subscription status is unavailable.");
return response.json() as Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionStatus[] }>;
}
async function control<T>(providerId: string, op: string, fields: Record<string, unknown> = {}): Promise<Reply<T>> {
const response = await fetch(PATH, { method: "POST", headers, body: JSON.stringify({ providerId, op, ...fields }) });
if (!response.ok) throw new Error("The subscription connection could not be changed.");
return response.json() as Promise<Reply<T>>;
}
function onChange(hub: Hub, listener: () => void): () => void {
const offEvent = hub?.subscribe("subscriptions-changed", listener);
const offResync = hub?.onResync(listener);
return () => { offEvent?.(); offResync?.(); };
}
/** Reserve the new tab synchronously in the click gesture. Only a one-use
* same-origin action URL enters app JS; provider URLs remain in the owner.
* Electron dev has no tabs: its narrow main-process opener performs this step. */
async function withBrowser<T>(action: () => Promise<Reply<T>>): Promise<T> {
const external = window.lh?.subscriptionTransport === "web" ? window.lh.openSubscriptionBrowser : undefined;
const tab = external ? null : window.open("about:blank", "_blank");
if (!external && !tab) throw new Error("Allow a sign-in tab to open, then try again.");
if (tab) tab.opener = null;
try {
const reply = await action();
if (reply.browserAction) {
if (!/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(reply.browserAction)) throw new Error("Invalid sign-in action.");
if (external) await external(reply.browserAction);
else if (tab && !tab.closed) tab.location.href = reply.browserAction;
else throw new Error("The sign-in tab was closed. Cancel and try again.");
} else tab?.close();
return reply.value;
} catch (error) { tab?.close(); throw error; }
}
export function createWebChatGPTBridge(hub: Hub): ChatGPTControlBridge {
const call = async (op: string, fields?: Record<string, unknown>) => (await control<ChatGPTStatus>("chatgpt", op, fields)).value;
return {
status: async () => (await status()).chatgpt,
login: (id) => withBrowser(() => control<ChatGPTStatus>("chatgpt", "login", { id })),
cancel: () => call("cancel"), select: (id) => call("select", { id }),
disconnect: (id) => call("disconnect", { id }), logout: (id) => call("logout", { id }),
consent: (enabled) => call("consent", { enabled }),
onChange: (listener) => onChange(hub, listener),
};
}
export function createWebNativeBridge(hub: Hub): NativeSubscriptionControlBridge {
const call = async (providerId: NativeSubscriptionProviderId, op: string, fields?: Record<string, unknown>) => { await control(providerId, op, fields); };
return {
status: async () => (await status()).native,
login: (providerId, enabled) => call(providerId, "login", { enabled }),
cancel: (providerId) => call(providerId, "cancel"),
respond: (providerId, attemptId, promptId, value) => call(providerId, "respond", { attemptId, promptId, value }),
open: async (providerId, attemptId, actionId) => { await withBrowser(() => control(providerId, "open", { attemptId, actionId })); },
retry: (providerId) => call(providerId, "retry"), disconnect: (providerId) => call(providerId, "disconnect"),
consent: (providerId, enabled) => call(providerId, "consent", { enabled }),
onChange: (listener) => onChange(hub, listener),
};
}
export function useChatGPTBridge(): ChatGPTControlBridge | undefined {
const hub = useNotifyHub();
return useMemo(() => typeof window === "undefined" ? undefined : window.lh?.subscriptionTransport !== "web" && window.lh?.chatgpt ? window.lh.chatgpt : createWebChatGPTBridge(hub), [hub]);
}
export function useNativeSubscriptionBridge(): NativeSubscriptionControlBridge | undefined {
const hub = useNotifyHub();
return useMemo(() => typeof window === "undefined" ? undefined : window.lh?.subscriptionTransport !== "web" && window.lh?.subscriptions ? window.lh.subscriptions : createWebNativeBridge(hub), [hub]);
}
@@ -6,16 +6,19 @@ import { notifySettingsSaved } from "@/components/settings-signal";
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
import { ChatGPTConnection } from "./chatgpt-connection";
import { Toggle } from "./controls";
import { useNativeSubscriptionBridge } from "./subscription-client";
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
export function subscriptionNotice(label: string, web = false): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored ${web ? "on the machine running LearnHarness" : "locally"} in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
export function subscriptionDisconnectCopy(label: string, web = false): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from ${web ? "the machine running LearnHarness" : "this device"}. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
const CONTROL_ERROR = "The connection could not be changed. Try again.";
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
const bridge = useNativeSubscriptionBridge();
const web = typeof window !== "undefined" && bridge !== window.lh?.subscriptions;
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
@@ -30,7 +33,7 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
async function refresh() {
const request = ++sequence.current;
try {
const values = await window.lh?.subscriptions?.status();
const values = await bridge?.status();
if (!mounted.current || request !== sequence.current || !Array.isArray(values)) return;
const accepted = NATIVE_SUBSCRIPTIONS.map(({ providerId }) => {
const next = values.find((s) => s.providerId === providerId), old = statusesRef.current.find((s) => s.providerId === providerId);
@@ -41,10 +44,10 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
}
useEffect(() => {
mounted.current = true; void refresh();
const off = window.lh?.subscriptions?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
const off = bridge?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
return () => { mounted.current = false; generation.current++; off?.(); };
// The root-lifetime subscriber reads current state through refs.
}, []);
}, [bridge]);
useEffect(() => { setPromptValue(""); }, [prompt?.id]);
function open(providerId: NativeSubscriptionProviderId) {
if (operation || statusesRef.current.some((s) => s.pending)) return;
@@ -62,7 +65,6 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
function close() {
if (!dialog || busy) return;
if (confirm) { setConfirm(false); setError(null); return; }
const bridge = window.lh?.subscriptions;
if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; }
generation.current++; setDialog(null); setError(null);
}
@@ -71,23 +73,21 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice");
}
async function login() {
const bridge = window.lh?.subscriptions;
if (!dialog || !bridge || operation || !status?.available) return;
const owner = ++generation.current; setOperation("login"); setError(null);
try { await bridge.login(dialog, backgroundDirty ? background : undefined); await refresh(); if (mounted.current && generation.current === owner) setDialog(null); }
catch { if (mounted.current && generation.current === owner) setError("Sign-in did not complete. Try connecting again."); }
finally { if (mounted.current && generation.current === owner) setOperation(null); }
}
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
return <Context.Provider value={{ statuses, busy: !!operation, open }}>
{children}
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
{confirm ? <>
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label, web)}</p>
</> : view === "notice" ? <>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label, web)}</p>
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>}
</> : <>
<div className="mt-4 flex items-center gap-3 text-xs text-foreground">
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { BrailleSpinner } from "@/components/braille-spinner";
import { SettingsRow, Toggle } from "./controls.tsx";
/** Observational snapshot from electron/main.cjs; never updater permission. */
export type UpdateState = {
status: "idle" | "checking" | "downloading" | "up-to-date" | "downloaded" | "error" | "disabled";
version: string | null;
revision: number;
};
export const APP_VERSION = process.env.LH_VERSION ?? "";
const HOMEPAGE = process.env.LH_HOMEPAGE ?? "";
export const MANUAL_UPDATE_HINT = "Checking downloads available updates. Downloaded updates install when you quit the app.";
export const AUTO_UPDATE_HINT = "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install.";
const UPDATE_ERROR = "Couldn’t check for updates. Try again later.";
/** Subscribe before requesting the snapshot. IPC replies and pushes can
* cross; a revision prevents an older reply from hiding a newer event.
* The banner shares this read-only observer so late loads recover too. */
export function useUpdateState() {
const [state, setState] = useState<UpdateState | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const lh = window.lh;
if (!lh) return;
let live = true;
let revision = -1;
const accept = (next: UpdateState) => {
if (!live || next.revision < revision) return;
revision = next.revision;
setState(next);
setFailed(false);
};
const off = lh.onUpdateStatus?.(accept);
void lh.getUpdateState?.().then(accept).catch(() => {
if (live && revision === -1) setFailed(true);
});
return () => { live = false; off?.(); };
}, []);
return { state, failed };
}
export function updateStatusText(state: UpdateState | null): string | null {
switch (state?.status) {
case "checking": return "Checking for updates…";
case "downloading": return state.version ? `Downloading version ${state.version}…` : "Downloading an update…";
case "up-to-date": return "No newer version was found at the last check.";
case "downloaded": return `${state.version ? `Version ${state.version}` : "An update"} is ready. It will install when you quit the app.`;
case "error": return UPDATE_ERROR;
default: return null;
}
}
/** Version information is useful everywhere; update controls only belong
* to a shell with an available updater. Opening this row never checks. */
export function UpdateSettings({
autoUpdate,
onAutoUpdateChange,
version = APP_VERSION,
homepage = HOMEPAGE,
}: {
autoUpdate: boolean;
onAutoUpdateChange: (on: boolean) => void;
version?: string;
homepage?: string;
}) {
const { state, failed } = useUpdateState();
const [checkFailed, setCheckFailed] = useState(false);
useEffect(() => setCheckFailed(false), [state]);
const active = state !== null && state.status !== "disabled";
const busy = state?.status === "checking" || state?.status === "downloading";
const status = state?.status === "downloaded" ? updateStatusText(state)
: failed || checkFailed ? UPDATE_ERROR : updateStatusText(state);
const releaseNotes = version && homepage ? `${homepage}/releases/tag/v${version}` : null;
const check = () => {
setCheckFailed(false);
void window.lh?.checkForUpdates?.().catch(() => setCheckFailed(true));
};
return (
<>
<SettingsRow
label={`Version ${version || "unknown"}`}
testid="row-version"
hint={
<>
{status && <span className="block" role="status" data-testid="update-status">{status}</span>}
{active && <span className="block">{MANUAL_UPDATE_HINT}</span>}
{releaseNotes && <a href={releaseNotes} target="_blank" rel="noopener noreferrer" className="underline-offset-2 hover:text-foreground hover:underline" data-testid="release-notes">Release notes</a>}
</>
}
>
{active && (state.status === "downloaded" ? (
<Button type="button" size="xs" className="cursor-pointer" data-testid="update-restart" onClick={() => window.lh?.installUpdate?.()}>Restart</Button>
) : (
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" data-testid="update-check" disabled={busy} aria-busy={busy} onClick={check}>
{busy && <BrailleSpinner label={state.status === "downloading" ? "Downloading update" : "Checking for updates"} />}
Check for updates
</Button>
))}
</SettingsRow>
{active && (
<SettingsRow label="Automatic updates" hint={AUTO_UPDATE_HINT} testid="row-auto-update">
<Toggle checked={autoUpdate} label="auto update" onChange={onAutoUpdateChange} />
</SettingsRow>
)}
</>
);
}
+30 -2
View File
@@ -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
View File
@@ -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);
}}
+10 -2
View File
@@ -49,6 +49,7 @@ const STORAGE_KEY = "lh:sidebar-open";
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
*/
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
import type { UpdateState } from "@/components/settings/update-settings.tsx";
const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator).
@@ -60,15 +61,22 @@ declare global {
lh?: {
/** Read-only host OS; determines the native-control gutter. */
platform?: string;
/** External Next desktop-dev uses the server's web connections. */
subscriptionTransport?: "desktop" | "web";
openSubscriptionBrowser?: (action: string) => Promise<void>;
chatgpt?: ChatGPTControlBridge;
subscriptions?: NativeSubscriptionControlBridge;
expandWindow: () => void;
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void;
getZoom?: () => Promise<number>;
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
/** Observational update events and a snapshot for late subscribers. */
onUpdateStatus?: (cb: (status: UpdateState) => void) => () => void;
getUpdateState?: () => Promise<UpdateState>;
/** Explicit manual check also downloads and stages available updates. */
checkForUpdates?: () => Promise<void>;
installUpdate?: () => void;
onOpenSettings?: (cb: (section: string) => void) => () => void;
};
}
}
+9 -2
View File
@@ -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>
);
}
+5 -18
View File
@@ -3,32 +3,19 @@
/**
* The auto-update banner (Wave C, owner 2026-10-10: silent download +
* restart-banner UX). The shell pushes "lh:update-status" once a staged
* update exists (electron/main.cjs update-downloaded); this is the whole
* renderer surface — a small fixed card, bottom-right, offering Restart.
* update exists (electron/main.cjs update-downloaded). Snapshot retrieval
* also covers a late load. The card offers Restart alongside Settings.
* Browser builds have no window.lh and never see a push: renders null.
*
* No dismiss: a downloaded update installs on quit anyway
* (autoInstallOnAppQuit); the banner is a courtesy, not a gate.
*/
import { useEffect, useState } from "react";
// The window.lh bridge type lives in components/sidebar.tsx (the incumbent
// declare-global) — onUpdateStatus/installUpdate are declared there too.
type UpdateStatus = { status: string; version: string | null };
import { useUpdateState } from "@/components/settings/update-settings.tsx";
export default function UpdateBanner() {
const [ready, setReady] = useState<UpdateStatus | null>(null);
useEffect(() => {
// Absent bridge (browser / dev) → stays hidden forever.
return window.lh?.onUpdateStatus?.((s) => {
if (s?.status === "downloaded") setReady(s);
});
}, []);
if (!ready) return null;
const { state: ready } = useUpdateState();
if (ready?.status !== "downloaded") return null;
return (
<div
data-testid="update-banner"
+5 -19
View File
@@ -29,25 +29,11 @@ export function splitQualified(id: string): { provider: string | null; id: strin
return { provider: id.slice(0, colon), id: id.slice(colon + 1) };
}
/**
* "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;
}