Merge upstream v0.4.0 into Gitea fork

This commit is contained in:
2026-10-31 13:45:06 +03:00
286 changed files with 24403 additions and 2546 deletions
+67
View File
@@ -0,0 +1,67 @@
name: Bug report
description: Report something that is not working as expected.
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve LearnHarness. Describe what you observed; you do not need to know the cause.
**This report is public.** Remove API keys, tokens, passwords and private information before posting. Never attach credential files or your whole data folder. Share only the learning content you are comfortable making public.
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the problem and what you expected to happen instead.
validations:
required: true
- type: input
id: version
attributes:
label: LearnHarness version or build
description: Include the app version, installer filename or source commit. If you cannot find it, write "unknown".
validations:
required: true
- type: input
id: operating_system
attributes:
label: Operating system
description: For example, Windows, macOS or your Linux distribution. Include the OS version if known.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps or context (optional)
description: How can we reproduce it? If it is intermittent or you are unsure, describe what you were doing when it happened.
validations:
required: false
- type: input
id: installation
attributes:
label: Installation method (optional)
description: For example, desktop installer, AppImage, system package, self-hosted web or source build.
validations:
required: false
- type: input
id: provider_model
attributes:
label: Provider and model (if relevant)
description: Include the provider, model and API-key or subscription connection type if relevant. Do not include any keys or tokens.
validations:
required: false
- type: textarea
id: transcript_excerpt
attributes:
label: Sanitized raw JSON or chat-log excerpt (optional)
description: A few relevant entries around the affected turn or tool call are especially helpful. Paste the excerpt directly, preserving JSON escaping where possible. Remove credentials, personal information and private learning content first. Leave this blank if the records are difficult to find.
render: json
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots or other context (optional)
description: Attach screenshots or add other relevant details. Check for private information before posting.
validations:
required: false
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
@@ -0,0 +1,17 @@
name: Feature request
description: Suggest a new feature or an improvement.
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Ideas are welcome; you do not need a finished design or implementation plan. A request opens a discussion, not a commitment to build it.
**This report is public.** Do not include credentials or private information.
- type: textarea
id: idea
attributes:
label: What would you like to see?
description: Describe your idea and the problem it would help you solve.
validations:
required: true
+15
View File
@@ -0,0 +1,15 @@
name: Question
description: Ask about using LearnHarness.
labels: [question]
body:
- type: markdown
attributes:
value: |
**This report is public.** Do not include credentials or private information.
- type: textarea
id: question
attributes:
label: What is your question?
description: Include any relevant context. Version, OS, provider or model details can help when they relate to your question.
validations:
required: true
+3
View File
@@ -14,6 +14,9 @@ dist/
# dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed
auth.json
chatgpt-credentials.json*
github-copilot-credentials.json*
xai-credentials.json*
# dev-fresh launcher scratch (fresh-user sim): disposable per launch
.fresh-data/
+4 -1
View File
@@ -12,7 +12,8 @@
<a href="https://learnharness.ai">Download</a> ·
<a href="#how-it-works">How it works</a> ·
<a href="docs/development.md">Documentation</a> ·
<a href="https://github.com/amosblomqvist/learnharness/issues">Issues</a>
<a href="https://github.com/amosblomqvist/learnharness/issues">Issues</a> ·
<a href="https://discord.gg/5tCHHQEB2e">Discord</a>
</p>
LearnHarness uses AI agents to plan and teach lessons from your current understanding to your goal. It builds a detailed picture of what you understand and how you reason. That picture is updated as you learn.
@@ -52,6 +53,8 @@ Add your provider API key and choose your models, then start a session with what
All three platforms support in-app updates.
**Dictation:** Create a [Deepgram API key](https://console.deepgram.com/) with the **Member role or higher** ([required for token minting](https://developers.deepgram.com/reference/auth/tokens/grant)) and add it under **Settings → Providers → Dictation**. Choose the language below the key; **Multilingual** supports automatic recognition and mixed-language speech. A `403 Insufficient permissions` error during token minting can indicate an insufficient key role.
## Your keys, your data
- **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs.
+3 -1
View File
@@ -1,4 +1,4 @@
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, turnFailure, type ChatDeleteMode } from "@/src/server";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -19,6 +19,8 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
messages: chat.messages,
pricing: sessionCost(chat),
turnInFlight: isTurnRunning(id),
// Also reaches idle views that missed the failing turn's stream.
turnError: turnFailure(id),
compacting: compactionInFlight(id),
coverage: sessionCoverage(id),
});
+4 -3
View File
@@ -5,8 +5,9 @@
* submitter too attaches here. One SSE stream per chat: a running turn →
* subscribe to the live turn object: FULL replay of the event log, then
* live chunks until settle ([DONE]). No running turn → the cheap terminal
* ({"type":"turn-settled"} + [DONE]): the record is current, the client
* refetches it.
* ({"type":"turn-settled"} + [DONE]): the client refetches the record.
* A retained settlement-failure notice precedes that terminal when a
* reply could not be saved before the submitter attached.
*
* 404 mirrors the POST handler's unknown-chat. Framework route is a thin
* wrapper over the server barrel (the import law); tests drive the seams
@@ -18,6 +19,6 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
const { id } = await ctx.params;
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
const handle = runningTurn(id);
if (!handle) return settledTurnResponse();
if (!handle) return settledTurnResponse(id);
return piTurnResponse(handle);
}
+6
View File
@@ -0,0 +1,6 @@
import { claudeCodeInstallationResponse } from "@/src/server";
/** Installation/version only; never reads auth status or makes a model request. */
export async function GET(request: Request) {
return claudeCodeInstallationResponse(request);
}
+9
View File
@@ -0,0 +1,9 @@
/** PR #62's single custom endpoint: discover/manual connect, offline pins,
* and disconnect. Host boundaries and mutations live behind the server barrel. */
import { customProviderGet, customProviderMutation } from "@/src/server";
export const dynamic = "force-dynamic";
export const GET = customProviderGet;
export const POST = customProviderMutation;
export const PATCH = customProviderMutation;
export const DELETE = customProviderMutation;
+2 -1
View File
@@ -10,7 +10,8 @@
* tests/auth-provider-keys.test.ts) — the settings UI reads it optionally
* with a graceful fallback.
*
* Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE STORE THROUGH
* Masked only — last 4 + length for ordinary keys; custom keys are fully
* redacted at every length (no credential suffix). PLAINTEXT NEVER LEAVES THE STORE THROUGH
* THIS API (owner decision). `source` tells the page whether the live
* value comes from the environment (dev .env.local / launch env) or the
* file, and `shadowedByEnv` explains why a saved key is not the live one
+9 -7
View File
@@ -5,12 +5,13 @@
* discipline, same as the per-chat and defaults routes); sections are
* independent, so partial writes can never clobber each other.
*
* section ∈ general | chat | agent | appearance | graph | compaction
* section ∈ general | chat | agent | appearance | graph | compaction | models | dictation
* Body: the complete desired section object. Parse + validate strictly
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
* supported list where one exists. The chat and models sections
* additionally pass the catalog gate (validateChatSettings /
* validateModelsSettings — unknown provider/model = 400). Thinking
* validateModelsSettings — unknown provider/model = 400, except unchanged
* model-slot selections may remain saved while unavailable). Thinking
* levels never reject a write (owner, interlude 2026-09-22): a pinned
* level CLAMPS to the named model's nearest supported level and the
* clamped value is stored — model swaps just work, same as the
@@ -63,6 +64,8 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
if (!parsed.ok) {
return Response.json({ error: parsed.error }, { status: 400 });
}
// Compare against the same record that this whole-section write replaces.
const record = readSettingsRecord() ?? { version: 1 };
let agentHealed: AgentSettings | undefined;
// Section-specific cross-validation (against the catalog / the prompt
@@ -73,11 +76,11 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
}
}
// Model slots wave (2026-09-20): the models section gets the same
// catalog gate the chat section gets — a slot must name a real
// provider+model and a thinking level it supports.
// New/replaced slot models pass the catalog gate. Unchanged saved model
// references may survive disconnect, including thinking-preference edits,
// so each unavailable slot can be repaired independently of the others.
if (section === "models") {
const verdict = validateModelsSettings(parsed.settings as ModelsSettings);
const verdict = validateModelsSettings(parsed.settings as ModelsSettings, record.models);
if (!verdict.ok) {
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
}
@@ -124,7 +127,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
// Read-modify-write ONE section of the record; everything else rides
// along untouched (other sections, unknown keys — sanitize preserves).
const record = readSettingsRecord() ?? { version: 1 };
const next = { ...record, [section]: sectionOut };
writeSettingsRecord(next);
return Response.json({ settings: next });
+6
View File
@@ -0,0 +1,6 @@
import { subscriptionBrowserResponse } from "@/src/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const GET = subscriptionBrowserResponse;
export const POST = subscriptionBrowserResponse;
+6
View File
@@ -0,0 +1,6 @@
import { subscriptionControlsResponse } from "@/src/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const GET = subscriptionControlsResponse;
export const POST = subscriptionControlsResponse;
+2 -1
View File
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { Chat } from "@/components/chat";
import { slotsConfigured } from "@/src/core";
import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord, turnFailure } from "@/src/server";
export const dynamic = "force-dynamic";
@@ -29,6 +29,7 @@ export default async function ChatPage({
// derived app-side (core/view.ts projectView; GET serves the same
// native messages).
initialMessages={chat.messages}
initialError={turnFailure(chat.id)}
initialConfigured={slotsConfigured(record?.models)}
initialPrompt={prompt}
// A teacher turn that outlived its client: the remounted view must
+18 -1
View File
@@ -38,7 +38,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"];
@@ -362,6 +362,12 @@ export const streamdownPlugins = {
math: createMathPlugin({ singleDollarTextMath: true }),
} as unknown as PluginConfig;
// Remend's incomplete-HTML heuristic mistakes `<b` in `$a<b$` for a
// partial tag and deletes the remaining source before math can tokenize.
// Preserve that text (unfinished prose HTML may show literally); Streamdown's
// separate sanitization/hardening and all other Markdown repairs stay enabled.
export const streamdownRemend = { htmlTags: false };
export const MessageResponse = memo(
({ className, children, ...props }: MessageResponseProps) => {
const { t } = useI18n();
@@ -385,6 +391,7 @@ export const MessageResponse = memo(
translations={translations}
components={mdComponents}
plugins={streamdownPlugins}
remend={streamdownRemend}
{...props}
>
{content}
@@ -398,6 +405,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 = ({
+2 -2
View File
@@ -25,7 +25,7 @@ import {
import { Streamdown } from "streamdown";
import { transformMathDelimiters } from "@/src/core";
import { streamdownPlugins } from "./message";
import { streamdownPlugins, streamdownRemend } from "./message";
import { mdComponents } from "@/components/md-blocks";
import { Shimmer } from "./shimmer";
@@ -212,7 +212,7 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown components={mdComponents} plugins={streamdownPlugins}>
<Streamdown components={mdComponents} plugins={streamdownPlugins} remend={streamdownRemend}>
{transformMathDelimiters(children)}
</Streamdown>
</CollapsibleContent>
+16 -14
View File
@@ -6,10 +6,11 @@ import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail";
import { GlyphMark } from "@/components/ui/glyph-mark";
import { ThinkingBlock } from "@/components/thinking-block";
import { PathText } from "@/components/file-path-chip";
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`).
@@ -21,8 +22,9 @@ 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 { t } = useI18n();
const answer = answerForPresentation(savedAnswer, input);
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
if (answer.dismissed) {
return (
@@ -88,13 +90,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>
)}
@@ -133,14 +135,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>
@@ -154,13 +156,13 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
data-testid="answered-other"
className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
>
<span>“{answer.other}”</span>
<span>“<PathText text={answer.other} />”</span>
</div>
)}
</div>
)}
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>}
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“<PathText text={answer.text} />”</div>}
{/* The confidence record (owner round 6): the slider's rail, disc
removed — the missing handle itself says "this is a record, not a
@@ -213,9 +215,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">{t("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>
@@ -227,7 +229,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">{t("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
@@ -17,7 +17,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.
*/
@@ -57,7 +57,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">{t(label)}</span>
<span>{failed ? t("failed") : t("delivered")}</span>
<span>{failed ? t("failed") : t("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
@@ -10,6 +10,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";
/**
@@ -18,22 +19,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
@@ -123,13 +112,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">{t("Figure")}</div>
<div className="flex items-center gap-1.5">
+217 -138
View File
@@ -7,7 +7,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,
@@ -16,6 +16,7 @@ import {
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
import { PathText } from "@/components/file-path-chip";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { BrailleSpinner } from "@/components/braille-spinner";
@@ -28,6 +29,8 @@ import { Composer } from "@/components/composer";
import { useEnterSubmits } from "@/components/enter-submits";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { dockStateKey } from "@/components/dock-state-key";
import { clearOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt, OPENING_PROMPT_FAILURE } 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";
@@ -37,10 +40,10 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
import {
isProviderKeyRefusal,
firstSourceTextParts,
cliContextWindowOf,
messageAnchorId,
textPartMessageId,
kbdEventForAction as kbdEventForChat,
needsContinuation,
type ChatInput,
type ChatSettings,
type LhMessage,
@@ -59,7 +62,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 {
@@ -68,9 +71,12 @@ import {
type CompactionDividerData,
} from "@/components/compaction-divider";
import { CoverageRing } from "@/components/extraction-coverage";
import { Button } from "@/components/ui/button";
import {
activeProblemOf,
pendingQuestionOf,
dismissedAnswer,
endsInFailedTurn,
findPendingToolCall,
phaseAt3,
phaseTimeline,
@@ -80,8 +86,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. */
/**
@@ -164,6 +168,18 @@ export function parseProviderKeyRefusal(message: string | undefined): boolean {
}
}
/** Retry sends no composer text. Its refusal must not resurrect the draft
* from an earlier, already-durable text send over today's unsent composer.
* Retire that fallback synchronously, never when the async Retry settles:
* a later ordinary send may own a new recovery draft by then. */
export function retryFailedResponse(
send: (input: ChatInput) => void,
submittedDraft: { current: string | null },
): void {
submittedDraft.current = null;
void send({ type: "retry" });
}
/** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -200,6 +216,7 @@ export function Chat({
chatId,
title,
initialMessages,
initialError,
initialPrompt,
initiallyInFlight,
initiallyCompacting = false,
@@ -209,6 +226,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. */
@@ -239,8 +258,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, hasConfirmedConversation, 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);
@@ -249,16 +268,6 @@ export function Chat({
// The reattach settled terminal → the sync-and-continue seam (below).
setReattachSettled(() => reattachSettledRef.current());
// New chat with a topic: send the first message exactly once
// (initialPromptOwed — the pure gate, pinned in the test suite).
const sentInitial = useRef(false);
useEffect(() => {
if (!sentInitial.current && initialPromptOwed(initialMessages, initialPrompt)) {
sentInitial.current = true;
void send({ type: "text", text: initialPrompt! });
}
}, [initialPrompt, initialMessages, send]);
// A turn running server-side, attached to no live view of ours (navigate
// away mid-turn, or a wake turn discovered mid-session): the reattach
// stream is the primary channel (resumeStream — the same rendering path
@@ -410,6 +419,7 @@ export function Chat({
void dismissQuestion(
p.toolCallId,
reading ? { ...answer, readingThinking: reading } : answer,
p.key,
);
if (busyRef.current) abortInFlightTurn();
return;
@@ -437,13 +447,11 @@ 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)
}, []);
const pending = findPendingToolCall(messages);
const pending = findPendingToolCall(messages, pendingQuestionOf(nativeMessages));
// The abort handler reads the pending question from a ref (the window
// listener is mount-scoped; the value it needs is render-scoped).
const pendingRef = useRef(pending);
@@ -461,7 +469,6 @@ export function Chat({
const busy = localStream || turnInFlight;
const busyRef = useRef(busy);
busyRef.current = busy;
// The attach handoff (stream-reattach wave): the moment a resumed
// stream takes over the rendering — the SDK left "ready", so
// submitted/streaming means chunks are flowing — localStream covers
@@ -533,12 +540,14 @@ export function Chat({
readingThinkingRef.current = "";
setReadingThinking("");
}, []);
const pendingQuestionId = pending?.toolCallId ?? null;
const pendingQuestionId = pending?.key ?? 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
@@ -555,31 +564,79 @@ 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);
const openingDraftRef = useRef<string | null>(null);
const [openingError, setOpeningError] = useState(false);
const openingMountedRef = useRef(false);
useEffect(() => {
openingMountedRef.current = true;
return () => { openingMountedRef.current = false; };
}, []);
const deliverOpeningPrompt = useCallback((prompt: string) => {
openingDraftRef.current = prompt;
// Persist uncertainty BEFORE the POST: another mount must restore,
// not race the still-arriving request. No storage means no auto-send.
if (!stashOpeningPrompt(chatId, prompt, true)) {
applyText(prompt);
setOpeningError(true);
return;
}
setOpeningError(false);
draftRef.current = prompt; // The existing 503 flow must restore it too.
applyText("");
void send({ type: "text", text: prompt }, () => {
clearOpeningPrompt(chatId);
openingDraftRef.current = null;
if (draftRef.current === prompt) draftRef.current = null;
}).then(accepted => {
if (!accepted && openingMountedRef.current && openingDraftRef.current === prompt && !textRef.current) {
applyText(prompt);
}
});
}, [chatId, send, applyText]);
const sentInitial = useRef(false);
useEffect(() => {
if (sentInitial.current) return;
sentInitial.current = true;
const entry = readOpeningPromptEntry(chatId);
const prompt = initialPrompt || entry?.text; // Legacy links still work.
if (!initialPromptOwed(initialMessages, prompt)) {
if (initialMessages.some(m => m.role === "user" || m.role === "assistant")) clearOpeningPrompt(chatId);
return;
}
openingDraftRef.current = prompt!;
if (entry?.attempted || initiallyInFlight) {
applyText(prompt!); // Interrupted start: the learner explicitly resends.
return;
}
deliverOpeningPrompt(prompt!);
}, [chatId, initialPrompt, initialMessages, initiallyInFlight, deliverOpeningPrompt, applyText]);
useEffect(() => {
if (!hasConfirmedConversation) return;
clearOpeningPrompt(chatId);
// A later canonical read may confirm a lost acknowledgment. Clear
// only the unchanged recovered draft, never the learner's newer work.
const opening = openingDraftRef.current;
if (opening !== null && textRef.current === opening) applyText("");
if (draftRef.current === opening) draftRef.current = null;
openingDraftRef.current = null;
setOpeningError(false);
}, [chatId, hasConfirmedConversation, applyText]);
// THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
// 503 models_not_configured envelope renders as the SETUP FLOW, never
@@ -594,8 +651,9 @@ export function Chat({
if (!error) return;
const missing = parseModelsRefusal(error.message);
// The key-refusal twin (zero-keys wave): a pinned model whose
// provider key is gone gets the SAME treatment — revert-draft and
// open setup (the dialog derives to its first-key stage on its own).
// provider key is gone also restores the draft. With another provider
// connected, repair belongs to the ordinary current-session picker;
// otherwise setup derives its exclusive provider/loading stage.
const keyRefused = parseProviderKeyRefusal(error.message);
if (!missing && !keyRefused) return; // a real error keeps the honest banner
// ORDER MATTERS: clearError() flips this effect's own dep (`error`)
@@ -615,7 +673,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;
@@ -635,10 +694,10 @@ export function Chat({
const cycleChatFields = useCallback((): readonly HTMLElement[] => {
const pick = (sel: string) => Array.from(document.querySelectorAll<HTMLElement>(sel));
return [
...pick('[data-testid="reading-thinking-field"] textarea'),
...pick('[data-testid="question-dock"] textarea'),
...pick('[data-testid="practice-dock"] textarea'),
...pick('[data-testid="composer"] textarea'),
...pick('[data-testid="reading-thinking-field"] [data-file-path-input]'),
...pick('[data-testid="question-dock"] [data-file-path-input]'),
...pick('[data-testid="practice-dock"] [data-file-path-input]'),
...pick('[data-testid="composer"] [data-file-path-input]'),
];
}, []);
useFieldCycle(cycleChatFields);
@@ -653,7 +712,7 @@ export function Chat({
* composer: with a question pending the Composer is unmounted, the ref
* is null, and the thinking field is left alone by design. */
const dictationWasActiveRef = useRef(false);
const composerInputRef = useRef<HTMLTextAreaElement>(null);
const composerInputRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (dictation.active) {
dictationWasActiveRef.current = true;
@@ -682,6 +741,12 @@ export function Chat({
// server's registry mutex is the structural gate; this is the
// affordance).
const [compacting, setCompacting] = useState(initiallyCompacting);
// A failed model response (issue #110): Retry keeps the saved input
// and completed work, without another learner message. Offered from
// "error" too: a dropped stream leaves the model there, and send()
// recovers from it. Later compaction requires new input instead.
const retryable = (status === "ready" || status === "error") && !busy && !compacting && endsInFailedTurn(nativeMessages);
const retryFailedTurn = () => retryFailedResponse(send, draftRef);
const canSend =
(text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting;
@@ -720,20 +785,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
@@ -908,8 +973,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
@@ -1146,6 +1211,18 @@ 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.
}
if (openingDraftRef.current !== null) {
deliverOpeningPrompt(t);
return;
}
draftRef.current = t; // the refusal flow's preserve-and-replay seam
applyText("");
void send({ type: "text", text: t });
@@ -1434,7 +1511,7 @@ export function Chat({
rail as the thinking rows — one quote geometry. */}
<div className="font-medium uppercase tracking-wide text-muted-foreground">{t("You")}</div>
<div className="lh-prose whitespace-pre-wrap text-foreground">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
<PathText text={m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} />
</div>
</div>
) : (
@@ -1572,9 +1649,15 @@ export function Chat({
{t("THINKING…")}</Shimmer>
</span>
)}
{openingError && <p role="alert" className="text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
{t("Something went wrong:")}{" "}{t(error.message)}
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
<span>{t("Something went wrong:")}{" "}{t(error.message)}</span>
{retryable && (
<Button variant="outline" size="xs" data-testid="retry-failed-turn" onClick={retryFailedTurn}>
{t("Retry")}
</Button>
)}
</div>
)}
</div>
@@ -1594,10 +1677,13 @@ export function Chat({
fold) and NEVER overlaps the composer. */}
<DockOverlay
pending={pending}
initialQuestionDraft={pending ? questionDraft(pending.key)?.form : undefined}
onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.key, { form }); }}
practice={activeProblem}
compactionRunning={compacting}
onSubmitThinking={submitThinking}
chatId={chatId}
onModelChange={setSelectedModel}
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
onOpenPlan={() => setPlanOpen(true)}
requiresReveal={requiresReveal}
@@ -1606,6 +1692,7 @@ export function Chat({
phase={phases.at(-1) ?? "probe"}
turns={turns}
contextTokens={contextTokens}
cliContextWindow={cliContextWindowOf(nativeMessages)}
pricing={pricing}
onReserve={setDockReserve}
dictationSlot={<DictationBar dictation={dictation} />}
@@ -1660,9 +1747,11 @@ 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.key, { readingThinking: readingThinkingRef.current });
const reading = readingThinkingRef.current.trim() || undefined;
clearReadingThinking();
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output);
// 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, pending.key);
}}
/>
</Conversation>
@@ -1698,7 +1787,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.
@@ -1749,7 +1837,8 @@ export function Chat({
* 2026-10-02 abort chain proper (question before turn). */
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null;
/**
* Does the ?prompt topic still owe its first send? The gate is "no real
* Does the opening topic still owe its first send? The topic is the
* server's ?prompt= or the sessionStorage stash. The gate is "no real
* conversation yet" (no user/assistant message) — NOT emptiness: a
* session started with map picks carries a graph-context MARKER as
* message[0], and that scaffolding must never swallow the first send
@@ -1818,6 +1907,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
function DockOverlay({
pending,
initialQuestionDraft,
onSaveQuestionDraft,
practice,
compactionRunning,
onSubmitThinking,
@@ -1827,10 +1918,12 @@ function DockOverlay({
phase,
turns,
contextTokens,
cliContextWindow,
pricing,
composer,
onSubmitAnswer,
chatId,
onModelChange,
onReserve,
plan,
onOpenPlan,
@@ -1839,7 +1932,9 @@ function DockOverlay({
setupCta,
queuedSteers,
}: {
pending: ReturnType<typeof findPendingToolCall>;
pending: ReturnType<typeof pendingQuestionOf>;
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;
@@ -1860,11 +1955,13 @@ function DockOverlay({
/** The session's usage log — the stats bar beneath the composer folds it. */
turns: TurnUsage[];
contextTokens: number | null;
cliContextWindow: ReturnType<typeof cliContextWindowOf>;
pricing?: SessionPricing;
composer: React.ReactNode;
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;
@@ -1903,7 +2000,7 @@ function DockOverlay({
const [revealed, setRevealed] = useState(false);
const revealPending = requiresReveal && !revealed;
const pendingId = pending?.toolCallId ?? null;
const pendingId = pending?.key ?? null;
const initialPendingRef = useRef(pendingId);
// Teach-question ARRIVAL state, applied during render — BEFORE the
@@ -1960,6 +2057,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.key}`
: 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
@@ -1998,28 +2106,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;
@@ -2055,48 +2168,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
@@ -2198,11 +2274,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);
@@ -2210,11 +2281,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;
@@ -2262,8 +2331,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
@@ -2292,11 +2363,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"}
@@ -2307,6 +2380,8 @@ function DockOverlay({
phase={phase}
onExpand={expandAndReveal}
onSubmit={onSubmitAnswer}
initialDraft={initialQuestionDraft}
onSaveDraft={onSaveQuestionDraft}
onEscape={dictationCancel}
dictationClaim={!revealPending}
compactionRunning={compactionRunning}
@@ -2329,15 +2404,19 @@ 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. */}
<StatsBar
turns={turns}
contextTokens={contextTokens}
cliContextWindow={cliContextWindow}
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
+5 -4
View File
@@ -5,6 +5,7 @@ import { useI18n } from "@/components/i18n";
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Hint } from "@/components/ui/hint";
import { PathInput } from "@/components/path-input";
/**
* The chat composer — content row ONLY (the persistent rounded shell lives in
@@ -61,7 +62,7 @@ export function Composer({
autoFocus?: boolean;
/** The page's handle to the textarea — the dictation-end focus (owner,
* 2026-10-01): a run ending hands the cursor to the bare composer. */
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
inputRef?: React.RefObject<HTMLDivElement | null>;
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
* when absent — Esc otherwise means nothing in the composer. */
onEscape?: () => void;
@@ -93,10 +94,11 @@ export function Composer({
act();
}}
>
<textarea
<PathInput
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onValueChange={onChange}
defaultDropTarget
onKeyDown={(e) => {
// Esc ends dictation before anything else Esc might someday mean
// here (the dictate decisions: click-again or Esc to stop).
@@ -118,7 +120,6 @@ export function Composer({
}}
placeholder={t(placeholder)}
autoFocus={autoFocus}
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
{/* The button follows the text (owner report 2026-10-03): while
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { LanguageSelect, useI18n } from "@/components/i18n";
import { SettingsRow } from "@/components/settings/controls";
import { useDictationActive } from "@/components/dictation-state";
/** Settings and the composer share the fork's general.sttLanguage preference. */
export function DictationLanguageSetting() {
const { t } = useI18n();
const active = useDictationActive();
return <SettingsRow label={t("Speech recognition language")} testid="row-dictation-language" dimmed={active}>
<LanguageSelect field="sttLanguage" disabled={active} />
</SettingsRow>;
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useSyncExternalStore } from "react";
import {
FACTORY_SETTINGS, isDictationLanguage, sanitizeUserSettings,
type DictationLanguage, type UserSettings,
} from "@/src/core";
import { notifySettingsSaved, onSettingsSaved } from "@/components/settings-signal";
const INITIAL = { language: undefined as DictationLanguage | undefined, saving: false, error: null as string | null };
/** One confirmed preference and one write in flight, shared by every surface.
* The factory keeps transport tests isolated without a second persistence path.
*/
export function createDictationLanguageStore(request: typeof fetch, saved: () => void = () => {}) {
let snapshot = INITIAL;
let reading: Promise<DictationLanguage> | null = null;
let writing: Promise<boolean> | null = null;
let revision = 0;
let needsRead = true;
const listeners = new Set<() => void>();
const publish = (patch: Partial<typeof INITIAL>) => {
snapshot = { ...snapshot, ...patch };
for (const listener of listeners) listener();
};
function load(refresh = false): Promise<DictationLanguage> {
if (writing) return getLanguage();
if (reading && !refresh) return reading;
if (!refresh && !needsRead && snapshot.language !== undefined) return Promise.resolve(snapshot.language);
const version = ++revision;
needsRead = true;
reading = Promise.resolve().then(async () => {
try {
const res = await request("/api/settings");
if (!res.ok) throw new Error();
const body = await res.json() as { settings: UserSettings | null };
if (!("settings" in body)) throw new Error();
const record = sanitizeUserSettings(body.settings);
if (body.settings !== null && record === null) throw new Error();
if (version !== revision) return getLanguage();
const language = record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language;
needsRead = false;
publish({ language, error: null });
return language;
} catch {
if (version !== revision) return getLanguage();
const message = "Could not load dictation language. Try again.";
publish({ error: message });
throw new Error(message);
} finally {
if (version === revision) reading = null;
}
});
return reading;
}
function save(language: DictationLanguage): Promise<boolean> {
// A different selection must never inherit the previous write's success.
if (writing || snapshot.language === undefined) return Promise.resolve(false);
revision++;
reading = null;
writing = Promise.resolve().then(async () => {
try {
const res = await request("/api/settings/dictation", {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ language }),
});
if (!res.ok) throw new Error();
const body = await res.json() as { settings: UserSettings };
const confirmed = body.settings?.dictation?.language;
if (!isDictationLanguage(confirmed)) throw new Error();
needsRead = false;
publish({ language: confirmed, error: null });
saved();
return true;
} catch {
publish({ error: "Could not save dictation language. Try again." });
return false;
} finally {
writing = null;
publish({ saving: false });
}
});
publish({ saving: true, error: null });
return writing;
}
async function getLanguage(): Promise<DictationLanguage> {
for (;;) {
if (writing) await writing;
else if (reading) await reading;
else if (needsRead || snapshot.language === undefined) await load();
else return snapshot.language;
}
}
return {
load, save, getLanguage,
getSnapshot: () => snapshot,
getServerSnapshot: () => INITIAL,
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
};
}
let announcingSave = false;
const store = createDictationLanguageStore((...args) => fetch(...args), () => {
// All our readers already received the confirmed PUT response. Keep the
// repo's notification contract without depending on a redundant GET.
announcingSave = true;
try { notifySettingsSaved("dictation"); } finally { announcingSave = false; }
});
let readers = 0;
let stopSignals: (() => void) | undefined;
function subscribe(listener: () => void) {
const off = store.subscribe(listener);
if (readers++ === 0) {
stopSignals = onSettingsSaved((section) => {
if (section === "dictation" && !announcingSave) void store.load(true).catch(() => {});
});
// A confirmed preference lives for the window, not the page. Navigation
// and reopening Settings must not add a new network failure point.
void store.getLanguage().catch(() => {});
}
return () => { off(); if (--readers === 0) { stopSignals?.(); stopSignals = undefined; } };
}
export function useDictationLanguage() {
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot, store.getServerSnapshot);
return { ...snapshot, load: store.load, save: store.save, getLanguage: store.getLanguage };
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useSyncExternalStore } from "react";
// The keyboard chain needs a synchronous answer; settings also subscribe
// so the language control locks from connecting until cleanup completes.
let active = false;
const listeners = new Set<() => void>();
export function isDictationActive(): boolean { return active; }
export function setDictationActive(next: boolean): void {
if (active === next) return;
active = next;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function useDictationActive(): boolean {
return useSyncExternalStore(subscribe, isDictationActive, () => false);
}
+75 -56
View File
@@ -6,6 +6,8 @@ import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, WAV_HEADER_BYTES, dictationUrl, dictationWav, type SttModel, type SttLanguage } from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n";
import { Hint } from "@/components/ui/hint";
import { setDictationActive } from "@/components/dictation-state";
export { isDictationActive } from "@/components/dictation-state";
/**
* Voice dictation — direct Deepgram streaming or whole-recording OpenRouter STT.
@@ -201,15 +203,16 @@ export function rmsToMeter(rms: number): number {
* window, one pending question, one target at a time.
*/
let questionTarget: ((text: string) => void) | null = null;
let composerTarget: ((text: string) => void) | null = null;
/* Is a dictation run live on this page? The Esc chain (keyboard wave A,
* components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the
* stop handler can't report back through a CustomEvent. Set at start(),
* cleared in cleanup() (every terminal path runs through cleanup). */
let dictationActiveFlag = false;
export function isDictationActive(): boolean {
return dictationActiveFlag;
/** File references use the very same default destination as dictation,
* including claims whose dock is currently collapsed. A focused editor can
* insert at its caret instead; callers use this only as the fallback. */
export function appendToPrimaryInput(text: string): boolean {
const target = questionTarget ?? composerTarget;
if (!target) return false;
target(text);
return true;
}
/* The finisher registry: submit paths FAR from the hook's owner (the
@@ -295,7 +298,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
const upload = useRef<AbortController | null>(null);
const drain = useRef<(() => void) | null>(null);
const stopping = useRef(false);
const [phase, setPhase] = useState<DictationPhase>("idle");
const captureDrained = useRef(false);
const [phase, setPhaseState] = useState<DictationPhase>("idle");
const phaseRef = useRef<DictationPhase>("idle");
const setPhase = useCallback((next: DictationPhase) => {
phaseRef.current = next;
setPhaseState(next);
}, []);
const [error, setError] = useState<string | null>(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
const levelRef = useRef(0);
@@ -303,6 +312,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
* retarget (composer↔question) is honored on the very next final. */
const appendRef = useRef(append);
appendRef.current = append;
useEffect(() => {
const target = (text: string) => appendRef.current(text);
composerTarget = target;
return () => {
if (composerTarget === target) composerTarget = null;
};
}, []);
// Live machinery (non-render values). The MediaStream tracks stop and
// the per-run nodes disconnect on teardown; the socket is recreated per
@@ -337,7 +353,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
}, []);
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
const cleanup = useCallback(() => {
const cleanup = useCallback((warmNext = true) => {
genRef.current++;
upload.current?.abort();
upload.current = null;
@@ -346,7 +362,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
audioFrames.current = [];
audioBytes.current = 0;
stopping.current = false;
dictationActiveFlag = false;
setDictationActive(false);
setDictating(false);
flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) {
@@ -375,7 +391,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
for (const w of waitersRef.current.splice(0)) w();
// Pre-mint for the next run (mitigation D) — best-effort; a keyless
// page just doesn't warm.
if (selected.current.model === DEFAULT_STT_MODEL) warmTokens.refresh();
if (warmNext && selected.current.model === DEFAULT_STT_MODEL) warmTokens.refresh();
}, []);
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
@@ -394,7 +410,11 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// faster-than-realtime sends.
for (const frame of pendingRef.current) ws.send(frame);
pendingRef.current = [];
setPhase("recording");
if (!stopping.current) setPhase("recording");
else if (captureDrained.current) {
try { ws.send(JSON.stringify({ type: "CloseStream" })); }
catch { cleanup(); }
}
});
ws.addEventListener("message", (ev) => {
if (gen !== genRef.current) return;
@@ -444,6 +464,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
);
const start = useCallback(async () => {
if (phaseRef.current !== "idle") return;
// Pin the complete configuration once; reconnects must retain both language and hints.
recordingUrl.current = selectedUrl.current;
recordingConfig.current = { ...selected.current };
@@ -451,8 +472,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
audioFrames.current = [];
audioBytes.current = 0;
stopping.current = false;
captureDrained.current = false;
setDictating(true);
dictationActiveFlag = true;
setDictationActive(true);
setError(null);
setPhase("connecting");
reconnectsRef.current = 0;
@@ -567,7 +589,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
const stop = useCallback(() => {
const gen = genRef.current;
if (phase !== "recording" && phase !== "connecting") return;
if (phaseRef.current !== "recording" && phaseRef.current !== "connecting") return;
if (stopping.current) return;
stopping.current = true;
setPhase("stopping");
@@ -615,47 +637,54 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
})();
return;
}
workletRef.current?.disconnect();
workletRef.current = null;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
const worklet = workletRef.current;
if (!worklet) { cleanup(); return; }
// Bound Stop even if a token/handshake never arrives. While connecting,
// the socket's open handler sends queued PCM before CloseStream.
safetyRef.current = setTimeout(() => {
if (gen === genRef.current) cleanup();
}, 3000);
void (async () => {
try {
ws.send(JSON.stringify({ type: "CloseStream" }));
} catch {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => { drain.current = null; reject(new Error("Could not finish microphone capture")); }, 2000);
drain.current = () => { clearTimeout(timer); resolve(); };
worklet.port.postMessage({ type: "flush" });
});
if (gen !== genRef.current) return;
worklet.disconnect();
workletRef.current = null;
captureDrained.current = true;
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "CloseStream" }));
} catch (error) {
if (gen !== genRef.current) return;
setError(error instanceof Error ? error.message : "Could not finish microphone capture");
cleanup();
return;
}
// Deepgram flushes remaining finals then closes; if it never
// does, the safety net ends the run anyway (dictate pattern).
safetyRef.current = setTimeout(() => {
if (gen === genRef.current) cleanup();
}, 3000);
} else {
cleanup();
}
}, [phase, cleanup]);
})();
}, [cleanup]);
const cancel = useCallback(() => {
if (phase === "idle") return;
if (phaseRef.current === "idle") return;
// Discard first, teardown second — cleanup() flushes, and the now-empty
// buffer makes that flush a no-op. Also kills a "stopping" run: the
// flush already underway is abandoned (its timer is cleared inside).
bufferRef.current = [];
cleanup();
}, [phase, cleanup]);
}, [cleanup]);
const finish = useCallback((): Promise<void> => {
return new Promise((resolve) => {
if (phase === "idle") {
if (phaseRef.current === "idle") {
resolve();
return;
}
waitersRef.current.push(resolve);
// "stopping" is already ending — the waiter just rides to cleanup.
if (phase === "recording" || phase === "connecting") stop();
if (phaseRef.current === "recording" || phaseRef.current === "connecting") stop();
});
}, [phase, stop]);
}, [stop]);
// Publish finish() for the sibling subtrees (the pending question's
// dock) — registered for as long as this page owns the hook.
@@ -667,10 +696,10 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
}, [finish]);
const toggle = useCallback(() => {
if (phase === "idle") void start();
else if (phase === "recording" || phase === "connecting") stop();
if (phaseRef.current === "idle") void start();
else if (phaseRef.current === "recording" || phaseRef.current === "connecting") stop();
// "stopping" presses are ignored — the flush is in flight.
}, [phase, start, stop]);
}, [start, stop]);
// Shortcut seams (keyboard wave A): ⌘D toggles from anywhere on the
// page; the Esc chain lands here as a CANCEL (owner, round 3). Each
@@ -680,9 +709,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
if ((general.sttModel ?? DEFAULT_STT_MODEL) === DEFAULT_STT_MODEL) warmTokens.refresh();
}, [general.sttModel]);
useEffect(() => () => {
// Navigation cancels a pending upload and releases the microphone without inserting text.
// Navigation cancels every pending await/upload without inserting into another page.
bufferRef.current = [];
cleanup();
cleanup(false);
}, [cleanup]);
useEffect(() => {
window.addEventListener("lh:kbd-toggle-dictation", toggle);
@@ -772,10 +801,7 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
return (
<div className="flex w-full min-w-0 items-center gap-1.5">
<Hint
// Errored: the button span truncates the line — the tooltip is its
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
// it's the Keys row label, the truth the user needs). Otherwise the
// quiet label + the live ⌘D binding.
// The error line truncates in the button; its tooltip reveals it.
label={error ? t(error) : t("Dictate")}
action={error ? undefined : "toggle-dictation"}
side="top"
@@ -792,10 +818,8 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
}}
aria-label={error ? t("Dictation failed: {v0}", { v0: t(error) }) : t("Start dictation")}
data-testid="dictation-toggle"
// Same blurring as the floating chrome (owner, fix wave round 3),
// but lower opacity — the mic stays quieter than the model pills.
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
>
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
>
<MicIcon className="size-3.5" />
<span className="max-w-full truncate">{error ? t(error) : t("Dictate")}</span>
</button>
@@ -811,12 +835,7 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
return (
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
<Hint
// The owner flagged this copy good ("Stop dictation (Esc)") —
// kept verbatim; only the machinery moved to the styled tooltip.
label={t("Stop dictation (Esc)")}
side="top"
>
<Hint label={t("Stop dictation (Esc)")} side="top">
<button
type="button"
onClick={toggle}
+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);
}
+62
View File
@@ -0,0 +1,62 @@
import React, { Fragment } from "react";
import {
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileVideo,
} from "lucide-react";
import { filePathLabel, parseFilePaths } from "@/src/core";
function fileIcon(label: string) {
const extension = label.split(".").at(-1)?.toLowerCase();
if (/^(png|jpe?g|gif|webp|svg|bmp|tiff?|avif|heic)$/.test(extension ?? "")) return FileImage;
if (/^(mp3|wav|ogg|flac|m4a|aac)$/.test(extension ?? "")) return FileAudio;
if (/^(mp4|mov|webm|mkv|avi)$/.test(extension ?? "")) return FileVideo;
if (/^(zip|gz|tar|bz2|xz|7z|rar)$/.test(extension ?? "")) return FileArchive;
if (/^(csv|tsv|xlsx?|ods)$/.test(extension ?? "")) return FileSpreadsheet;
if (/^(js|jsx|ts|tsx|py|rb|rs|go|c|cpp|h|java|json|html|css|sh|yaml|yml)$/.test(extension ?? "")) return FileCode;
if (/^(pdf|txt|md|mdx|rtf|docx?|odt|tex|epub)$/.test(extension ?? "")) return FileText;
return File;
}
export interface FilePathChipProps {
path: string;
/** Editors can make the chip atomic; transcript renderers can omit this. */
contentEditable?: false;
className?: string;
}
/** Pure presentation: no links, open actions, HTML injection or path probing. */
export function FilePathChip({ path, contentEditable, className }: FilePathChipProps) {
const label = filePathLabel(path);
const Icon = fileIcon(label);
return (
<span
role="img"
aria-label={path}
title={path}
contentEditable={contentEditable}
className={`inline-flex max-w-full items-center gap-1 rounded border bg-muted/50 px-1.5 py-0.5 align-middle text-sm${className ? ` ${className}` : ""}`}
>
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</span>
);
}
/** Ordinary learner text stays ordinary text; only quoted paths become chips. */
export function PathText({ text }: { text: string }) {
return (
<>
{parseFilePaths(text).map((part) => (
<Fragment key={part.start}>
{part.kind === "path" ? <FilePathChip path={part.path} /> : part.text}
</Fragment>
))}
</>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { FileSymlinkIcon, XIcon } from "lucide-react";
import { quoteFilePath } from "@/src/core";
import { appendToPrimaryInput } from "@/components/dictation";
import { INSERT_FILE_PATHS_EVENT } from "@/components/path-input";
const BROWSER_NOTICE = "Local file paths can only be attached in the desktop app. You can paste a full path instead.";
/** The content area only: the sidebar is deliberately outside this zone.
* Existing files stay where they are; only quoted path strings enter drafts. */
export function FilePathDropZone({ children, className }: { children: React.ReactNode; className?: string }) {
const root = useRef<HTMLElement>(null);
const depth = useRef(0);
const [dragging, setDragging] = useState(false);
const [notice, setNotice] = useState("");
const hasFiles = (event: React.DragEvent) => Array.from(event.dataTransfer.types).includes("Files");
const clearDrag = () => { depth.current = 0; setDragging(false); };
useEffect(() => {
// The sidebar remains a non-target, but dropping a file there must not
// navigate the app away. No indicator or attachment is produced there.
const preventOutsideNavigation = (event: DragEvent) => {
if (Array.from(event.dataTransfer?.types ?? []).includes("Files")
&& event.target instanceof Node && !root.current?.contains(event.target)) event.preventDefault();
};
window.addEventListener("dragover", preventOutsideNavigation);
window.addEventListener("drop", preventOutsideNavigation);
return () => {
window.removeEventListener("dragover", preventOutsideNavigation);
window.removeEventListener("drop", preventOutsideNavigation);
};
}, []);
return (
<main
ref={root}
className={className}
data-testid="file-path-drop-zone"
onDragEnter={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
depth.current++;
setDragging(true);
setNotice("");
}}
onDragOver={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(event) => {
if (!hasFiles(event)) return;
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
}}
onDragEnd={clearDrag}
onDrop={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.stopPropagation();
clearDrag();
const resolve = window.lh?.getPathForFile;
if (!resolve) { setNotice(BROWSER_NOTICE); return; }
// Directories are not file references. This check uses drag metadata,
// never a server request or a new filesystem capability.
const entries = Array.from(event.dataTransfer.items).filter((item) => item.kind === "file");
const paths: string[] = [];
let skipped = 0;
for (const [index, file] of Array.from(event.dataTransfer.files).entries()) {
try {
if (entries[index]?.webkitGetAsEntry?.()?.isDirectory) { skipped++; continue; }
const path = resolve(file);
quoteFilePath(path); // Same rooted-path validity rule as typed chips.
paths.push(path);
} catch { skipped++; }
}
if (paths.length === 0) {
setNotice("No local file paths were available. Drop existing files from your computer.");
return;
}
const focused = document.activeElement?.closest<HTMLElement>("[data-file-path-input]");
if (focused && event.currentTarget.contains(focused)) {
focused.dispatchEvent(new CustomEvent(INSERT_FILE_PATHS_EVENT, { detail: paths }));
} else if (!appendToPrimaryInput(paths.map(quoteFilePath).join(" "))) {
setNotice("Open a chat to attach file paths.");
return;
}
setNotice(skipped ? "Some dropped items had no available file path." : "");
}}
>
{children}
{dragging && (
<div data-testid="file-path-drop-indicator" className="pointer-events-none absolute inset-2 z-50 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-background/85 p-6 text-center backdrop-blur-sm">
<div className="flex max-w-md flex-col items-center gap-3">
<FileSymlinkIcon className="size-8 text-primary" aria-hidden />
<span className="font-ui text-sm">{window.lh?.getPathForFile ? "Drop to attach file paths" : BROWSER_NOTICE}</span>
</div>
</div>
)}
{notice && <div role="status" className="absolute bottom-2 left-1/2 z-50 flex max-w-[90%] -translate-x-1/2 items-center gap-2 rounded-md border bg-card px-3 py-2 font-ui text-xs shadow-sm">
<span>{notice}</span>
<button type="button" aria-label="Dismiss attachment notice" onClick={() => setNotice("")} className="shrink-0 rounded-sm p-1 hover:bg-accent"><XIcon className="size-3" aria-hidden /></button>
</div>}
</main>
);
}
+36 -36
View File
@@ -9,11 +9,14 @@ 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";
import { StatsBar } from "@/components/stats-bar";
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { PathInput } from "@/components/path-input";
import { SessionStarter } from "@/components/start-session";
import type { TurnUsage } from "@/src/core";
/**
@@ -44,15 +47,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.
@@ -66,10 +62,13 @@ export function Home({
},
});
const [busy, setBusy] = useState(false);
const startingRef = useRef(false);
const starterRef = useRef(new SessionStarter());
const [startError, setStartError] = useState(false);
/* Run end focuses the topic input (owner, 2026-10-01): the hands are on
* the keyboard from the stop. Falling edge only; this surface's only
* dictation target IS this textarea, so no case-split is needed. */
const topicInputRef = useRef<HTMLTextAreaElement>(null);
const topicInputRef = useRef<HTMLDivElement>(null);
// Tab navigates INTO the topic composer (owner, keyboard round
// 2026-10-06): the only text field on this surface — and home's
// composer is its OWN textarea, not the shared Composer component,
@@ -115,7 +114,7 @@ export function Home({
};
const begin = async () => {
const t = topicRef.current.trim();
if (!t || busy) return;
if (!t || startingRef.current) return;
// Unconfigured: the send INTERCEPTS into the setup flow (the draft
// stays in the textarea; the learner sends it after Save).
if (!configured) {
@@ -126,36 +125,37 @@ 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.
}
startingRef.current = true;
setBusy(true);
setStartError(false);
try {
// The map context (owner, 2026-10-04): picks in EITHER mode ride
// the CREATION as graphContext — the server writes a
// graph-context marker as message[0] (a separate custom message
// before the learner's first user message, propositions + the
// chains among them); the prompt stays the learner's own words.
const res = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: t,
...(mode === "practice" ? { kind: "practice" } : {}),
...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}),
}),
});
const { id } = (await res.json()) as { id: string };
// No settings PUT on create (per-session models, owner
// 2026-09-28): the SERVER stamps the newborn from the strong
// slot — the same default this surface's model control edits.
router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`);
const target = await starterRef.current.start({
title: t,
...(mode === "practice" ? { kind: "practice" } : {}),
...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}),
}, fetch, JSON.stringify(catalog?.slots.strong));
router.push(target);
} catch {
startingRef.current = false;
setBusy(false);
setStartError(true);
}
};
@@ -243,6 +243,7 @@ export function Home({
)
)}
/>
{startError && <p role="alert" className="mb-2 text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
<form
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
onSubmit={(e) => {
@@ -250,13 +251,14 @@ export function Home({
void start();
}}
>
<textarea
<PathInput
ref={topicInputRef}
value={topic}
onChange={(e) => {
topicRef.current = e.target.value;
setTopic(e.target.value);
onValueChange={(value) => {
topicRef.current = value;
setTopic(value);
}}
defaultDropTarget
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again or
// Esc) — stopPropagation claims it (keyboard wave A) so
@@ -274,7 +276,6 @@ export function Home({
}}
placeholder={mode === "practice" ? t("What do you want to practice?") : t("What do you want to learn?")}
autoFocus
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
{/* No tooltip on the start square — the ↵ glyph is the
@@ -328,7 +329,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" || section === "claudeCode") {
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 };
}
+24 -67
View File
@@ -10,61 +10,17 @@ 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
* source — no hardcoded fallback exists anywhere, so a model missing
* from the index reads limit-null and the meter renders usage-only,
* the honest-render precedent). Consumed by the stats bar: the same
* cached /api/models fetch the picker rides feeds the denominator. */
/** provider:id → known contextWindow over the cached catalog, mirroring
* the server's catalogContextLimits join. CLI aliases have no global
* denominator: the stats bar uses that session's actual CLI report instead.
* Missing limits always render usage-only, without a hardcoded fallback. */
export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Map<string, number> {
return new Map(catalog.models.map((m) => [`${m.provider}:${m.id}`, m.contextWindow]));
return new Map(catalog.models.flatMap((m) => m.contextWindow === null ? [] : [[`${m.provider}:${m.id}`, m.contextWindow] as const]));
}
/**
@@ -87,7 +43,9 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
const { t } = useI18n();
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);
@@ -102,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 */
});
@@ -127,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;
@@ -239,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.");
@@ -318,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={t("Model selector")}
label={effectiveId ? `${effectiveProvider ? `${effectiveProvider}:` : ""}${effectiveId}` : t("Model selector")}
action="open-model-picker"
side="bottom"
>
+47 -39
View File
@@ -10,10 +10,12 @@ 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";
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { SessionStarter } from "@/components/start-session";
import type { ChatSettings, GraphSnapshot } from "@/src/core";
/** The draft's pick set (owner round 10): the SAME shape the home pick
@@ -35,18 +37,18 @@ export type DraftPick = { id: string; name: string; statement: string };
const DOCK = "mx-auto w-full max-w-[46rem] px-4";
/**
* The draft's whole behavior as one pure seam: create the session (title =
* the first message), persist the lifted pre-chat settings, return the push
* target — /chat/<id>?prompt=<msg> — whose initialPrompt path delivers the
* message as turn 1. Injected fetch/router so tests pin the contract.
* Returns null on empty text; rethrows on transport failure (the caller's
* busy flag resets).
* Create, safely hand over the full opening text, then return a short
* /chat/<id> target. Storage/creation failures leave the caller's draft
* intact. A per-composer starter reuses an already-created session on an
* unchanged retry. The server stamps settings at birth; no settings PUT.
*/
export async function startDraftSession(
text: string,
fetchLike: typeof fetch,
mode?: "practice",
picks?: readonly DraftPick[],
starter = new SessionStarter(),
settingsIdentity?: string,
): Promise<string | null> {
const t = text.trim();
if (!t) return null;
@@ -54,19 +56,13 @@ export async function startDraftSession(
// from the pick bar creates its session WITH the graph context — the
// same contract as the home screen's practice send (the marker with
// [id] name: statement lines, target validation's truth set).
const res = await fetchLike("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
mode === "practice"
? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) }
: { title: t },
),
});
const { id } = (await res.json()) as { id: string };
// No settings PUT on create (per-session models, owner 2026-09-28):
// the SERVER stamps the newborn from the strong slot.
return `/chat/${id}?prompt=${encodeURIComponent(t)}`;
return starter.start(
mode === "practice"
? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) }
: { title: t },
fetchLike,
settingsIdentity,
);
}
/** The pure shell (no router) — the render-test seam: the draft view's
@@ -79,6 +75,7 @@ export function DraftShell({
text,
onChange,
onSend,
startError = false,
setupCta,
mode = "teach",
picks = [],
@@ -90,6 +87,7 @@ export function DraftShell({
text: string;
onChange: (v: string) => void;
onSend: () => void;
startError?: boolean;
/** The practice draft (owner, 2026-10-04): ?mode=practice — the
* created session carries kind:"practice" and the copy changes
* (the practice agent opens; no probing). */
@@ -119,7 +117,7 @@ export function DraftShell({
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
// the only text field on this surface — the cycle's whole chain.
const cycleComposer = useCallback((): readonly HTMLElement[] =>
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] textarea')),
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] [data-file-path-input]')),
[]);
useFieldCycle(cycleComposer);
const dictation = useDictation({
@@ -143,7 +141,7 @@ export function DraftShell({
};
/* Run end focuses the composer (owner, 2026-10-01): the hands are on
* the keyboard from the stop — falling edge of `active` only. */
const composerInputRef = useRef<HTMLTextAreaElement>(null);
const composerInputRef = useRef<HTMLDivElement>(null);
const dictationWasActiveRef = useRef(false);
useEffect(() => {
if (dictation.active) {
@@ -193,6 +191,7 @@ export function DraftShell({
)}
setup={setupCta}
/>
{startError && <p role="alert" className="pointer-events-auto mb-2 text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<Composer
busy={busy}
@@ -247,6 +246,9 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
setText(v);
};
const [busy, setBusy] = useState(false);
const startingRef = useRef(false);
const starterRef = useRef(new SessionStarter());
const [startError, setStartError] = useState(false);
// THE PICK BAR (owner round 10): the draft's lifted pick set + the
// snapshot the standalone chain-table picker reads. Lazy — the graph
// is fetched on FIRST open only, then reused.
@@ -279,19 +281,12 @@ 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(() => {
const draft = textRef.current.trim();
if (!draft || busy) return;
if (!draft || startingRef.current) return;
if (!configured) {
setSetupOpen(true);
return;
@@ -299,25 +294,39 @@ 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.
}
startingRef.current = true;
setBusy(true);
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
setStartError(false);
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined,
starterRef.current, JSON.stringify(catalog?.slots.strong))
.then((target) => {
if (target) router.push(target);
else setBusy(false);
else { startingRef.current = false; setBusy(false); }
})
.catch(() => setBusy(false));
}, [busy, router, configured, zeroKeys, strongKeyless]);
.catch(() => {
startingRef.current = false;
setBusy(false);
setStartError(true);
});
}, [router, configured, zeroKeys, catalog, mode]);
return (
<>
<DraftShell
busy={busy}
startError={startError}
text={text}
onChange={applyText}
onSend={send}
@@ -372,7 +381,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.
+59
View File
@@ -0,0 +1,59 @@
/**
* Same-tab handover of a new session's full opening text. Never put the
* message in the navigation URL: long query payloads can be rejected before
* the chat page loads. Storage is required for automatic delivery, not an
* optional best-effort side effect of navigating away from the source draft.
*/
export const OPENING_PROMPT_FAILURE = "Couldn’t open the session. Your message is still here. Try sending again.";
export interface OpeningPrompt {
text: string;
/** Written BEFORE sending. A later mount restores a draft, never reposts. */
attempted: boolean;
}
const keyFor = (id: string) => `lh.openingPrompt.${id}`;
export function stashOpeningPrompt(id: string, text: string, attempted = false): boolean {
try {
const value = JSON.stringify({ text, attempted } satisfies OpeningPrompt);
// Catch both the storage getter and its methods (quota/security errors).
const storage = globalThis.sessionStorage;
storage.setItem(keyFor(id), value);
return storage.getItem(keyFor(id)) === value;
} catch {
return false;
}
}
export function readOpeningPromptEntry(id: string): OpeningPrompt | undefined {
try {
const value = globalThis.sessionStorage.getItem(keyFor(id));
if (!value) return undefined;
try {
const entry: unknown = JSON.parse(value);
if (entry && typeof entry === "object" && "text" in entry && typeof entry.text === "string" &&
entry.text.length > 0 && "attempted" in entry && typeof entry.attempted === "boolean") {
return { text: entry.text, attempted: entry.attempted };
}
} catch { /* Old raw-text stashes have no proof that sending never began. */ }
return { text: value, attempted: true };
} catch {
return undefined;
}
}
export function readOpeningPrompt(id: string): string | undefined {
return readOpeningPromptEntry(id)?.text;
}
export function clearOpeningPrompt(id: string): boolean {
try {
globalThis.sessionStorage.removeItem(keyFor(id));
return true;
} catch {
// Canonical conversation/acknowledgment remains the truth. A later
// mount with real conversation can try cleanup again; never crash it.
return false;
}
}
+257
View File
@@ -0,0 +1,257 @@
import { parseFilePaths, quoteFilePath } from "@/src/core";
/** Tokens store their complete source, not their abbreviated visual label. */
export const PATH_SOURCE_ATTRIBUTE = "data-file-path-source";
/** Presentation-only line box for the caret after a final model newline. */
export const PATH_CARET_ATTRIBUTE = "data-file-path-caret";
export type TextSelection = { anchor: number; focus: number };
export type DOMPoint = { node: Node; offset: number };
type Segment = { text: string; start: DOMPoint; end: DOMPoint; node?: Text; token?: Element };
function childIndex(node: Node): number {
return Array.prototype.indexOf.call(node.parentNode!.childNodes, node);
}
/** Browser editing can leave BR/block nodes; never use innerText (layout/whitespace dependent). */
function segments(root: HTMLElement): Segment[] {
const result: Segment[] = [];
function walk(node: Node) {
if (node.nodeType === 3) {
const text = node as Text;
result.push({ text: text.data, start: { node, offset: 0 }, end: { node, offset: text.length }, node: text });
return;
}
if (node.nodeType !== 1) return;
const element = node as Element;
if (element.hasAttribute(PATH_CARET_ATTRIBUTE)) return;
const raw = element.getAttribute(PATH_SOURCE_ATTRIBUTE);
if (raw !== null) {
const index = childIndex(node);
result.push({ text: raw, start: { node: node.parentNode!, offset: index }, end: { node: node.parentNode!, offset: index + 1 }, token: element });
return;
}
if (element.tagName === "BR") {
// Chromium's lone BR is an empty editor/paragraph placeholder.
if (node.parentNode?.childNodes.length === 1) return;
const index = childIndex(node);
result.push({ text: "\n", start: { node: node.parentNode!, offset: index }, end: { node: node.parentNode!, offset: index + 1 } });
return;
}
Array.from(node.childNodes).forEach((child, index) => {
const block = child.nodeType === 1 && /^(DIV|P)$/.test((child as Element).tagName);
const previous = node.childNodes[index - 1];
const previousBlock = previous?.nodeType === 1 && /^(DIV|P)$/.test((previous as Element).tagName);
if (index > 0 && (block || previousBlock)) {
result.push({ text: "\n", start: { node, offset: index }, end: { node, offset: index } });
}
walk(child);
});
}
walk(root);
return result;
}
export function readPathInput(root: HTMLElement): string {
return segments(root).map((part) => part.text).join("");
}
/** Map a logical UTF-16 offset to a DOM boundary, snapping a chip interior atomically. */
export function pathInputPoint(root: HTMLElement, offset: number): DOMPoint {
const parts = segments(root);
const trailing = root.lastChild;
if (trailing?.nodeType === 1 && (trailing as Element).hasAttribute(PATH_CARET_ATTRIBUTE)
&& offset >= parts.reduce((length, part) => length + part.text.length, 0)) {
// A text-node endpoint after its final newline has no line box in Chromium.
// The boundary immediately before this BR is on the visible final line.
return { node: root, offset: childIndex(trailing) };
}
let remaining = Math.max(0, offset);
for (const part of parts) {
if (remaining <= part.text.length) {
if (part.node) return { node: part.node, offset: remaining };
return remaining <= part.text.length / 2 ? part.start : part.end;
}
remaining -= part.text.length;
}
return { node: root, offset: root.childNodes.length };
}
/** Works without Range: useful in DOM test environments as well as real Selection. */
export function pathInputOffset(root: HTMLElement, node: Node, offset: number): number | null {
if (node !== root && !root.contains(node)) return null;
let total = 0;
for (const part of segments(root)) {
if (part.node === node) return total + Math.min(Math.max(0, offset), part.text.length);
if (part.token?.contains(node)) {
// A mouse/programmatic selection can end inside a noneditable label.
// Keep distinct interior endpoints so atomicPathRange expands copy/cut
// to the complete source instead of leaking the abbreviated filename.
let visualOffset = node.nodeType === 3 ? offset : Array.from(node.childNodes).slice(0, offset).reduce((length, child) => length + (child.textContent?.length ?? 0), 0);
let cursor = node;
while (cursor !== part.token) {
let sibling = cursor.previousSibling;
while (sibling) { visualOffset += sibling.textContent?.length ?? 0; sibling = sibling.previousSibling; }
cursor = cursor.parentNode!;
}
const visualLength = part.token.textContent?.length ?? 0;
return total + (visualLength ? Math.round(Math.min(visualLength, visualOffset) / visualLength * part.text.length) : 0);
}
if (part.start.node === node && part.start.offset >= offset) return total;
if (part.end.node === node && part.end.offset <= offset) {
total += part.text.length;
continue;
}
// A boundary in an ancestor is before/after every descendant segment.
let ancestor: Node = part.node ?? part.token ?? part.start.node;
while (ancestor.parentNode && ancestor.parentNode !== node) ancestor = ancestor.parentNode;
if (ancestor.parentNode === node && childIndex(ancestor) >= offset) return total;
total += part.text.length;
}
return total;
}
/** Validate a browser beforeinput StaticRange before projecting its exact endpoints. */
export function pathInputStaticRange(root: HTMLElement, range: Pick<StaticRange, "startContainer" | "startOffset" | "endContainer" | "endOffset">): TextSelection | null {
function valid(node: Node, offset: number) {
return (node === root || root.contains(node)) && Number.isInteger(offset) && offset >= 0
&& offset <= (node.nodeType === 3 ? (node as Text).length : node.childNodes.length);
}
if (!valid(range.startContainer, range.startOffset) || !valid(range.endContainer, range.endOffset)) return null;
const anchor = pathInputOffset(root, range.startContainer, range.startOffset);
const focus = pathInputOffset(root, range.endContainer, range.endOffset);
return anchor === null || focus === null || anchor >= focus ? null : { anchor, focus };
}
export function readPathSelection(root: HTMLElement): TextSelection | null {
const selection = root.ownerDocument.getSelection?.();
if (!selection?.anchorNode || !selection.focusNode) return null;
const anchor = pathInputOffset(root, selection.anchorNode, selection.anchorOffset);
const focus = pathInputOffset(root, selection.focusNode, selection.focusOffset);
return anchor === null || focus === null ? null : { anchor, focus };
}
export function restorePathSelection(root: HTMLElement, selection: TextSelection): void {
const browserSelection = root.ownerDocument.getSelection?.();
if (!browserSelection) return;
const anchor = pathInputPoint(root, selection.anchor);
const focus = pathInputPoint(root, selection.focus);
browserSelection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
}
/** Returns portal hosts. React owns only their visual contents, never the editable tree. */
export function renderPathInput(root: HTMLElement, value: string): Array<{ host: HTMLElement; path: string }> {
const document = root.ownerDocument;
const fragment = document.createDocumentFragment();
const chips: Array<{ host: HTMLElement; path: string }> = [];
for (const part of parseFilePaths(value)) {
if (part.kind === "text") {
fragment.append(document.createTextNode(part.text));
} else {
const host = document.createElement("span");
host.contentEditable = "false";
host.setAttribute("contenteditable", "false");
host.setAttribute(PATH_SOURCE_ATTRIBUTE, part.text);
fragment.append(host);
chips.push({ host, path: part.path });
}
}
// Empty boundary text nodes allow a caret immediately before/after an atomic chip.
if (!fragment.firstChild || fragment.firstChild.nodeType !== 3) fragment.prepend(document.createTextNode(""));
if (fragment.lastChild?.nodeType !== 3) fragment.append(document.createTextNode(""));
if (value.endsWith("\n")) {
// pre-wrap alone collapses the final empty line. This BR supplies its
// height/caret without adding a second newline to the stored source.
const caret = document.createElement("br");
caret.setAttribute(PATH_CARET_ATTRIBUTE, "");
caret.setAttribute("aria-hidden", "true");
fragment.append(caret);
}
root.replaceChildren(fragment);
root.dataset.empty = value.length === 0 ? "true" : "false";
return chips;
}
export function clampPathSelection(value: string, selection: TextSelection): TextSelection {
const clamp = (offset: number) => Math.min(value.length, Math.max(0, offset));
return { anchor: clamp(selection.anchor), focus: clamp(selection.focus) };
}
/** A selection intersecting any part of a chip edits/copies that complete source. */
export function atomicPathRange(value: string, selection: TextSelection): { start: number; end: number } {
const clamped = clampPathSelection(value, selection);
let start = Math.min(clamped.anchor, clamped.focus);
let end = Math.max(clamped.anchor, clamped.focus);
for (const part of parseFilePaths(value)) {
if (part.kind !== "path") continue;
if (start > part.start && start < part.end) start = part.start;
if (end > part.start && end < part.end) end = part.end;
}
return { start, end };
}
export function replacePathSelection(value: string, selection: TextSelection, text: string) {
const { start, end } = atomicPathRange(value, selection);
return { value: value.slice(0, start) + text + value.slice(end), selection: { anchor: start + text.length, focus: start + text.length } };
}
export function insertFilePaths(value: string, selection: TextSelection, paths: string[]) {
const { start, end } = atomicPathRange(value, selection);
const text = paths.map(quoteFilePath).join(" ");
if (!text) return { value, selection };
const before = start > 0 && !/\s/.test(value[start - 1]!) ? " " : "";
const after = end < value.length && !/\s/.test(value[end]!) ? " " : "";
return replacePathSelection(value, selection, before + text + after);
}
export function adjacentPathRange(value: string, selection: TextSelection, direction: "backward" | "forward"): TextSelection | null {
if (selection.anchor !== selection.focus) return selection;
const caret = selection.focus;
for (const part of parseFilePaths(value)) {
if (part.kind === "path" && (direction === "backward" ? caret > part.start && caret <= part.end : caret >= part.start && caret < part.end)) {
return { anchor: part.start, focus: part.end };
}
}
return null;
}
export type PathEditSnapshot = { value: string; selection: TextSelection };
/** Match the text input value sanitization algorithm: remove CR/LF, not spaces. */
export function normalizePathInputValue(value: string, singleLine: boolean): string {
return singleLine ? value.replace(/[\r\n]/g, "") : value;
}
export function normalizePathEdit(snapshot: PathEditSnapshot, singleLine: boolean): PathEditSnapshot {
if (!singleLine) return snapshot;
const selection = clampPathSelection(snapshot.value, snapshot.selection);
return {
value: normalizePathInputValue(snapshot.value, true),
selection: {
anchor: normalizePathInputValue(snapshot.value.slice(0, selection.anchor), true).length,
focus: normalizePathInputValue(snapshot.value.slice(0, selection.focus), true).length,
},
};
}
/** Independent of the browser's DOM undo stack (normalization replaces native nodes). */
export class PathInputHistory {
private undoStack: PathEditSnapshot[] = [];
private redoStack: PathEditSnapshot[] = [];
push(snapshot: PathEditSnapshot) {
this.undoStack.push({ value: snapshot.value, selection: { ...snapshot.selection } });
this.redoStack = [];
}
reset() { this.undoStack = []; this.redoStack = []; }
undo(current: PathEditSnapshot): PathEditSnapshot | null {
const previous = this.undoStack.pop();
if (!previous) return null;
this.redoStack.push(current);
return previous;
}
redo(current: PathEditSnapshot): PathEditSnapshot | null {
const next = this.redoStack.pop();
if (!next) return null;
this.undoStack.push(current);
return next;
}
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { FilePathChip } from "./file-path-chip";
import { cn } from "../lib/utils";
import {
adjacentPathRange, atomicPathRange, clampPathSelection, insertFilePaths,
normalizePathEdit, normalizePathInputValue, pathInputStaticRange, PathInputHistory, readPathInput, readPathSelection, renderPathInput,
replacePathSelection, restorePathSelection,
type PathEditSnapshot, type TextSelection,
} from "./path-input-dom";
export const INSERT_FILE_PATHS_EVENT = "lh:insert-file-paths";
export type PathInputProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "dangerouslySetInnerHTML" | "defaultValue" | "onChange"> & {
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
autoFocus?: boolean;
defaultDropTarget?: boolean;
/** Native text-input semantics: strip CR/LF and leave Enter to the caller. */
singleLine?: boolean;
};
type ChipHost = { host: HTMLElement; path: string };
type Controller = {
external: (value: string) => void;
remember: () => void;
};
/**
* A controlled plaintext editor. Only the source string is state; chips are atomic
* views of it. React never reconciles the editable DOM after a native mutation.
*/
export const PathInput = React.forwardRef<HTMLDivElement, PathInputProps>(function PathInput({
value, onValueChange, placeholder, autoFocus, defaultDropTarget = false, singleLine = false, className,
onKeyDown, onFocus, onBlur, onInput, onPaste, onCopy, onCut,
onCompositionStart, onCompositionEnd, ...props
}, forwardedRef) {
const rootRef = React.useRef<HTMLDivElement>(null);
const controllerRef = React.useRef<Controller | null>(null);
const callbacks = React.useRef({ onValueChange, singleLine });
const composing = React.useRef(false);
const [chips, setChips] = React.useState<ChipHost[]>([]);
// Keep this text child immutable: SSR/hydration gets the complete draft,
// while subsequent React renders leave the browser-owned editable DOM alone.
const initial = React.useRef({ value: normalizePathInputValue(value, singleLine), autoFocus, singleLine });
React.useImperativeHandle(forwardedRef, () => rootRef.current!, []);
React.useLayoutEffect(() => { callbacks.current = { onValueChange, singleLine }; }, [onValueChange, singleLine]);
React.useLayoutEffect(() => {
const root = rootRef.current!;
const document = root.ownerDocument;
let current = normalizePathInputValue(initial.current.value, initial.current.singleLine);
let saved: TextSelection = { anchor: current.length, focus: current.length };
let beforeEdit: PathEditSnapshot | null = null;
let compositionStart: PathEditSnapshot | null = null;
let pendingExternal: string | null = null;
let pendingPaths: string[] = [];
let pendingPaste: string[] = [];
let mounted = true;
const history = new PathInputHistory();
function remember() {
if (document.activeElement !== root) return;
const selection = readPathSelection(root);
if (selection) saved = selection;
}
function snapshot(): PathEditSnapshot {
return { value: current, selection: { ...saved } };
}
function paint(selection: TextSelection, restore: boolean) {
setChips(renderPathInput(root, current));
saved = clampPathSelection(current, selection);
if (restore) restorePathSelection(root, saved);
}
function change(next: PathEditSnapshot, record = true, before = snapshot(), notify = true) {
next = normalizePathEdit(next, callbacks.current.singleLine);
if (next.value === current) { paint(next.selection, document.activeElement === root); return; }
if (record) history.push(before);
current = next.value;
paint(next.selection, document.activeElement === root);
if (notify) callbacks.current.onValueChange(current);
}
function replace(text: string) {
remember();
change(replacePathSelection(current, saved, text));
}
function travel(direction: "undo" | "redo") {
remember();
const next = history[direction](snapshot());
if (next) change(next, false);
}
function stage(paths: string[]) {
if (composing.current) { pendingPaths.push(...paths); return; }
remember();
// Global drop may have moved focus: saved selection is still authoritative.
let next: PathEditSnapshot;
try { next = insertFilePaths(current, saved, paths); }
catch { return; } // Invalid event payloads never mutate learner text.
change(next);
root.focus();
restorePathSelection(root, saved);
}
function insertEvent(event: Event) {
const paths: unknown = (event as CustomEvent<unknown>).detail;
if (Array.isArray(paths) && paths.every((path) => typeof path === "string")) stage(paths);
}
function beforeInput(event: InputEvent) {
if (composing.current || event.isComposing) return;
remember();
beforeEdit = snapshot();
if (event.inputType === "historyUndo" || event.inputType === "historyRedo") {
event.preventDefault();
travel(event.inputType === "historyUndo" ? "undo" : "redo");
} else if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") {
event.preventDefault();
if (!callbacks.current.singleLine) replace("\n");
} else if (event.inputType.startsWith("format") || event.inputType === "insertHTML") {
event.preventDefault();
} else if (event.inputType === "deleteByCut") {
// The cut handler owns both the full-source clipboard and deletion.
// Never perform a second deletion if a browser also sends beforeinput.
event.preventDefault();
} else if (/^delete(?:(?:Word|SoftLine|HardLine)(?:Backward|Forward)|EntireSoftLine)$/.test(event.inputType)) {
// Modifier keys have platform-specific word/visual-line semantics.
// Only the browser knows that extent; never substitute an adjacent chip.
let targets: StaticRange[] = [];
try { targets = event.getTargetRanges?.() ?? []; } catch { /* Native fallback below. */ }
const range = targets.length === 1 ? pathInputStaticRange(root, targets[0]!) : null;
if (range && event.cancelable) {
event.preventDefault();
// replacePathSelection expands any intersected chip atomically.
change(replacePathSelection(current, range, ""));
}
// Missing/invalid/non-single targets: native edit + input projection owns it.
} else if (event.inputType.startsWith("delete")) {
const range = adjacentPathRange(current, saved, event.inputType.includes("Backward") ? "backward" : "forward");
if (range) {
event.preventDefault();
change(replacePathSelection(current, range, ""));
}
}
}
function input() {
if (composing.current) {
root.dataset.empty = normalizePathInputValue(readPathInput(root), callbacks.current.singleLine).length === 0 ? "true" : "false";
return;
}
const nextValue = readPathInput(root);
const selection = readPathSelection(root) ?? saved;
const previous = beforeEdit ?? snapshot();
beforeEdit = null;
change({ value: nextValue, selection }, true, previous);
}
function keyDown(event: KeyboardEvent) {
if (composing.current || event.isComposing || event.keyCode === 229) return;
// Shift+Delete is native Cut on Windows/Linux, not forward deletion.
// Let the browser dispatch cut so our clipboard handler can own it.
if (event.key === "Delete" && event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) return;
remember();
if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key.toLowerCase() === "z" || event.key.toLowerCase() === "y")) {
event.preventDefault();
travel(event.key.toLowerCase() === "y" || event.shiftKey ? "redo" : "undo");
} else if ((event.key === "Backspace" || event.key === "Delete") && !event.metaKey && !event.ctrlKey && !event.altKey) {
const range = adjacentPathRange(current, saved, event.key === "Backspace" ? "backward" : "forward");
if (range) {
event.preventDefault();
change(replacePathSelection(current, range, ""));
}
}
}
function paste(event: ClipboardEvent) {
if (event.defaultPrevented) return;
event.preventDefault();
const text = event.clipboardData?.getData("text/plain") ?? "";
// Image/file/HTML-only clipboard contents are not text replacement.
if (text === "") return;
if (composing.current) pendingPaste.push(text);
else replace(text);
}
function copy(event: ClipboardEvent) {
if (event.defaultPrevented) return;
remember();
if (saved.anchor === saved.focus || !event.clipboardData) return;
const { start, end } = atomicPathRange(current, saved);
event.clipboardData.setData("text/plain", current.slice(start, end));
event.preventDefault();
if (event.type === "cut" && !composing.current) change(replacePathSelection(current, saved, ""));
}
function startComposition() {
remember();
composing.current = true;
compositionStart = snapshot();
}
function endComposition() {
// Some browsers send the final input immediately AFTER compositionend.
queueMicrotask(() => {
if (!mounted) return;
let nextValue = readPathInput(root);
let selection = readPathSelection(root) ?? saved;
const previous = compositionStart ?? snapshot();
const reset = pendingExternal !== null && !pendingExternal.startsWith(previous.value);
const nativeChanged = normalizePathInputValue(nextValue, callbacks.current.singleLine) !== current;
const notify = pendingExternal === null || (!reset && nativeChanged);
if (pendingExternal !== null) {
if (pendingExternal.startsWith(previous.value)) {
nextValue += pendingExternal.slice(previous.value.length);
} else {
nextValue = pendingExternal;
selection = clampPathSelection(nextValue, selection);
}
}
pendingExternal = null;
compositionStart = null;
beforeEdit = null;
composing.current = false;
if (reset) history.reset();
change({ value: nextValue, selection }, !reset, previous, notify);
if (pendingPaste.length) { const text = pendingPaste.join(""); pendingPaste = []; replace(text); }
if (pendingPaths.length) { const paths = pendingPaths; pendingPaths = []; stage(paths); }
});
}
controllerRef.current = {
remember,
external(next) {
next = normalizePathInputValue(next, callbacks.current.singleLine);
if (composing.current) { pendingExternal = next; return; }
if (next === current) return;
remember();
const previous = snapshot();
// Dictation appends are undoable; a controlled reset starts fresh history.
if (next && next.startsWith(current)) history.push(previous);
else history.reset();
const selection = saved.anchor === current.length && saved.focus === current.length
? { anchor: next.length, focus: next.length } : clampPathSelection(next, saved);
current = next;
paint(selection, document.activeElement === root);
},
};
paint(saved, false);
if (initial.current.autoFocus) { root.focus(); restorePathSelection(root, saved); }
root.addEventListener(INSERT_FILE_PATHS_EVENT, insertEvent);
root.addEventListener("beforeinput", beforeInput);
root.addEventListener("input", input);
root.addEventListener("keydown", keyDown);
root.addEventListener("paste", paste);
root.addEventListener("copy", copy);
root.addEventListener("cut", copy);
root.addEventListener("compositionstart", startComposition);
root.addEventListener("compositionend", endComposition);
document.addEventListener("selectionchange", remember);
return () => {
mounted = false;
composing.current = false;
controllerRef.current = null;
root.removeEventListener(INSERT_FILE_PATHS_EVENT, insertEvent);
root.removeEventListener("beforeinput", beforeInput);
root.removeEventListener("input", input);
root.removeEventListener("keydown", keyDown);
root.removeEventListener("paste", paste);
root.removeEventListener("copy", copy);
root.removeEventListener("cut", copy);
root.removeEventListener("compositionstart", startComposition);
root.removeEventListener("compositionend", endComposition);
document.removeEventListener("selectionchange", remember);
};
}, []);
React.useLayoutEffect(() => { controllerRef.current?.external(value); }, [value, singleLine]);
return <>
<div
{...props}
ref={rootRef}
role="textbox"
aria-label={props["aria-label"] ?? placeholder}
aria-multiline={singleLine ? "false" : "true"}
aria-placeholder={placeholder}
contentEditable
suppressContentEditableWarning
data-file-path-input=""
data-empty={initial.current.value.length === 0 ? "true" : "false"}
data-file-path-default={defaultDropTarget ? "" : undefined}
data-placeholder={placeholder}
className={cn("block w-full min-w-0 outline-none data-[empty=true]:before:pointer-events-none data-[empty=true]:before:text-muted-foreground data-[empty=true]:before:content-[attr(data-placeholder)]", singleLine ? "whitespace-pre break-normal overflow-x-auto overflow-y-hidden" : "whitespace-pre-wrap break-words overflow-y-auto", className)}
onKeyDown={(event) => {
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) { event.stopPropagation(); return; }
onKeyDown?.(event);
}}
onFocus={(event) => { controllerRef.current?.remember(); onFocus?.(event); }}
onBlur={(event) => { controllerRef.current?.remember(); onBlur?.(event); }}
onInput={onInput}
onPaste={onPaste}
onCopy={onCopy}
onCut={onCut}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
>{initial.current.value}</div>
{chips.map(({ host, path }) => createPortal(<FilePathChip path={path} contentEditable={false} />, host))}
</>;
});
+5 -5
View File
@@ -2,9 +2,9 @@
import { useI18n } from "@/components/i18n";
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
@@ -69,7 +69,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">{t("Problem")}</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
<ToolContentResponse>{statement}</ToolContentResponse>
</div>
</div>
);
@@ -135,7 +135,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
@@ -146,7 +146,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
<div>
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Model solution")}</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
<ToolContentResponse>{reveal}</ToolContentResponse>
</div>
</div>
</>
+10 -10
View File
@@ -4,12 +4,12 @@ import { useI18n } from "@/components/i18n";
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 { PathInput } from "@/components/path-input";
import { cn } from "@/lib/utils";
import { repairMathForDisplay } from "@/src/core";
/**
* The practice dock (practice sessions, owner design 2026-10-04): an
@@ -109,10 +109,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">{t("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>
@@ -123,12 +123,13 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
className="m-1.5 rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
>
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("YOUR THINKING")}</div>
<textarea
{t("YOUR THINKING")}
</div>
<PathInput
value={thinking}
onChange={(e) => {
thinkingRef.current = e.target.value;
setThinking(e.target.value);
onValueChange={(value) => {
thinkingRef.current = value;
setThinking(value);
}}
onKeyDown={(e) => {
// Esc ends dictation — stopPropagation claims it (the
@@ -147,7 +148,6 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
}
}}
placeholder={t("Work here, think out loud.")}
rows={4}
className={cn(
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
)}
+51 -31
View File
@@ -7,10 +7,12 @@ 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 { PathInput } from "@/components/path-input";
import { ToolContentResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import {
buildAnswer,
repairMathForDisplay,
normalizeQuizType,
CONFIDENCE_STOPS,
DEFAULT_CONFIDENCE_INDEX,
@@ -30,6 +32,7 @@ import {
type DockSelection,
type ExplainToolInput,
type QuestionAnswerData,
type QuestionFormDraft,
type QuestionData,
type QuestionMode,
type QuizToolInput,
@@ -73,6 +76,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;
@@ -102,6 +108,8 @@ export function QuestionDock({
reveal = false,
onExpand,
onSubmit,
initialDraft,
onSaveDraft,
onEscape,
phase = "probe",
dictationClaim = true,
@@ -157,6 +165,8 @@ export function QuestionDock({
phase={phase}
active={expanded}
onSubmit={onSubmit}
initialDraft={initialDraft}
onSaveDraft={onSaveDraft}
onEscape={onEscape}
dictationClaim={dictationClaim}
compactionRunning={compactionRunning}
@@ -168,7 +178,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
@@ -253,6 +263,8 @@ function QuestionDockForm({
phase = "probe",
active,
onSubmit,
initialDraft,
onSaveDraft,
onEscape,
dictationClaim = true,
compactionRunning = false,
@@ -315,22 +327,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
@@ -394,6 +406,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));
};
@@ -423,8 +444,8 @@ function QuestionDockForm({
expanded — collapsed, the bar's native Enter expands it. */
const [kbdIdx, setKbdIdx] = useState<number | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const otherInputRef = useRef<HTMLInputElement>(null);
const thinkingRef = useRef<HTMLTextAreaElement>(null);
const otherInputRef = useRef<HTMLDivElement>(null);
const thinkingRef = useRef<HTMLDivElement>(null);
// One row past the options is the trailing row: quiz → don't-know,
// ask → Other (a question never has both). Text mode (ask/explain)
// has no rows — the composer IS the answer surface.
@@ -457,7 +478,7 @@ function QuestionDockForm({
const focusRow = (idx: number) => {
const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null;
if (!row) return;
const el = (row.querySelector("input") as HTMLElement | null) ?? row;
const el = (row.querySelector("input, [data-file-path-input]") as HTMLElement | null) ?? row;
el.focus();
};
@@ -548,7 +569,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"
? t("Probe")
@@ -564,11 +585,11 @@ function QuestionDockForm({
{qd.mode === "multi-select" && t(" · 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>
)}
@@ -604,7 +625,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
@@ -613,9 +634,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>
@@ -680,11 +701,12 @@ function QuestionDockForm({
</span>
)}
</span>
<input
<PathInput
ref={otherInputRef}
singleLine
value={other}
onChange={(e) => {
setOther(e.target.value);
onValueChange={(value) => {
setOther(value);
// Typing selects the row — displacing in
// single-select, alongside the siblings in multi.
setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev)));
@@ -834,11 +856,11 @@ function QuestionDockForm({
{qd.mode === "text" ? t("Your answer") : t("YOUR THINKING")}
</div>
{qd.mode === "text" ? (
<textarea
<PathInput
value={text}
onChange={(e) => {
answerTextRef.current = e.target.value;
setText(e.target.value);
onValueChange={(value) => {
answerTextRef.current = value;
setText(value);
}}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again
@@ -858,16 +880,15 @@ function QuestionDockForm({
}
}}
placeholder={t("Type your answer…")}
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
) : (
<textarea
<PathInput
ref={thinkingRef}
value={thinking}
onChange={(e) => {
thinkingTextRef.current = e.target.value;
setThinking(e.target.value);
onValueChange={(value) => {
thinkingTextRef.current = value;
setThinking(value);
}}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again
@@ -888,7 +909,6 @@ function QuestionDockForm({
}
}}
placeholder={t("Why this answer? Whatever comes to mind.")}
rows={1}
className={cn(
"field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
// Phase-scaled prominence: teaching invites depth,
+5 -4
View File
@@ -4,6 +4,7 @@ import { useI18n } from "@/components/i18n";
import { useCallback } from "react";
import { joinDictation, useDictationTarget } from "@/components/dictation";
import { PathInput } from "@/components/path-input";
/**
* The reading-thinking field (owner design 2026-10-03, "two questions per
@@ -55,10 +56,11 @@ export function ReadingThinkingField({
className="pointer-events-auto mb-2 rounded-2xl border bg-card/60 px-3 py-2 transition-colors focus-within:border-primary/50"
>
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("Thinking while reading")}</div>
<textarea
{t("Thinking while reading")}
</div>
<PathInput
value={value}
onChange={(e) => onChange(e.target.value)}
onValueChange={onChange}
/* py-0.5 (owner round, 2026-10-07): the scrollbar fix. Source
Sans 3's font box is 22px at 16px; lh-input's 1.25 leading
gives a 20px line box — the content area sticks 1px below the
@@ -97,7 +99,6 @@ export function ReadingThinkingField({
}
}}
placeholder={t("Whatever comes to mind as you read.")}
rows={1}
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent py-0.5 outline-none placeholder:text-muted-foreground/70"
/>
</div>
+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]);
+239 -48
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,
pendingQuestionOf,
failedResponseAtEnd,
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>;
}
}
@@ -103,12 +118,13 @@ async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): Asyn
/** The dangling question call's tool name (the optimistic answer result's
* toolName — the server stamps the same from its own record scan). */
export function pendingCallTool(messages: readonly LhMessage[], toolCallId: string): string | null {
for (const m of messages) {
if ((m as { role?: string }).role !== "assistant") continue;
const content = (m as { content?: unknown }).content;
if (!Array.isArray(content)) continue;
for (const block of content as Array<{ type?: string; id?: string; name?: string }>) {
if (block.type === "toolCall" && block.id === toolCallId) return block.name ?? null;
// Match projection's newest-occurrence pairing for pre-uniquifier records.
for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]!;
if (m.role !== "assistant") continue;
for (let bi = m.content.length - 1; bi >= 0; bi--) {
const block = m.content[bi]!;
if (block.type === "toolCall" && block.id === toolCallId) return block.name;
}
}
return null;
@@ -164,6 +180,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
export interface SessionState {
messages: LhViewMessage[];
nativeMessages: LhMessage[];
/** Server-read history only; optimistic bubbles are not delivery proof. */
hasConfirmedConversation: boolean;
/** Completed conversation usage; null after compaction or before first usage. */
contextTokens: number | null;
status: SessionStatus;
@@ -196,6 +214,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 +240,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,17 +252,24 @@ 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
// consume cancels the older — one reader at a time.
private consumeAbort: AbortController | null = null;
/** Retain the latest request across stop(): an opening POST still owns
* its refusal after view detachment, but never after a newer request. */
private latestRequest: AbortController | null = null;
/** Synchronous ready check for the gate (send/read mount logic). */
readyRef = true;
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
@@ -262,18 +292,19 @@ export class SessionModel {
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
: undefined;
const messages = [...this.base, ...this.tail];
const latest = messages.at(-1);
// One rule for live and restored failures: only the final completed
// assistant can own the banner. While trying again, hide the old
// failure from the first Thinking frame, but show a new failure at end.
const latest = failedResponseAtEnd(messages);
// Presentation/compaction markers do not resolve a failure. While
// trying again, hide the old banner from the first Thinking frame,
// but show a new completed failure from this consume immediately.
const providerError =
latest?.role === "assistant" && latest.stopReason === "error" &&
latest !== undefined &&
!this.incompleteUsageIds.has(latest.id) && (!live || latest.id === this.liveFailureId)
? { message: latest.errorMessage ?? "An error occurred." }
: null;
return {
messages: projectView(messages, live),
nativeMessages: messages,
hasConfirmedConversation: this.base.some(m => m.role === "user" || m.role === "assistant"),
contextTokens: measuredContextTokens(messages, this.incompleteUsageIds),
status: this.statusValue,
error: this.errorValue ?? providerError,
@@ -300,6 +331,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 +378,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 +483,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 +514,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,10 +555,11 @@ 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;
this.latestRequest = controller;
// REPLAY ONTO A CLEAN SLATE (owner, optimistic round 3, 2026-10-02):
// the reattach stream replays the turn from its FIRST event — an
// aborted earlier attempt (React StrictMode's simulated remount)
@@ -519,16 +586,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 +621,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");
}
}
@@ -562,13 +644,51 @@ export class SessionModel {
* POST + consume. Ready-gated: one turn per client at a time (the
* server's 409 + identity retry covers the wake-race window).
*/
async send(input: ChatInput): Promise<void> {
if (!this.readyRef) return;
async send(input: ChatInput, onAccepted?: () => void, questionKey?: string): Promise<boolean> {
const recovering = this.statusValue === "error";
if (!this.readyRef && !recovering) return false;
const subject = input.type === "answer" ? pendingQuestionOf([...this.base, ...this.tail]) : null;
// A render-scoped answer must not move to another occurrence sharing
// its id. This identity is local only; the server's wire is unchanged.
if (input.type === "answer" && questionKey !== undefined &&
(subject?.key !== questionKey || subject.toolCallId !== input.toolCallId)) return false;
const expectedKey = questionKey ?? (input.type === "answer" && subject?.toolCallId === input.toolCallId ? subject.key : undefined);
this.consumeAbort?.abort();
const controller = new AbortController();
this.consumeAbort = controller;
this.latestRequest = 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;
}
const current = pendingQuestionOf(record.messages);
if (current?.toolCallId !== input.toolCallId || (expectedKey !== undefined && current.key !== expectedKey)) {
// 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,11 +705,14 @@ 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, expectedKey ?? input.toolCallId);
}
if (optimistic.length > 0) this.mutate(() => {
this.tail = [...this.tail, ...optimistic];
});
let accepted = false;
try {
// SUBMITS ARE NOT ABORTABLE BY VIEW TEARDOWN (owner, 2026-10-10 —
// the dead-session-start postmortem): the POST must NOT ride
@@ -605,16 +728,24 @@ 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;
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
// text rides the error message — the banner shows it, and the
// page's refusal flow JSON-parses the envelope out of it.
throw new Error((await res.text()) || `The send failed (${res.status}).`);
// Keep model/key refusal envelopes verbatim: the page's refusal
// flow parses them. Retry's 400s are ordinary user-facing
// guidance, so show their error text without JSON syntax.
let message = (await res.text()) || `The send failed (${res.status}).`;
if (input.type === "retry" && res.status === 400) {
try {
const refusal = JSON.parse(message) as { error?: unknown } | null;
if (typeof refusal?.error === "string" && refusal.error.length > 0) message = refusal.error;
} catch { /* Non-envelope failures keep their original text. */ }
}
throw new Error(message);
}
accepted = true;
// Opening-message handover ends at acceptance, not stream completion.
onAccepted?.();
// CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze
// postmortem): the POST acknowledges and closes; there is no stream
// on this response to consume. Attach through the SAME reattach wire
@@ -626,12 +757,37 @@ export class SessionModel {
// A stop() or a newer send/consume won while the ack was in
// flight: the turn is live server-side either way — do not attach
// a stream from a view that is gone or superseded.
if (this.consumeAbort !== controller) return;
if (this.consumeAbort !== controller) return true;
await this.resumeStream(true);
return true;
} catch (err) {
if ((err as Error).name === "AbortError") return; // stop() or a newer consume won
const failedOpening = input.type === "text" && onAccepted !== undefined && !accepted;
if (failedOpening) {
// The POST outlives stop(), including StrictMode's cleanup. Remove
// only its own failed optimistic bubble even if a newer request
// won; never touch canonical messages or another attempt's tail.
const ids = new Set(optimistic.map(m => m.id));
this.mutate(() => { this.tail = this.tail.filter(m => !ids.has(m.id)); });
}
const detachedOpening = failedOpening && this.consumeAbort === null && this.latestRequest === controller;
if ((this.consumeAbort !== controller || controller.signal.aborted) && !detachedOpening) return accepted;
// A detached opening refusal must still reach the replayed mount's
// existing setup recovery. A newer send/reattach ALWAYS owns errors
// and status instead, even if that request has since been stopped.
// Ordinary text and answer teardown behavior stays unchanged.
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 means we observed this POST's acknowledgment. false does
// NOT prove non-delivery: a network failure may hide acceptance.
// Opening-message callers restore a draft, never automatically retry.
return accepted;
}
}
@@ -679,9 +835,9 @@ export class SessionModel {
/** The answer path (the dock): the answer IS the question call's tool
* result (fork 1), riding as its input event. */
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise<void> {
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData, questionKey?: string): Promise<void> {
void tool;
return this.send({ type: "answer", toolCallId, answer: output });
return this.send({ type: "answer", toolCallId, answer: output }, undefined, questionKey).then(() => undefined);
}
/** The dismissal path (owner design 2026-10-03, "A2"): the skipped
@@ -690,8 +846,10 @@ export class SessionModel {
* user's choice; the next plain send is the wake. On failure the
* durable record wins: the error surfaces, the record-changed sync
* (or any later refetch) replaces the optimistic append with truth. */
async dismissQuestion(toolCallId: string, answer: QuestionAnswerData): Promise<void> {
async dismissQuestion(toolCallId: string, answer: QuestionAnswerData, questionKey?: string): Promise<void> {
if (!this.readyRef) return;
const subject = pendingQuestionOf([...this.base, ...this.tail]);
if (questionKey !== undefined && (subject?.key !== questionKey || subject.toolCallId !== toolCallId)) return;
const tool = pendingCallTool([...this.base, ...this.tail], toolCallId) ?? "quiz";
this.mutate(() => {
this.tail = [...this.tail, { ...newAnswerResult(toolCallId, tool, answer), id: localId() }];
@@ -740,10 +898,34 @@ export class SessionModel {
this.setStatus("ready");
}
/** Submission snapshots outlive the dock, not the session mount. */
questionDraft(questionKey: string): QuestionDraft | undefined {
return this.questionDrafts.get(questionKey);
}
saveQuestionDraft(questionKey: string, draft: Partial<QuestionDraft>): void {
this.questionDrafts.set(questionKey, { ...this.questionDrafts.get(questionKey), ...draft });
}
private forgetResolvedDrafts(record: readonly LhMessage[]): void {
const pendingKey = pendingQuestionOf(record)?.key;
for (const key of this.questionDrafts.keys()) {
if (key !== pendingKey) this.questionDrafts.delete(key);
}
for (const [id, key] of this.unconfirmedAnswers) {
if (key !== pendingKey) 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
@@ -763,10 +945,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;
+1 -1
View File
@@ -4,7 +4,7 @@ import { findSessionTextMatches } from "@/src/core";
* hidden/unmounted sections do not. Never walk embedded documents or drafts.
* Do NOT exclude aria-hidden globally: KaTeX's VISIBLE HTML uses it too. */
const EXCLUDED = [
"script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas",
"script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas", '[contenteditable="true"]',
"title", "defs", "metadata", "desc", ".katex-mathml", ".sr-only", "[hidden]",
"[data-session-find-exclude]", "[data-lh-menu]", '[role="menu"]', '[role="dialog"]', '[role="tooltip"]',
].join(",");
+19 -16
View File
@@ -9,6 +9,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"
@@ -272,22 +273,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">{t("Drag to pan · scroll to zoom")}</span>
+16 -7
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
@@ -100,22 +102,25 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
[model],
);
const send = useCallback((input: ChatInput) => void model.send(input), [model]);
const send = useCallback((input: ChatInput, onAccepted?: () => void) => model.send(input, onAccepted), [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) =>
void model.submitAnswer(tool, toolCallId, output),
(tool: string, toolCallId: string, output: QuestionAnswerData, questionKey?: string) =>
void model.submitAnswer(tool, toolCallId, output, questionKey),
[model],
);
const dismissQuestion = useCallback(
(toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer),
(toolCallId: string, answer: QuestionAnswerData, questionKey?: string) => void model.dismissQuestion(toolCallId, answer, questionKey),
[model],
);
const dismissProblem = useCallback(
(problemCallId: string) => void model.dismissProblem(problemCallId),
[model],
);
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
const questionDraft = useCallback((questionKey: string) => model.questionDraft(questionKey), [model]);
const saveQuestionDraft = useCallback((questionKey: string, draft: Parameters<SessionModel["saveQuestionDraft"]>[1]) => model.saveQuestionDraft(questionKey, 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]);
@@ -124,15 +129,19 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
return {
messages: state.messages,
nativeMessages: state.nativeMessages,
hasConfirmedConversation: state.hasConfirmedConversation,
contextTokens: state.contextTokens,
status: state.status,
error: state.error,
steerQueue: state.steerQueue,
send,
continueIfOwed,
steer,
submitAnswer,
dismissQuestion,
dismissProblem,
questionDraft,
saveQuestionDraft,
setMessages,
adoptRecord,
stop,
+2 -2
View File
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
// dispatches it after save AND delete — deletions used to be invisible to
// the zero-keys pill and the setup dialog's auto-advance.
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction";
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation" | "claudeCode";
/** Writers call this after the PUT resolves ok — never before (a failed
* write notifies nothing; the record is unchanged). */
@@ -47,4 +47,4 @@ export function useSettingsSaved(handler: (section: SettingsSection) => void): v
const saved = useRef(handler);
saved.current = handler;
useEffect(() => onSettingsSaved((section) => saved.current(section)), []);
}
}
+21 -57
View File
@@ -57,6 +57,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,
@@ -65,7 +67,6 @@ import {
type AgentSettings,
type CompactionSettings,
type GraphSettings,
type ModelCatalogEntry,
type ModelSlotName,
type ModelsSettings,
} from "@/src/core";
@@ -73,8 +74,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { ZapIcon } from "lucide-react";
import { Hint } from "@/components/ui/hint";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { SettingsGroup, SettingsRow, SettingsMenuTrigger, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
import { NativeSubscriptionBackgroundAccess } from "./subscription-connections";
@@ -105,15 +105,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 {
@@ -247,8 +238,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;
@@ -264,9 +257,9 @@ function MenuTrigger({
disabled?: boolean;
}) {
const { t } = useI18n();
return (
<button
type="button"
const button = (
<SettingsMenuTrigger
value={t(value || emptyLabel)}
aria-label={ariaLabel}
aria-expanded={open}
disabled={disabled}
@@ -274,16 +267,11 @@ function MenuTrigger({
onClick={() => {
if (!disabled) onClick();
}}
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
} ${widthClass}`}
>
<span className="min-w-0 flex-1 truncate">{t(value || emptyLabel)}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
unavailable={disabled}
className={widthClass}
/>
);
return hint ? <Hint label={hint}>{button}</Hint> : button;
}
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk
@@ -319,19 +307,19 @@ export function AgentSection({
onCompactionChange: (next: CompactionSettings) => void;
}) {
const { t } = useI18n();
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
const [savingModels, setSavingModels] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const updateModels = async (next: ModelsSettings) => {
if (savingModels) return;
setSavingModels(true); setModelsError(null);
setSavingModels(true);
setModelsError(null);
try { await onModelsChange(next); }
catch { setModelsError("Could not save model settings"); }
finally { setSavingModels(false); }
};
/** 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
@@ -366,28 +354,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
@@ -396,8 +365,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))
@@ -413,7 +380,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 —
@@ -657,9 +623,6 @@ export function AgentSection({
const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined;
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null;
// Vision slots pick from image-capable models only.
const slotCatalog =
slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog;
return (
<SettingsRow
key={slot}
@@ -720,9 +683,10 @@ export function AgentSection({
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ModelMenuList
models={slotCatalog ?? undefined}
models={catalog ?? undefined}
effectiveId={modelId}
effectiveProvider={provider}
vision={slot === "vision"}
onPick={(entry) => {
// Auto-populate (chunk 2, owner: "populate everywhere"): the
// fresh pick writes a level — the stored one rides as-is
+8 -15
View File
@@ -43,7 +43,7 @@ import { MenuShell } from "@/components/menu-shell";
import { Hint } from "@/components/ui/hint";
import { useAppearance } from "./appearance.tsx";
import { FontMenuList } from "./model-menu";
import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
import { CommitInput, SettingsMenuTrigger, SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
/** The factory reading family — the DEFAULT row's identity (unset =
* this). Exported for tests. */
@@ -213,29 +213,22 @@ export function AppearanceSection({
testid="row-font"
>
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
{/* The MenuTrigger look (agent-section's collapsed
select — it lives unexported there, so the
classes ride inline; the searchable machine is
the shared part). The tooltip rides the CLOSED
button ONLY (owner ruling 2026-09-26: never while
{/* The shared Agent/Appearance collapsed-menu trigger.
The tooltip rides the CLOSED button ONLY
(owner ruling 2026-09-26: never while
the dropdown is out — it popped over the open
menu when the wrap covered the whole anchor
span). */}
{(() => {
const triggerButton = (
<button
type="button"
<SettingsMenuTrigger
value={appearance?.fontFamily || DEFAULT_READING_FONT}
aria-label={t("Reading font")}
aria-expanded={fontMenuOpen}
data-testid="font-trigger"
onClick={openFontMenu}
className="flex h-7 min-w-20 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || (language === "ru" ? "Noto Sans" : DEFAULT_READING_FONT)}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
className="min-w-20"
/>
);
return fontMenuOpen ? (
triggerButton
+14 -13
View File
@@ -7,8 +7,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.";
@@ -44,6 +46,8 @@ const Context = createContext({
* are used; account IDs remain control values, never displayed account labels. */
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const { t } = useI18n();
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);
@@ -82,14 +86,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);
@@ -123,7 +127,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 {
@@ -134,7 +137,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);
@@ -164,13 +167,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);
@@ -198,7 +201,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);
@@ -217,7 +220,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" };
@@ -244,7 +246,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,
@@ -265,7 +266,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">{t(CHATGPT_DISCONNECT_COPY.title)}</p>
<p className="mt-3 text-xs text-foreground">{managed?.email || t("ChatGPT account")}</p>
<p className="mt-3 text-xs text-muted-foreground">{t(CHATGPT_DISCONNECT_COPY.body)}</p>
<p className="mt-3 text-xs text-muted-foreground">{t(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">{t(context.error)}</p>}
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>{t("Cancel")}</Button>
@@ -280,7 +281,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
</select>
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>{t("Manage…")}</Button>}
</div>}
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{t(CHATGPT_NOTICE)}</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{t(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={t(BACKGROUND_LABEL)} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
@@ -331,13 +332,13 @@ function AccountDialog({ children, onClose, busy }: { children: React.ReactNode;
}
/** Shared compact settings/setup row; account selection precedes the quiet action. */
export function ChatGPTConnection(_props: { setup?: boolean } = {}) {
export function ChatGPTConnection({ labelClassName }: { setup?: boolean; labelClassName?: string } = {}) {
const { t } = useI18n();
const ui = useContext(Context);
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
const accounts = ui.status.accounts.filter((a) => a.connected);
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? t("Manage…") : t("Connect")} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
<ConnectionRow label="ChatGPT" labelClassName={labelClassName} actionTestid={active ? undefined : "chatgpt-login"} action={active ? t("Manage…") : t("Connect")} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
{active && <select aria-label={t("ChatGPT account")} value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
</select>}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus, type UserSettings } from "@/src/core";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { Button } from "@/components/ui/button";
import { Toggle } from "./controls";
export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string {
switch (status?.state) {
case "not-found": return "Not found";
case "meets-minimum": return `Version ${status.version} · meets minimum`;
case "update-required": return `Version ${status.version} · update required`;
default: return "Version could not be checked";
}
}
export function ClaudeCodeInstallationStatus() {
const [enabled, setEnabled] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
const [busy, setBusy] = useState(false);
const request = useRef<AbortController | null>(null);
const settingsRead = useRef<AbortController | null>(null);
const settingsWrite = useRef<AbortController | null>(null);
const read = useCallback(async () => {
settingsRead.current?.abort();
const controller = new AbortController();
settingsRead.current = controller;
setLoaded(false);
try {
const response = await fetch("/api/settings", { cache: "no-store", signal: controller.signal });
if (!response.ok) throw new Error("settings unavailable");
const body = await response.json() as { settings: UserSettings | null };
if (!controller.signal.aborted) {
setEnabled(body.settings?.claudeCode?.enabled === true);
setLoaded(true);
setError(null);
}
} catch {
if (!controller.signal.aborted) setError("Could not load Claude Code settings.");
}
}, []);
useEffect(() => {
void read();
return () => { settingsRead.current?.abort(); settingsWrite.current?.abort(); };
}, [read]);
useSettingsSaved((section) => { if (section === "claudeCode") void read(); });
const save = async (next: boolean) => {
const controller = new AbortController();
settingsWrite.current = controller;
settingsRead.current?.abort();
setSaving(true);
setError(null);
try {
const response = await fetch("/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: next }), signal: controller.signal });
if (!response.ok) throw new Error("settings write failed");
const body = await response.json() as { settings: UserSettings };
if (typeof body.settings?.claudeCode?.enabled !== "boolean") throw new Error("invalid settings response");
if (!controller.signal.aborted) {
setEnabled(body.settings.claudeCode.enabled);
notifySettingsSaved("claudeCode");
}
} catch {
if (!controller.signal.aborted) setError("Could not save Claude Code settings.");
} finally {
if (!controller.signal.aborted) setSaving(false);
}
};
const refresh = useCallback(async () => {
if (!enabled) return;
request.current?.abort();
const controller = new AbortController();
request.current = controller;
setBusy(true);
try {
const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal, headers: { "x-learnharness-claude-code": "1" } });
if (!response.ok) throw new Error("status unavailable");
const value = await response.json() as ClaudeCodeStatus;
if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state)
|| value.minimumVersion !== CLAUDE_CODE_MINIMUM_VERSION
|| (value.version !== null && (typeof value.version !== "string" || !/^\d+\.\d+\.\d+$/.test(value.version)))) throw new Error("invalid status");
if (!controller.signal.aborted) setStatus(value);
} catch {
if (!controller.signal.aborted) setStatus(null);
} finally {
if (!controller.signal.aborted) setBusy(false);
}
}, [enabled]);
useEffect(() => {
if (enabled) void refresh();
else { request.current?.abort(); setStatus(null); setBusy(false); }
return () => request.current?.abort();
}, [enabled, refresh]);
return <div className="space-y-2" data-testid="claude-code-installation">
<div className="flex items-center gap-3">
<span className="font-prose text-(length:--lh-prose-size) text-foreground">Claude Code</span>
<Toggle label="Claude Code" checked={enabled} disabled={!loaded || saving} onChange={(next) => void save(next)} />
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{enabled && <>
<div className="flex flex-wrap items-center gap-3">
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" disabled={busy} onClick={() => void refresh()} data-testid="claude-code-check-again">Check again</Button>
</div>
<p className="text-xs leading-relaxed text-muted-foreground"><strong className="font-medium">Install and authenticate Claude Code in your terminal, then select a Claude Code model here.</strong> LearnHarness starts it when needed—no background process required.</p>
<p className="text-xs leading-relaxed text-muted-foreground">Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. <a href="https://code.claude.com/docs/en/setup" target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-foreground">Setup ↗</a></p>
<p className="text-xs leading-relaxed text-muted-foreground">Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.</p>
</>}
</div>;
}
+4 -4
View File
@@ -6,12 +6,12 @@ import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import { Button } from "@/components/ui/button";
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string }) {
const { t } = useI18n();
export function ConnectionRow({ label, action, actionVariant, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; actionVariant?: "outline" | "ghost"; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
const variant = actionVariant ?? (action === "Connect" ? "outline" : "ghost");
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
<span className="font-ui text-xs text-foreground">{label}</span>
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
{children}
<Button type="button" size="xs" variant={action === t("Connect") ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === t("Connect") ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
<Button type="button" size="xs" variant={variant} className={`cursor-pointer disabled:cursor-not-allowed ${variant === "outline" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
</div>;
}
+27 -3
View File
@@ -11,7 +11,7 @@ import { useI18n } from "@/components/i18n";
* control together) and the control itself locks — the row owns the dim.
*/
import { useState, type ReactNode } from "react";
import { useState, type ComponentPropsWithRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/** A section block: pronounced mono heading + content. */
@@ -68,7 +68,7 @@ export function SettingsRow({
className,
}: {
label: string;
hint?: string;
hint?: ReactNode;
children: React.ReactNode;
testid?: string;
/** Dependency graying, graph-view style: the whole row dims when the
@@ -81,13 +81,37 @@ export function SettingsRow({
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40", className)}>
<span className="min-w-0">
<span className="block font-prose text-(length:--lh-prose-size)">{t(label)}</span>
{hint && <span className="block text-xs text-muted-foreground/70">{t(hint)}</span>}
{hint && <span className="block text-xs text-muted-foreground/70">{typeof hint === "string" ? t(hint) : hint}</span>}
</span>
{children}
</div>
);
}
/** The existing Agent/Appearance collapsed-menu look, shared with dictation.
* `unavailable` preserves callers that gate clicks without native disabling;
* `disabled` retains native button semantics where the caller requires them. */
export function SettingsMenuTrigger({
value, unavailable, disabled, className, ...props
}: Omit<ComponentPropsWithRef<"button">, "value" | "children"> & {
value: string;
unavailable?: boolean;
}) {
return <button
type="button"
{...props}
disabled={disabled}
className={cn(
"flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors",
disabled || unavailable ? "cursor-not-allowed" : "hover:border-muted-foreground/50",
className,
)}
>
<span className="min-w-0 flex-1 truncate">{value}</span>
<span aria-hidden className="text-muted-foreground">▾</span>
</button>;
}
/** The on/off switch — a plain button in the switch role. */
export function Toggle({
checked,
+520 -20
View File
@@ -1,6 +1,6 @@
"use client";
import { translator, type Translator } from "@/src/core";
import { useI18n } from "@/components/i18n";
import { LanguageSelect, useI18n } from "@/components/i18n";
/**
* The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
@@ -27,9 +27,19 @@ import { useI18n } from "@/components/i18n";
*/
import { useEffect, useRef, useState } from "react";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
import {
AUTH_KEYS,
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
CUSTOM_PROVIDER,
normalizeBaseUrl,
type AuthKeyStatus,
type CustomModel,
type CustomModelOverride,
} from "@/src/core";
import { SettingsGroup, SettingsRow } from "./controls.tsx";
import { SubscriptionConnections } from "./subscription-connections.tsx";
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
import { ProviderMenuList } from "./model-menu.tsx";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal";
@@ -47,12 +57,40 @@ interface KeysResponse {
providers?: ProviderKeyInfo[];
}
/** The custom endpoint's non-secret status (GET /api/custom-provider). */
interface CustomStatus {
keyReuseRequired: boolean;
revision: string;
authMode: "keyless" | "apiKey";
keySource: "env" | "file" | "none";
baseUrl: string;
models: CustomModel[];
/** User pins by model id (context / max output / thinking). */
overrides: Record<string, CustomModelOverride>;
manualModels: string[];
keyPresent: boolean;
}
async function readCustomStatus(inspectKey = false): Promise<CustomStatus> {
const response = await fetch(`/api/custom-provider${inspectKey ? "?authMode=apiKey" : ""}`);
if (!response.ok) throw new Error("Unable to load custom configuration.");
const cp = await response.json() as Partial<CustomStatus>;
return { keyReuseRequired: Boolean(cp.keyReuseRequired), revision: response.headers.get("etag") ?? "", authMode: cp.authMode ?? "keyless", keySource: cp.keySource ?? "none", baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], overrides: cp.overrides ?? {}, manualModels: cp.manualModels ?? [], keyPresent: Boolean(cp.keyPresent) };
}
/** The curated tools group, pinned by id (the catalog owns the copy;
* these ids are the contract). Derived from the catalog (search wave,
* 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the
* section can never drift from core/auth-keys.ts again. */
const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[];
/** The custom endpoint's synthetic Add-provider entry (offered only while unconfigured). */
export const CUSTOM_PROVIDER_INFO: ProviderKeyInfo = {
id: CUSTOM_PROVIDER.id,
envVar: CUSTOM_PROVIDER.envVar,
label: CUSTOM_PROVIDER.label,
};
/** The classification driving the table groups. Pure — exported for
* tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
* no special row — it rides the same provider classification. */
@@ -85,6 +123,10 @@ export function classifyKeys(
const unknownRows: AuthKeyStatus[] = [];
for (const s of statuses) {
if (isTool(s.id)) continue;
// The custom endpoint's key has its OWN home (the Custom endpoint
// section), so it never shows here — not as a provider row, not as an
// "other stored variable".
if (s.id === CUSTOM_PROVIDER.envVar) continue;
if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s);
else if (!s.info) unknownRows.push(s);
// (pre-interlude catalogs: a curated-but-unclassified status still
@@ -99,6 +141,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, t: Translator = translator("en")): string {
@@ -291,7 +341,7 @@ function AddProviderRow({
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>{t("+ Add provider key")}</span>
<span>+ Add a provider</span>
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
</button>
{open && (
@@ -328,6 +378,7 @@ export function KeysSection({
const { t } = useI18n();
const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
const followedProvider = useRef<string | null>(null);
const [loading, setLoading] = useState(true);
/** The unfolded row (its id), null = all folded. Seeded by the
* deep-link (initialRow) and re-followed when it retargets. */
@@ -335,34 +386,49 @@ export function KeysSection({
/** The paste editor for a freshly chosen Add-provider target. */
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
const [addError, setAddError] = useState<string | null>(null);
const followedProvider = useRef<string | null>(null);
/** The custom endpoint's status + whether its connect editor is open. */
const [custom, setCustom] = useState<CustomStatus | null>(null);
const [addingCustom, setAddingCustom] = useState(false);
// Escape folds any open editor (unfolded row or the Add-provider paste)
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
// only stopPropagation that outruns both the input's React handler and
// the dialog's window-level listener.
useEffect(() => {
if (openRow === null && adding === null) return;
if (openRow === null && adding === null && !addingCustom) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
// A language dropdown above this editor in the dismissal stack owns
// the first Escape; do not swallow it at document capture phase.
if (document.querySelector('[data-testid="dictation-language-menu"], [data-testid="language-select-sttLanguage-menu"]')) return;
e.preventDefault();
e.stopPropagation();
setOpenRow(null);
setAdding(null);
setAddingCustom(false);
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [openRow, adding]);
}, [openRow, adding, addingCustom]);
const reload = () =>
fetch("/api/keys")
.then((r) => r.json())
.then((body: KeysResponse) => {
setStatuses(body.keys ?? []);
if (body.providers) setProviders(body.providers);
})
.catch(() => setStatuses([]))
.finally(() => setLoading(false));
const reload = async () => {
try {
const body = (await fetch("/api/keys").then((r) => r.json())) as KeysResponse;
setStatuses(body.keys ?? []);
if (body.providers) setProviders(body.providers);
} catch {
setStatuses([]);
} finally {
setLoading(false);
}
// Best-effort and independent: the custom endpoint's status must never
// blank the provider-key list above if its fetch fails.
try {
setCustom(await readCustomStatus());
} catch {
/* custom status unavailable — leave it unset */
}
};
useEffect(() => {
void reload();
@@ -409,6 +475,17 @@ export function KeysSection({
notifySettingsSaved("keys");
}
/** Forget the custom endpoint (config + key server-side), same rhythm as
* deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */
async function disconnectCustom(revision: string): Promise<string | null> {
const res = await fetch("/api/custom-provider", { method: "DELETE", headers: { "content-type": "application/json", "if-match": revision }, body: JSON.stringify({}) }).catch(() => null);
if (!res?.ok) return res ? ((await res.json().catch(() => ({}))).error ?? "Disconnect failed.") : "Disconnect failed. Check your connection.";
setAddingCustom(false);
await reload();
notifySettingsSaved("keys");
return null;
}
if (loading || statuses === null) {
return <p className="px-6 py-5 text-xs text-muted-foreground/70">{t("Loading keys…")}</p>;
}
@@ -430,7 +507,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}
@@ -445,12 +522,50 @@ export function KeysSection({
testid={`key-${status.id.toLowerCase()}`}
/>
))}
{custom?.baseUrl && (
<CustomEndpointRow
status={custom}
expanded={addingCustom}
onToggle={() => setAddingCustom((v) => !v)}
/>
)}
{addingCustom && (
<CustomEndpointEditor
revision0={custom?.revision ?? ""}
authMode0={custom?.authMode ?? "keyless"}
keySource0={custom?.keySource ?? "none"}
keyReuseRequired0={custom?.keyReuseRequired ?? false}
onReload={async () => { await reload(); setAddingCustom(false); }}
baseUrl0={custom?.baseUrl ?? ""}
keyPresent={custom?.keyPresent ?? false}
models={custom?.models ?? []}
overrides={custom?.overrides ?? {}}
manualModels0={custom?.manualModels ?? []}
onSaved={async () => {
setAddingCustom(false);
await reload();
notifySettingsSaved("keys");
}}
onPinsSaved={async () => {
await reload();
notifySettingsSaved("keys");
}}
onDelete={disconnectCustom}
onCancel={() => setAddingCustom(false)}
/>
)}
{providers !== undefined && (
<AddProviderRow
addable={addable}
addable={custom?.baseUrl ? addable : [...addable, CUSTOM_PROVIDER_INFO]}
onPick={(p) => {
setAdding(p);
setAddError(null);
if (p.id === CUSTOM_PROVIDER.id) {
setAdding(null);
setAddingCustom(true);
} else {
setAddingCustom(false);
setAdding(p);
}
}}
/>
)}
@@ -472,7 +587,11 @@ export function KeysSection({
<SubscriptionConnections />
</SettingsGroup>
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
<SettingsGroup label={t("External CLI")} testid="group-external-cli">
<ClaudeCodeInstallationStatus />
</SettingsGroup>
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={t(label)} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
<KeyTableRow
key={status.id}
@@ -491,6 +610,17 @@ export function KeysSection({
testid={`key-${status.id.toLowerCase()}`}
/>
))}
{label === "Dictation" && <>
<SettingsRow label={t("Speech recognition language")} testid="row-dictation-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
<p data-testid="dictation-setup-hint" className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
Create a <a
href="https://console.deepgram.com/"
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>Deepgram API key</a> with the Member role or higher, then add it above.
</p>
</>}
</SettingsGroup>)}
</>
@@ -550,3 +680,373 @@ function AddProviderEditor({
</>
);
}
/** One donor editor for first setup and Settings; setup omits offline pin and
* disconnect controls, while authentication/revisions/manual IDs stay identical. */
export function CustomEndpointSetup({ onSaved }: { onSaved: () => void | Promise<void> }) {
const { t } = useI18n();
const [status, setStatus] = useState<CustomStatus | null>(null);
const [error, setError] = useState(false);
async function reload() {
try { setStatus(await readCustomStatus()); setError(false); }
catch { setError(true); }
}
useEffect(() => { void reload(); }, []);
if (!status || error) return <div className="text-xs">{t(error ? "Unable to load custom configuration." : "Loading custom configuration…")}{error && <button onClick={() => void reload()}>{t("Retry")}</button>}</div>;
return <CustomEndpointEditor key={status.revision} setup revision0={status.revision} authMode0={status.authMode} keySource0={status.keySource} keyReuseRequired0={status.keyReuseRequired} baseUrl0={status.baseUrl} keyPresent={status.keyPresent} models={status.models} overrides={status.overrides} manualModels0={status.manualModels} onSaved={onSaved} onPinsSaved={reload} onReload={reload} onCancel={reload} />;
}
/** The custom endpoint's status row (shown once configured); clicking re-opens the editor. */
function CustomEndpointRow({
status,
expanded,
onToggle,
}: {
status: CustomStatus;
expanded: boolean;
onToggle: () => void;
}) {
const { t } = useI18n();
const count = status.models.length;
const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"} · ${status.authMode === "keyless" ? "keyless" : status.keyPresent ? "API key" : "API key missing"}`;
return (
<div data-testid="custom-endpoint-row">
<button
type="button"
data-testid="custom-endpoint-row-btn"
aria-expanded={expanded}
onClick={onToggle}
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left cursor-pointer transition-colors hover:bg-accent/50"
>
<span className="font-ui text-xs text-foreground">{t("Custom endpoint")}</span>
<span data-testid="custom-endpoint-status" className="max-w-[60%] truncate font-mono text-[11px] text-muted-foreground">
{line}
</span>
</button>
</div>
);
}
/** A pin row's raw input state — "" means "no pin": the endpoint's own value,
* then the compiled default, decides. `reasoning` is tri-state. */
interface PinDraft {
context: string;
maxTokens: string;
reasoning: "" | "on" | "off";
}
const pinDraft = (pin: CustomModelOverride | undefined): PinDraft => ({
context: pin?.context === undefined ? "" : String(pin.context),
maxTokens: pin?.maxTokens === undefined ? "" : String(pin.maxTokens),
reasoning: pin?.reasoning === undefined ? "" : pin.reasoning ? "on" : "off",
});
/** One draft row per discovered model, seeded from the stored pins. */
function draftsFor(
models: readonly CustomModel[],
overrides: Record<string, CustomModelOverride>,
): Record<string, PinDraft> {
return Object.fromEntries(models.map((m) => [m.id, pinDraft(overrides[m.id])]));
}
/** Drafts → the PATCH payload (replace-whole): a blank field sends nothing and
* a fully blank model is absent — which IS the un-pin. */
function pinsPayload(drafts: Record<string, PinDraft>): Record<string, CustomModelOverride> {
const out: Record<string, CustomModelOverride> = {};
for (const [id, d] of Object.entries(drafts)) {
const pin: CustomModelOverride = {};
if (d.context.trim()) pin.context = Number(d.context);
if (d.maxTokens.trim()) pin.maxTokens = Number(d.maxTokens);
if (d.reasoning !== "") pin.reasoning = d.reasoning === "on";
if (Object.keys(pin).length) Object.defineProperty(out, id, { value: pin, enumerable: true });
}
return out;
}
/** testids must survive ids carrying slashes, colons, dots. */
const pinTestid = (prefix: string, id: string) => `${prefix}-${id.replace(/[^a-z0-9-]/gi, "_")}`;
/** The custom endpoint's connect editor: base URL + key → POST /api/custom-provider
* (validate + discover + register), and the per-model limits form → PATCH (pins,
* no probe, so it works with the local server switched off). Image capability is
* detected automatically. */
function CustomEndpointEditor({
setup = false,
revision0, authMode0, keySource0, keyReuseRequired0, onReload,
baseUrl0,
keyPresent: initialKeyPresent,
models,
overrides,
manualModels0,
onSaved,
onPinsSaved,
onCancel,
onDelete,
}: {
setup?: boolean;
revision0: string;
authMode0: "keyless" | "apiKey";
keySource0: "env" | "file" | "none";
keyReuseRequired0: boolean;
onReload: () => void | Promise<void>;
baseUrl0: string;
keyPresent: boolean;
/** The discovered models — one limits row each. */
models: readonly CustomModel[];
/** Their stored user pins. */
overrides: Record<string, CustomModelOverride>;
manualModels0: readonly string[];
onSaved: () => void | Promise<void>;
/** Pins saved — the editor stays open (a form, not a connect). */
onPinsSaved: () => void | Promise<void>;
onCancel: () => void;
onDelete?: (revision: string) => Promise<string | null>;
}) {
const { t } = useI18n();
const [revision, setRevision] = useState(revision0);
const [authMode, setAuthMode] = useState(authMode0);
const [keySource, setKeySource] = useState(keySource0);
const [keyPresent, setKeyPresent] = useState(initialKeyPresent);
const [keyReuseRequired, setKeyReuseRequired] = useState(keyReuseRequired0);
const [authLoading, setAuthLoading] = useState(false);
const [reuseKey, setReuseKey] = useState(false);
useEffect(() => {
if (authMode !== "apiKey") return;
let cancelled = false;
setAuthLoading(true);
readCustomStatus(true).then((status) => {
if (cancelled) return;
setKeySource(status.keySource);
setKeyPresent(status.keyPresent);
setKeyReuseRequired(status.keyReuseRequired);
if (status.keySource === "env") setKey("");
}).catch(() => { if (!cancelled) setError("Unable to inspect effective key. Reload configuration and retry."); })
.finally(() => { if (!cancelled) setAuthLoading(false); });
return () => { cancelled = true; };
}, [authMode]);
const [baseUrl, setBaseUrl] = useState(baseUrl0);
const [key, setKey] = useState("");
const [keyTouched, setKeyTouched] = useState(false);
const [manualModels, setManualModels] = useState(manualModels0.join(", "));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The limits form: edited drafts over the stored pins, plus the last saved
// payload as the dirty baseline (the server's echo is what gets stored).
const [drafts, setDrafts] = useState<Record<string, PinDraft>>(() => draftsFor(models, overrides));
const [baseline, setBaseline] = useState(() => JSON.stringify(pinsPayload(draftsFor(models, overrides))));
const [pinsBusy, setPinsBusy] = useState(false);
const [pinsError, setPinsError] = useState<string | null>(null);
const changedTarget = normalizeBaseUrl(baseUrl) !== normalizeBaseUrl(baseUrl0);
const canConnect = Boolean(baseUrl.trim()) && (authMode === "keyless" || (!authLoading && ((Boolean(key.trim()) && keySource !== "env") || (keyPresent && (!(changedTarget || keyReuseRequired) || reuseKey)))));
const draftOf = (m: CustomModel): PinDraft => drafts[m.id] ?? pinDraft(overrides[m.id]);
const setDraft = (m: CustomModel, patch: Partial<PinDraft>) =>
setDrafts((prev) => ({ ...prev, [m.id]: { ...draftOf(m), ...patch } }));
const current = Object.fromEntries(models.map((m) => [m.id, draftOf(m)])) as Record<string, PinDraft>;
const dirty = JSON.stringify(pinsPayload(current)) !== baseline;
async function savePins() {
if (pinsBusy || busy || !dirty) return;
setPinsBusy(true);
setPinsError(null);
const res = await fetch("/api/custom-provider", {
method: "PATCH",
headers: { "content-type": "application/json", "if-match": revision },
body: JSON.stringify({ overrides: pinsPayload(current) }),
}).catch(() => null);
if (!res || !res.ok) {
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
setPinsError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection.");
setPinsBusy(false);
return;
}
setRevision(res.headers.get("etag") ?? revision);
// The server's stored map is the truth — echo it back.
const body = (await res.json().catch(() => null)) as { overrides?: Record<string, CustomModelOverride> } | null;
const next = draftsFor(models, body?.overrides ?? pinsPayload(current));
setDrafts(next);
setBaseline(JSON.stringify(pinsPayload(next)));
await onPinsSaved();
setPinsBusy(false);
}
async function save() {
if (busy || pinsBusy || !canConnect) return;
setBusy(true);
setError(null);
const res = await fetch("/api/custom-provider", {
method: "POST",
headers: { "content-type": "application/json", "if-match": revision },
body: JSON.stringify({ baseUrl, authMode, reuseKey, manualModels, ...(authMode === "apiKey" && keyTouched && key.trim() && keySource !== "env" ? { key } : {}) }),
}).catch(() => null);
if (!res || !res.ok) {
const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection.");
setBusy(false);
return;
}
await onSaved();
setBusy(false);
}
return (
<div data-testid="custom-endpoint-editor" className="flex flex-col gap-2 rounded-sm px-1 py-1.5">
<div className="flex items-center gap-2">
<span className="shrink-0 font-ui text-xs text-foreground">{t("Custom endpoint")}</span>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={t("Base URL, e.g. http://localhost:11434/v1")}
aria-label={t("Custom endpoint base URL")}
data-testid="custom-baseurl-input"
disabled={busy}
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
</div>
<input
type="text"
value={manualModels}
onChange={(e) => setManualModels(e.target.value)}
placeholder={t("Model IDs, comma-separated — optional, needed when the endpoint has no /models list")}
aria-label={t("Custom endpoint model IDs")}
data-testid="custom-models-input"
disabled={busy}
className="h-7 min-w-0 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<label className="flex items-center gap-2 text-xs">{t("Authentication")}
<select aria-label={t("Custom endpoint authentication")} data-testid="custom-auth-mode" value={authMode} disabled={busy || pinsBusy} onChange={(e) => { setAuthMode(e.target.value as "keyless" | "apiKey"); setReuseKey(false); }}>
<option value="keyless">{t("Keyless")}</option><option value="apiKey">{t("API key")}</option>
</select>
</label>
{authMode === "apiKey" && keySource === "env" && <p className="text-[11px] text-muted-foreground" data-testid="custom-env-owned">{t("Environment supplies the effective key. Change the environment to replace it.")}</p>}
{authMode === "apiKey" && keyPresent && (changedTarget || keyReuseRequired) && <label className="text-[11px]"><input type="checkbox" data-testid="custom-reuse-key" checked={reuseKey} onChange={(e) => setReuseKey(e.target.checked)} /> {t("Allow reuse of the effective existing key for this URL")}</label>}
<div className="flex items-center gap-2">
{authMode === "apiKey" && <input
type="password"
value={key}
onChange={(e) => {
setKey(e.target.value);
setKeyTouched(true);
if (!e.target.value.trim()) setAuthMode("keyless");
}}
placeholder={keyPresent ? "Leave blank to keep effective key" : "API key"}
aria-label={t("Custom endpoint API key")}
data-testid="custom-key-input"
disabled={busy || pinsBusy || keySource === "env"}
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>}
<button
type="button"
onClick={() => void save()}
disabled={busy || pinsBusy || !canConnect}
data-testid="custom-save"
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
{busy ? "Connecting…" : "Connect"}
</button>
<button
type="button"
onClick={onCancel}
disabled={busy}
data-testid="custom-cancel"
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
{t("Cancel")}
</button>
{/* Disconnect rides beside Connect, exactly the stored-key rows' Delete:
quiet text, one click, no confirm — and only once there IS an
endpoint (a fresh add has nothing to delete). */}
{onDelete && baseUrl0.trim() && (
<button
type="button"
onClick={async () => { setBusy(true); const err = await onDelete(revision); if (err) setError(err); setBusy(false); }}
disabled={busy || pinsBusy}
data-testid="custom-delete"
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
{t("Disconnect")}
</button>
)}
</div>
{!setup && models.length > 0 && (
<div className="flex flex-col gap-1" data-testid="custom-pins">
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1">
{models.map((m) => {
const d = draftOf(m);
return (
<div key={m.id} className="flex items-center gap-2" data-testid={pinTestid("custom-pin-row", m.id)}>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground" title={m.id}>
{m.id}
<span className="block text-[10px] text-muted-foreground">{t("Context:")} {d.context.trim() ? "user pin" : m.context ? `reported ${m.context}` : `fallback ${CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW}`} {t("· Output:")} {d.maxTokens.trim() ? "user pin" : m.maxTokens ? `reported ${m.maxTokens}` : `fallback ${CUSTOM_MODEL_DEFAULT_MAX_TOKENS}`}</span>
</span>
<input
type="text"
inputMode="numeric"
value={d.context}
onChange={(e) => setDraft(m, { context: e.target.value })}
placeholder={String(m.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW)}
aria-label={`${m.id} context window in tokens`}
data-testid={pinTestid("custom-pin-context", m.id)}
disabled={pinsBusy}
className="h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<input
type="text"
inputMode="numeric"
value={d.maxTokens}
onChange={(e) => setDraft(m, { maxTokens: e.target.value })}
placeholder={String(m.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS)}
aria-label={`${m.id} max output tokens`}
data-testid={pinTestid("custom-pin-maxtokens", m.id)}
disabled={pinsBusy}
className="h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<select
value={d.reasoning}
onChange={(e) => setDraft(m, { reasoning: e.target.value as PinDraft["reasoning"] })}
aria-label={`${m.id} reasoning effort support`}
data-testid={pinTestid("custom-pin-reasoning", m.id)}
disabled={pinsBusy}
className="h-7 shrink-0 rounded-sm border border-border bg-background px-1 font-ui text-[11px] text-foreground outline-none focus:border-muted-foreground/50"
>
<option value="">{t("Reasoning effort: Use reported")}</option>
<option value="on">{t("Reasoning effort: Supported")}</option>
<option value="off">{t("Reasoning effort: Not supported")}</option>
</select>
</div>
);
})}
</div>
<div className="flex items-center gap-2">
{pinsError && (
<p role="alert" data-testid="custom-pins-error" className="text-[11px] text-red-500">
{pinsError}
</p>
)}
<button
type="button"
onClick={() => void savePins()}
disabled={pinsBusy || !dirty}
data-testid="custom-pins-save"
className="ml-auto shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
{pinsBusy ? "Saving…" : "Save limits"}
</button>
</div>
</div>
)}
<div className="flex flex-col gap-1 text-[11px] text-muted-foreground" data-testid="custom-help">
{!setup && models.length > 0 && <p data-testid="custom-budget-help">{t("Budget pins do not resize backend context. Blank uses reported limits, then fallback (8192 context / 4096 output). Custom pricing is unknown.")}</p>}
<p data-testid="custom-reasoning-help">{t("Reasoning support is a capability declaration. The effort picker’s Off may not disable a custom backend’s default thinking.")}</p>
</div>
{(error || pinsError) && <button type="button" data-testid="custom-reload" onClick={() => void onReload()} className="text-left text-xs underline">{t("Reload configuration")}</button>}
{error && (
<p role="alert" data-testid="custom-error" className="text-xs text-red-500">
{error}
</p>
)}
</div>
);
}
+40 -82
View File
@@ -23,10 +23,12 @@ import { useI18n } from "@/components/i18n";
import { useEffect, useRef, useState } from "react";
import type { ModelCatalogEntry } from "@/src/core";
import { fuzzyScore, subscriptionLabel } from "@/src/core";
import { CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
import { formatTokens } from "@/components/usage-format";
import { SearchMenuList } from "./search-menu-list";
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
if (!cost) return "Unpriced";
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
return `${fmt(cost.input)}/${fmt(cost.output)}`;
}
@@ -40,6 +42,7 @@ export function ModelMenuList({
effectiveProvider,
onPick,
footer,
vision,
}: {
/** The full catalog — undefined while it loads. */
models: ModelCatalogEntry[] | undefined;
@@ -54,92 +57,47 @@ export function ModelMenuList({
onPick: (entry: ModelCatalogEntry) => void;
/** Quiet footer note under the list (context-specific). */
footer?: string;
/** Vision slot: only image-capable models are selectable — plus every
* custom-endpoint model, whose capability the endpoint may not report.
* Image-capable ones float to the top. */
vision?: boolean;
}) {
const { t, language } = useI18n();
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
// Keep the arrow-walked result visible in the scrolling list.
useEffect(() => {
if (menuIdx === null) return;
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
// The filtered, ranked model list.
const results = (models ?? [])
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
.filter((r) => r.s !== null)
.sort((a, b) => b.s! - a.s! || a.m.id.localeCompare(b.m.id))
.map((r) => r.m);
const { t } = useI18n();
// Vision slot: built-ins must report image input; custom-endpoint models
// stay selectable (the endpoint may not report a modality). Image-capable
// ones float to the top of the list.
const visionItems = vision
? models?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id)
: models;
return (
<>
<input
ref={searchRef}
data-testid="model-search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setMenuIdx(null); // new query → the walk restarts on Enter/↓
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
if (results.length === 0) return;
setMenuIdx((i) =>
e.key === "ArrowDown"
? Math.min((i ?? -1) + 1, results.length - 1)
: Math.max((i ?? results.length) - 1, 0),
);
} else if (e.key === "Enter") {
const chosen = results[menuIdx ?? 0];
if (chosen) {
e.preventDefault();
e.stopPropagation();
onPick(chosen);
}
}
}}
<SearchMenuList
items={visionItems}
itemKey={(m) => `${m.provider}/${m.id}`}
searchTerms={(m) => [m.provider, m.id, m.name]}
compare={(a, b) => a.id.localeCompare(b.id)}
prioritize={vision ? (m) => m.input.includes("image") : undefined}
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
onPick={onPick}
searchTestid="model-search"
listTestid="model-list"
optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
placeholder={t("Search models…")}
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
loading={t("Loading models…")}
empty={t("No models match.")}
renderItem={(m) => <>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title={t(m.provider === CLAUDE_CODE_PROVIDER ? "Billing is determined by your Claude Code configuration; LearnHarness does not estimate it." : subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : m.cost ? "USD per 1M tokens in/out" : "Price unavailable")}>
{t(m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost))}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{m.contextWindow === null ? "—" : formatTokens(m.contextWindow)}</span>
</span>
</>}
/>
{!models && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading models…")}</div>}
{models && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">{t("No models match.")}</div>
)}
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((m, i) => (
<li key={`${m.provider}/${m.id}`}>
<button
type="button"
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
data-idx={i}
onClick={() => onPick(m)}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider) && i !== menuIdx ? " bg-muted/60" : ""}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? t("Subscription connection") : t("USD per 1M tokens in/out")}>
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow, language)}</span>
</span>
</button>
</li>
))}
</ul>
{footer && (
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
)}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { fuzzyScore } from "@/src/core";
/** The model picker's search, arrow-walk, row styling and scrolling machine.
* Models and dictation languages supply only their catalog and row content.
* The caller owns the shared MenuShell and dismissal behavior. */
export function SearchMenuList<T>({
items, itemKey, searchTerms, compare, prioritize, isSelected, onPick, renderItem,
searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty,
disabled = false,
}: {
items: readonly T[] | undefined;
itemKey: (item: T) => string;
searchTerms: (item: T) => string[];
compare?: (a: T, b: T) => number;
/** Optional grouping key: items it answers true for sort ahead of the
* rest, fuzzy rank still deciding within each group (a stable partition,
* never a filter — nothing is hidden). */
prioritize?: (item: T) => boolean;
isSelected: (item: T) => boolean;
onPick: (item: T) => void;
renderItem: (item: T) => ReactNode;
searchTestid: string;
listTestid: string;
optionTestid: (item: T) => string;
placeholder: string;
ariaLabel?: string;
loading?: string;
empty: string;
disabled?: boolean;
}) {
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => { searchRef.current?.focus(); }, []);
useEffect(() => {
if (menuIdx === null) return;
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
const results = (items ?? [])
.map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) }))
.filter((result) => result.score !== null)
.sort(
(a, b) =>
(prioritize ? Number(prioritize(b.item)) - Number(prioritize(a.item)) : 0) ||
b.score! - a.score! ||
(compare?.(a.item, b.item) ?? 0),
)
.map(({ item }) => item);
return <>
<input
type="text"
ref={searchRef}
data-testid={searchTestid}
aria-label={ariaLabel}
value={query}
disabled={disabled}
onChange={(event) => { setQuery(event.target.value); setMenuIdx(null); }}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
if (disabled || results.length === 0) return;
setMenuIdx((i) => event.key === "ArrowDown"
? Math.min((i ?? -1) + 1, results.length - 1)
: Math.max((i ?? results.length) - 1, 0));
} else if (event.key === "Enter") {
const chosen = results[menuIdx ?? 0];
if (!disabled && chosen !== undefined) {
event.preventDefault();
event.stopPropagation();
onPick(chosen);
}
}
}}
placeholder={placeholder}
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
{!items && loading && <div className="px-2 py-3 text-center text-muted-foreground">{loading}</div>}
{items && results.length === 0 && <div className="px-2 py-3 text-center text-muted-foreground">{empty}</div>}
<ul ref={listRef} data-testid={listTestid} className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((item, i) => <li key={itemKey(item)}>
<button
type="button"
data-testid={optionTestid(item)}
data-idx={i}
disabled={disabled}
onClick={() => onPick(item)}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${isSelected(item) && i !== menuIdx ? " bg-muted/60" : ""}`}
>{renderItem(item)}</button>
</li>)}
</ul>
</>;
}
+12 -19
View File
@@ -41,7 +41,8 @@ import {
} from "@/src/core";
import { LanguageSelect, SttModelSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { SettingsGroup, SettingsRow, 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";
@@ -77,10 +78,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,
}: {
@@ -94,6 +91,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}
@@ -367,20 +365,15 @@ export function SettingsDialogBody({
title={t("General")}
testid="section-general"
>
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
<SettingsRow
label={t("Auto-update")}
hint={AUTO_UPDATE_HINT}
testid="row-auto-update"
>
<Toggle
checked={general.autoUpdate ?? true}
label={t("auto update")}
onChange={(on) =>
void write("general", { autoUpdate: on })
}
/>
</SettingsRow>
<SettingsGroup label={t("Language")} testid="group-language">
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
</SettingsGroup>
<UpdateSettings
autoUpdate={general.autoUpdate ?? true}
onAutoUpdateChange={(on) =>
void write("general", { autoUpdate: on })
}
/>
</SettingsSection>
)}
{active === "stt" && (
@@ -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]);
}
@@ -9,17 +9,32 @@ 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, t: Translator = translator("en")): string { return t("Your prompts, tool results, and relevant session context are sent to {v0} 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.", { v0: label }); }
export function subscriptionDisconnectCopy(label: string, t: Translator = translator("en")): string { return t("Disconnect {v0} 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.", { v0: label }); }
export function subscriptionNotice(label: string, webOrTranslator: boolean | Translator = false, translate: Translator = translator("en")): string {
const web = typeof webOrTranslator === "boolean" && webOrTranslator;
const t = typeof webOrTranslator === "function" ? webOrTranslator : translate;
return t(web
? "Your prompts, tool results, and relevant session context are sent to {v0} to run the work you select. Credentials are stored on the machine running LearnHarness in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key."
: "Your prompts, tool results, and relevant session context are sent to {v0} 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.", { v0: label });
}
export function subscriptionDisconnectCopy(label: string, webOrTranslator: boolean | Translator = false, translate: Translator = translator("en")): string {
const web = typeof webOrTranslator === "boolean" && webOrTranslator;
const t = typeof webOrTranslator === "function" ? webOrTranslator : translate;
return t(web
? "Disconnect {v0} from LearnHarness? This stops work using this connection and removes its saved credentials from the machine running LearnHarness. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them."
: "Disconnect {v0} 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.", { v0: label });
}
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 { t } = useI18n();
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");
@@ -34,7 +49,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);
@@ -45,10 +60,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;
@@ -66,7 +81,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);
}
@@ -75,24 +89,22 @@ 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={t("{v0} connection", { v0: descriptor.label })} 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">{t("Disconnect")}{" "}{descriptor.label}?</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label, t)}</p>
<p className="mt-3 text-sm font-semibold text-red-400">{t("Disconnect {v0}?", { v0: descriptor.label })}</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label, web, t)}</p>
</> : view === "notice" ? <>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label, t)}</p>
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{t(COPILOT_POLICY_NOTICE)}</p>}
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label, web, t)}</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">
<Toggle label={t("Allow background use")} checked={management ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (management && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} />
@@ -123,17 +135,20 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
</Context.Provider>;
}
export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) {
export function NativeSubscriptionConnection({ providerId, labelClassName }: { providerId: NativeSubscriptionProviderId; labelClassName?: string }) {
const { t } = useI18n();
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
<ConnectionRow label={descriptor.label} action={status.connectionId ? t("Manage…") : t("Connect")} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
<ConnectionRow label={descriptor.label} labelClassName={labelClassName} action={status.connectionId ? t("Manage…") : t("Connect")} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
{status.error && <p role="alert" className="text-xs text-red-400">{t(status.error)}</p>}
</div>;
}
export function SubscriptionConnections({ setup = false }: { setup?: boolean }) {
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} />)}</div>;
// Providers uses ordinary settings-label typography. The setup surface
// keeps its existing compact labels; dialogs and actions are untouched.
const labelClassName = setup ? undefined : "font-prose text-(length:--lh-prose-size) text-foreground";
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} labelClassName={labelClassName} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} labelClassName={labelClassName} />)}</div>;
}
export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) {
const { t } = useI18n();
+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>
)}
</>
);
}
+37 -4
View File
@@ -17,10 +17,10 @@ import { useI18n } from "@/components/i18n";
* 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 { CUSTOM_PROVIDER, MODEL_SLOTS, type AuthKeyStatus, type ModelCatalogResponse } from "@/src/core";
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
import { useModelCatalog } from "@/components/model-picker";
import type { ModelCatalogResponse } from "@/src/core";
import { useModelCatalog } from "@/components/model-catalog";
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
* classifyKeys (the keys section's own classification — no drift):
@@ -31,7 +31,40 @@ export function hasProviderKey(
statuses: readonly AuthKeyStatus[],
providers: readonly ProviderKeyInfo[] | undefined,
): boolean {
return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
// A stored custom-endpoint key counts too: classifyKeys drops it from
// providerRows (it has its own dedicated row), but a connected custom
// endpoint IS a way to run inference, so it clears the zero-keys gate.
return (
classifyKeys(statuses, providers).providerRows.some((s) => s.present) ||
statuses.some((s) => s.id === CUSTOM_PROVIDER.envVar && 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
+171 -132
View File
@@ -15,7 +15,8 @@ import { useI18n } from "@/components/i18n";
* 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
@@ -25,6 +26,9 @@ import { useI18n } from "@/components/i18n";
* 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).
*
@@ -38,13 +42,15 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import type React from "react";
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings, CUSTOM_PROVIDER } from "@/src/core";
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 { type ProviderKeyInfo, CUSTOM_PROVIDER_INFO, CustomEndpointSetup } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu";
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection";
import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections";
@@ -75,27 +81,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;
}
@@ -131,6 +136,8 @@ export function SetupKeysBody({
onValue,
onSave,
menuAnchor,
isCustom,
onCustomSaved,
}: {
/** The full provider roster (GET /api/keys providers) — undefined
* while it loads; the picker renders an honest empty state then. */
@@ -148,6 +155,10 @@ export function SetupKeysBody({
onSave: () => void;
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
menuAnchor: React.RefObject<HTMLDivElement | null>;
/** Chosen provider is the custom endpoint: a base URL field joins the key
* field. All three optional so plain-key callers (and tests) stay unchanged. */
isCustom?: boolean;
onCustomSaved?: () => void;
}) {
const { t } = useI18n();
return (
@@ -181,33 +192,35 @@ export function SetupKeysBody({
</MenuShell>
)}
</div>
{provider && (
<div className="flex max-w-96 items-center gap-2">
<input
type="text"
value={value}
onChange={(e) => onValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave();
}}
placeholder={t("Paste key…")}
aria-label={t("{v0} key", { v0: provider.label })}
data-testid="setup-key-input"
disabled={saving}
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={onSave}
disabled={saving || !value.trim()}
data-testid="setup-key-save"
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? t("Saving…") : t("Save key")}
</button>
{provider && (isCustom ? <CustomEndpointSetup onSaved={onCustomSaved ?? onSave} /> : (
<div className="flex max-w-96 flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="text"
value={value}
onChange={(e) => onValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave();
}}
placeholder={t("Paste key…")}
aria-label={t("{v0} key", { v0: provider.label })}
data-testid="setup-key-input"
disabled={saving}
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={onSave}
disabled={saving || !value.trim()}
data-testid="setup-key-save"
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? t("Saving…") : t("Save key")}
</button>
</div>
</div>
)}
))}
{error && (
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
{t(error)}
@@ -219,25 +232,39 @@ 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 { t } = useI18n();
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);
@@ -252,64 +279,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
@@ -344,26 +372,28 @@ 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;
if (!keyProvider || keySaving) return;
if (!keyValue.trim()) return;
setKeySaving(true);
setKeyError(null);
const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value: keyValue }),
}).catch(() => null);
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value: keyValue }),
}).catch(() => null);
setKeySaving(false);
if (!res || !res.ok) {
const body = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
@@ -401,6 +431,22 @@ export function SetupDialog({
onDone(models);
}
const keysBody = <SetupKeysBody
providers={keyProviders ? [...keyProviders, CUSTOM_PROVIDER_INFO] : undefined}
provider={keyProvider}
value={keyValue}
saving={keySaving}
error={keyError}
menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef}
isCustom={keyProvider?.id === CUSTOM_PROVIDER.id}
onCustomSaved={() => { setKeyProvider(null); notifySettingsSaved("keys"); }}
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"
@@ -430,31 +476,15 @@ export function SetupDialog({
</Hint>
</header>
{stage === "keys" ? (
<div className="flex flex-col px-6 py-5">
{failed ? (
<p className="text-sm text-foreground">
{t("Model catalog could not be loaded. Check the connection and retry.")}</p>
) : (
<SetupKeysBody
providers={keyProviders}
provider={keyProvider}
value={keyValue}
saving={keySaving}
error={t(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">
{t("Model catalog could not be loaded. Check the connection and retry.")} <button type="button" data-testid="setup-retry" className="underline" onClick={refresh}>{t("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">
@@ -462,14 +492,18 @@ 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") || m.provider === CUSTOM_PROVIDER.id)
: 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">{t(SLOT_META[slot].label)}</span>
{!body?.slots?.[slot]?.model && !picked[slot] && (
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
{!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 +515,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={t("{v0} model", { v0: SLOT_META[slot].label })}
@@ -494,6 +529,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 +548,12 @@ 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}
vision={slot === "vision"}
onPick={(m) => {
editedSlots.current.add(slot);
setPicked((prev) => ({ ...prev, [slot]: m }));
// Auto-populate (chunk 2, owner: "populate
// everywhere"): no explicit level → the
@@ -527,7 +565,7 @@ export function SetupDialog({
}));
setOpenMenu(null);
}}
footer={catalog && catalog.length === 0 ? t("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 +601,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);
}}
+14 -3
View File
@@ -52,6 +52,8 @@ 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";
import { FilePathDropZone } from "@/components/file-path-drop-zone";
const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator).
@@ -63,6 +65,11 @@ declare global {
lh?: {
/** Read-only host OS; determines the native-control gutter. */
platform?: string;
/** Resolve an OS-backed File without copying/uploading its contents. */
getPathForFile?: (file: File) => 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;
@@ -70,9 +77,13 @@ declare global {
onZoom?: (cb: (factor: number) => void) => () => void;
getZoom?: () => Promise<number>;
setLanguage?: (language: "en" | "ru") => void;
/** 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;
};
}
}
@@ -941,7 +952,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return (
<SettingsDialogProvider>
<SessionsSidebar />
<main className="min-w-0 flex-1">{children}</main>
<FilePathDropZone className="relative min-w-0 flex-1">{children}</FilePathDropZone>
{/* The single document-level keydown listener (keyboard wave A) —
after the content in the tree, but it's window-level so order only
matters for the events it dispatches, never for DOM priority. */}
+34
View File
@@ -0,0 +1,34 @@
import { clearOpeningPrompt, OPENING_PROMPT_FAILURE, stashOpeningPrompt } from "./opening-prompt";
interface SessionCreation {
title: string;
kind?: "practice";
graphContext?: { propositions: readonly { id: string; name: string; statement: string }[] };
}
/** Per source-composer instance. Retain a successful creation if storage
* fails, so an unchanged retry does not create another empty session.
* Changed text/context/settings is a different start, not a stale title.
*/
export class SessionStarter {
private created?: { identity: string; id: string };
async start(body: SessionCreation, fetchLike: typeof fetch, settingsIdentity?: string): Promise<string> {
const identity = JSON.stringify([body, settingsIdentity]);
if (this.created?.identity !== identity) {
if (this.created) clearOpeningPrompt(this.created.id);
this.created = undefined;
const res = await fetchLike("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(OPENING_PROMPT_FAILURE);
const result = await res.json() as { id?: unknown };
if (typeof result.id !== "string" || !/^[A-Za-z0-9_-]+$/.test(result.id)) throw new Error(OPENING_PROMPT_FAILURE);
this.created = { identity, id: result.id };
}
if (!stashOpeningPrompt(this.created.id, body.title)) throw new Error(OPENING_PROMPT_FAILURE);
return `/chat/${this.created.id}`;
}
}
+33 -10
View File
@@ -2,8 +2,8 @@
import { useI18n } from "@/components/i18n";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { subscriptionLabels, subscriptionLabel, type Language, type SessionPricing, type TurnUsage } from "@/src/core";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { CLAUDE_CODE_PROVIDER, subscriptionLabels, subscriptionLabel, type cliContextWindowOf, type Language, type SessionPricing, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
@@ -12,6 +12,15 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
return limit === null ? numerator : `${numerator} / ${formatTokens(limit, language)}`;
}
/** CLI aliases have no global catalog limit: only this session's matching
* response can supply a denominator. Other providers keep their catalog join. */
export function contextLimitForModel(model: string | undefined, limits: ReadonlyMap<string, number> | undefined,
cliWindow: ReturnType<typeof cliContextWindowOf> = null): number | null {
if (!model) return null;
if (model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)) return cliWindow?.model === model ? cliWindow.tokens : null;
return limits?.get(model) ?? null;
}
/**
* The below-composer stats bar (wave 4 observability): the session's
* money/context footprint as one quiet row beneath the composer —
@@ -26,25 +35,31 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
* Spending arrives already priced by the server through Pi's catalog and
* calculator. Missing cost stays "—", never fake zero. Context is a separate
* completed-conversation measurement, never a money-log/summarizer fold or
* transcript estimate. Its catalog denominator follows the selected model;
* model changes preserve the numerator. Successful compaction clears it to ?.
* transcript estimate. Its denominator follows the selected model, from the
* catalog or the session's CLI report; model changes preserve the numerator.
* Successful compaction clears the numerator and any old CLI report.
*/
export function StatsBar({
turns,
contextTokens = null,
cliContextWindow = null,
pricing,
dictationSlot,
chatId,
leadingSlot,
onModelChange,
}: {
turns: TurnUsage[];
contextTokens?: number | null;
cliContextWindow?: ReturnType<typeof cliContextWindowOf>;
pricing?: SessionPricing;
/** The bound session (per-session models, owner 2026-09-28): present
* on the in-session bar — the model control binds THE SESSION'S own
* 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
@@ -58,6 +73,10 @@ export function StatsBar({
const { t, language } = useI18n();
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);
@@ -82,18 +101,19 @@ export function StatsBar({
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
// Catalog-only denominator; the picker reports its effective selection.
// Missing/pending catalog means usage-only, never a hardcoded maximum.
// The picker reports its effective selection. CLI windows are session-
// scoped; unknown metadata always means usage-only, never a guessed maximum.
const { catalog } = useModelCatalog();
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
const limit = contextLimitForModel(selectedModel, limits, cliContextWindow);
const contextLabel = formatContextMeasurement(contextTokens, limit, language);
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
// Honest cost: show dollars only when at least one turn priced;
// otherwise "—" (never a fake $0.0000).
const pricedCount = turns.length - unpriced - subscription;
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers, (value) => formatUsd(value, language));
const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`));
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers, (value) => formatUsd(value, language));
return (
<div
@@ -146,6 +166,9 @@ export function StatsBar({
{contextLabel}
</span>
</div>
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && (
<p>{t("Window reported by Claude Code; may be lower for auto-compaction.")}</p>
)}
<div className="flex justify-between gap-2">
<span>{subscription ? t("Usage") : t("Total cost")}</span>
<span className="tabular-nums text-card-foreground">{costLabel}</span>
@@ -172,7 +195,7 @@ export function StatsBar({
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
<span className="text-muted-foreground">{turn.steps.length}{" "}{t("steps")}</span>
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c, language))}</span>
<span>{turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c, language))}</span>
</span>
</li>
);
@@ -193,7 +216,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>
);
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "lucide-react";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { cn } from "@/lib/utils";
import { PathText } from "@/components/file-path-chip";
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual
* language for every surface where the learner's own thinking renders —
@@ -130,7 +131,7 @@ export function ThinkingBlock({
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
</button>
{open && (
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50">{text}</span>
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50"><PathText text={text} /></span>
)}
</div>
);
+5 -18
View File
@@ -5,33 +5,20 @@ import { useI18n } from "@/components/i18n";
/**
* 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 { t } = useI18n();
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
@@ -32,25 +32,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;
}
+25 -15
View File
@@ -1,6 +1,6 @@
# Learning Harness — Architecture
Status: **the canonical architecture spec** — describes the as-built system, verified against the code by a full audit on 2026-09-18 (every file under `src/`, `app/`, `components/`, `electron/`, `scripts/`, the lint config, and the test suite; `lint` and `typecheck` clean on the audited tree). The pre-audit spec is archived at [`archive/architecture-2026-09-08.md`](archive/architecture-2026-09-08.md) — history only. Product reasoning lives in [`docs/vision.md`](vision.md); file-by-file layout lives in the README.
Status: **the canonical architecture spec** — describes the as-built system, verified against the code by a full audit on 2026-09-18 (every file under `src/`, `app/`, `components/`, `electron/`, `scripts/`, the lint config, and the test suite; `lint` and `typecheck` clean on the audited tree). The pre-audit spec is archived at [`docs/archive/architecture-2026-09-08.md`](archive/architecture-2026-09-08.md) — history only. Product reasoning lives in [`docs/vision.md`](vision.md); file-by-file layout lives in the README.
**What this is.** A standalone learning app (web + Electron desktop): the learner's knowledge accumulates into one persistent, cross-session knowledge graph, built from tutoring sessions in which a teacher agent authors a formal, visible dependency tree (the plan tree) and verifies understanding with structured questions. The graph is the home page; the session is the classroom; everything else serves those two.
@@ -307,26 +307,34 @@ sequenceDiagram
- **The prober→teacher handoff**: the first turn(s) run the prober (floor/ceiling via quiz, goal via ask); `probe_done` (server-executed) files the assessment and queues a handoff steer; from the first `tool-probe_done` part in the transcript, the handler builds the teacher agent for every later turn. Phases (`core/phases.ts`) fold strictly: probe → plan → teach.
- **The formal plan tree is author-once**: the teacher authors the session's dependency map with a single schema-validated `plan_init` tool (a mermaid-subset graph, parsed deterministically with line-numbered errors, validated as a DAG). Progress is never mutated — statuses (`current`/`visited`/upcoming) are **derived** from tagged quiz calls in the transcript; the tree panel and the plan blocks render that derivation.
- **Job tools are server-executed, ack-and-steer**: `research`/`demo`/`visual` resolve immediately mid-turn with a fire-and-forget ack (the teacher keeps teaching); `spawn(job, input)` fires the background runner.
- Each turn service is a named, error-contained seam taking explicit deps; failures log one `[chat <id>]` line and never touch the response. Swapping any of them touches nothing else.
- Turn services are named seams taking explicit dependencies. Extraction contains its errors, but **canonical persistence must not fail silently**: a failed input write refuses acceptance without starting an agent; `persistTurn` throws on a failed reply write. The runner reports settlement failure before `[DONE]` and releases liveness even when normal settlement fails. Steer reconciliation uses the durable record, so a later bookkeeping failure cannot replay already-saved learner input.
- **Late attachment must still see settlement failure.** A small, memory-only notice per session precedes the settled stream terminal when the failing turn has already left the live registry. The next accepted turn, successful session deletion, or server exit clears it. Reopening seeds the existing banner from that notice and waits for ordinary learner input; dismissing the banner is not permission to automatically retry the model. No synthetic failure entry is added to the saved transcript.
- **Recovery never blindly resends an answer.** Submitted question drafts survive optimistic unmount in memory; the existing Submit action reconciles uncertain acceptance against the canonical record and running-turn state before sending again. EOF or a rejected stream read without a terminal triggers one read-only reattachment; another transport failure shows the existing error banner rather than claiming completion. Explicit completion, cancellation, and the literal-final-entry saved-error rule remain unchanged.
### 5.1 The wake pipeline (Workbench → Classroom)
On sub-agent completion the runner builds a **steer message** — a user-role `UIMessage` whose text part is the model-facing result and whose `data-subagent-artifact` part carries the full result for the client (provider conversion drops data parts) — and hands it to the wake pipeline:
**Worker completion and session delivery are separate lifecycles.** Dispatch persists the tool input on the job before spawning. On completion, the runner saves its result, terminal worker status (`done`/`error`), and `delivery: {kind: "result", status: "pending"}` in one job-record update. It then queues the native custom **steer message** without awaiting delivery. Its content is model-facing text; details carry the UI artifact. Worker resources are released and the Background agents row disappears even if an unanswered question holds the result indefinitely. Dispatch cards say “finished,” not “delivered.” Only computing workers can be aborted; abort saves an independent pending abort notice.
```mermaid
flowchart TB
R["job runner finishes sub-agent"] -->|"deliver(steer) — injectable seam"| ENQ["enqueue steer in per-chat queue (globalThis)"]
ENQ --> PUMP["pump(chatId) — one loop per chat (pumping set serializes)"]
PUMP --> GATE{"turn-free AND no pending question? (400ms poll)"}
GATE -->|no| PUMP
GATE -->|yes| REFRESH["re-fetch fresh record, re-check steerDelivered (claim race)"]
REFRESH --> RUN["turnRunner(chatId, record + steer) — the SAME handleChatRequest path"]
RUN -->|"200"| FIN["finalizeSteer → finalizeJob (done/error)<br/>— status flips only after the wake turn persists"]
RUN -->|"409"| BACKOFF["backoff 500→1000→2000→5000ms → retry"]
CRASH["crash recovery: listJobs → recoverStaleJobs (stale >15min)<br/>re-deliver results / respawn via setJobRespawn"] --> PUMP
R["save worker outcome + pending delivery; release worker"] --> ENQ["one registry entry per steer ID, including wake cargo"]
ENQ --> LIVE{"live agent and background consent?"}
LIVE -->|yes| STEER["pi steering at native boundaries; answer run takes parked results"]
LIVE -->|no| GATE["wait for turn-free, question-free, unpaused, consented session"]
GATE --> RUN["idle wake through the normal turn composition"]
RUN -->|"409"| BACKOFF["existing backoff 500→1000→2000→5000ms"]
BACKOFF --> GATE
RUN -->|"403"| HOLD["retain ownership until consent permits delivery"]
RUN -->|"other failed start"| QUIET["quiet pending; next learner run or restart recovery"]
RUN --> SAVE["canonical marker persistence, not merely HTTP 200"]
STEER --> SAVE
SAVE --> FIN["acknowledge delivery; worker outcome unchanged"]
CRASH["lazy recovery + live-agent registration restore known pending jobs"] --> ENQ
```
Job status flips done/error only after the wake turn persists. Delivery is **exactly-once**: deterministic steer ids (`steer_<jobId>`, `steer_probe_<chatId>`), `steerDelivered` checks at every claim point, and `claimSteers` finalizing in-transcript steers instead of re-appending. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate, append-only `mergeAdopted` adoption) and renders steers as artifacts — never a bubble.
The per-chat registry retains ownership across live handoff, idle-wake flight, and 409/403 return; repeated submissions share one entry/promise. A deterministic marker ID in the **saved conversation** completes delivery. Streaming visibility alone does not. A failed bookkeeping acknowledgement cannot replay a saved marker, and later teaching/model failure does not reopen delivery or retry the accepted turn. Failed starts leave results durably pending without a new error UI or retry timer; the in-memory entry stays parked until a learner run or process restart. Normal question/dismissal waits and consent deferrals are not failures.
Recovery reads saved job input before legacy transcript lookup. Missing research questions fail honestly without invoking the researcher. Legacy running jobs with saved results become finished workers with pending delivery; legacy terminal jobs without delivery metadata remain untouched, rather than guessing that old outcomes should be replayed. Pending result and abort notices are restored before a newly registered agent's first steering poll, even without a preceding jobs-feed read. Deleting a session discards its pending delivery obligations; only live turns and computing workers block deletion. These guarantees use the existing JSON storage and single-process delivery registry, not a transactional queue or a promise of exactly-once model execution.
Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (needs a vision-capable model — the `vision` slot in settings).
@@ -423,9 +431,11 @@ The Electron main is a launcher + credential/security guard, never a host for te
### 8.1 Subscription connection boundary
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. In packaged desktop mode Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
The renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
The packaged desktop renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
For ordinary Next web/dev, `src/server/web-subscriptions.ts` composes these same plain-Node controllers into one lazy process-global credential owner beside configured `AUTH_FILE`. The server injects the private transport through `src/agent/subscription-runtime.ts`; the agent layer never imports the server layer. Existing scoped leases, authority revisions and foreground/background abort listeners work through that transport, without exposing an HTTP token/lease operation. `src/server/web-subscription-http.ts` exposes only same-origin status/control operations. Generic `subscriptions-changed` notifications use the existing SSE notify→fetch channel; opaque one-use browser actions redirect to provider pages without putting authorization URLs in app JSON. Browser adapters preserve the UI bridge contracts; Electron dev uses the web owner with a narrow system-browser opener. Desktop markers fail closed rather than falling back to web credentials. This is the existing shared developer-server trust model, not user authentication or DNS-rebinding protection; LAN behavior is unchanged. Only one server process may own an auth directory. See [development](development.md#subscription-connections-in-webdev) for storage, browser locality and access limitations.
`subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances.
+11
View File
@@ -24,6 +24,17 @@ prompt, plan continuity, persistence, threshold, scheduling, and authorization
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
calls. A cut that drops no active history does not launch a summarizer.
When the active cutoff is applied, conversation requests carry the latest summary
once, followed by the retained transcript without older compaction markers. When
a subsequent compaction supplies that prior summary separately, it includes it
once, plus the newly superseded transcript without summary markers. All markers
and original messages remain stored. The existing full-history fallback for an
unresolved cutoff is unchanged, including its summary text.
A provider response stopped at its output limit (`length`) is an incomplete
summary, not a successful checkpoint. Compaction fails without changing the
existing history or prior summary; a later attempt can retry normally.
## Conversation meter
The numerator is the latest qualifying completed conversation response's native
+133
View File
@@ -0,0 +1,133 @@
# Custom and local models
LearnHarness supports **one custom OpenAI-compatible endpoint with multiple models**.
The setup and Settings UI are based on PR #62 by Daniel Buckner and Abhishek Thakur.
Pi owns inference, message conversion, tools, streaming, and reasoning-effort handling.
## Configure an endpoint
1. Open **Settings → Providers → + Add a provider → Custom endpoint**. The same
editor is available from the first-run provider picker.
2. Enter the server's complete API base URL, for example
`http://localhost:11434/v1`. LearnHarness does **not** append `/v1`.
3. Choose **Keyless** or **API key**:
- **Keyless** never uses a saved or environment custom-provider key for
authentication. Pi's OpenAI client receives an internal dummy credential.
- **API key** uses the effective `CUSTOM_OPENAI_API_KEY`. A real environment
value wins over a file-saved value; the editor identifies environment ownership.
Any nonempty trimmed custom key is accepted—there is no arbitrary length floor.
- Changing the URL requires a new effective key or explicit consent to reuse
the existing effective key. A key is never silently forwarded to a new URL.
Clearing authentication selects Keyless.
4. Enter optional **model IDs**, separated by commas or whitespace. This supports
servers whose chat endpoint works but whose `/models` endpoint is absent.
5. Click **Connect**, then choose models for Strong, Cheap, and Vision.
HTTP and HTTPS, including localhost/private-network servers, are supported. URLs
with embedded credentials, query strings, or fragments are rejected. Put a key in
the authentication field, not the URL. Discovery redirects are rejected.
Connect requests `GET <base>/models`. Discovery metadata wins over duplicate manually
entered IDs. A failed probe can fall back to entered IDs; a failed probe for the
same URL preserves its previous model snapshot and pins. A different URL starts
with its own models. **401/403 always fails**, even with entered IDs. Response-size,
model-count, and redirect violations also fail explicitly rather than silently
truncating or bypassing a discovery limit.
Configuration is not a continuous health check. An endpoint may be configured while
offline, and model capability or output quality is not proven by listing its ID.
No backend/model installation, downloads, or OAuth flows are performed.
## Model budgets and reasoning support
Settings exposes offline-editable pins per model:
- **Context budget**: user pin → reported context → **8192 fallback**.
- **Maximum output**: user pin → reported output limit → **4096 fallback**.
- **Reasoning support**: user declaration → reported support.
Every positive integer token pin is accepted, including a 512-token output limit.
Blank fields remove that pin. The editor identifies reported, fallback, and user-pin
values. **These are request/compaction budgets; changing them does not resize the
backend's actual context.** Configure the server's active context separately and
enter budgets that match it. Reconnecting prunes pins only to the resulting model
IDs; pin saves do not contact the server.
Reasoning support is a **capability declaration, not an execution toggle**. The
existing Pi-backed effort picker remains available. In particular, **Off may not
disable a custom backend's default thinking**: an absent effort parameter is not a
universal disable command. There is no compatibility/thinking-map editor, Ollama
preset, or model-name inference. Backends requiring additional nonstandard headers
or compatibility configuration are outside this release's custom-endpoint UI.
## Images and Vision
All custom models can be selected for Vision; models that report image support
appear first. The endpoint owner is responsible for choosing an appropriate model.
Built-in model filtering is unchanged.
Missing discovery metadata does not silently remove images: **every custom-engine
image-bearing request attempts delivery**, including teaching `read` results,
rendered PDF pages, and visual-maker screenshots. A request-local model clone permits
Pi's native image encoding; registered/catalog metadata is not promoted to claim
that every model understands images. A text-only backend may reject the request;
that error is surfaced, with no silent text-only fallback. This does not establish
real-model image-understanding quality.
## Credentials, reloads, and concurrent editing
Nonsecret configuration is stored in the existing `settings.json.customProvider`;
file-saved credentials use the existing `auth.json` key seam. A reserved internal
string entry, `__learnharness_custom_provider_binding`, binds authorization to the
endpoint, mode, and a random nonsecret `credentialBindingId` in custom settings.
The file key and its binding are published in one existing atomic auth-map write.
The internal entry is not an environment variable or an editable/displayed key.
Neither keys nor raw upstream errors are returned by the custom-provider API.
Every startup and upstream operation requires matching binding IDs, URL, and mode
before using credentials. A crash between auth/settings writes, or a failed
settings write whose auth rollback also fails, leaves the custom provider disabled
rather than sending a new key to an old endpoint. Reload and explicitly reconnect
to repair authorization. A shadowed file key retains its own authorized URL: if an
environment key is reused for another URL and later disappears, the old file key
is not silently forwarded there. Keyless also requires its nonsecret binding, but
never resolves a stored/environment key. Disconnect leaves an internal disabled
marker so a latent environment key cannot revive an interrupted disconnect.
Use the custom endpoint editor to change custom credentials; generic
`/api/keys` PUT/DELETE cannot bypass binding. No credential-derived fingerprint is
stored in settings or a public revision. These guards cover process crashes and
restart using the existing atomic rename; they are not a power-loss/fsync
transaction system. Missing auth storage can initialize normally; unreadable or
malformed private storage fails closed without overwriting its contents.
Each upstream model operation resolves the current custom model, endpoint, effective
credential, limits, and original saved effort preference against Pi's current model
metadata. Already-started requests keep their setup. A removed model or disconnected
provider fails rather than falling back; saved model selections and history are not
rewritten.
Edits use an opaque nonsecret ETag/If-Match revision. A stale edit returns **409** with
reload/retry guidance. A connect also checks configuration and effective credentials
after discovery, and merges only the custom section into the latest settings record,
preserving intervening unrelated edits. Failed mutations never claim success.
Desktop requests use the existing desktop capability. Browser mutations must be
same-origin JSON requests. Limits are **15 seconds** for discovery, **8 MiB** for its
response, **10,000** listed models, and **64 KiB** for configuration-write bodies.
These are protections for the new endpoint, not a multiuser authentication system.
## Pricing and previous development configuration
Custom model pricing is **unknown/unpriced**, not $0—even for an endpoint that may
be locally hosted. Pi-required internal zero-rate placeholders are not presented as
free inference. There is no rate editor in this release.
The earlier, unshipped app-data `models.json` feature is retired and is **not read**.
No user file is deleted or renamed, and there is no importer, coexistence mode,
profile reset, history rewrite, or silent model remapping. Existing development
profiles need manual endpoint configuration and model reselection. Unbound early
GUI development configurations also require explicit reconnect/new-key entry or
the existing reuse-consent checkbox, even at the same URL; they are never silently
trusted or migrated. This release
supports one OpenAI-compatible custom endpoint, not multiple arbitrary protocols.
+20 -9
View File
@@ -2,13 +2,13 @@
The developer README (split out of the storefront README, 2026-10-10). Everything below is for people working ON LearnHarness, not using it.
A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](docs/pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page.
A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page.
Not a chat app with a graph bolted on: sessions are probe→teach lessons, the plan tree is a structured, schema-validated object, sessions accumulate into the graph through the extraction pipeline (propositions + reasoning chains — the stored truth; the canvas renders a derived image), and the teacher reaches back into the graph through read-only prober tools on its own judgment so probing compounds across sessions.
- [`docs/architecture.md`](docs/architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.)
- Living design doc (all decisions): [`docs/vision.md`](docs/vision.md)
- Build plan + wave/gate results: [`docs/plan.md`](docs/plan.md)
- [`docs/architecture.md`](architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.)
- Living design doc (all decisions): [`docs/vision.md`](vision.md)
- Build plan + wave/gate results: [`docs/plan.md`](plan.md)
## Commands
@@ -22,6 +22,7 @@ npm start # serve the production build
npm test # the full suite (node:test via tsx; deterministic, no LLM, no network)
npm run lint # eslint — includes the import law (architecture §2)
npm run typecheck # tsc --noEmit
node scripts/smoke-web-subscriptions.mjs # after build: offline Next start/dev + reload gate
npm run desktop:dev # next dev + Electron shell pointed at it
npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain)
@@ -32,7 +33,7 @@ npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extrac
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
```
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
For Windows/Linux packages and the three-OS CI/release flow, see [`docs/desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
## Environment (`.env.local`)
@@ -47,9 +48,19 @@ Secrets and machine-specific paths only. Model choice and thinking budgets are t
The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, read/written directly by the server via `LH_AUTH_FILE`). PDFs render natively with pdfjs/Skia; SVGs use resvg.
### Subscription connections in web/dev
Ordinary `npm run dev` and `npm start` support the existing **ChatGPT, GitHub Copilot and Grok** connections in Settings → Providers. No separate launcher or change to Next's listening/LAN behavior is required. `npm run desktop:dev` uses these same dev-server connections; the packaged desktop app keeps its separate main-process credentials and private IPC transport.
Web/dev saves `chatgpt-credentials.json`, `github-copilot-credentials.json` and `xai-credentials.json` **beside the configured API-key `auth.json`**: the project directory by default, or the directory containing `LH_AUTH_FILE`. These are owner-only plaintext files, not files under `data/`, desktop Application Support, or a global Pi profile. Git and standalone-build exclusions cover both the files and atomic-write temporary files. Run only one server process per credential directory: refresh serialization is process-local, not a cross-process lock.
Sign-in opens a new tab in the browser that initiated it; permit that tab if your browser blocks popups. **ChatGPT sign-in must be completed in a browser on the server's machine**, because its callback listener remains `127.0.0.1` on that machine. Copilot/Grok use device codes. In the Electron dev shell, provider pages open in the system browser instead. Once connected, credentials and refresh remain server-owned; other browsers using that server share its connections and background-use consent, just as they share its API keys. Disconnect, storage-failure handling, and provider-specific background consent retain the existing behavior; subscription routes never fall back to API-key billing.
This remains a **trusted developer/shared-server mode**, not multi-user hosting. Subscription controls require same-origin JSON/custom-header requests; browser-opening actions are short-lived, origin-bound and one-use. These checks prevent ordinary cross-origin browser controls, **not user authentication or DNS-rebinding protection**. Do not expose an unprotected dev/server instance to untrusted users or the public Internet; a public deployment needs an independent access/host gate. Remote ChatGPT callback routing, per-user accounts and credential import are not implemented.
## Architecture
Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](docs/architecture.md).
Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](architecture.md).
```mermaid
flowchart TB
@@ -165,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority
**Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Deepgram Console and states the Member-or-higher key-role requirement.
## Testing
@@ -179,10 +190,10 @@ All deterministic, no LLM, no network (engines are injected — pi-ai's faux pro
## Lineage
The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](docs/pi-engine-swap.md)).
The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](pi-engine-swap.md)).
## Data
Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`.
**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
+9 -2
View File
@@ -21,7 +21,8 @@ philosophy so they share a view of the whole process).
field it must not use. No plan tools, no jobs, no demos.
- **The teacher** runs everything from planning onward: full tool set
(teach quiz with nodeId, plan_init, jobs, demos). Constructed the moment the
transcript carries a `probe_done`.
transcript carries a concluded `probe_done`: a non-failed/non-aborted
assistant call followed by a matching successful tool result.
- **The swap happens between probing and planning** — the planning phase
needs full knowledge of how teaching is done, so the teacher agent starts
at planning.
@@ -43,6 +44,12 @@ The prober concludes explicitly:
- **Refuses while a question is open** ("resolve the open question first") —
same doctrine as nodeId validation.
- **Refuses a second conclusion** (idempotent: the probe concludes once).
Conclusion requires a later `probe_done` result matching the call's ID
and tool name. The calling generation must not have failed or aborted;
the result must not be `isError` or explicitly contain `details.ok:false`.
Clean acknowledgments without an explicit `ok:true` remain valid. Bare,
unmatched, errored, or refused calls keep probing open; saved attempts
are never deleted or replay-executed by this detection rule.
- The assessment is the tool call's input, so it streams to the learner live
and is durable in the transcript by the tool result itself. A dedicated
assessment card/viewer comes later (UI).
@@ -53,7 +60,7 @@ The prober concludes explicitly:
- **Side effect: it queues the handoff.** When the prober's turn ends, the
wake pipeline (the same machinery that delivers sub-agent results and
abort steers) fires a server-side turn with a handoff steer — the teacher
is constructed (probe_done is in the transcript) and authors the plan.
is constructed (probe_done has successfully concluded) and authors the plan.
The learner experiences assessment → plan as one continuous flow. If the
wake is lost (crash window), the next client message still constructs the
teacher — the transcript is the source of truth.
+63
View File
@@ -0,0 +1,63 @@
# Tool-content presentation recovery
## Contract
Model-authored tool content is parsed against its schema before presentation. Math or document recovery must not change persisted tool arguments, option identities, learner answers, shuffling, grading, or future answer snapshots. Those use the source-derived, schema-parsed content.
`ToolContentResponse` projects text through `repairMathForDisplay`, then uses the ordinary Markdown renderer. Teaching/probing questions, asks, recalls/explanations, and practice statements/solutions share this path. Ordinary assistant prose does not receive tool-content recovery.
There are three separate responsibilities:
1. **Argument/shape parsing:** decode encoded argument shapes, then schema conversion, validation and cleaning. No LaTeX recovery here.
2. **Content projection:** distinguish literal code, prose/document structure and math; recover only supported escaping defects.
3. **Presentation:** Markdown/KaTeX rendering, including paragraph layout. In labeled Why/answer reveals, only the first direct paragraph is inline with its label; subsequent paragraphs retain their normal layout.
## Recovery rules
### Literal code
Code and inline-code source ranges come from the rendering stack's CommonMark parser with its math extensions. This covers fences, indentation, quotes and lists without maintaining separate container heuristics. The projection copies those source ranges verbatim; it does not serialize an AST back into Markdown.
### Document escapes
A literal two-character `\n` is different from a physical newline. Document-layout recovery requires evidence such as repeated document-escape candidates or a candidate alongside over-escaped known TeX commands. Complete known n-prefixed TeX words, including `\nu`, `\nabla`, `\neq` and declared n-prefixed macros, take precedence over interpreting their prefix as document layout. Code, genuine TeX comments and recognized path tokens are protected.
Recovered double-dollar display framing puts the formula and closing fence on their own lines. Decoding the opening line alone can otherwise turn closing dollars into erroneous TeX content.
### JSON-swallowed command prefixes
Inside math, a control character can be the result of JSON consuming the start of a command: TAB + `ext` instead of `\text`, for example. Recovery reconstructs a command only if the control character plus the **complete following alphabetic word** names a known KaTeX command. All supported occurrences recover in one projection.
The owner-approved typical-model-authoring policy is:
- **Inline math:** recover recognized prefixes, including physical LF + `u` to `\nu`, and TAB + `o` to `\to`. There is no word-length cutoff.
- **Display math:** keep physical LF and CRLF as source formatting; recover recognized non-newline prefixes.
- **Correctly written commands:** remain commands in both modes. `\nu` is never decoded into LF + `u`.
- **Unrecognized whitespace:** remains unchanged. `x<TAB>+<TAB>y` does not become invented `\t`/`\ty` commands.
The control-character case is inherently ambiguous: whitespace followed by a variable may happen to spell a command. This policy targets typical model-authored LaTeX, not every syntactically possible human whitespace arrangement. TeX parse success alone cannot distinguish the two meanings.
### Extra command escaping, percentages and rows
Recovery examines the original TeX context before changing backslash runs:
- Complete native environment pairs and native `\substack` arguments preserve genuine row separators.
- Consistently extra-escaped environment pairs establish an escaping factor for commands **and rows**. For example, an encoded four-backslash row becomes two backslashes, not one.
- Outside protected native row/comment contexts, extra prefixes on known commands can recover. Unknown command words are not invented.
- Doubled percent escapes in ordinary numeric expressions can recover to `\%`, including numeric percentages at the end of a display formula. Genuine following comment text or equation rows remain literal. This is not a blanket `%` addition to an even-backslash rule.
- As an additional guard, partial command recovery does not turn parseable source into a TeX parse failure. Parse success is not the primary correctness test: both a corrupted matrix and a lost percentage can parse successfully.
Unbalanced, unsupported or ambiguous content is not generally corrected into new mathematics. This is an escaping recovery projection, not a general TeX authoring repair system. There are no language-specific decoders.
## Historical answers
`answerForPresentation` joins model-authored display fields to the original tool call without changing recorded answer facts. Option labels bind only through unambiguous selected values, exact/source-recovered labels, or one uniquely remaining pair in a complete bijection. Ambiguous rows keep their saved snapshots. Saved order, indices, selected values, verdicts, confidence and learner text are retained. Dismissed answers are unchanged. No migration or record rewrite is performed.
## Verification
- `tests/content-recovery.test.ts`: recovery/preservation rules and adversarial row/comment/code examples.
- `tests/tool-content-presentation.test.tsx`: provenance-labeled synthetic corpus regressions, historical joins and ambiguity.
- `tests/tool-content-interaction.test.tsx`: select/submit/persist/reveal flow and paragraph structure.
- Existing Markdown, card, practice, tool-shape and grading suites remain applicable.
The local frozen corpus can be replayed offline with `scripts/replay-tool-rendering-corpus.ts`. Replay verifies source hashes and preserves baseline findings; it reports glyph loss, matrix rows, rendering errors, code bytes, schema regressions, answer facts and projection convergence. Corpus collection and larger diagnostic batches are separate from product recovery and are not prerequisites for running these unit tests.
+93 -21
View File
@@ -24,12 +24,13 @@
* would shadow every settings-page key change until restart.
*/
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron");
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
const { desktopAPIHeaders } = require("./desktop-api.cjs");
const { openWebSubscriptionBrowser } = require("./web-subscription-browser.cjs");
const { randomBytes } = require("node:crypto");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
@@ -140,6 +141,7 @@ let subscriptions = null;
let subscriptionChild = null;
// Per-launch request capability, never OAuth tokens and never renderer state.
let desktopAPISecret = null;
let reopenWindow = null;
const trustedAppWindows = new Set();
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
@@ -315,6 +317,7 @@ function createWindow(url) {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
...(process.env.LH_DEV_URL ? { additionalArguments: ["--lh-web-subscriptions"] } : {}),
preload: path.join(__dirname, "preload.cjs"),
},
// Live-resize + first-paint flash (owner, 2026-09-17): during a resize
@@ -437,31 +440,57 @@ const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
* and before-quit alone would race the swap. */
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
let autoUpdater = null; // electron-updater's, packaged builds only
// Observational only: this snapshot NEVER gates a check, download or install.
// The revision lets late subscribers reject an older in-flight IPC snapshot.
let updateState = { status: "idle", version: null, revision: 0 };
function broadcastUpdateStatus(status) {
for (const win of BrowserWindow.getAllWindows()) {
try {
win.webContents.send("lh:update-status", status);
} catch {
/* window mid-teardown — the next push retries */
function reportUpdateStatus(status, version = null) {
try {
// A later check/error must not hide an update already staged for quit.
if (updateState.status === "downloaded" && status !== "downloaded") return;
updateState = { status, version: typeof version === "string" ? version : null, revision: updateState.revision + 1 };
for (const win of BrowserWindow.getAllWindows()) {
try {
win.webContents.send("lh:update-status", updateState);
} catch {
/* window mid-teardown — snapshot retrieval covers a later load */
}
}
} catch {
/* Status reporting must never interrupt the updater. */
}
}
/** Read the SAME data directory passed to the packaged server. Only an
* explicit false opts out; absent/corrupt/unreadable settings keep updates ON.
* Re-read for each automatic check so saving the toggle needs no restart. */
function automaticUpdatesEnabled() {
try {
const settings = JSON.parse(fs.readFileSync(path.join(DATA_DIR(), "settings.json"), "utf8"));
return settings?.general?.autoUpdate !== false;
} catch {
return true;
}
}
function checkForUpdates({ manual = false } = {}) {
if (!autoUpdater) return;
if (!manual && !automaticUpdatesEnabled()) {
log("auto-update: automatic check skipped (turned off in Settings)");
return;
}
// Do not gate on display state: a stale spinner/error must never disable
// future background checks. electron-updater owns its in-flight work.
autoUpdater
.checkForUpdates()
.then((result) => {
// The download itself is silent (autoDownload); the banner fires on
// update-downloaded. Manual checks additionally answer directly.
if (!manual) return;
const version = result?.updateInfo?.version;
if (!version) dialog.showMessageBox({ type: "info", message: nt("You're up to date."), detail: nt("LearnHarness {version} is the latest version.", { version: app.getVersion() }), buttons: [nt("OK")] });
// A declined check (e.g. an unsupported package) establishes no
// latest-version claim. Real results arrive through updater events.
if (!result) reportUpdateStatus("idle");
})
.catch((err) => {
log(`update check failed: ${err?.message ?? err}`);
if (manual) dialog.showMessageBox({ type: "warning", message: nt("Couldn't check for updates."), buttons: [nt("OK")], detail: String(err?.message ?? err) });
reportUpdateStatus("error");
});
}
@@ -478,12 +507,14 @@ function installUpdateNow() {
function setupAutoUpdater() {
if (!app.isPackaged) {
log("auto-update: dev build — updater disabled");
reportUpdateStatus("disabled");
return;
}
try {
autoUpdater = require("electron-updater").autoUpdater;
} catch (err) {
log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`);
reportUpdateStatus("disabled");
return;
}
const feed = process.env.LH_UPDATE_FEED;
@@ -501,11 +532,37 @@ function setupAutoUpdater() {
debug: (m) => {}, // too chatty for the persistent log
error: (m) => log(`updater error: ${m}`),
};
autoUpdater.on("checking-for-update", () => reportUpdateStatus("checking"));
autoUpdater.on("update-available", (info) => reportUpdateStatus("downloading", info?.version));
autoUpdater.on("update-not-available", () => reportUpdateStatus("up-to-date"));
autoUpdater.on("update-downloaded", (info) => {
log(`update downloaded: ${info?.version} — banner shown`);
broadcastUpdateStatus({ status: "downloaded", version: info?.version ?? null });
reportUpdateStatus("downloaded", info?.version);
});
autoUpdater.on("error", (err) => log(`updater error: ${err?.message ?? err}`));
autoUpdater.on("error", (err) => {
log(`updater error: ${err?.message ?? err}`);
reportUpdateStatus("error");
});
}
/** One result surface for manual checks. Preload buffers the navigation
* request until React subscribes, including when all macOS windows closed. */
function checkForUpdatesFromMenu() {
try {
const win = BrowserWindow.getAllWindows()[0] ?? reopenWindow?.();
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
const openSettings = () => {
try { win.webContents.send("lh:open-settings", "general"); } catch { /* window closed */ }
};
if (win.webContents.isLoadingMainFrame()) win.webContents.once("did-finish-load", openSettings);
else openSettings();
}
} catch {
/* Navigation failure must not prevent the manual check. */
}
checkForUpdates({ manual: true });
}
function zoomStep(win, dir) {
@@ -541,9 +598,8 @@ function installMenu() {
label: app.name,
submenu: [
{ role: "about", label: nt("About LearnHarness") },
// Manual check (Wave C): answers via native dialog — no renderer
// round-trip. The 24h automatic checks stay silent.
{ label: nt("Check for Updates…"), click: () => checkForUpdates({ manual: true }) },
// Manual checks report their result in Settings → General.
{ label: nt("Check for Updates…"), click: () => checkForUpdatesFromMenu() },
{ type: "separator" },
{ role: "hide", label: nt("Hide LearnHarness") },
{ role: "hideOthers", label: nt("Hide Others") },
@@ -657,6 +713,13 @@ async function main() {
return subscriptionControl(command, subscriptions);
});
// The dev shell has no browser tabs. Only a one-use action on its own
// Next server may be resolved here; never accept an arbitrary external URL.
ipcMain.handle("lh:subscriptions:web-browser", async (event, action) => {
if (!devUrl || !trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted development request.");
await openWebSubscriptionBrowser(action, new URL(url).origin, { openExternal: (target) => shell.openExternal(target) });
});
installMenu();
ipcMain.on("lh:language", (event, language) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return;
@@ -672,6 +735,14 @@ async function main() {
// are log lines — this can never block boot.
setupAutoUpdater();
ipcMain.on("lh:update-install", () => installUpdateNow());
ipcMain.handle("lh:update:get", (event) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
return updateState;
});
ipcMain.handle("lh:update:check", (event) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
checkForUpdates({ manual: true });
});
// Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes
// the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that
@@ -688,13 +759,14 @@ async function main() {
const target = SIDEBAR_AUTOCLOSE;
if (width < target) win.setBounds({ x, y, width: target, height });
});
reopenWindow = () => { chatgpt?.resume(); subscriptions?.resume(); return createWindow(url); };
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
if (BrowserWindow.getAllWindows().length === 0) reopenWindow();
});
createWindow(url);
log(`window open: ${url}`);
// First update check AFTER the window exists (the downloaded-banner
// push needs a listener); dev builds no-op (no autoUpdater).
// Startup + 24h checks stay main-owned and read the current preference.
// No renderer subscription is required; late loads retrieve the snapshot.
if (autoUpdater) {
checkForUpdates();
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
+35 -4
View File
@@ -19,17 +19,35 @@
*
* window.lh.onUpdateStatus(cb) / window.lh.installUpdate() — the
* auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes
* {status:"downloaded", version} once an update is staged; installUpdate
* asks main to kill the server and quit-and-install. Browser builds:
* both absent, the banner never renders.
* {status, version, revision} on updater events; installUpdate asks main
* to kill the server and quit-and-install. getUpdateState retrieves the
* snapshot for a late subscriber; checkForUpdates requests a manual
* check (including download/install-on-quit, even with automatic checks OFF).
*
* window.lh.onOpenSettings(cb) — menu navigation, buffered until React
* subscribes after a window is reopened. Browser builds have no bridge.
*
* Channel names are mirrored in electron/main.cjs — keep them in sync.
*/
const { contextBridge, ipcRenderer } = require("electron");
const { contextBridge, ipcRenderer, webUtils } = require("electron");
let openSettingsListener = null;
let pendingOpenSettings = null;
ipcRenderer.on("lh:open-settings", (_event, section) => {
if (openSettingsListener) openSettingsListener(section);
else pendingOpenSettings = section;
});
contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
setLanguage: (language) => ipcRenderer.send("lh:language", language),
// Resolve only a renderer-supplied, OS-backed File. No filesystem API,
// uploads, copies, or DOM File IPC to main; sandbox/isolation stay on.
getPathForFile: (file) => webUtils.getPathForFile(file),
...(process.argv?.includes("--lh-web-subscriptions") ? {
subscriptionTransport: "web",
openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
} : {}),
chatgpt: {
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
@@ -72,4 +90,17 @@ contextBridge.exposeInMainWorld("lh", {
return () => ipcRenderer.removeListener("lh:update-status", listener);
},
installUpdate: () => ipcRenderer.send("lh:update-install"),
getUpdateState: () => ipcRenderer.invoke("lh:update:get"),
checkForUpdates: () => ipcRenderer.invoke("lh:update:check"),
onOpenSettings: (cb) => {
openSettingsListener = cb;
if (pendingOpenSettings !== null) {
const section = pendingOpenSettings;
pendingOpenSettings = null;
cb(section);
}
return () => {
if (openSettingsListener === cb) openSettingsListener = null;
};
},
});
+11 -10
View File
@@ -1,7 +1,7 @@
"use strict";
const { randomUUID } = require("node:crypto");
const path = require("node:path");
const { createRequire, registerHooks } = require("node:module");
const { registerHooks } = require("node:module");
const { pathToFileURL } = require("node:url");
const { PROVIDERS, fileStore, validateCredential, safeHttps, failure } = require("./subscription-store.cjs");
@@ -21,17 +21,18 @@ const MESSAGES = {
function publicError(error) { return MESSAGES[error?.code] ?? MESSAGES.unavailable; }
/** Resolve the SAME SDK installation as the standalone server, through public exports. */
async function loadSubscriptionSDK(baseDirectory) {
const resolve = createRequire(path.join(baseDirectory, "package.json"));
const load = (id) => {
// Pi exports are import-only. Resolve the public export with ESM
// conditions, then remove this synchronous, exact-specifier hook
// BEFORE evaluation; no private package path or global hook remains.
const parentURL = pathToFileURL(path.join(baseDirectory, "package.json")).href;
const load = async (id) => {
// The manifest URL plus fragment is an exact resolution token, never
// evaluated. Normal ESM resolution selects the public import export
// from the server's installation, including under test-loader hooks.
const token = `${parentURL}#${encodeURIComponent(id)}`;
const hook = registerHooks({ resolve(specifier, context, next) {
return next(specifier, specifier === id ? { ...context, conditions: [...context.conditions, "import"] } : context);
if (specifier !== token) return next(specifier, context);
hook.deregister();
return next(id, { ...context, parentURL });
} });
let file;
try { file = resolve.resolve(id); } finally { hook.deregister(); }
return import(pathToFileURL(file).href);
try { return await import(token); } finally { hook.deregister(); }
};
const [sdk, copilot, xai] = await Promise.all([load("@earendil-works/pi-ai"), load("@earendil-works/pi-ai/providers/github-copilot"), load("@earendil-works/pi-ai/providers/xai")]);
return { ...sdk, ...copilot, ...xai };
+19
View File
@@ -0,0 +1,19 @@
"use strict";
/** Desktop-dev has no tabs. Resolve only an opaque action on its own Next
* server, then open the owner-validated provider page outside the renderer.
* The caller must authenticate its trusted app main frame first. */
async function openWebSubscriptionBrowser(action, origin, { fetch: request = globalThis.fetch, openExternal }) {
if (typeof action !== "string" || !/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(action)) throw new Error("Invalid sign-in action.");
const response = await request(new URL(action, origin), {
method: "POST", redirect: "manual", credentials: "omit",
headers: { origin, "x-learnharness-subscriptions": "1" },
signal: AbortSignal.timeout(15000),
});
const target = response.headers.get("location");
if (response.status !== 303 || !target) throw new Error("Sign-in action expired.");
const parsed = new URL(target);
if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error("Invalid provider page.");
await openExternal(target);
}
module.exports = { openWebSubscriptionBrowser };
+14
View File
@@ -1,6 +1,11 @@
import { readFileSync } from "node:fs";
import type { NextConfig } from "next";
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) as { version: string; homepage: string };
const nextConfig: NextConfig = {
// Public build metadata only; no new runtime Electron version API.
env: { LH_VERSION: pkg.version, LH_HOMEPAGE: pkg.homepage },
// Desktop target (Electron wrapper, 2026-09-17): standalone output = a
// self-contained .next/standalone/server.js the Electron shell spawns
// on 127.0.0.1. Harmless for plain web deploys (extra build output).
@@ -43,6 +48,9 @@ const nextConfig: NextConfig = {
"*": [
"**/data/**/*",
"auth.json",
"chatgpt-credentials.json*",
"github-copilot-credentials.json*",
"xai-credentials.json*",
".env*",
],
},
@@ -70,6 +78,11 @@ const nextConfig: NextConfig = {
// npm installs only the current platform's binary, so this glob
// resolves to exactly one Skia binary per build.
"node_modules/@napi-rs/canvas-*/**/*",
// Pi-compatible image resizing runs in an unbundled worker. Photon
// loads its colocated WASM through Node, also in packaged Electron.
"src/agent/image-processing/*.mjs",
"src/agent/image-processing/LICENSE",
"node_modules/@silvia-odwyer/photon-node/**/*",
// Wave E stage 2: resvg rasterizes visual-maker SVGs natively; same
// per-platform-binary story as the canvas above (one per build).
"node_modules/@resvg/resvg-js-*/**/*",
@@ -98,6 +111,7 @@ const nextConfig: NextConfig = {
"@earendil-works/pi-agent-core",
"pdfjs-dist", // sources v0: bundling breaks its node fake-worker path
"@napi-rs/canvas", // native binding — bundling breaks the .node require
"@silvia-odwyer/photon-node", // CJS loads its adjacent WASM from disk
"@resvg/resvg-js", // native binding — same
],
};
+1145 -199
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -27,10 +27,12 @@
"dependencies": {
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@modelcontextprotocol/sdk": "1.32.1",
"@mozilla/readability": "^0.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@resvg/resvg-js": "^2.6.2",
"@rive-app/react-webgl2": "^4.32.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
@@ -51,11 +53,14 @@
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-math": "3.0.0",
"media-chrome": "^4.19.2",
"micromark-extension-math": "3.1.0",
"mermaid": "^11.17.0",
"motion": "^13.1.1",
"nanoid": "^6.0.1",
"next": "^16.3.2",
"next": "^16.4.3",
"next-themes": "^0.4.6",
"pdfjs-dist": "^6.3.289",
"puppeteer-core": "^25.8.0",
@@ -76,7 +81,7 @@
"katex": "$katex"
},
"devDependencies": {
"@next/eslint-plugin-next": "^16.3.3",
"@next/eslint-plugin-next": "^16.4.3",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
@@ -84,7 +89,7 @@
"@types/turndown": "^5.0.6",
"@typescript-eslint/parser": "^8.68.0",
"concurrently": "^10.0.5",
"electron": "^44.4.1",
"electron": "^44.9.3",
"electron-builder": "26.16.1",
"eslint": "^9.39.5",
"eslint-import-resolver-typescript": "^4.4.5",
+2 -1
View File
@@ -52,7 +52,8 @@ if (exists(leakedData)) {
// (the DEV BYOK key store, 0600) byte-identical into the DMG (caught in
// the 2026-10-08 payload audit; excluded in next.config.ts — this guard
// makes a regression loud instead of silent).
for (const secret of ["auth.json", ".env", ".env.local"]) {
const subscriptionSecrets = fs.readdirSync(STANDALONE).filter((name) => /^(chatgpt|github-copilot|xai)-credentials\.json/.test(name));
for (const secret of ["auth.json", ...subscriptionSecrets, ".env", ".env.local"]) {
if (exists(path.join(STANDALONE, secret))) {
console.error(`[build-server] REFUSING: standalone contains ${secret} — dev secrets would ship in the app.`);
console.error(" Check outputFileTracingExcludes in next.config.ts.");
+195
View File
@@ -0,0 +1,195 @@
/** Fake-input regression for Next's real compression/backpressure path.
* Runs in a disposable child so zlib instrumentation cannot affect the app.
* node scripts/smoke-compression-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { once } from "node:events";
import * as fs from "node:fs";
import * as http from "node:http";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
export function verifyCompressionRuntime(root, runtime = process.execPath) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-compression-runtime-"));
try {
for (const dir of ["data", "tmp"]) fs.mkdirSync(path.join(home, dir));
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
// Allowlist rather than inherit credentials, Node preloads, or app settings.
const env = Object.fromEntries(["PATH", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"].flatMap(key =>
process.env[key] === undefined ? [] : [[key, process.env[key]]]));
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(root)], {
cwd: home,
env: { ...env, HOME: home, USERPROFILE: home, TMPDIR: path.join(home, "tmp"), TEMP: path.join(home, "tmp"), TMP: path.join(home, "tmp"),
LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), NODE_ENV: "production", ELECTRON_RUN_AS_NODE: "1", NEXT_TELEMETRY_DISABLED: "1" },
encoding: "utf8", timeout: 30_000, maxBuffer: 128 * 1024,
});
assert.equal(result.error, undefined, `compression probe failed: ${result.error?.message}`);
assert.equal(result.status, 0, `compression probe failed:\n${result.stdout}${result.stderr}`);
const report = JSON.parse(result.stdout);
assert.equal(report.passed, true);
console.log(`[compression-runtime] passed (${report.node}): gzip Node/Web, identity, no-transform, disconnect`);
return report;
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}
async function probe(root) {
const require = createRequire(path.join(root, "package.json"));
const inside = (specifier) => {
const resolved = fs.realpathSync(require.resolve(specifier));
const relative = path.relative(fs.realpathSync(root), resolved);
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), `dependency escaped payload: ${resolved}`);
return require(resolved);
};
const zlib = require("node:zlib");
const descriptor = Object.getOwnPropertyDescriptor(zlib, "createGzip");
const records = [];
const warnings = [];
const onWarning = warning => {
if (warning.name === "MaxListenersExceededWarning" && warning.type === "drain" && warning.emitter?.constructor.name === "Gzip") warnings.push(warning.message);
};
process.on("warning", onWarning);
let active;
Object.defineProperty(zlib, "createGzip", { ...descriptor, value: (...args) => {
const stream = descriptor.value(...args);
const record = active;
record.gzip = stream;
record.registrations = 0;
record.drains = 0;
record.maxListeners = stream.getMaxListeners();
stream.on("newListener", event => { if (event === "drain") record.registrations++; });
const emit = stream.emit;
stream.emit = function(event, ...args) {
if (event === "drain") record.drains++;
return emit.call(this, event, ...args);
};
return stream;
} });
const compression = inside("next/dist/compiled/compression");
const { pipeNodeReadableToNodeResponse, pipeToNodeResponse } = inside("next/dist/server/pipe-readable");
const { releaseCompressionStream } = inside("next/dist/server/lib/release-compression-stream");
const middleware = compression();
const chunk = randomBytes(128 * 1024);
const pieces = 24;
const server = http.createServer((req, res) => {
const record = { mode: req.url, backpressure: 0, lateResumes: 0 };
active = record;
records.push(record);
middleware(req, res, () => {});
// Match router-server's abort teardown; do not compensate for drain ownership.
record.closed = new Promise(resolve => res.once("close", () => {
if (!res.writableFinished) releaseCompressionStream(res);
record.finished = res.writableFinished;
resolve();
}));
res.setHeader("Content-Type", "text/plain");
if (req.url === "/no-transform") res.setHeader("Cache-Control", "no-cache, no-transform");
const write = res.write;
res.write = function(...args) {
const ok = write.apply(this, args);
if (!ok) record.backpressure++;
return ok;
};
const source = Readable.from(Array.from({ length: pieces }, () => chunk));
record.source = source;
const resume = source.resume;
source.resume = function() {
if (this.destroyed) record.lateResumes++;
return resume.call(this);
};
record.pipe = req.url === "/web" ? pipeToNodeResponse(Readable.toWeb(source), res) : pipeNodeReadableToNodeResponse(source, res);
// Observe failures immediately, including client abort, without unhandled rejections.
record.pipe.catch(error => { record.error = error; res.destroy(); });
});
async function request(mode) {
await new Promise((resolve, reject) => {
const req = http.get({ host: "127.0.0.1", port: server.address().port, path: mode, agent: false,
headers: { "Accept-Encoding": mode === "/identity" ? "identity" : "gzip" } }, res => {
const chunks = [];
res.on("data", data => {
if (mode === "/abort") { res.destroy(); resolve(); return; }
chunks.push(data);
// A slow reader exercises both zlib and socket backpressure.
res.pause();
setTimeout(() => res.resume(), 1);
});
res.on("error", reject);
res.on("end", () => {
try {
const record = records.at(-1);
record.encoding = res.headers["content-encoding"] || "identity";
const body = Buffer.concat(chunks);
const output = record.encoding === "gzip" ? zlib.gunzipSync(body) : body;
assert.equal(output.length, pieces * chunk.length);
for (let i = 0; i < pieces; i++) assert.deepEqual(output.subarray(i * chunk.length, (i + 1) * chunk.length), chunk);
record.payloadVerified = true;
resolve();
} catch (error) { reject(error); }
});
});
req.setTimeout(5_000, () => req.destroy(new Error(`compression request stalled: ${mode}`)));
req.on("error", reject);
});
const record = records.at(-1);
await record.closed;
await record.pipe;
assert.equal(record.error, undefined);
assert.ok(record.backpressure > 0, `${mode} did not exercise backpressure`);
if (mode === "/abort") {
assert.equal(record.finished, false);
if (!record.gzip.closed) await once(record.gzip, "close");
assert.equal(record.gzip.destroyed, true);
assert.equal(record.source.destroyed, true);
} else {
assert.equal(record.finished, true);
assert.equal(record.payloadVerified, true);
}
if (mode === "/node" || mode === "/web") {
assert.equal(record.encoding, "gzip");
assert.ok(record.backpressure > 10, "must exceed the original warning threshold");
assert.ok(record.drains > 10, "must repeat real drain cycles");
assert.equal(record.registrations, 1, "drain registrations must not scale with writes");
assert.equal(record.gzip.listenerCount("drain"), 1, "upstream retains one response-lifetime listener");
}
if (mode === "/identity" || mode === "/no-transform") {
assert.equal(record.gzip, undefined);
assert.equal(record.encoding, "identity");
} else {
assert.equal(record.maxListeners, 10, "do not raise or silence the listener limit");
if (mode !== "/web") {
// The gzip listener survives off() but must ignore post-teardown drain.
record.gzip.emit("drain");
assert.equal(record.lateResumes, 0);
}
}
}
try {
server.listen(0, "127.0.0.1");
await once(server, "listening");
for (const mode of ["/node", "/web", "/identity", "/no-transform", "/abort"]) await request(mode);
await new Promise(resolve => setImmediate(resolve)); // Node delivers warnings asynchronously.
assert.deepEqual(warnings, []);
return { passed: true, node: process.version, cases: records.map(({ mode, backpressure, registrations, payloadVerified, finished }) => ({ mode, backpressure, registrations, payloadVerified, finished })) };
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
process.off("warning", onWarning);
Object.defineProperty(zlib, "createGzip", descriptor);
}
}
const args = process.argv.slice(2);
const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback;
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (args[0] === "--probe") {
probe(path.resolve(args[1])).then(report => console.log(JSON.stringify(report))).catch(error => { console.error(error); process.exitCode = 1; });
} else {
verifyCompressionRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath));
}
}
+425
View File
@@ -0,0 +1,425 @@
/** Offline synthetic UI + actual Electron preload smoke. No providers/accounts.
* Run: node scripts/smoke-file-paths.mjs
* App/profile/auth/OS-backed fixture files are temporary; only this script's
* processes are stopped. Browser requests outside the owned origin are blocked.
*/
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs";
import net from "node:net";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import puppeteer from "puppeteer-core";
const root = path.resolve(import.meta.dirname, "..");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-file-paths-"));
const require = createRequire(import.meta.url);
const checks = [];
const errors = [];
const visualFailures = [];
const coverageFailures = [];
const posts = [];
const blocked = [];
const networkFailures = [];
const consoleErrors = [];
const children = [];
const browsers = [];
const screenshots = [];
let serverLogs = "";
let electronLogs = "";
let page;
let origin;
let shuttingDown = false;
const fixtureCatalog = (() => {
const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" };
return { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null, slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} };
})();
async function freePort() {
const listener = net.createServer();
listener.listen(0, "127.0.0.1");
await once(listener, "listening");
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
async function stop(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, "exit");
child.kill("SIGTERM");
const timer = setTimeout(() => child.kill("SIGKILL"), 5000); timer.unref();
await exited; clearTimeout(timer);
}
async function startup(child, getLogs, marker, timeout = 90000) {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => finish(new Error(`Startup timed out: ${getLogs()}`)), timeout);
const inspect = () => { if (marker.test(getLogs())) finish(); };
const exited = code => finish(new Error(`Process exited ${code}: ${getLogs()}`));
function finish(error) {
clearTimeout(timer); child.stdout.off("data", inspect); child.stderr.off("data", inspect); child.off("exit", exited);
if (error) reject(error); else resolve();
}
child.stdout.on("data", inspect); child.stderr.on("data", inspect); child.once("exit", exited); inspect();
});
}
async function instrument(target) {
target.setDefaultTimeout(20000);
target.on("pageerror", error => { if (!shuttingDown) errors.push(error.message); });
target.on("requestfailed", request => { if (!shuttingDown) networkFailures.push({ url: request.url(), error: request.failure()?.errorText }); });
target.on("console", message => { if (message.type() === "error") consoleErrors.push(message.text()); });
await target.setRequestInterception(true);
target.on("request", async request => {
try {
const url = new URL(request.url());
if (url.protocol === "data:" || url.protocol === "about:") return await request.continue();
if (url.origin !== origin) { blocked.push(request.url()); return await request.abort(); }
const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
if (url.pathname === "/api/models") return await respond(fixtureCatalog);
if (url.pathname === "/api/events") return await request.respond({ status: 204 });
// Never forward agent, answer or import requests, even if a UI bug
// submits while dropping. Record their complete synthetic bodies.
if (request.method() === "POST" && (/^\/api\/(chat|answer)(\/|$)/.test(url.pathname) || /\/(answer|import|turn|retry)(\/|$)/.test(url.pathname))) {
posts.push({ pathname: url.pathname, body: JSON.parse(request.postData() || "{}") });
return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) });
}
if (/^\/api\/chats\/[^/]+\/stream$/.test(url.pathname)) return await request.respond({ status: 204 });
return await request.continue();
} catch (error) {
if (!shuttingDown && !/Target closed|Session closed|Invalid InterceptionId|already handled/.test(String(error))) errors.push(String(error));
}
});
}
const inputSelector = '[data-file-path-input]';
async function open(target, route) {
await target.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
await target.waitForSelector(inputSelector);
await target.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"], [data-testid="setup-cta-keys"]'));
await target.waitForFunction(() => document.querySelector('[data-testid="file-path-drop-zone"]'));
}
async function read(target, selector = inputSelector) {
return target.$eval(selector, element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join(""));
}
async function expectValue(target, expected, selector = inputSelector) {
await target.waitForFunction((selector, expected) => {
const element = document.querySelector(selector);
return element && [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === expected;
}, {}, selector, expected);
}
async function fill(target, value, selector = inputSelector) {
await target.$eval(selector, (element, value) => {
element.focus(); element.textContent = value;
const selection = document.getSelection(); selection.collapse(element, element.childNodes.length);
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
}, value);
await expectValue(target, value, selector);
}
async function caret(target, offset, selector = inputSelector) {
await target.$eval(selector, (element, offset) => {
element.focus(); let rest = offset;
for (const node of element.childNodes) {
const source = node.nodeType === 1 ? node.getAttribute("data-file-path-source") : null;
const length = source?.length ?? node.textContent.length;
if (rest <= length) {
if (source !== null) {
const index = [...element.childNodes].indexOf(node);
document.getSelection().collapse(element, index + (rest > 0 ? 1 : 0));
} else document.getSelection().collapse(node, rest);
document.dispatchEvent(new Event("selectionchange")); return;
}
rest -= length;
}
document.getSelection().collapse(element, element.childNodes.length);
document.dispatchEvent(new Event("selectionchange"));
}, offset);
}
async function shortcut(target, key, shift = false) {
await target.keyboard.down("Meta"); if (shift) await target.keyboard.down("Shift");
await target.keyboard.press(key);
if (shift) await target.keyboard.up("Shift"); await target.keyboard.up("Meta");
}
async function clipboardCommand(target, command, key, modifiers = 4) {
// Headless Chromium does not run the macOS menu accelerator for Meta+C/V.
// CDP's native edit command triggers the real browser clipboard event.
const session = await target.createCDPSession();
try { await session.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: key ?? (command === "copy" ? "c" : "v"), modifiers, commands: [command] }); }
finally { await session.detach(); }
}
async function syntheticDrop(target, paths, selector = '[data-testid="file-path-drop-zone"]') {
return target.$eval(selector, (zone, paths) => {
const transfer = new DataTransfer();
window.__syntheticPaths = new WeakMap();
window.lh = { platform: "darwin", getPathForFile(file) { return window.__syntheticPaths.get(file) ?? ""; } };
for (const [index, path] of paths.entries()) {
const file = new File(["never read"], `synthetic-display-${index}.pdf`, { type: "application/pdf" });
window.__syntheticPaths.set(file, path);
for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("File bytes must not be read"); } });
transfer.items.add(file);
}
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
zone.dispatchEvent(event); return event.defaultPrevented;
}, paths);
}
async function blur(target) { await target.evaluate(() => { document.activeElement?.blur(); }); }
function seedChatFixtures() {
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
const transcript = Array.from({ length: 100 }, (_, index) => `Synthetic lesson paragraph ${index}: one and one make two. These are offline fixture notes.`).join("\n\n");
const assistant = (id, content) => ({ id, role: "assistant", api: "anthropic-messages", provider: "anthropic", model: "synthetic", timestamp: 1, stopReason: "toolUse", usage, content });
const call = (id, name, args) => ({ type: "toolCall", id, name, arguments: args });
const result = (id, toolCallId, toolName, details) => ({ id, role: "toolResult", toolCallId, toolName, content: [{ type: "text", text: "Synthetic tool completed." }], ...(details ? { details } : {}), isError: false, timestamp: 2 });
const quiz = { type: "check", nodeId: "addition", question: "What is 1 + 1?", options: [{ value: "two", label: "2" }, { value: "three", label: "3" }], explanation: "Adding one to one gives two.", correctAnswer: "two" };
const records = [
{ id: "path_teach", messages: [assistant("m_probe", [{ type: "text", text: transcript }, call("call_probe", "probe_done", { assessment: { strands: [{ strand: "Addition", assessment: "Ready for a check." }] } })]), result("m_probe_result", "call_probe", "probe_done", { ok: true }), assistant("m_quiz", [call("call_quiz", "quiz", quiz)])] },
{ id: "path_probe", messages: [assistant("m_probe_question", [{ type: "text", text: transcript }, call("call_probe_question", "probe", quiz)])] },
{ id: "path_practice", kind: "practice", messages: [assistant("m_problem", [{ type: "text", text: transcript }, call("call_problem", "problem", { statement: "Compute 1 + 1. Explain your reasoning.", mode: "recall", modelSolution: "1 + 1 = 2." })]), result("m_problem_result", "call_problem", "problem")] },
];
for (const record of records) {
record.messages.unshift({ id: "sent_user", role: "user", content: "Attached `/synthetic/transcript notes.pdf`", timestamp: 0 });
fs.writeFileSync(path.join(home, "data", "chats", `${record.id}.json`), JSON.stringify({ version: 1, title: `Synthetic ${record.id}`, createdAt: "2026-10-28T00:00:00.000Z", updatedAt: "2026-10-28T00:00:00.000Z", ...record }));
}
}
async function foldDock(target, barSelector) {
// Let growth/geometry muting settle, then use a real wheel gesture so
// stick-to-bottom records user intent instead of a programmatic clamp.
// The explicit reveal owns a bounded 2s fold-suppression window.
await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 2250)));
const log = await target.$('[role="log"]'); const box = await log.boundingBox();
await target.mouse.move(box.x + box.width / 2, box.y + 100);
// Multiple real wheel frames let stick-to-bottom publish isAtBottom
// before DockOverlay evaluates the next away-from-bottom scroll event.
for (const deltaY of [-420, -420, -840]) {
await target.mouse.wheel({ deltaY });
await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 100)));
}
try { await target.waitForSelector(barSelector, { visible: true, timeout: 5000 }); return true; }
catch {
coverageFailures.push({ url: target.url(), case: barSelector, message: "Native wheel did not expose collapsed bar", scrollers: await target.evaluate(() => [...document.querySelectorAll('[role="log"], [role="log"] *')].filter(element => element.scrollHeight > element.clientHeight + 100 && getComputedStyle(element).overflowY !== "visible").map(element => ({ tag: element.tagName, className: element.className, scrollTop: element.scrollTop, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight }))) });
return false;
}
}
try {
fs.mkdirSync(path.join(home, "data", "chats"), { recursive: true });
fs.writeFileSync(path.join(home, "auth.json"), "{}\n");
seedChatFixtures();
const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js");
const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js");
const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"), `@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`]));
fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`);
const port = await freePort(); origin = `http://127.0.0.1:${port}`;
const env = { PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development", LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1", NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs") };
const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); children.push(server);
const captureServer = data => { serverLogs = (serverLogs + data).slice(-28000); };
server.stdout.on("data", captureServer); server.stderr.on("data", captureServer);
await startup(server, () => serverLogs, /Ready in/);
if (!process.argv.includes("--desktop-only")) {
const chrome = await puppeteer.launch({ executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true, userDataDir: path.join(home, "chrome-profile"), env, args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"] }); browsers.push(chrome);
page = await chrome.newPage(); await page.setViewport({ width: 1280, height: 900 }); await instrument(page);
await chrome.defaultBrowserContext().overridePermissions(origin, ["clipboard-read", "clipboard-write", "clipboard-sanitized-write"]);
const token = "`/synthetic/course notes.pdf`";
for (const route of ["/", "/chat/new"]) {
await open(page, route);
// Sidebar drops prevent browser navigation but must not stage paths or
// activate the right-content attachment indicator.
assert.equal(await page.$eval("aside", sidebar => {
const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "sidebar.pdf"));
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); sidebar.dispatchEvent(event); return event.defaultPrevented;
}), true);
assert.equal(await page.$('[data-testid="file-path-drop-indicator"]'), null);
await expectValue(page, "");
// Browser mode has no bridge: prevent file navigation with a clear notice.
const prevented = await page.$eval('[data-testid="file-path-drop-zone"]', zone => {
const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "notes.pdf"));
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); zone.dispatchEvent(event); return event.defaultPrevented;
});
assert.equal(prevented, true);
await page.waitForFunction(() => document.querySelector('[role="status"]')?.textContent.includes("desktop app"));
await fill(page, "read "); await page.keyboard.type(token + " now");
await expectValue(page, `read ${token} now`);
assert.equal(await page.$$eval('[data-file-path-input] [role="img"]', elements => elements.length), 1);
// Native browser arrows cross the atomic host without entering its label.
await caret(page, 5); await page.keyboard.press("ArrowRight");
assert.equal(await page.evaluate(() => !!document.getSelection().anchorNode?.parentElement?.closest('[role="img"]')), false);
await page.keyboard.press("Backspace"); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
await shortcut(page, "KeyZ", true); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await caret(page, 5); await page.keyboard.press("Delete"); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
// Select the actual rendered label; copy must emit the full source token.
await page.$eval('[data-file-path-input] [role="img"] span', label => {
const range = document.createRange(); range.selectNodeContents(label); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
});
await clipboardCommand(page, "copy");
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token);
await clipboardCommand(page, "cut", "Delete", 8);
await expectValue(page, "read now");
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token, "Shift+Delete is native cut, not editor-only deletion");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
// Clipboard image-only input must not erase the current selected path.
await page.$eval('[data-file-path-input]', editor => {
const host = editor.querySelector('[data-file-path-source]'); const range = document.createRange(); range.selectNode(host); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
const data = new DataTransfer(); data.items.add(new File(["image fixture"], "image.png", { type: "image/png" }));
editor.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }));
});
await expectValue(page, `read ${token} now`);
// Pasting HTML-bearing clipboard content is plain text, at the range.
await page.evaluate(() => navigator.clipboard.writeText('<img src=x onerror="alert(1)">\n中'));
await clipboardCommand(page, "paste");
await expectValue(page, 'read <img src=x onerror="alert(1)">\n中 now');
assert.equal(await page.$('[data-file-path-input] img'), null);
await fill(page, "readthis"); await caret(page, 4);
assert.equal(await syntheticDrop(page, ["/synthetic/one.pdf", "~/two`final.pdf"]), true);
await expectValue(page, "read `/synthetic/one.pdf` ``~/two`final.pdf`` this");
assert.equal(posts.length, 0, "typing/paste/drop must never submit");
await fill(page, "one visible line"); await caret(page, "one visible line".length);
const beforeNewline = await page.$eval(inputSelector, element => element.getBoundingClientRect().height);
await page.keyboard.down("Shift"); await page.keyboard.press("Enter"); await page.keyboard.up("Shift");
await expectValue(page, "one visible line\n");
const afterNewline = await page.$eval(inputSelector, element => {
const range = document.getSelection().getRangeAt(0); const rect = range.getBoundingClientRect();
return { height: element.getBoundingClientRect().height, caret: { top: rect.top, height: rect.height }, html: element.innerHTML };
});
// Record this independently so a visual editor regression does not hide
// the higher-priority actual Electron/target-routing evidence below.
try { assert.ok(afterNewline.height > beforeNewline, `Trailing newline must occupy a visible blank line: ${JSON.stringify({ beforeNewline, afterNewline })}`); }
catch (error) { visualFailures.push({ route, message: error.message }); }
await page.keyboard.type("second line");
const afterTyping = await read(page);
try { assert.equal(afterTyping, "one visible line\nsecond line", "Typing after Shift+Enter must stay after the newline"); }
catch (error) { visualFailures.push({ route, message: error.message }); }
assert.equal(posts.length, 0, "Shift+Enter is a newline, never submit");
await fill(page, "composer fallback"); await blur(page);
await syntheticDrop(page, ["/synthetic/default.pdf"]);
await expectValue(page, "composer fallback `/synthetic/default.pdf`");
// Small viewport: wrapped multiline content stays scrollable inside its editor.
await page.setViewport({ width: 900, height: 520 });
await fill(page, (`${token} a long synthetic line wrapping at narrow widths.\n`).repeat(24));
const sizing = await page.$eval(inputSelector, element => ({ height: element.clientHeight, scrollHeight: element.scrollHeight, overflowY: getComputedStyle(element).overflowY, bodyWidth: document.body.scrollWidth, viewport: innerWidth }));
assert.ok(sizing.scrollHeight > sizing.height && sizing.height > 0, JSON.stringify(sizing));
assert.ok(["auto", "scroll"].includes(sizing.overflowY));
assert.ok(sizing.bodyWidth <= sizing.viewport + 1, JSON.stringify(sizing));
const screenshot = path.join(home, `chrome-${route === "/" ? "home" : "new"}.png`); await page.screenshot({ path: screenshot }); screenshots.push({ surface: route, bytes: fs.statSync(screenshot).size, sizing });
await page.setViewport({ width: 1280, height: 900 });
checks.push(`${route}: native typing/chips/arrows/deletion/undo-redo/full-source clipboard/plaintext paste/caret drop/newlines/wrap-scroll`);
}
// Only an explicit Enter may initiate a send; agent endpoint is refused.
await fill(page, "explicit synthetic send `/synthetic/one.pdf`");
await page.keyboard.press("Enter");
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
await new Promise((resolve, reject) => {
const deadline = Date.now() + 15000;
const inspect = () => { if (posts.length) resolve(); else if (Date.now() > deadline) reject(new Error("Explicit Enter did not send")); else setTimeout(inspect, 50); }; inspect();
});
assert.equal(posts.length, 1); assert.equal(posts[0].body.input.text, "explicit synthetic send `/synthetic/one.pdf`");
checks.push("Explicit Enter submitted exactly one full-source prompt; endpoint intercepted/refused");
// These raw JSON fixtures are synthetic native records, never store imports.
const reading = '[data-testid="reading-thinking-field"] [data-file-path-input]';
const question = '[data-testid="thinking-field"] [data-file-path-input]';
const practice = '[data-testid="practice-thinking-field"] [data-file-path-input]';
await open(page, "/chat/path_teach");
await page.waitForSelector('[data-testid="teach-check-reveal"]', { visible: true });
assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.title), "/synthetic/transcript notes.pdf");
assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.textContent), "transcript notes.pdf");
checks.push("Seeded sent learner message renders a filename chip with full-path title; raw record retains quoted full path");
await blur(page); await syntheticDrop(page, ["/synthetic/reading.pdf"]);
await expectValue(page, "`/synthetic/reading.pdf`", reading);
assert.equal(await read(page, question), "");
checks.push("Unrevealed teaching check: unfocused drop lands in reading-thinking via dictation registry");
await page.locator('[data-testid="teach-check-reveal"]').click();
await page.waitForSelector(question, { visible: true });
await fill(page, "AB", reading); await caret(page, 1, reading);
await syntheticDrop(page, ["/synthetic/focused-reading.pdf"]);
await expectValue(page, "A `/synthetic/focused-reading.pdf` B", reading);
assert.equal(await read(page, question), "", "focused reading field overrides opened question default");
await blur(page); await syntheticDrop(page, ["/synthetic/question.pdf"]);
await expectValue(page, "`/synthetic/question.pdf`", question);
checks.push("Opened teaching check: current reading focus wins; otherwise default routes to question-thinking");
if (await foldDock(page, '[data-testid="question-dock-bar"]')) {
await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-question.pdf"]);
await expectValue(page, "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`", question);
await page.locator('[data-testid="question-dock-bar"]').click();
await page.waitForSelector(question, { visible: true });
assert.equal(await read(page, question), "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`");
checks.push("Collapsed revealed teaching dock keeps question-thinking claim/state through expansion");
}
await open(page, "/chat/path_probe"); await page.waitForSelector(question, { visible: true });
await blur(page); await syntheticDrop(page, ["/synthetic/probe.pdf"]);
await expectValue(page, "`/synthetic/probe.pdf`", question);
checks.push("Probe-phase pending question default routes to question-thinking");
await open(page, "/chat/path_practice"); await page.waitForSelector(practice, { visible: true });
await blur(page); await syntheticDrop(page, ["/synthetic/practice.pdf"]);
await expectValue(page, "`/synthetic/practice.pdf`", practice);
checks.push("Expanded practice thinking is the unfocused drop default");
if (await foldDock(page, '[data-testid="practice-dock-bar"]')) {
await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-practice.pdf"]);
// Practice keeps the owning hook/state mounted, but hides its field
// by returning the bar. Inspect the controlled text after expansion.
assert.equal(await page.$('[role="status"]'), null, "successful attachment has no confirmation bubble");
await page.locator('[data-testid="practice-dock-bar"]').click();
await page.waitForSelector(practice, { visible: true });
await expectValue(page, "`/synthetic/practice.pdf` `/synthetic/collapsed-practice.pdf`", practice);
checks.push("Collapsed practice thinking keeps its claim/state through expansion without submission");
}
assert.equal(posts.length, 1, "all claim/drop cases stage text only");
}
// Actual Electron main + actual sandboxed preload. Temporary launcher sets
// userData BEFORE production main appends its -dev suffix or takes a lock.
const debugPort = await freePort();
const launcher = path.join(home, "electron-launcher.cjs");
fs.mkdirSync(path.join(home, "desktop-profile"), { recursive: true });
fs.mkdirSync(path.join(home, "desktop-profile-dev"), { recursive: true });
fs.writeFileSync(launcher, `const {app,session}=require('electron');\napp.setPath('home',${JSON.stringify(home)});\napp.setPath('userData',${JSON.stringify(path.join(home, "desktop-profile"))});\napp.commandLine.appendSwitch('remote-debugging-port',${JSON.stringify(String(debugPort))});\napp.commandLine.appendSwitch('disable-background-networking');\napp.commandLine.appendSwitch('disable-sync');\napp.whenReady().then(()=>session.defaultSession.webRequest.onBeforeRequest((details,cb)=>{let u;try{u=new URL(details.url)}catch{return cb({cancel:true})}cb({cancel:!(${JSON.stringify(origin)}===u.origin||(u.protocol==='ws:'&&u.host===${JSON.stringify(new URL(origin).host)})||['about:','data:','devtools:'].includes(u.protocol))});}));\nrequire(${JSON.stringify(path.join(root, "electron", "main.cjs"))});\nconsole.log('LH_FILE_PATH_PROFILE '+JSON.stringify({home:app.getPath('home'),userData:app.getPath('userData')}));\n`);
const electron = spawn(require("electron"), [launcher], { cwd: root, env: { ...env, LH_DEV_URL: origin }, stdio: ["ignore", "pipe", "pipe"] }); children.push(electron);
const captureElectron = data => { electronLogs = (electronLogs + data).slice(-24000); }; electron.stdout.on("data", captureElectron); electron.stderr.on("data", captureElectron);
await startup(electron, () => electronLogs, /DevTools listening on (ws:\/\/[^\s]+)/, 45000);
const profile = JSON.parse(electronLogs.match(/LH_FILE_PATH_PROFILE ([^\r\n]+)/)[1]);
assert.equal(profile.home, home); assert.equal(profile.userData, path.join(home, "desktop-profile-dev"));
const desktopLaunch = { executable: require("electron"), args: [launcher], LH_DEV_URL: origin, HOME: home, userDataBeforeMain: path.join(home, "desktop-profile"), actualProfile: profile, cleanup: "all temporary profiles/data/fixtures removed on exit" };
const endpoint = electronLogs.match(/DevTools listening on (ws:\/\/[^\s]+)/)[1];
const desktop = await puppeteer.connect({ browserWSEndpoint: endpoint }); browsers.push(desktop);
const desktopPage = await desktop.waitForTarget(target => target.type() === "page").then(target => target.page());
await instrument(desktopPage); page = desktopPage;
await open(desktopPage, "/chat/new");
assert.equal(await desktopPage.evaluate(() => typeof window.lh?.getPathForFile), "function");
const fixturePath = path.join(home, "actual OS-backed α `notes`.pdf"); fs.writeFileSync(fixturePath, "%PDF-1.7\nsynthetic offline fixture\n");
await desktopPage.evaluate(() => { const input = document.createElement("input"); input.type = "file"; input.id = "os-backed-fixture"; document.body.append(input); });
await (await desktopPage.$("#os-backed-fixture")).uploadFile(fixturePath);
const resolved = await desktopPage.evaluate(() => {
const input = document.querySelector("#os-backed-fixture");
return { count: input.files.length, actual: window.lh.getPathForFile(input.files[0]), virtual: window.lh.getPathForFile(new File(["virtual"], "/not/a/backing/path.pdf")) };
});
assert.equal(resolved.count, 1); assert.equal(resolved.actual, fixturePath); assert.equal(resolved.virtual, "");
await fill(desktopPage, "readthis"); await caret(desktopPage, 4);
const beforeDesktopDrop = posts.length;
assert.equal(await desktopPage.evaluate(() => {
const file = document.querySelector("#os-backed-fixture").files[0];
for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("No file content read"); } });
const transfer = new DataTransfer(); transfer.items.add(file);
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
document.querySelector('[data-testid="file-path-drop-zone"]').dispatchEvent(event); return event.defaultPrevented;
}), true);
const expectedQuoted = "``" + fixturePath + "``";
await expectValue(desktopPage, `read ${expectedQuoted} this`);
assert.equal(await desktopPage.$eval('[data-file-path-input] [role="img"]', chip => chip.title), fixturePath);
assert.equal(posts.length, beforeDesktopDrop, "actual OS-backed desktop drop must stage only");
assert.equal(await desktopPage.$('[role="status"]'), null, "successful desktop attachment has no confirmation bubble");
assert.ok(fs.existsSync(fixturePath));
assert.deepEqual(fs.readdirSync(path.join(home, "data")).filter(name => /upload|attachment/i.test(name)), []);
checks.push("Actual Electron production preload: Puppeteer-selected OS-backed File resolves exact Unicode/backtick full path; virtual File returns empty; real DataTransfer drop inserts at caret without submit or file reads");
assert.deepEqual(errors, []);
console.log(JSON.stringify({ checks, visualFailures, coverageFailures, screenshots, desktopLaunch, postsIntercepted: posts.length, blockedExternalRequests: blocked, isolation: "temporary HOME/data/auth/browser+Electron profiles; synthetic catalog; agent POSTs refused; no providers", origin }, null, 2));
assert.deepEqual(visualFailures, [], "Visible trailing-newline/caret projection must pass");
assert.deepEqual(coverageFailures, [], "Required collapsed target coverage must run");
} catch (error) {
console.error(error);
console.error(JSON.stringify({ checks, visualFailures, coverageFailures, errors, consoleErrors, networkFailures: networkFailures.slice(-30), posts, origin, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(element => ({ placeholder: element.getAttribute("data-placeholder"), text: element.textContent, html: element.innerHTML.slice(0, 1200) })), text: document.body.innerText.slice(-2600) })).catch(() => null), serverLogs, electronLogs }, null, 2));
process.exitCode = 1;
} finally {
shuttingDown = true;
for (const browser of browsers.reverse()) { try { await browser.close(); } catch {} }
for (const child of children.reverse()) await stop(child);
fs.rmSync(home, { recursive: true, force: true });
}
+228
View File
@@ -0,0 +1,228 @@
/**
* OFFLINE image runtime gate: exercise the shipped Photon/WASM resize core and
* actual worker, with file modules confined to the standalone payload. No app,
* provider, canvas, or network needed; a tiny synthetic PNG is generated here.
*
* node scripts/smoke-image-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import { deflateSync } from "node:zlib";
import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs";
export function verifyImageRuntime(standalone, runtime = process.execPath) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-image-runtime-"));
try {
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone)], {
cwd: home,
env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" },
encoding: "utf8", timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 128 * 1024,
});
if (result.status !== 0 || result.error) {
throw new Error(`image runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
}
assert.match(result.stdout, /Photon in-process and worker resizing passed/, "probe exited without success marker");
process.stdout.write(result.stdout);
} finally { fs.rmSync(home, { recursive: true, force: true }); }
}
// Self-contained so the eval worker can install the SAME guards using only
// builtins, before importing any payload module. Module hooks do not otherwise
// propagate from the dedicated probe process into Worker threads.
function installGuards(require, standalone) {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { fileURLToPath } = require("node:url");
const { registerHooks, syncBuiltinESMExports } = require("node:module");
const root = fs.realpathSync(standalone);
const inside = (file) => {
const real = fs.realpathSync(file);
const relative = path.relative(root, real);
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
`dependency escaped standalone payload: ${file}`);
return real;
};
const photonRoot = path.join(root, "node_modules/@silvia-odwyer/photon-node");
const manifest = JSON.parse(fs.readFileSync(inside(path.join(photonRoot, "package.json")), "utf8"));
assert.equal(manifest.version, "0.3.4", "unexpected Photon engine version");
const photonEntry = inside(path.join(photonRoot, "photon_rs.js"));
const photonWasm = inside(path.join(photonRoot, "photon_rs_bg.wasm"));
assert.ok(fs.statSync(photonWasm).size > 0, "empty Photon WASM asset");
let attempts = 0;
const deny = () => { attempts++; throw new Error("OFFLINE image gate: network forbidden"); };
globalThis.fetch = deny;
globalThis.WebSocket = class { constructor() { deny(); } };
for (const [name, methods] of [
["http", ["request", "get"]], ["https", ["request", "get"]],
["net", ["connect", "createConnection"]], ["tls", ["connect"]],
["dgram", ["createSocket"]],
["dns", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
["dns/promises", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
]) {
const module = require(`node:${name}`);
for (const method of methods) module[method] = deny;
}
require("node:net").Socket.prototype.connect = deny;
const loaded = new Set();
const wasmReads = new Set();
const originalRead = fs.readFileSync;
fs.readFileSync = function (file, ...args) {
const filename = file instanceof URL ? fileURLToPath(file) : file;
const wasmFile = typeof filename === "string" && filename.endsWith(".wasm") ? inside(filename) : null;
const bytes = originalRead.call(this, file, ...args);
if (wasmFile) wasmReads.add(wasmFile);
return bytes;
};
syncBuiltinESMExports();
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
if (result.url.startsWith("file:")) loaded.add(inside(fileURLToPath(result.url)));
else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
return result;
},
});
return {
report() {
assert.equal(attempts, 0, "image runtime attempted network access");
assert.ok(loaded.has(photonEntry), "Photon JS was not loaded from the payload");
assert.ok(wasmReads.has(photonWasm), "Photon WASM was not loaded from the payload");
return { modules: loaded.size, wasmFiles: [...wasmReads], networkAttempts: attempts };
},
cleanup() {
hooks.deregister();
fs.readFileSync = originalRead;
syncBuiltinESMExports();
},
};
}
/** Pure Node 2×2 RGBA PNG, with valid PNG CRCs and zlib image data. */
function fixturePng() {
const chunk = (type, data) => {
const buffer = Buffer.alloc(data.length + 12);
buffer.writeUInt32BE(data.length);
buffer.write(type, 4, "ascii");
data.copy(buffer, 8);
let crc = ~0;
for (const byte of buffer.subarray(4, -4)) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
buffer.writeUInt32BE(~crc >>> 0, buffer.length - 4);
return buffer;
};
const header = Buffer.alloc(13);
header.writeUInt32BE(2, 0);
header.writeUInt32BE(2, 4);
header[8] = 8; // bit depth
header[9] = 6; // RGBA
const pixels = Buffer.from([
0, 255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 0, 255, 255, 255, 255, 255, 255,
]);
return Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), chunk("IHDR", header), chunk("IDAT", deflateSync(pixels)), chunk("IEND", Buffer.alloc(0))]);
}
function assertResized(result) {
assert.ok(result, "resize returned null (missing/broken Photon must not silently pass)");
assert.equal(result.originalWidth, 2);
assert.equal(result.originalHeight, 2);
assert.equal(result.width, 1);
assert.equal(result.height, 1);
assert.equal(result.wasResized, true);
assert.equal(result.mimeType, "image/png");
const png = Buffer.from(result.data, "base64");
assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
assert.equal(png.readUInt32BE(16), 1);
assert.equal(png.readUInt32BE(20), 1);
}
async function probeWorker(root, inputBytes, options, responseType) {
const readyType = "lh:image-runtime-ready";
const worker = new Worker(`
const { parentPort, workerData } = require("node:worker_threads");
const guards = (${installGuards.toString()})(require, workerData.root);
const post = parentPort.postMessage.bind(parentPort);
parentPort.postMessage = (message, ...args) => {
if (message?.type === workerData.responseType) {
post({ ...message, runtimeProof: guards.report() }, ...args);
} else post(message, ...args);
};
void import(workerData.entry).then(() => post({ type: workerData.readyType }));
`, {
eval: true,
workerData: { root, responseType, readyType, entry: pathToFileURL(path.join(root, "src/agent/image-processing/resize-worker.mjs")).href },
});
try {
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("image resize worker timed out")), 10_000);
const fail = (error) => { clearTimeout(timer); reject(error); };
let ready = false;
worker.once("error", fail);
worker.once("exit", (code) => fail(new Error(`image resize worker exited before response: ${code}`)));
worker.on("message", (message) => {
try {
if (message?.type === readyType) {
assert.equal(ready, false, "duplicate worker READY");
ready = true;
worker.postMessage({ inputBytes, mimeType: "image/png", options });
} else if (message?.type === responseType) {
assert.equal(ready, true, "worker replied before its import completed");
assert.equal(message.error, undefined, `worker resize failed: ${message.error}`);
assertResized(message.result);
assert.ok(message.runtimeProof?.modules > 0, "missing worker module-confinement proof");
assert.equal(message.runtimeProof.networkAttempts, 0);
assert.equal(message.runtimeProof.wasmFiles.length, 1);
clearTimeout(timer);
resolve(message.runtimeProof);
}
} catch (error) { fail(error); }
});
});
} finally { await worker.terminate(); }
}
async function probe(standalone) {
const root = fs.realpathSync(standalone);
const guards = installGuards(createRequire(import.meta.url), root);
try {
const entry = pathToFileURL(path.join(root, "src/agent/image-processing/resize-core.mjs"));
const { resizeImageInProcess, IMAGE_RESIZE_WORKER_RESPONSE_TYPE } = await import(entry.href);
assert.equal(typeof resizeImageInProcess, "function");
assert.equal(IMAGE_RESIZE_WORKER_RESPONSE_TYPE, "pi:image-resize-response");
const input = fixturePng();
const options = { maxWidth: 1, maxHeight: 1, maxBytes: 1024 * 1024 };
assertResized(await resizeImageInProcess(input, "image/png", options));
const inProcess = guards.report();
console.log(`[smoke-image-runtime] shipped resize core: PNG 2×2 → 1×1; ${inProcess.modules} modules and Photon WASM confined to ${root}`);
const worker = await probeWorker(root, input, options, IMAGE_RESIZE_WORKER_RESPONSE_TYPE);
guards.report();
console.log(`[smoke-image-runtime] shipped resize worker: READY then tagged PNG 2×2 → 1×1; ${worker.modules} modules and Photon WASM confined to ${root}`);
console.log("[smoke-image-runtime] Photon in-process and worker resizing passed; zero network attempts");
} finally { guards.cleanup(); }
}
async function main() {
const args = process.argv.slice(2);
if (args[0] === "--probe") return probe(args[1]);
const value = (name, fallback) => {
const index = args.indexOf(name);
if (index < 0) return fallback;
assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`);
return path.resolve(args[index + 1]);
};
verifyImageRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => { console.error(`[smoke-image-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; });
}
+254
View File
@@ -0,0 +1,254 @@
/** Focused synthetic live-UI check. No model/provider calls: /api/chat is
* intercepted, catalog is a fixture, app HOME/data/auth are throwaway, and
* the browser uses a fresh profile. Creates only synthetic session records.
* Run: node scripts/smoke-opening-prompt.mjs
*/
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs";
import net from "node:net";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import puppeteer from "puppeteer-core";
const root = path.resolve(import.meta.dirname, "..");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh154-ui-"));
fs.mkdirSync(path.join(home, "data"));
fs.writeFileSync(path.join(home, "auth.json"), "{}\n");
// Next's supported font fixture avoids external font downloads in this check.
const require = createRequire(import.meta.url);
const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js");
const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js");
const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [
getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"),
`@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`,
]));
fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`);
const listener = net.createServer();
listener.listen(0, "127.0.0.1");
await once(listener, "listening");
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const env = {
PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development",
LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"),
LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1",
NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs"),
};
const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], {
cwd: root, env, stdio: ["ignore", "pipe", "pipe"],
});
let logs = "";
const capture = data => { logs = (logs + data).slice(-24000); };
server.stdout.on("data", capture); server.stderr.on("data", capture);
let browser;
let page;
const errors = [];
const checks = [];
try {
await new Promise((resolve, reject) => {
const deadline = setTimeout(() => reject(new Error("app startup timed out")), 60000);
const ready = () => { if (logs.includes("Ready in")) { clearTimeout(deadline); server.stdout.off("data", ready); resolve(); } };
server.stdout.on("data", ready);
server.once("exit", code => { clearTimeout(deadline); reject(new Error(`app exited ${code}`)); });
ready();
});
browser = await puppeteer.launch({
executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true, userDataDir: path.join(home, "browser"), env,
args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"],
});
page = await browser.newPage();
page.setDefaultTimeout(45000);
page.on("pageerror", err => errors.push(err.message));
const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" };
const catalog = { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null,
slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} };
const posts = [];
const creates = [];
const accepted = new Map();
let mode = "accepted";
let held = [];
await page.setRequestInterception(true);
page.on("request", async request => {
try {
const url = new URL(request.url());
if (url.origin !== origin) { await request.abort(); return; }
const pathname = url.pathname;
const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
if (pathname === "/api/models") return await respond(catalog);
// Notifications are outside this handover check. 204 tells native
// EventSource not to reconnect or hold a test connection open.
if (pathname === "/api/events") return await request.respond({ status: 204 });
if (pathname === "/api/chat") {
const body = JSON.parse(request.postData()); posts.push(body);
if (mode === "held-post") { held.push(request); return; }
if (mode === "lost-ack") return await request.abort("failed");
if (mode === "refused") return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) });
accepted.set(body.chatId, [{ id: "saved_user", role: "user", content: body.input.text, timestamp: Date.now() }]);
return await respond({ ok: true });
}
if (/^\/api\/chats\/[^/]+\/stream$/.test(pathname)) {
// Keep teacher delivery unfinished: cleanup must NOT wait for it.
held.push(request); return;
}
const match = pathname.match(/^\/api\/chats\/([^/]+)$/);
if (match && request.method() === "GET" && accepted.has(match[1])) {
return await respond({ messages: accepted.get(match[1]), turns: [], turnInFlight: false });
}
if (pathname === "/api/chats" && request.method() === "POST") creates.push(JSON.parse(request.postData()));
await request.continue();
} catch (err) { if (!/Target closed|Session closed|Invalid InterceptionId/.test(String(err))) errors.push(String(err)); }
});
async function open(route) {
// CDP-intercepted requests do not settle themselves on navigation.
// Release synthetic streams from prior cases rather than pin slots.
await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {})));
await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
await page.waitForSelector(route === "/" ? "[data-file-path-input]" : '[data-testid="composer"] [data-file-path-input]');
await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]'));
}
async function fill(text) {
await page.evaluate(value => {
const input = document.querySelector('[data-testid="composer"] [data-file-path-input]') || document.querySelector("[data-file-path-input]");
input.textContent = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}, text);
}
async function submit() {
await page.waitForFunction(() => {
const button = document.querySelector('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]');
return button && !button.disabled;
});
await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click();
}
async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); }
async function assertDraft(text) { await page.waitForFunction(value => {
const input = document.querySelector('[data-testid="composer"] [data-file-path-input]');
return input && [...input.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === value;
}, {}, text); }
const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim();
for (const route of ["/", "/chat/new"]) {
for (const text of ["a short synthetic topic", long]) {
await open(route);
const count = posts.length;
await fill(text); await submit();
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname) && !location.search);
await page.waitForFunction(() => document.querySelector('[data-testid="composer"]'));
await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt.")));
assert.equal(posts.length, count + 1);
assert.equal(posts.at(-1).input.text, text);
assert.equal(new URL(page.url()).search, "");
assert.equal(await stash(posts.at(-1).chatId), null);
checks.push(`${route}: ${text.length > 1000 ? "long" : "short"} input delivered intact through short URL; stash removed while stream held`);
}
}
// The old long-query mechanism really is rejected by this isolated Node server.
const oldResponse = await fetch(`${origin}${new URL(page.url()).pathname}?prompt=${encodeURIComponent(long)}`);
assert.equal(oldResponse.status, 431); await oldResponse.text();
checks.push("old long-query request returned431 (browser-shell blank appearance not asserted)");
for (const route of ["/", "/chat/new"]) {
await open(route);
await page.evaluate(() => {
const setItem = Storage.prototype.setItem;
Storage.prototype.setItem = function(key, value) {
if (window.failOpeningStorage && key.startsWith("lh.openingPrompt.")) throw new DOMException("synthetic quota", "QuotaExceededError");
return setItem.call(this, key, value);
};
window.failOpeningStorage = true;
});
const createCount = creates.length; const sendCount = posts.length;
await fill(long); await submit();
await page.waitForSelector('[role="alert"]');
assert.equal(new URL(page.url()).pathname, route);
assert.equal(await page.$eval("[data-file-path-input]", element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("")), long);
assert.equal(posts.length, sendCount);
assert.equal(creates.length, createCount + 1);
await page.evaluate(() => { window.failOpeningStorage = false; });
await submit();
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt.")));
assert.equal(creates.length, createCount + 1, "unchanged retry reuses creation");
assert.equal(posts.at(-1).input.text, long);
checks.push(`${route}: quota failure preserved visible draft/no navigation; retry reused creation`);
}
for (const failure of ["lost-ack", "refused", "held-post"]) {
mode = failure;
await open("/chat/new");
const text = `recover interrupted ${failure}`;
await fill(text); await submit();
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
await page.waitForFunction(() => Object.values(sessionStorage).some(v => v.includes('"attempted":true')));
if (failure !== "held-post") await assertDraft(text);
const count = posts.length;
await page.reload({ waitUntil: "domcontentloaded" });
await assertDraft(text);
assert.equal(posts.length, count, "interrupted start must not automatically repost");
checks.push(`${failure}: reload restored full draft, no automatic repost`);
if (failure === "lost-ack") {
mode = "accepted";
await submit();
const id = new URL(page.url()).pathname.split("/").at(-1);
await page.waitForFunction(chat => !sessionStorage.getItem(`lh.openingPrompt.${chat}`), {}, id);
assert.equal(posts.length, count + 1);
assert.equal(posts.at(-1).input.text, text);
checks.push("restored draft sent only after explicit click and cleaned up on acceptance");
}
}
mode = "accepted";
// Marker-only session must still automatically deliver a fresh opening.
await open("/chat/new");
const markerId = await page.evaluate(async () => {
const res = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "marker fixture", graphContext: { propositions: [{ id: "synthetic_node", name: "Fixture", statement: "Synthetic" }] } }) });
return (await res.json()).id;
});
await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "marker opening", attempted: false })), markerId);
const markerCount = posts.length;
await page.goto(`${origin}/chat/${markerId}`, { waitUntil: "domcontentloaded" });
await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId);
assert.equal(posts.length, markerCount + 1);
assert.equal(posts.at(-1).input.text, "marker opening");
checks.push("graph-marker-only record still receives opening text");
// A real persisted user prevents delivery and cleans a stale copy on mount.
const chatFile = path.join(home, "data", "chats", `${markerId}.json`);
const record = JSON.parse(fs.readFileSync(chatFile, "utf8"));
record.messages.push({ id: "confirmed_fixture", role: "user", content: "already received", timestamp: Date.now() });
fs.writeFileSync(chatFile, JSON.stringify(record));
await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "stale opening", attempted: true })), markerId);
const completedCount = posts.length;
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId);
assert.equal(posts.length, completedCount);
checks.push("persisted real conversation removed stale stash without reposting");
await open("/chat/new");
const legacyId = await page.evaluate(async () => {
const response = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "legacy fixture" }) });
return (await response.json()).id;
});
const legacyCount = posts.length;
await page.goto(`${origin}/chat/${legacyId}?prompt=${encodeURIComponent("legacy opening")}`, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.body.innerText.includes("legacy opening"));
await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, legacyId);
assert.equal(posts.length, legacyCount + 1);
assert.equal(posts.at(-1).input.text, "legacy opening");
checks.push("legacy short ?prompt link still sends its complete opening message");
assert.deepEqual(errors, []);
console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2));
} catch (err) {
console.error(err);
console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(e => ({ length: [...e.childNodes].map(n => n.nodeType === 1 ? n.getAttribute('data-file-path-source') ?? n.textContent : n.textContent).join('').length, placeholder: e.getAttribute('data-placeholder') })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2));
console.error(logs); process.exitCode = 1;
} finally {
await browser?.close();
const exited = once(server, "exit");
server.kill("SIGTERM");
const killTimer = setTimeout(() => server.kill("SIGKILL"), 5000); killTimer.unref();
await exited; clearTimeout(killTimer);
fs.rmSync(home, { recursive: true, force: true });
}
+21 -8
View File
@@ -1,7 +1,7 @@
/**
* OFFLINE native Pi Google initialization gate (no generation or provider calls).
* Unlike /api/models, this traverses Pi's lazy adapter and constructs GoogleGenAI.
* The real onPayload hook throws a unique sentinel BEFORE generateContentStream.
* OFFLINE native Pi Google/Bedrock initialization gate (no generation or provider calls).
* Unlike /api/models, this traverses Pi's lazy adapters and constructs real SDK clients.
* The real onPayload hook throws a unique sentinel BEFORE SDK request dispatch.
* All file modules must resolve inside the payload, including nested dependencies.
*
* node scripts/smoke-provider-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
@@ -80,18 +80,31 @@ async function probe(standalone) {
// factories, never the checkout's module or a direct/fake Google adapter.
const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js"));
const { createModels } = await import(entry.href);
// Keep every native chat implementation evaluable in the standalone
// payload even though the custom-provider UI intentionally exposes one API.
for (const api of ["anthropic-messages", "openai-completions", "openai-responses", "openai-codex-responses", "azure-openai-responses", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "mistral-conversations", "pi-messages"]) {
const implementation = await import(new URL(`api/${api}.js`, entry).href);
assert.equal(typeof implementation.streamSimple, "function", `${api}: native implementation missing`);
}
noNetwork();
console.log("[smoke-provider-runtime] all 10 native chat implementations evaluated offline");
const models = createModels();
for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) {
for (const [name, factory, api, label] of [
["google", "googleProvider", "google-generative-ai", "Google"],
["opencode", "opencodeProvider", "google-generative-ai", "Google"],
["opencode-go", "opencodeGoProvider", "google-generative-ai", "Google"],
["amazon-bedrock", "amazonBedrockProvider", "bedrock-converse-stream", "Bedrock"],
]) {
const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory]();
models.setProvider(provider);
const model = provider.getModels().find((candidate) => candidate.api === "google-generative-ai");
assert.ok(model, `${name}: missing Google-backed catalog model`);
const model = provider.getModels().find((candidate) => candidate.api === api);
assert.ok(model, `${name}: missing ${api} catalog model`);
const sentinel = `LH_OFFLINE_ON_PAYLOAD_${randomUUID()}`;
let reached = 0;
const result = await models.streamSimple(model, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, {
apiKey: "dummy-offline-provider-gate-not-a-key",
onPayload(payload, selected) {
assert.equal(selected.api, "google-generative-ai");
assert.equal(selected.api, api);
assert.ok(payload && typeof payload === "object");
reached++;
throw new Error(sentinel);
@@ -101,7 +114,7 @@ async function probe(standalone) {
assert.equal(result.stopReason, "error");
assert.equal(result.errorMessage, sentinel, `${name}: unexpected initialization error`);
noNetwork();
console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`);
console.log(`[smoke-provider-runtime] ${name}: native lazy ${label} adapter reached onPayload; generation stopped`);
}
// Exercise the public lazy OAuth surface offline. toAuth evaluates the
// real flow module and its imports but neither logs in nor refreshes.
+3
View File
@@ -27,6 +27,7 @@
import { spawn } from "node:child_process";
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs";
import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs";
import { createRequire } from "node:module";
import * as fs from "node:fs";
import * as http from "node:http";
@@ -83,6 +84,8 @@ async function main() {
verifyDemoRuntime(STANDALONE, RUNTIME);
// /api/models only reads catalogs; initialize the real lazy SDK offline too.
verifyProviderRuntime(STANDALONE, RUNTIME);
// A plain GET without Accept-Encoding never exercises gzip backpressure.
verifyCompressionRuntime(STANDALONE, RUNTIME);
const port = await freePort();
const url = `http://127.0.0.1:${port}`;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
+125
View File
@@ -0,0 +1,125 @@
/** Offline Next runtime gate (run after next build, with no dev server running).
* Proves cold-start registration, cross-route owner/events and dev reloads using
* synthetic credentials in a fresh profile. No OAuth, provider traffic or real
* auth/data. The dev reload changes only a source mtime, restoring it afterward.
* This is a test, not an alternative app launcher. */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
import { spawn } from "node:child_process";
import { createServer } from "node:net";
const ROOT = path.resolve(import.meta.dirname, "..");
const require = createRequire(import.meta.url);
const { SCOPES } = require("../electron/chatgpt.cjs");
const sdk = (file) => import(pathToFileURL(path.join(ROOT, "node_modules/@earendil-works/pi-ai/dist", file)));
const { createModels } = await sdk("index.js");
const { builtinProviders } = await sdk("providers/all.js");
const { githubCopilotProvider } = await sdk("providers/github-copilot.js");
const models = createModels();
for (const provider of builtinProviders()) models.setProvider(provider);
const model = models.getModels("openai").find((entry) => entry.api === "openai-responses");
const copilot = githubCopilotProvider().getModels()[0];
assert(model && copilot, "Installed SDK catalogs required.");
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-runtime-"));
await fs.chmod(profile, 0o700);
for (const name of ["home", "config", "cache", "xdg-data", "data", "pi", "claude", "codex", "tmp"]) await fs.mkdir(path.join(profile, name), { mode: 0o700 });
await fs.writeFile(path.join(profile, "auth.json"), "{}\n", { mode: 0o600 });
const preload = path.join(profile, "offline-fetch.mjs");
await fs.writeFile(preload, `const original = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
if (url.href === 'https://api.openai.com/v1/models') return Response.json({ models: [{ slug: ${JSON.stringify(model.id)}, display_name: 'Offline fixture ChatGPT', visibility: 'list' }] });
if (['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) return original(input, init);
throw new Error('Offline subscription gate forbids external network: ' + url.origin + url.pathname);
};\n`, { mode: 0o600 });
// Intentionally an allowlist, not a copy of the calling shell's environment.
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: path.join(profile, "home"),
XDG_CONFIG_HOME: path.join(profile, "config"), XDG_CACHE_HOME: path.join(profile, "cache"), XDG_DATA_HOME: path.join(profile, "xdg-data"),
PI_CODING_AGENT_DIR: path.join(profile, "pi"), PI_AGENT_DIR: path.join(profile, "pi"), CLAUDE_CONFIG_DIR: path.join(profile, "claude"), CODEX_HOME: path.join(profile, "codex"),
TMPDIR: path.join(profile, "tmp"), LH_AUTH_FILE: path.join(profile, "auth.json"), LH_DATA_DIR: path.join(profile, "data"),
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, NEXT_TELEMETRY_DISABLED: "1",
};
const saved = {
version: 1, hostId: "offline-host", allowBackgroundUse: true, activeId: "offline-account",
registrations: [{ id: "offline-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "offline-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }],
};
function timeout(promise, milliseconds, message) {
let timer;
return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]).finally(() => clearTimeout(timer));
}
async function freePort() {
const socket = createServer();
await new Promise((resolve, reject) => { socket.once("error", reject); socket.listen(0, "127.0.0.1", resolve); });
const { port } = socket.address(); await new Promise((resolve) => socket.close(resolve)); return port;
}
async function run(mode) {
await fs.writeFile(path.join(profile, "chatgpt-credentials.json"), JSON.stringify(saved), { mode: 0o600 });
for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(profile, `${id}-credentials.json`), JSON.stringify({
version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001",
credential: { type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) },
}), { mode: 0o600 });
const port = await freePort(), origin = `http://localhost:${port}`;
let logs = "", exited = false;
const child = spawn(process.execPath, [path.join(ROOT, "node_modules/next/dist/bin/next"), mode, "-p", String(port)], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
const exit = new Promise((resolve) => child.once("exit", () => { exited = true; resolve(); }));
try {
await timeout(new Promise((resolve, reject) => {
const receive = (data) => { logs = (logs + data).slice(-16000); if (logs.includes("Ready in")) resolve(); };
child.stdout.on("data", receive); child.stderr.on("data", receive);
child.once("error", reject); child.once("exit", (code) => reject(new Error(`Next ${mode} exited ${code}.`)));
}), 60000, `Next ${mode} readiness timeout.`);
const request = (url, command) => fetch(origin + url, {
...(command ? { method: "POST", body: JSON.stringify(command) } : {}),
headers: { origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" },
signal: AbortSignal.timeout(60000),
});
// Controls have not been imported through an HTTP request yet.
const catalog = (await (await request("/api/models")).json()).providers;
for (const id of ["chatgpt", "github-copilot-subscription", "xai-subscription"]) assert(catalog.find((provider) => provider.id === id)?.keyPresent, `${mode}: cold ${id} unavailable.`);
let state = await (await request("/api/subscriptions")).json();
assert.equal(state.chatgpt.accounts[0].email, "fixture@example.invalid"); assert.equal(state.native.length, 2);
for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!JSON.stringify(state).includes(secret));
const stream = await fetch(origin + "/api/events", { signal: AbortSignal.timeout(60000) });
const reader = stream.body.getReader();
try {
const first = await reader.read(); assert(first.value);
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "consent", enabled: false })).status, 200);
let events = new TextDecoder().decode(first.value);
while (!events.includes("subscriptions-changed")) {
const frame = await timeout(reader.read(), 5000, "Cross-route subscription SSE timeout.");
assert(!frame.done, "Subscription event stream ended early."); events += new TextDecoder().decode(frame.value);
}
} finally { await reader.cancel(); }
state = await (await request("/api/subscriptions")).json(); assert.equal(state.chatgpt.allowBackgroundUse, false);
const login = await (await request("/api/subscriptions", { providerId: "chatgpt", op: "login" })).json();
assert(login.browserAction); assert(!JSON.stringify(login).includes("auth.openai.com"));
if (mode === "dev") {
const file = path.join(ROOT, "src/server/web-subscriptions.ts"), times = await fs.stat(file);
await fs.utimes(file, new Date(), new Date());
try { assert.equal((await request("/api/models")).status, 200); assert.equal((await request("/api/subscriptions")).status, 200); }
finally { await fs.utimes(file, times.atime, times.mtime); }
}
const navigate = () => fetch(origin + login.browserAction, { redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, signal: AbortSignal.timeout(30000) });
const browser = await navigate(); assert.equal(browser.status, 303, `${mode}: owner/action lost across routes or reload.`);
const location = new URL(browser.headers.get("location")); assert.equal(location.origin, "https://auth.openai.com"); assert.equal(new URL(location.searchParams.get("redirect_uri")).hostname, "127.0.0.1");
assert.equal((await navigate()).status, 410);
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "cancel" })).status, 200);
assert.equal((await fetch(origin + "/api/subscriptions", { method: "POST", headers: { origin: "https://unrelated.invalid", "content-type": "application/json", "x-learnharness-subscriptions": "1" }, body: JSON.stringify({ providerId: "chatgpt", op: "disconnect", id: "offline-account" }) })).status, 403);
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "disconnect", id: "offline-account" })).status, 200);
assert.equal((await (await request("/api/models")).json()).providers.find((provider) => provider.id === "chatgpt").keyPresent, false);
console.log(`[smoke-web-subscriptions] ${mode}: cold catalogs, safe status, cross-route SSE/consent/disconnect, opaque redirect/replay, CSRF${mode === "dev" ? ", owner retained across source reload" : ""} passed`);
} catch (error) { console.error(logs); throw error; }
finally {
if (!exited) child.kill("SIGTERM");
try { await timeout(exit, 5000, "Next shutdown timeout."); }
catch { child.kill("SIGKILL"); await exit; }
}
}
try { await run("start"); await run("dev"); }
finally { await fs.rm(profile, { recursive: true, force: true }); }
+13 -7
View File
@@ -1,4 +1,5 @@
import type { Model } from "@earendil-works/pi-ai";
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
@@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision]
function desktopIPCConnected(): boolean {
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
}
function runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); }
function bridge(): BridgeState {
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
if (!global[key]) {
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
global[key] = state;
process.on("message", (raw: unknown) => {
const receive = (raw: unknown) => {
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
if (m?.channel !== "lh:chatgpt") return;
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
acceptStatus(state, { ...m.stamp, status: m.status });
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
@@ -43,8 +45,11 @@ function bridge(): BridgeState {
if (m.ok === true) call.resolve(m.value);
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
}
});
};
process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); });
onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); });
process.on("disconnect", () => {
if (webSubscriptionsEnabled()) return;
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
for (const controller of state.controllers.keys()) controller.abort("all");
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
@@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
}
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:chatgpt", op, fields);
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
return new Promise<T>((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
@@ -74,7 +80,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
});
}
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
@@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
}
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
const s = bridge();
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
return runtimeConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
}
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
@@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
/** Preserve account order; unknown capabilities/windows are never fabricated. */
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
const state = bridge();
if (!desktopIPCConnected()) { clearCatalog(state); return; }
if (!runtimeConnected()) { clearCatalog(state); return; }
const sequence = ++state.catalogRequest;
try {
const value = await chatGPTRequest<ModelsValue>("models");
+90
View File
@@ -0,0 +1,90 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
type BinaryLookup = {
env: Record<string, string | undefined>;
platform: NodeJS.Platform;
homeDir: string;
cwd?: string;
isExecutable: (path: string) => boolean;
};
type SearchSpec = {
paths: path.PlatformPath;
names: string[];
knownDirs: string[];
};
function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
const paths = path.win32;
const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming");
return {
paths,
// Direct spawn deliberately uses no shell. A .cmd-only npm install
// is not runnable and must not count as an available provider.
names: ["claude.exe"],
knownDirs: [
paths.join(homeDir, ".local", "bin"),
paths.join(homeDir, ".claude", "local"),
env.npm_config_prefix ?? paths.join(appData, "npm"),
],
};
}
function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
const paths = path.posix;
const npmBin = env.npm_config_prefix
? paths.join(env.npm_config_prefix, "bin")
: paths.join(homeDir, ".npm-global", "bin");
return {
paths,
names: ["claude"],
knownDirs: [
paths.join(homeDir, ".local", "bin"),
paths.join(homeDir, ".claude", "local"),
npmBin,
"/opt/homebrew/bin",
"/usr/local/bin",
],
};
}
export function resolveClaudeBinary({ env, platform, homeDir, cwd = process.cwd(), isExecutable }: BinaryLookup): string | null {
const { paths, names, knownDirs } = platform === "win32"
? getWindowsSearchSpec(env, homeDir)
: getPosixSearchSpec(env, homeDir);
const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean);
for (const name of names) {
for (const dir of [...pathDirs, ...knownDirs]) {
// Discovery is relative to the server cwd; probes/model work use
// different directories and must receive the same absolute identity.
const candidate = paths.resolve(cwd, dir, name);
if (isExecutable(candidate)) return candidate;
}
}
return null;
}
function isExecutableFile(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
export function getClaudeBinary(): string | null {
return resolveClaudeBinary({
env: process.env,
platform: process.platform,
homeDir: os.homedir(),
isExecutable: isExecutableFile,
});
}
+188
View File
@@ -0,0 +1,188 @@
import type { Usage } from "@earendil-works/pi-ai";
export type CliUsage = Partial<Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">>;
export type CliBlock = { type: "text" } | { type: "thinking" } | { type: "toolUse"; id: string; name: string };
export type CliEvent =
| { type: "messageStart"; usage: CliUsage }
| { type: "blockStart"; index: number; block: CliBlock }
| { type: "textDelta"; index: number; text: string }
| { type: "thinkingDelta"; index: number; thinking: string }
| { type: "toolInputDelta"; index: number; partialJson: string }
| { type: "blockStop"; index: number }
| { type: "messageDelta"; stopReason: string | null; usage: CliUsage }
| { type: "messageStop" }
| { type: "assistantError"; error: string; text: string }
| { type: "resultError"; text: string }
| { type: "toolResults"; ids: string[] };
type RawUsage = {
input_tokens?: number | null;
output_tokens?: number | null;
cache_read_input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
};
type RawContentBlock = { type: string; id?: string; name?: string };
type RawDelta = {
type?: string;
text?: string;
thinking?: string;
partial_json?: string;
stop_reason?: string | null;
};
type RawStreamEvent = {
type: string;
index?: number;
message?: { usage?: RawUsage };
content_block?: RawContentBlock;
delta?: RawDelta;
usage?: RawUsage;
};
type RawCliLine = {
type?: string;
event?: RawStreamEvent;
error?: string | null;
message?: { content?: { type: string; text?: string; tool_use_id?: string }[] };
is_error?: boolean;
result?: string;
subtype?: string;
errors?: string[];
};
function toUsage(raw: RawUsage): CliUsage {
const usage: CliUsage = {};
if (typeof raw.input_tokens === "number") usage.input = raw.input_tokens;
if (typeof raw.output_tokens === "number") usage.output = raw.output_tokens;
if (typeof raw.cache_read_input_tokens === "number") usage.cacheRead = raw.cache_read_input_tokens;
if (typeof raw.cache_creation_input_tokens === "number") usage.cacheWrite = raw.cache_creation_input_tokens;
return usage;
}
function toBlock(raw: RawContentBlock): CliBlock | null {
switch (raw.type) {
case "text":
return { type: "text" };
case "thinking":
return { type: "thinking" };
case "tool_use":
if (typeof raw.id !== "string" || typeof raw.name !== "string") return null;
return { type: "toolUse", id: raw.id, name: raw.name };
default:
return null;
}
}
function parseDelta(index: number, delta: RawDelta): CliEvent | null {
switch (delta.type) {
case "text_delta":
if (typeof delta.text !== "string") return null;
return { type: "textDelta", index, text: delta.text };
case "thinking_delta":
if (typeof delta.thinking !== "string") return null;
return { type: "thinkingDelta", index, thinking: delta.thinking };
case "input_json_delta":
if (typeof delta.partial_json !== "string") return null;
return { type: "toolInputDelta", index, partialJson: delta.partial_json };
default:
return null;
}
}
function parseBlockEvent(index: number, event: RawStreamEvent): CliEvent | null {
switch (event.type) {
case "content_block_start": {
if (!event.content_block) return null;
const block = toBlock(event.content_block);
return block ? { type: "blockStart", index, block } : null;
}
case "content_block_delta":
return event.delta ? parseDelta(index, event.delta) : null;
case "content_block_stop":
return { type: "blockStop", index };
default:
return null;
}
}
function parseStreamEvent(event: RawStreamEvent): CliEvent | null {
switch (event.type) {
case "message_start":
return { type: "messageStart", usage: toUsage(event.message?.usage ?? {}) };
case "message_delta": {
const stopReason = event.delta?.stop_reason ?? null;
return { type: "messageDelta", stopReason, usage: toUsage(event.usage ?? {}) };
}
case "message_stop":
return { type: "messageStop" };
default:
return event.index === undefined ? null : parseBlockEvent(event.index, event);
}
}
function getText(line: RawCliLine): string {
const blocks = Array.isArray(line.message?.content) ? line.message.content : [];
const texts = blocks.flatMap((block) => (block.type === "text" && block.text ? [block.text] : []));
return texts.join("\n");
}
function getResultErrorText(line: RawCliLine): string {
if (line.result) return line.result;
if (line.errors?.length) return line.errors.join("\n");
return `claude CLI failed: ${line.subtype ?? "unknown error"}`;
}
export function parseCliLine(text: string): CliEvent | null {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const line = parsed as RawCliLine;
switch (line.type) {
case "stream_event":
return line.event ? parseStreamEvent(line.event) : null;
case "assistant":
if (typeof line.error !== "string") return null;
return { type: "assistantError", error: line.error, text: getText(line) };
case "user": {
const blocks = Array.isArray(line.message?.content) ? line.message.content : [];
const ids = blocks.flatMap((block) => block.type === "tool_result" && typeof block.tool_use_id === "string" ? [block.tool_use_id] : []);
return ids.length ? { type: "toolResults", ids } : null;
}
case "result":
if (line.is_error !== true) return null;
return { type: "resultError", text: getResultErrorText(line) };
default:
return null;
}
}
+8
View File
@@ -0,0 +1,8 @@
export { CLAUDE_CODE_PROVIDER } from "../../core/contracts/claude-code.ts";
export const CLAUDE_CODE_API = "claude-code-cli";
export enum ClaudeCodeError {
CliNotFound = "claude CLI not found",
NotSignedIn = "not signed in",
}
+71
View File
@@ -0,0 +1,71 @@
import { randomUUID } from "node:crypto";
const INITIALIZE_TIMEOUT_MS = 5_000;
/** A best-effort, local-only CLI control query. Wire contract matches the
* official SDK0.3.259 source; this does not import or execute the SDK.
* Summary deliberately avoids the default full mode's token-count requests.
* CLI maxTokens is an effective/auto-compaction budget, not a nominal model max. */
export class ContextWindowProbe {
private readonly initializeId = randomUUID();
private summaryId: string | undefined;
private initialized = false;
private timer: NodeJS.Timeout | undefined;
private ready = false;
private closed = false;
constructor(
private readonly write: (line: string) => void,
private readonly sendPrompt: () => void,
private readonly onWindow: (tokens: number) => void,
) {}
start(): void {
this.timer = setTimeout(() => this.proceed(false), INITIALIZE_TIMEOUT_MS);
this.timer.unref();
this.request(this.initializeId, { subtype: "initialize" });
}
/** Consume control replies separately from model/tool output, including
* late/foreign replies while a native question tool is already waiting. */
handle(value: unknown): boolean {
if (!value || typeof value !== "object" || !("type" in value) || value.type !== "control_response") return false;
if (this.closed || !("response" in value) || !value.response || typeof value.response !== "object") return true;
const response = value.response as Record<string, unknown>;
if (response.request_id === this.initializeId) this.proceed(response.subtype === "success");
else if (this.summaryId && response.request_id === this.summaryId) {
this.summaryId = undefined;
if (response.subtype !== "success") return true;
const body = response.response as Record<string, unknown> | undefined;
const tokens = body?.maxTokens;
if (typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0) this.onWindow(tokens);
}
return true;
}
/** Retry when the CLI has begun a model response: pre-first-turn metadata
* is not guaranteed by the protocol. Refresh also follows later responses
* within the same held process, without opening a second process. */
refresh(): void {
if (!this.initialized || this.closed) return;
this.summaryId = randomUUID();
this.request(this.summaryId, { subtype: "get_context_usage", detail: "summary" });
}
close(): void { this.closed = true; clearTimeout(this.timer); }
private proceed(query: boolean): void {
if (this.ready || this.closed) return;
this.ready = true;
clearTimeout(this.timer);
this.initialized = query;
if (query) this.refresh();
// Never wait for metadata to run the actual user request. No support,
// no response, or malformed metadata simply leaves the window unknown.
this.sendPrompt();
}
private request(id: string, request: Record<string, string>): void {
this.write(JSON.stringify({ type: "control_request", request_id: id, request }));
}
}
+38
View File
@@ -0,0 +1,38 @@
import { execFile } from "node:child_process";
import { tmpdir } from "node:os";
import { externalCliEnv } from "../../core/auth-env.ts";
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "../../core/contracts/claude-code.ts";
import { getClaudeBinary } from "./binary.ts";
export type ReadClaudeVersion = (binary: string) => Promise<string>;
/** No shell, auth-status command, model call, or credential-file inspection. */
export const readClaudeVersion: ReadClaudeVersion = (binary) => new Promise((resolve, reject) => {
execFile(binary, ["--version"], {
cwd: tmpdir(), env: externalCliEnv(), windowsHide: true,
timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 4_096,
}, (error, stdout) => error ? reject(error) : resolve(stdout));
});
export async function inspectClaudeInstallation(
findBinary: () => string | null = getClaudeBinary,
readVersion: ReadClaudeVersion = readClaudeVersion,
): Promise<ClaudeCodeStatus> {
const base = { version: null, minimumVersion: CLAUDE_CODE_MINIMUM_VERSION };
const binary = findBinary();
if (!binary) return { ...base, state: "not-found" };
try {
const output = await readVersion(binary);
// Expose only a version, never arbitrary CLI stdout/stderr or an absolute path.
const match = /^(\d+)\.(\d+)\.(\d+)(?: \(Claude Code\))?$/.exec(output.trim());
if (!match) return { ...base, state: "version-unchecked" };
const parts = match.slice(1).map(Number);
if (!parts.every(Number.isSafeInteger)) return { ...base, state: "version-unchecked" };
const minimum = CLAUDE_CODE_MINIMUM_VERSION.split(".").map(Number);
const differing = parts.findIndex((part, index) => part !== minimum[index]);
const meetsMinimum = differing === -1 || parts[differing]! > minimum[differing]!;
return { ...base, version: parts.join("."), state: meetsMinimum ? "meets-minimum" : "update-required" };
} catch {
return { ...base, state: "version-unchecked" };
}
}
+32
View File
@@ -0,0 +1,32 @@
import { spawn } from "node:child_process";
import { getClaudeBinary } from "./binary.ts";
import { inspectClaudeInstallation, readClaudeVersion, type ReadClaudeVersion } from "./installation.ts";
import type { RunClaude } from "./stream.ts";
export class ClaudeLaunchError extends Error {}
/** Injectable seams keep all verification offline; production uses the installed CLI. */
export function createClaudeLauncher({
findBinary = getClaudeBinary,
readVersion = readClaudeVersion,
spawnProcess = spawn,
}: {
findBinary?: () => string | null;
readVersion?: ReadClaudeVersion;
spawnProcess?: typeof spawn;
} = {}): RunClaude {
return async (args, options) => {
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
const binary = findBinary();
if (!binary) return null;
const status = await inspectClaudeInstallation(() => binary, readVersion);
if (status.state === "update-required") {
throw new ClaudeLaunchError(`Claude Code ${status.version} requires an update. LearnHarness requires at least ${status.minimumVersion}; update Claude Code outside LearnHarness.`);
}
if (status.state !== "meets-minimum") {
throw new ClaudeLaunchError("Claude Code's version could not be checked. Check your installation outside LearnHarness and try again.");
}
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
return spawnProcess(binary, args, { cwd: options.cwd, env: options.env, stdio: "pipe" });
};
}
+43
View File
@@ -0,0 +1,43 @@
export type LiveProcess = { readonly owner: symbol; readonly sessionId: string | undefined; toolCallIds: string[]; kill(): void };
// Tools usually call back within seconds, but a question tool holds its call until the learner
// answers. Past this, the process is killed and the answer starts a fresh one with a full replay.
// It stays under MCP_TOOL_TIMEOUT so the process is killed before the CLI gives up on its own.
export const LIVE_PROCESS_IDLE_TIMEOUT_MS = 10 * 60_000;
/** Test seam (runner.ts's pdfModelOverride idiom). */
export const liveProcessIdleTimeoutMs = { current: LIVE_PROCESS_IDLE_TIMEOUT_MS };
// Each live process mapped to the timer that kills it if pi never calls back.
const liveProcesses = new Map<LiveProcess, NodeJS.Timeout>();
export function addLiveProcess(process: LiveProcess): void {
removeLiveProcess(process);
const timer = setTimeout(() => {
removeLiveProcess(process);
process.kill();
}, liveProcessIdleTimeoutMs.current);
timer.unref();
liveProcesses.set(process, timer);
}
function hasSameIds(ids: string[], otherIds: string[]): boolean {
return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id));
}
export function getLiveProcess(toolCallIds: string[], owner: symbol, sessionId: string | undefined): LiveProcess | null {
if (!sessionId || toolCallIds.length === 0) return null;
for (const process of liveProcesses.keys()) {
if (process.owner === owner && process.sessionId === sessionId && hasSameIds(process.toolCallIds, toolCallIds)) return process;
}
return null;
}
export function removeLiveProcess(process: LiveProcess): void {
clearTimeout(liveProcesses.get(process));
liveProcesses.delete(process);
}
+34
View File
@@ -0,0 +1,34 @@
import { createProvider, type Api, type Model, type Provider, type SimpleStreamOptions, type StreamFunction } from "@earendil-works/pi-ai";
import { CLAUDE_CODE_API, CLAUDE_CODE_PROVIDER } from "./constants.ts";
const SHARED_MODEL_FIELDS = {
api: CLAUDE_CODE_API,
provider: CLAUDE_CODE_PROVIDER,
baseUrl: "",
input: ["text", "image"],
// Pi-required placeholders, not prices: the CLI's own auth/routing
// determines charges. Server pricing and the client catalog report unpriced.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: true,
// The CLI has no thinking-off switch; effort low is the floor.
thinkingLevelMap: { off: null, minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
contextWindow: 1_000_000,
maxTokens: 128_000,
} satisfies Omit<Model<Api>, "id" | "name">;
export const CLAUDE_CODE_MODELS: readonly Model<Api>[] = [
{ ...SHARED_MODEL_FIELDS, id: "opus", name: "Claude Opus" },
{ ...SHARED_MODEL_FIELDS, id: "sonnet", name: "Claude Sonnet" },
{ ...SHARED_MODEL_FIELDS, id: "haiku", name: "Claude Haiku" },
];
export function createClaudeCodeProvider(streamSimple: StreamFunction<Api, SimpleStreamOptions>): Provider {
return createProvider({
id: CLAUDE_CODE_PROVIDER,
name: "Claude Code",
auth: { apiKey: { name: "Claude Code CLI", resolve: async () => ({ auth: {}, source: "claude CLI" }) } },
models: CLAUDE_CODE_MODELS,
api: { stream: streamSimple, streamSimple },
});
}
+27
View File
@@ -0,0 +1,27 @@
import type { Api, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
/** Compare model-visible history, not app IDs, timestamps, usage, or Pi's added thinkingLevel. */
export function conversationForReuse(messages: Message[]): unknown[] {
return messages.flatMap((message): unknown[] => {
switch (message.role) {
case "system": return [];
case "user": return [{ role: message.role, content: message.content }];
case "assistant": return [{ role: message.role, content: message.content }];
case "toolResult": return [{
role: message.role,
toolCallId: message.toolCallId,
toolName: message.toolName,
content: message.content,
isError: message.isError,
}];
}
});
}
export function configurationForReuse(model: Model<Api>, context: TranscriptContext, effort: string): unknown {
const systemPrompt = getCurrentSystemPrompt(context.messages);
const tools = getCurrentTools(context.messages);
// The wire is JSON: TypeBox's schema symbols are not CLI configuration.
return JSON.parse(JSON.stringify({ model, effort, systemPrompt, tools })) as unknown;
}
+695
View File
@@ -0,0 +1,695 @@
import { type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { isDeepStrictEqual } from "node:util";
import {
createAssistantMessageEventStream,
type Api,
type AssistantMessage,
type AssistantMessageEventStream,
type Message,
type Model,
type SimpleStreamOptions,
type StopReason,
type StreamFunction,
type ThinkingLevel,
type ToolCall,
type ToolResultMessage,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages";
import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse";
import { externalCliEnv } from "../../core/auth-env.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts";
import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts";
import { ClaudeCodeError } from "./constants.ts";
import { ContextWindowProbe } from "./context-window.ts";
import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts";
import { configurationForReuse, conversationForReuse } from "./reuse-context.ts";
import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts";
import { toClaudeInput, toStreamJsonLine } from "./transcript.ts";
type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal };
type ClaudeChild = ChildProcessWithoutNullStreams | null;
export type RunClaude = (args: string[], options: RunClaudeOptions) => ClaudeChild | Promise<ClaudeChild>;
// A held MCP call lasts until pi calls back with the tool result, which for a question tool is
// whenever the learner answers; this must outlast the live-process idle timeout so the CLI never
// times out first.
const MCP_TOOL_TIMEOUT_MS = 30 * 60_000;
const PRIVATE_FILE_MODE = 0o600;
const EFFORTS: Record<ThinkingLevel, AnthropicEffort> = {
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
};
const STOP_REASONS: Record<string, StopReason> = {
end_turn: "stop",
pause_turn: "stop",
stop_sequence: "stop",
max_tokens: "length",
tool_use: "toolUse",
refusal: "error",
};
const REFUSAL_MESSAGE = "The model refused to complete the request";
const ABORT_MESSAGE = "Request was aborted";
const START_FAILED_MESSAGE = "claude CLI failed to start";
const NO_STOP_REASON_MESSAGE = "claude CLI ended the message without a stop reason";
const NOT_SIGNED_IN_ERROR = "authentication_failed";
export function toEffort(level: ThinkingLevel | undefined): AnthropicEffort {
return level ? EFFORTS[level] : "medium";
}
type ClaudeArgsInput = {
model: string;
effort: AnthropicEffort;
systemPromptFile: string;
mcpConfigFile: string;
allowedTools: string[];
};
export function toClaudeArgs(input: ClaudeArgsInput): string[] {
return [
"-p",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
"--include-partial-messages",
"--model",
input.model,
"--effort",
input.effort,
"--system-prompt-file",
input.systemPromptFile,
"--tools",
"",
"--strict-mcp-config",
"--mcp-config",
input.mcpConfigFile,
"--setting-sources",
"user",
"--settings",
JSON.stringify({ disableAllHooks: true }),
"--disable-slash-commands",
"--no-session-persistence",
"--permission-prompts",
"none",
...(input.allowedTools.length > 0 ? ["--allowedTools", input.allowedTools.join(",")] : []),
];
}
function createOutput(model: Model<Api>): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
}
function getAssistantErrorMessage(error: string, text: string): string {
return error === NOT_SIGNED_IN_ERROR ? ClaudeCodeError.NotSignedIn : text || error;
}
class ClaudeTurn {
private readonly output: AssistantMessage & Pick<LhMessage, "cliContextWindow">;
private readonly contentIndexes = new Map<number, number>();
private readonly partialJson = new Map<number, string>();
private readonly toolBlocks = new Map<number, ToolCall>();
private readonly intents: ToolCall[] = [];
started = false;
finished = false;
constructor(
private readonly stream: AssistantMessageEventStream,
model: Model<Api>,
) {
this.output = createOutput(model);
}
handle(event: CliEvent): void {
if (this.finished) return;
switch (event.type) {
case "messageStart":
this.updateUsage(event.usage);
this.start();
return;
case "blockStart":
this.startBlock(event.index, event.block);
return;
case "textDelta": {
const found = this.getBlock(event.index);
if (!found) return;
const { contentIndex, block } = found;
if (block.type !== "text") return;
block.text += event.text;
this.stream.push({ type: "text_delta", contentIndex, delta: event.text, partial: this.output });
return;
}
case "thinkingDelta": {
const found = this.getBlock(event.index);
if (!found) return;
const { contentIndex, block } = found;
if (block.type !== "thinking") return;
block.thinking += event.thinking;
this.stream.push({ type: "thinking_delta", contentIndex, delta: event.thinking, partial: this.output });
return;
}
case "toolInputDelta": {
const found = this.getBlock(event.index);
if (!found) return;
const { block } = found;
if (block.type !== "toolCall") return;
const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson;
this.partialJson.set(event.index, partialJson);
block.arguments = parseStreamingJson(partialJson);
return;
}
case "blockStop":
this.stopBlock(event.index);
return;
case "messageDelta":
this.updateUsage(event.usage);
if (event.stopReason) this.setStopReason(event.stopReason);
return;
case "messageStop":
// Tool intent is not execution admission. The process publishes only
// calls that actually reach the authenticated MCP bridge.
if (this.output.stopReason !== "toolUse") this.finish();
return;
case "assistantError":
this.fail(getAssistantErrorMessage(event.error, event.text), "error");
return;
case "resultError":
this.fail(event.text, "error");
return;
}
}
get waitingForAdmission(): boolean { return !this.finished && this.output.stopReason === "toolUse"; }
getIntents(): ToolCall[] { return this.intents; }
getMessage(): AssistantMessage { return this.output; }
setContextWindow(tokens: number | undefined): void {
if (!this.finished && tokens !== undefined) this.output.cliContextWindow = tokens;
}
admit(calls: ToolCall[]): void {
this.start();
for (const call of calls) {
const contentIndex = this.output.content.length;
this.output.content.push(call);
this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output });
this.stream.push({ type: "toolcall_end", contentIndex, toolCall: call, partial: this.output });
}
this.output.stopReason = "toolUse";
this.finish();
}
fail(message: string, reason: "aborted" | "error"): void {
if (this.finished) return;
this.finished = true;
this.output.stopReason = reason;
this.output.errorMessage = message;
this.stream.push({ type: "error", reason, error: this.output });
this.stream.end();
}
private start(): void {
if (this.started) return;
this.started = true;
this.stream.push({ type: "start", partial: this.output });
}
private startBlock(index: number, block: CliBlock): void {
const contentIndex = this.output.content.length;
this.contentIndexes.set(index, contentIndex);
switch (block.type) {
case "text":
this.output.content.push({ type: "text", text: "" });
this.stream.push({ type: "text_start", contentIndex, partial: this.output });
return;
case "thinking":
this.output.content.push({ type: "thinking", thinking: "" });
this.stream.push({ type: "thinking_start", contentIndex, partial: this.output });
return;
case "toolUse":
if (!block.name.startsWith(`mcp__${BRIDGE_SERVER_NAME}__`)) {
this.fail("claude CLI emitted an unsupported native tool call", "error");
return;
}
this.toolBlocks.set(index, { type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} });
return;
}
}
private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null {
const tool = this.toolBlocks.get(index);
if (tool) return { contentIndex: -1, block: tool };
const contentIndex = this.contentIndexes.get(index);
if (contentIndex === undefined) return null;
const block = this.output.content[contentIndex];
return block ? { contentIndex, block } : null;
}
private stopBlock(index: number): void {
const found = this.getBlock(index);
if (!found) return;
const { contentIndex, block } = found;
this.contentIndexes.delete(index);
switch (block.type) {
case "text":
this.stream.push({ type: "text_end", contentIndex, content: block.text, partial: this.output });
return;
case "thinking":
this.stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: this.output });
return;
case "toolCall":
Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? ""));
this.partialJson.delete(index);
this.toolBlocks.delete(index);
this.intents.push(block);
return;
}
}
private updateUsage(usage: CliUsage): void {
Object.assign(this.output.usage, usage);
const { input, output, cacheRead, cacheWrite } = this.output.usage;
this.output.usage.totalTokens = input + output + cacheRead + cacheWrite;
}
private setStopReason(stopReason: string): void {
const mapped = STOP_REASONS[stopReason];
if (!mapped) {
this.output.stopReason = "error";
this.output.errorMessage = `Unhandled stop reason: ${stopReason}`;
return;
}
this.output.stopReason = mapped;
}
private finish(): void {
const { stopReason } = this.output;
if (stopReason === "error") {
this.fail(this.output.errorMessage ?? REFUSAL_MESSAGE, "error");
return;
}
if (stopReason === "pending" || stopReason === "aborted") {
this.fail(this.output.errorMessage ?? NO_STOP_REASON_MESSAGE, "error");
return;
}
this.finished = true;
this.stream.push({ type: "done", reason: stopReason, message: this.output });
this.stream.end();
}
}
type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mcpConfigFile: string };
async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise<TurnFiles> {
const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-"));
let privateDir: string | undefined;
try {
privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-"));
const systemPromptFile = join(privateDir, "system-prompt.md");
const mcpConfigFile = join(privateDir, "mcp.json");
await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE });
await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE });
return { cwd, privateDir, systemPromptFile, mcpConfigFile };
} catch (error) {
await Promise.all([rm(cwd, { recursive: true, force: true }), ...(privateDir ? [rm(privateDir, { recursive: true, force: true })] : [])]);
throw error;
}
}
async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise<void> {
await Promise.all([
bridge.close(),
rm(files.cwd, { recursive: true, force: true }),
rm(files.privateDir, { recursive: true, force: true }),
]);
}
class ClaudeProcess implements LiveProcess {
toolCallIds: string[] = [];
private stderr = "";
private closed = false;
private stopping = false;
private killTimer: NodeJS.Timeout | undefined;
private signal: AbortSignal | undefined;
private sourceCalls: ToolCall[] = [];
private sourceComplete = false;
private admitted: PendingToolCall[] = [];
private readonly cancelledIds = new Set<string>();
private pendingError: string | undefined;
private contextWindow: number | undefined;
private contextProbe: ContextWindowProbe | undefined;
constructor(
private readonly child: ChildProcessWithoutNullStreams,
private readonly bridge: ToolBridge,
private readonly files: TurnFiles,
private turn: ClaudeTurn,
readonly owner: symbol,
readonly sessionId: string | undefined,
private readonly configuration: unknown,
private conversation: unknown[],
) {}
start(stdinLine: string, signal: AbortSignal | undefined): void {
this.child.once("close", (code: number | null) => this.handleClose(`claude CLI exited with code ${code}`));
this.child.once("error", (error) => {
console.error("claude CLI process failed:", error);
this.turn.fail(START_FAILED_MESSAGE, "error");
this.handleClose(START_FAILED_MESSAGE);
});
this.child.stderr.setEncoding("utf8");
this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-65_536); });
this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error));
this.contextProbe = new ContextWindowProbe(
(line) => this.child.stdin.write(`${line}\n`),
() => { if (!this.stopping) this.child.stdin.write(`${stdinLine}\n`); },
(tokens) => { this.contextWindow = tokens; this.turn.setContextWindow(tokens); },
);
createInterface({ input: this.child.stdout }).on("line", (line) => {
try { this.handleLine(line); }
catch { this.fail("claude CLI emitted malformed stream output"); }
});
this.setSignal(signal);
if (!this.stopping) this.contextProbe.start();
}
canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean {
const conversation = conversationForReuse(messages);
return !this.stopping && owner === this.owner && !!sessionId && sessionId === this.sessionId
&& isDeepStrictEqual(configuration, this.configuration)
&& isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation)
&& conversation.length === this.conversation.length + this.admitted.length
&& results.length === this.admitted.length
&& new Set(results.map((result) => result.toolCallId)).size === results.length
&& this.admitted.every((call) => results.some((result) => result.toolCallId === call.id && result.toolName === call.name));
}
continueTurn(turn: ClaudeTurn, messages: Message[], results: ToolResultMessage[], signal: AbortSignal | undefined): void {
removeLiveProcess(this);
const admitted = this.admitted;
this.admitted = [];
this.conversation = structuredClone(conversationForReuse(messages));
this.turn = turn;
this.turn.setContextWindow(this.contextWindow);
this.sourceComplete = this.sourceCalls.length > 0;
this.setSignal(signal);
if (this.stopping) return;
for (const call of admitted) {
const result = results.find((candidate) => candidate.toolCallId === call.id)!;
this.bridge.resolveToolCall(call, result);
}
this.flushAdmissions();
}
kill(): void {
removeLiveProcess(this);
if (this.stopping || this.closed) return;
this.stopping = true;
this.contextProbe?.close();
this.child.kill();
this.killTimer = setTimeout(() => this.child.kill("SIGKILL"), 1000);
this.killTimer.unref();
}
fail(message: string): void {
// A parallel request can fail after the admitted slice was handed to
// Pi. Retain that failure for its continuation instead of silently
// replaying and hiding the compatibility error.
if (this.sessionId && this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message;
this.turn.fail(message, "error");
this.kill();
if (this.pendingError) addLiveProcess(this);
}
consumeFailure(owner: symbol, turn: ClaudeTurn): boolean {
if (owner !== this.owner || !this.pendingError) return false;
const message = this.pendingError;
this.pendingError = undefined;
this.kill();
turn.fail(message, "error");
return true;
}
cancelCall(call: PendingToolCall): void {
this.cancelledIds.add(call.id);
// Already admitted work belongs to Pi and cannot be undone. A queued
// request, however, must never become executable after cancellation.
this.sourceCalls = this.sourceCalls.filter((intent) => intent.id !== call.id);
}
/** Only authenticated MCP requests, never stdout intent alone, admit side effects. */
flushAdmissions(): void {
if (this.stopping || this.turn.finished || !this.sourceComplete) return;
const pending = this.bridge.getPendingToolCalls();
if (pending.length === 0) return;
const calls: ToolCall[] = [];
for (const call of pending) {
const intent = this.sourceCalls.find((candidate) => candidate.id === call.id);
if (!intent || intent.name !== call.name || !isDeepStrictEqual(intent.arguments, call.arguments)) {
this.fail("claude CLI compatibility error: MCP call does not match its streamed tool intent");
return;
}
calls.push(intent);
}
this.sourceCalls = this.sourceCalls.filter((call) => !pending.some((admitted) => admitted.id === call.id));
this.admitted = pending;
this.toolCallIds = pending.map((call) => call.id);
this.turn.admit(calls);
this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()])));
if (this.sessionId) addLiveProcess(this);
else this.kill(); // No trusted scope: return the batch, then use fresh replay.
}
private readonly abort = () => {
this.turn.fail(ABORT_MESSAGE, "aborted");
this.kill();
};
private setSignal(signal: AbortSignal | undefined): void {
this.signal?.removeEventListener("abort", this.abort);
this.signal = signal;
signal?.addEventListener("abort", this.abort, { once: true });
if (signal?.aborted) this.abort();
}
private handleLine(line: string): void {
if (this.stopping || !line.trim()) return;
if (this.contextProbe?.handle(JSON.parse(line))) return;
const event = parseCliLine(line);
if (!event) return;
if (event.type === "toolResults") {
if (event.ids.some((id) => this.sourceCalls.some((call) => call.id === id))) {
this.fail("claude CLI refused a harness tool call before MCP admission");
}
return;
}
// Moving on before held calls were answered means the CLI did not
// honor this tool wait. Never execute further stdout-only intentions.
if (this.turn.finished) {
this.fail("claude CLI moved on while harness tool calls were waiting for results");
return;
}
if (event.type === "messageStart" && this.sourceComplete && this.sourceCalls.length > 0) {
this.fail("claude CLI moved on without admitting its harness tool calls");
return;
}
if (event.type === "messageStart") this.contextProbe?.refresh();
this.turn.handle(event);
if (this.turn.finished) { this.kill(); return; }
if (event.type === "messageStop" && this.turn.waitingForAdmission) {
const intents = this.turn.getIntents();
if (intents.length === 0 || new Set(intents.map((call) => call.id)).size !== intents.length) {
this.fail("claude CLI emitted an invalid tool batch");
return;
}
this.sourceCalls = intents.filter((call) => !this.cancelledIds.has(call.id));
this.sourceComplete = true;
this.flushAdmissions();
}
}
private handleClose(message: string): void {
if (this.closed) return;
this.closed = true;
this.contextProbe?.close();
clearTimeout(this.killTimer);
removeLiveProcess(this);
if (this.pendingError) addLiveProcess(this);
this.signal?.removeEventListener("abort", this.abort);
if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim());
this.turn.fail(message, "error");
removeTurnResources(this.bridge, this.files).catch((error: unknown) => {
console.error("claude CLI cleanup failed:", error);
});
}
}
function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean {
const assistantIndex = messages.findLastIndex((message) => message.role === "assistant");
const assistant = messages[assistantIndex];
if (assistant?.role !== "assistant") return false;
const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []);
const process = getLiveProcess(ids, owner, sessionId);
if (!(process instanceof ClaudeProcess)) return false;
if (process.consumeFailure(owner, turn)) return true;
const trailing = messages.slice(assistantIndex + 1);
const results = trailing.filter((message) => message.role === "toolResult");
if (trailing.length !== results.length || !process.canContinue(owner, sessionId, configuration, messages, results)) {
process.kill();
return false;
}
process.continueTurn(turn, messages, results, signal);
return true;
}
function toClaudeEnv(): NodeJS.ProcessEnv {
// Preserve native CLI auth/routing, including launch-environment credentials.
// Claude Code chooses the account and billing method, not LearnHarness.
return { ...externalCliEnv(), MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) };
}
async function startProcess(
runClaude: RunClaude,
turn: ClaudeTurn,
model: Model<Api>,
context: TranscriptContext,
options: SimpleStreamOptions | undefined,
owner: symbol,
configuration: unknown,
env: NodeJS.ProcessEnv,
): Promise<void> {
if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; }
const input = toClaudeInput(context.messages);
let cliProcess: ClaudeProcess | undefined;
const bridge = await createToolBridge(input.tools, {
onCall: () => cliProcess?.flushAdmissions(),
onError: (error) => cliProcess?.fail(error.message),
onCancel: (call) => cliProcess?.cancelCall(call),
});
let files: TurnFiles;
try {
files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig);
} catch (error) {
await bridge.close();
throw error;
}
if (options?.signal?.aborted) {
turn.fail(ABORT_MESSAGE, "aborted");
await removeTurnResources(bridge, files);
return;
}
let child: ChildProcessWithoutNullStreams | null;
try {
const args = toClaudeArgs({
model: model.id,
effort: toEffort(options?.reasoning),
systemPromptFile: files.systemPromptFile,
mcpConfigFile: files.mcpConfigFile,
allowedTools: bridge.allowedTools,
});
child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal });
} catch (error) {
await removeTurnResources(bridge, files);
throw error;
}
if (!child) {
turn.fail(ClaudeCodeError.CliNotFound, "error");
await removeTurnResources(bridge, files);
return;
}
cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, options?.sessionId, configuration, structuredClone(conversationForReuse(context.messages)));
cliProcess.start(toStreamJsonLine(input.userContent), options?.signal);
}
export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction<Api, SimpleStreamOptions> {
const owner = Symbol("claude-code stream owner");
return (model, context, options) => {
const stream = createAssistantMessageEventStream();
const turn = new ClaudeTurn(stream, model);
const env = toClaudeEnv();
// An existing child must not retain stale authentication/routing after env changes.
const configuration = { request: configurationForReuse(model, context, toEffort(options?.reasoning)), env };
if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream;
startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => {
console.error("claude CLI turn failed:", error);
turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error");
});
return stream;
};
}
export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher());

Some files were not shown because too many files have changed in this diff Show More