diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c26c5c..a4b167c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,5 +63,7 @@ jobs: NODE_ENV: test ELECTRON_RUN_AS_NODE: "1" - run: npm run build + # Check the fresh trace before any static copying/curation. + - run: node scripts/smoke-provider-runtime.mjs - run: node scripts/build-server.mjs - run: node scripts/smoke-server.mjs --electron diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feb8377..276edf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,6 +129,8 @@ jobs: NODE_ENV: test ELECTRON_RUN_AS_NODE: "1" - run: npm run build + # Check the fresh trace before any static copying/curation. + - run: node scripts/smoke-provider-runtime.mjs - run: node scripts/build-server.mjs - run: node scripts/smoke-server.mjs --electron - name: Validate macOS release secrets @@ -169,6 +171,25 @@ jobs: - name: Windows/Linux rehearsal (no publish) if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS' run: npx electron-builder ${{ matrix.builder_args }} --publish never + - name: Initialize packaged providers offline (host and packaged Electron Node) + run: | + case "$RUNNER_OS" in + macOS) + app="dist/mac-arm64/LearnHarness.app/Contents" + payload="$app/Resources/server/standalone" + runtime="$app/MacOS/LearnHarness" + ;; + Windows) + payload="dist/win-unpacked/resources/server/standalone" + runtime="dist/win-unpacked/LearnHarness.exe" + ;; + Linux) + payload="dist/linux-unpacked/resources/server/standalone" + runtime="dist/linux-unpacked/learnharness" + ;; + esac + node scripts/smoke-provider-runtime.mjs --standalone "$payload" + node scripts/smoke-provider-runtime.mjs --standalone "$payload" --runtime "$runtime" - name: Exercise packaged demo runtime (own Electron, no system Chrome) run: | case "$RUNNER_OS" in diff --git a/app/api/activity/route.ts b/app/api/activity/route.ts index e225725..fa91157 100644 --- a/app/api/activity/route.ts +++ b/app/api/activity/route.ts @@ -1,8 +1,8 @@ /** * GET /api/activity — the dock's one poll: every running background * process, every view (chat, home, everywhere). Composition only — the - * feed lives in server/observability.ts; cost folds client-side via - * core/prices.ts. See that module's header for why /api/jobs stays + * feed lives in server/observability.ts; costs are projected server-side + * through Pi's catalog and public calculator. See that module's header for why /api/jobs stays * chat-scoped and untouched. */ import { activityFeed } from "@/src/server"; diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 9ae3fda..1b19ce5 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -1,4 +1,4 @@ -import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server"; +import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -17,6 +17,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str return Response.json({ ...chat, messages: chat.messages, + pricing: sessionCost(chat), turnInFlight: isTurnRunning(id), compacting: compactionInFlight(id), coverage: sessionCoverage(id), diff --git a/app/api/graph/costs/route.ts b/app/api/graph/costs/route.ts index eaa8459..a2f209f 100644 --- a/app/api/graph/costs/route.ts +++ b/app/api/graph/costs/route.ts @@ -4,7 +4,7 @@ * three usage channels (chat extraction runs, notes-source runs, * cluster-naming runs). Thin transport over src/server's graphCosts(); * the fold itself is pure core (core/cost-summary.ts). Dollars are - * inferred from PRICES at read time — never stored, so a price sync + * inferred from Pi's catalog/calculator at read time — never stored, so a catalog update * re-prices the whole history for free. * * Scope (user-decided): the graph pipeline ONLY — teaching turns and diff --git a/app/api/models/route.ts b/app/api/models/route.ts index 90e28b4..160302e 100644 --- a/app/api/models/route.ts +++ b/app/api/models/route.ts @@ -1,4 +1,4 @@ -import { modelCatalog } from "@/src/server"; +import { modelCatalog, refreshConnectionCatalog } from "@/src/server"; /** * The model picker's data (model-control wave): pi-ai's configured-provider @@ -7,5 +7,6 @@ import { modelCatalog } from "@/src/server"; * catalog: no network, stable within a pi-ai version. */ export async function GET() { - return Response.json(modelCatalog()); + await refreshConnectionCatalog(); + return Response.json(modelCatalog(), { headers: { "Cache-Control": "no-store" } }); } diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 2a8bf34..32d7469 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -4,8 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { CallUsage, JobKind, JobRecord } from "@/src/core"; -import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core"; -import { formatTokens, formatUsd } from "@/components/usage-format"; +import { isModelsRefusal, isProviderKeyRefusal } from "@/src/core"; +import { formatTokens, planCostLabel } from "@/components/usage-format"; import { useServerEvent, useServerResync } from "@/components/server-events"; /** @@ -20,7 +20,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events"; * Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined, * oldest-first + extraction runs), fetched when truth moves (events * channel, S5) and folded client-side with core/prices.ts. - * Cost is INFERRED (jobCost); the context line is the LAST model call's + * Cost is INFERRED server-side through Pi; the context line is the LAST model call's * input+output against the model's limit (JobUsage.lastStep — pending * contract) — never a cumulative sum, which would be the wrong number. * Absent data renders "—", never zero (the doctrine). @@ -48,6 +48,8 @@ interface FeedUsage extends NonNullable { } export interface FeedJob extends JobRecord { + /** Read-time server estimate; absent on older transport payloads. */ + costUsd?: number | null; title: string; usage?: FeedUsage; /** Server-joined live catalog limit (owner amendment 2026-09-23) — @@ -200,7 +202,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] { // blob shape's call — here the line simply hides). const limit = job.contextLimit; if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) { - // Same metric as the session meter (prices.ts stepFootprint — the + // Same metric as the session meter (four normalized buckets — the // context-meter audit, owner 2026-09-23): all four directions. // Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output // alone reads wildly low on cache-heavy jobs. @@ -220,7 +222,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] { title: job.title, chatId: job.chatId, startedAt: Date.parse(job.createdAt) || 0, - cost: jobCost(job), + cost: job.costUsd ?? null, context, // BYOK (owner, 2026-09-22): a refusal is info, not failure — // the honest line the runner left ("failed") reads as setup @@ -348,7 +350,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { Background agents {blobs.length > 0 && ( - {totalCost === null ? "—" : formatUsd(totalCost)} + {planCostLabel(totalCost, blobs.some((blob) => blob.model?.startsWith("chatgpt:")))} )} @@ -423,7 +425,7 @@ function ActivityBlobRow({ )} - {blob.cost === null ? "—" : formatUsd(blob.cost)} + {planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)} {formatElapsed(now - blob.startedAt)} @@ -462,7 +464,7 @@ function ActivityBlobRow({
Cost - {blob.cost === null ? "—" : formatUsd(blob.cost)} + {planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
diff --git a/components/chat.tsx b/components/chat.tsx index 719a4d0..4986f3f 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -57,7 +57,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 } from "@/src/core"; +import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core"; import type { LhViewMessage } from "@/src/core"; import { PhaseChip, PhaseDivider } from "@/components/phase-divider"; import { @@ -236,7 +236,7 @@ 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, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = + const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initiallyInFlight }); const sessionFindScopeRef = useRef(null); @@ -717,8 +717,9 @@ 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[] }; + const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing }; if (Array.isArray(record.turns)) setTurns(record.turns); + setPricing(record.pricing); if (!Array.isArray(record.messages)) return; setMessages(record.messages); // The continuation seam (contracts.ts protocol §4): a job result @@ -845,12 +846,13 @@ export function Chat({ const planTree = useMemo(() => latestTree(messages), [messages]); // ── Session usage (wave-4 stats bar) ── - // The money channel: the record's turns log, folded client-side by - // core/prices.ts. Seeded once on mount (the page doesn't carry turns), + // The money channel: the record's turns log and read-time server Pi + // pricing projection. Seeded once on mount (the page doesn't carry turns), // refreshed when the local stream settles (each turn appends to the log // server-side), and kept fresh by syncFromServer whenever it already // fetches the record. No turns yet → the bar renders "—" everywhere. const [turns, setTurns] = useState([]); + const [pricing, setPricing] = useState(); // The per-chat model control state (model-picker writes via PUT; the // record is the source of truth, lifted here so the stats bar re-renders). // The dock's reserved tail height (DockOverlay reports it; the spacer at @@ -864,8 +866,9 @@ export function Chat({ try { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; - const record = (await res.json()) as { turns?: TurnUsage[] }; + const record = (await res.json()) as { turns?: TurnUsage[]; pricing?: SessionPricing }; if (Array.isArray(record.turns)) setTurns(record.turns); + setPricing(record.pricing); } catch { // transient blip: stats stay on the last known figures } @@ -1604,6 +1607,8 @@ export function Chat({ onReadingThinking={applyReadingThinking} phase={phases.at(-1) ?? "probe"} turns={turns} + contextTokens={contextTokens} + pricing={pricing} onReserve={setDockReserve} dictationSlot={} dictationCancel={dictation.cancel} @@ -1824,6 +1829,8 @@ function DockOverlay({ onReadingThinking, phase, turns, + contextTokens, + pricing, composer, onSubmitAnswer, chatId, @@ -1855,6 +1862,8 @@ function DockOverlay({ phase: Phase3; /** The session's usage log — the stats bar beneath the composer folds it. */ turns: TurnUsage[]; + contextTokens: number | null; + pricing?: SessionPricing; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; /** The model control's pass-through (the stats bar hosts it). */ @@ -2327,6 +2336,8 @@ function DockOverlay({ morphing shell so it never joins a height animation. */} diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 606b567..293bf44 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -294,9 +294,17 @@ function timeAgo(startedAt: number): string { /** Run/stage cost at 3 decimals (pipeline pennies are real money at this * scale); headline totals use 2. Null (unpriced model) renders "—". */ -function fmtRunCost(dollars: number | null): string { +function fmtRunCost(dollars: number | null, subscription = false): string { + if (subscription) return dollars === null ? "ChatGPT plan" : `$${dollars.toFixed(3)} API + ChatGPT plan`; return dollars === null ? "—" : `$${dollars.toFixed(3)}`; } +function graphUsageLabel(costs: Pick): string { + const stages = [costs.stages.extraction, costs.stages.audit]; + const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0); + const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0); + const dollars = displayCostTotals(costs).totalDollars; + return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : priced ? `$${dollars.toFixed(2)}` : "—"; +} /** * The cost figures the UI DISPLAYS (owner refinement round, 2026-09-24): @@ -349,7 +357,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) { open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > - ${displayCostTotals(costs).totalDollars.toFixed(2)} + {graphUsageLabel(costs)} {open && ( @@ -378,7 +386,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { ]; // The displayed figures come from displayCostTotals (naming excluded — // the pill and this panel must agree). - const { totalDollars, unpricedRuns } = displayCostTotals(costs); + const { unpricedRuns } = displayCostTotals(costs); return (
@@ -386,7 +394,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { Graph pipeline costs - ${totalDollars.toFixed(2)} + {graphUsageLabel(costs)} {unpricedRuns > 0 && ( · {unpricedRuns} unpriced )} @@ -397,8 +405,8 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
{label} - {fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)} - {stage.unpricedRuns > 0 && stage.dollars > 0 && ( + {fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, (stage.subscriptionRuns ?? 0) > 0)} + {stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && ( +{stage.unpricedRuns} unpriced )} @@ -422,7 +430,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { )} - {fmtRunCost(run.dollars)} + {fmtRunCost(run.dollars, (run.subscriptionRuns ?? 0) > 0)}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}
))}
diff --git a/components/model-picker.tsx b/components/model-picker.tsx index a09d522..1ae092d 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -8,7 +8,7 @@ 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, useSettingsSaved } from "@/components/settings-signal"; +import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; /** GET /api/models is cached per page load for instant menu opens, but @@ -17,51 +17,40 @@ import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/t * 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 | undefined; - -function fetchCatalog(force = false): Promise { - if (force) { - catalogCache = undefined; - catalogInflight = undefined; - } - catalogInflight ??= fetch("/api/models") - .then((r) => { - if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); - return r.json() as Promise; - }) - .then((c) => { - catalogCache = c; - return c; - }) - .catch((err) => { - catalogInflight = undefined; // retry on next mount/open - throw err; - }); - return catalogInflight; +let catalogInflight: Promise | 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; }) + .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(() => { - if (catalogCache) return; - let alive = true; - fetchCatalog() - .then((c) => alive && setCatalog(c)) - .catch(() => { - /* the picker stays closed on failure; the buttons still - display the strong slot from the record fetch */ - }); - return () => { - alive = false; - }; - }, []); - const refresh = useCallback(() => { - fetchCatalog(true) - .then(setCatalog) - .catch(() => { - /* refresh is best-effort: the cached list stays on failure */ + 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 }; } @@ -92,7 +81,7 @@ export function catalogLimits(catalog: Pick): Ma * changing the new-session default. Unpicked reads "—" (the * no-factory-fallback language: the UI never presents a default model). */ -export function ModelControl({ chatId }: { chatId?: string }) { +export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) { const { catalog, refresh } = useModelCatalog(); const [models, setModels] = useState(); const [session, setSession] = useState(); @@ -147,6 +136,9 @@ export function ModelControl({ chatId }: { chatId?: string }) { : strong; const effectiveId = override?.model?.id; const effectiveProvider = override?.model?.provider; + useEffect(() => { + onModelChange?.(effectiveProvider && effectiveId ? `${effectiveProvider}:${effectiveId}` : undefined); + }, [effectiveProvider, effectiveId, onModelChange]); const storedLevel = override?.thinkingLevel; const entry = catalog?.models.find( (m) => m.id === effectiveId && m.provider === effectiveProvider, diff --git a/components/session-core.ts b/components/session-core.ts index af3c252..2d765fa 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -17,6 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; import { needsContinuation, + measuredContextTokens, newAnswerResult, newNextMessage, newProblemDismissedMarker, @@ -163,6 +164,8 @@ function sameDetails(a: unknown, b: unknown): boolean { export interface SessionState { messages: LhViewMessage[]; nativeMessages: LhMessage[]; + /** Completed conversation usage; null after compaction or before first usage. */ + contextTokens: number | null; status: SessionStatus; error: { message: string } | null; /** Steers sent but not yet drained (composer steering, 2026-10-03): @@ -220,6 +223,7 @@ export class SessionModel { // The live turn's execution layer (view model mid-turn states): private executions = new Map(); private streamingIds = new Set(); + private incompleteUsageIds = new Set(); /** The in-flight assistant message's client id (see applyWireEvent). */ private streamingMessageId: string | null = null; private statusValue: SessionStatus = "ready"; @@ -257,6 +261,7 @@ export class SessionModel { return { messages: projectView([...this.base, ...this.tail], live), nativeMessages: [...this.base, ...this.tail], + contextTokens: measuredContextTokens([...this.base, ...this.tail], this.incompleteUsageIds), status: this.statusValue, error: this.errorValue, steerQueue: this.steerQueueValue, @@ -364,6 +369,8 @@ export class SessionModel { // the wire) still wins. const id = message.id ?? this.streamingMessageId ?? localId(); const withId = { ...message, id } as LhMessage; + if (agent.type === "message_end") this.incompleteUsageIds.delete(id); + else this.incompleteUsageIds.add(id); if (agent.type === "message_start") { this.streamingMessageId = id; this.streamingIds.add(id); @@ -487,6 +494,7 @@ export class SessionModel { // every path that actually replays onto a stale tail). if (!refetchRecordFirst) { this.mutate(() => { + this.incompleteUsageIds.clear(); this.tail = []; this.streamingMessageId = null; }); @@ -502,6 +510,7 @@ export class SessionModel { if (Array.isArray(record.messages)) { this.mutate(() => { this.base = record.messages!; + this.incompleteUsageIds.clear(); this.tail = []; }); } @@ -509,6 +518,7 @@ export class SessionModel { // The record did not land — restore the clean-slate guard // before the replay (no atomic swap is coming). this.mutate(() => { + this.incompleteUsageIds.clear(); this.tail = []; this.streamingMessageId = null; }); @@ -714,6 +724,7 @@ export class SessionModel { setMessages(record: LhMessage[]): void { this.mutate(() => { this.base = record; + this.incompleteUsageIds.clear(); this.tail = []; this.streamingMessageId = null; // the tail (and its slot) is gone }); diff --git a/components/session.ts b/components/session.ts index 2fb1e06..e0652cf 100644 --- a/components/session.ts +++ b/components/session.ts @@ -124,6 +124,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U return { messages: state.messages, nativeMessages: state.nativeMessages, + contextTokens: state.contextTokens, status: state.status, error: state.error, steerQueue: state.steerQueue, diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 32346ed..5395eec 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -72,6 +72,7 @@ import { prettyModel } from "@/components/usage-format"; import { cn } from "@/lib/utils"; import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx"; import { ModelMenuList, ThinkingMenuList } from "./model-menu"; +import { ChatGPTBackgroundAccess } from "./chatgpt-connection"; /** One slot's display identity — the tier's roster as the row hint rides * with the resolved default (owner: the hint ALWAYS names the default). */ @@ -131,19 +132,6 @@ export function composeGraphTools(agent: AgentSettings | undefined, on: boolean) return { ...agent, graphTools: { enabled: on } }; } -/** The research tool runs on the search port — the teacher registry DROPS - * it when no engine is live (chat-handler's searchAvailable, the same - * /api/search signal the section fetches). Pure — exported for tests. - * Teacher-stream flag 2026-09-22: the settings row grays on the SAME - * signal, so the row and the actual registry availability can never - * disagree. `search === null` (loading/failed) grays NOTHING. */ -export function researchToolUnavailable( - toolId: string, - search: { available: boolean } | null, -): boolean { - return toolId === "research" && search !== null && !search.available; -} - /** Compose: one slot's model/thinking override. Empty model = absent * override (Default — falls to the factory pin); empty thinking = the * tier keeps its resolved thinking budget. Per-field independence, same @@ -357,18 +345,23 @@ export function AgentSection({ // 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 readModels = useCallback(() => { + 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) => r.json()) + .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(() => setCatalog(null)); + .catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); }); }, []); useSettingsSaved((section) => { - if (section === "models") readModels(); + 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 @@ -377,6 +370,7 @@ export function AgentSection({ }); useEffect(() => { + modelsMounted.current = true; readModels(); fetch("/api/settings/prompts") .then((r) => r.json()) @@ -393,6 +387,7 @@ export function AgentSection({ setEngineKeys(present); }) .catch(() => {}); + return () => { modelsMounted.current = false; modelsRequest.current++; }; }, []); // The reason line re-reads /api/search AFTER an engine write lands — @@ -653,6 +648,7 @@ export function AgentSection({ ref={thisMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2" > + {slot !== "strong" && } ); })()} - {TOGGLE_TOOLS.map((id) => { - // Research needs a live search engine; the row + toggle gray - // on the same signal the registry drops the tool on (see - // researchToolUnavailable). The reason rides as the hint. - const searchDown = researchToolUnavailable(id, search); - return ( - - onSectionChange(composeTool(agent, id, on))} - /> - - ); - })} + {TOGGLE_TOOLS.map((id) => ( + + onSectionChange(composeTool(agent, id, on))} + /> + + ))} {(() => { const engine = agent?.search?.engine ?? null; const anyKey = engineKeys.tavily === true || engineKeys.brave === true; diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx new file mode 100644 index 0000000..86fbe0b --- /dev/null +++ b/components/settings/chatgpt-connection.tsx @@ -0,0 +1,380 @@ +"use client"; + +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { ChatGPTStatus } from "@/src/core"; +import { useBackdropDismiss } from "@/components/backdrop-dismiss"; +import { Button } from "@/components/ui/button"; +import { Toggle } from "./controls"; + +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 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."; +export const CHATGPT_DISCONNECT_COPY = { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." }; +const empty: ChatGPTStatus = { available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null }; +type Account = ChatGPTStatus["accounts"][number]; +type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string; account: Account; confirm?: boolean } | { kind: "warning" }; +type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; backgroundDirty: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" }; +const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again."; +function isStatus(value: unknown): value is ChatGPTStatus { + if (!value || typeof value !== "object") return false; + const s = value as ChatGPTStatus; + return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") && + (s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) && + s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean")); +} +function replyError(value: unknown): string { + return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR; +} +const Context = createContext({ + status: empty, + busy: false, + error: null as string | null, + inlineError: null as string | null, + warning: null as string | null, + connect: () => {}, + manage: (_id: string) => {}, + select: (_id: string) => {}, + dismissWarning: () => {}, +}); + +/** UI lifetime spans setup's keys → models transition. Only existing safe controls + * are used; account IDs remain control values, never displayed account labels. */ +export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { + const [status, setStatus] = useState(empty); + const [dialog, setDialog] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [dismissedError, setDismissedError] = useState(null); + const [background, setBackground] = useState(true); + const [backgroundDirty, setBackgroundDirty] = useState(false); + const [registration, setRegistration] = useState(""); + const operation = useRef<"login" | "cancel" | "change" | "disconnect" | null>(null); + const generation = useRef(0); + const statusSequence = useRef(0); + const mounted = useRef(false); + const dialogRef = useRef(null); + const statusRef = useRef(status); + const loginAttempt = useRef(null); + const receive = useRef<(value: unknown) => void>(() => {}); + function showDialog(next: Dialog | null) { dialogRef.current = next; setDialog(next); } + function publishStatus(value: unknown): ChatGPTStatus | null { + if (!isStatus(value)) { setError(replyError(value)); return null; } + statusSequence.current++; statusRef.current = value; setStatus(value); + return value; + } + function owns(attempt: LoginAttempt) { return mounted.current && loginAttempt.current === attempt && generation.current === attempt.generation; } + function finish(attempt: LoginAttempt) { + if (!owns(attempt)) return; + loginAttempt.current = null; operation.current = null; setBusy(false); + } + async function complete(attempt: LoginAttempt, next: ChatGPTStatus) { + const account = next.accounts.find((a) => a.id === next.activeId && a.connected); + if (next.error || !next.available || !account || (attempt.registration && account.id !== attempt.registration)) { + finish(attempt); setError(next.error || CONTROL_ERROR); return; + } + attempt.phase = "consent"; attempt.accountId = account.id; + let replyFailure: string | null = null; + try { + if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) { + operation.current = "change"; + const request = ++statusSequence.current; + const result: unknown = await window.lh!.chatgpt!.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(); + if (!owns(attempt)) return; + saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current; + } else saved = publishStatus(result); + if (!saved) { finish(attempt); return; } + next = saved; + } + if (!owns(attempt)) return; + const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected); + if (replyFailure || next.error || next.pending || !next.available || !current || (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background)) setError(replyFailure || next.error || CONTROL_ERROR); + else if (dialogRef.current === attempt.dialog) showDialog(null); + } catch { if (owns(attempt)) setError(CONTROL_ERROR); } + finally { finish(attempt); } + } + function reconcile(next: ChatGPTStatus, loginReply = false) { + const attempt = loginAttempt.current; + if (!attempt || !owns(attempt)) return; + if (attempt.phase === "consent") { + if (next.pending || next.error || !next.available || next.activeId !== attempt.accountId || !next.accounts.some((a) => a.id === attempt.accountId && a.connected)) { + finish(attempt); setError(next.error || CONTROL_ERROR); + } + return; + } + if (next.pending) { attempt.pending = true; attempt.phase = "pending"; return; } + // login() normally acknowledges pending immediately; verified completion is + // a later status notification. The acknowledgement itself is not success. + if (attempt.pending || loginReply) void complete(attempt, next); + } + receive.current = (value) => { + const next = publishStatus(value); + if (next) reconcile(next); + }; + useEffect(() => { + mounted.current = true; + const bridge = window.lh?.chatgpt; + const refresh = async () => { + const request = ++statusSequence.current; + try { + const next: unknown = await bridge?.status(); + if (next && mounted.current && request === statusSequence.current) receive.current(next); + } catch { if (mounted.current && request === statusSequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); } + }; + void refresh(); + const off = bridge?.onChange(() => void refresh()); + return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); }; + }, []); + + 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); + const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; + const warning = visibleError === REVOCATION_WARNING ? visibleError : null; + function openConnect() { + if (operation.current) return; + setBackground(statusRef.current.allowBackgroundUse); + setBackgroundDirty(false); + setRegistration(""); + setError(null); + setDismissedError(status.error); + showDialog({ kind: "connect" }); + } + function close() { + if (operation.current === "disconnect" || operation.current === "change" || operation.current === "cancel") return; + const current = dialogRef.current; + if (current?.kind === "manage" && current.confirm) { showDialog({ ...current, confirm: false }); setError(null); return; } + const cancel = loginAttempt.current !== null || statusRef.current.pending; + const intent = ++generation.current; + const owner = dialogRef.current; + loginAttempt.current = null; + if (!cancel) showDialog(null); + setError(null); + setDismissedError(status.error); + if (cancel) { + // 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) => { + 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(); + if (!mounted.current || generation.current !== intent) return; + next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current; + } else next = publishStatus(value); + if (next && !next.pending) { + setDismissedError(value.error); // Dismiss only this cancellation's error, not a newer failure. + if (dialogRef.current === owner) showDialog(null); + } else if (next) setError(CONTROL_ERROR); + }).catch(() => { if (mounted.current && generation.current === intent) setError(CONTROL_ERROR); }).finally(() => { + if (mounted.current && generation.current === intent) { operation.current = null; setBusy(false); } + }); + } + } + async function control(kind: "change" | "disconnect", action: () => Promise, target?: string) { + if (operation.current) return; + const intent = ++generation.current; + const request = ++statusSequence.current; + const owner = dialogRef.current; + operation.current = kind; + setBusy(true); setError(null); + if (kind === "disconnect") setDismissedError(null); + try { + const value: unknown = await action(); + if (!mounted.current || intent !== generation.current) 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(); + if (!mounted.current || intent !== generation.current) return; + next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current; + } else next = publishStatus(value); + if (!next) return; + // Dismissed status warnings belong to an earlier action. Only a new + // disconnect outcome (or an explicit IPC error envelope above) reopens one. + const failure = [value.error, next.error].find((message) => message && (kind === "disconnect" || message !== dismissedError)) ?? null; + if (kind === "disconnect" && !next.accounts.some((a) => a.id === target)) { + if (dialogRef.current === owner) { + if (failure === REVOCATION_WARNING) showDialog({ kind: "warning" }); + else if (!failure) showDialog(null); + } + setError(failure); + } else setError(failure || (kind === "disconnect" ? CONTROL_ERROR : null)); + } catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); } + 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" }; + loginAttempt.current = attempt; operation.current = "login"; + const request = ++statusSequence.current; + setBusy(true); setError(null); setDismissedError(null); + try { + let value: unknown = await bridge.login(registration || undefined); + if (!owns(attempt) || attempt.phase === "consent") return; + // Never publish an old acknowledgement over a newer event. If events + // coalesced past pending before the acknowledgement arrived, read the + // current backend state after it rather than losing that completion. + if (request !== statusSequence.current && isStatus(value)) { + if (value.error) { setError(value.error); finish(attempt); return; } + attempt.pending = true; attempt.phase = "pending"; + const refresh = ++statusSequence.current; + value = await bridge.status(); + if (!owns(attempt) || loginAttempt.current?.phase === "consent" || refresh !== statusSequence.current) return; + } + const next = publishStatus(value); + if (!next) { finish(attempt); return; } + reconcile(next, true); + } catch { if (owns(attempt)) { setError(CONTROL_ERROR); finish(attempt); } } + // 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, + manage: (id: string) => { + const account = statusRef.current.accounts.find((a) => a.id === id); + if (!operation.current && account) { setError(null); showDialog({ kind: "manage", accountId: id, account }); } + }, + select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); }, + dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); }, + }; + return + {children} + {dialog && + {dialog.kind === "warning" ? <> +

{REVOCATION_WARNING}

+
+ : dialog.kind === "manage" && dialog.confirm ?
+

{CHATGPT_DISCONNECT_COPY.title}

+

{managed?.email || "ChatGPT account"}

+

{CHATGPT_DISCONNECT_COPY.body}

+ {context.error &&

{context.error}

} +
+ + +
+
: <> + {dialog.kind === "connect" ? <> +

Continue with ChatGPT

+ {status.accounts.length > 0 &&
+ + {registration && } +
} +

{CHATGPT_NOTICE}

+ : managed && <> +
+ {status.accounts.length > 1 && managedPresent && !busy ? : {managed.email || "ChatGPT account"}} +
+
+ +
+ } +
button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background"> + { + if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); } + else if (bridge) void control("change", () => bridge.consent(enabled)); + }} /> + {BACKGROUND_LABEL}{CHATGPT_BACKGROUND_HINT} +
+ {dialog.kind === "manage" &&

{CHATGPT_NOTICE}

} + + {!status.available &&

ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.

} + {status.pending &&

Waiting for browser sign-in…

} + {context.error &&

{context.error}

} +
+ {dialog.kind === "manage" && } + + {dialog.kind === "connect" && void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />} +
+ } +
} +
; +} + +function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onClick: () => void; disabled: boolean; testid?: string }) { + return ; +} + +function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) { + const ref = useRef(null); + const closeRef = useRef(onClose); + closeRef.current = onClose; + const backdropDismiss = useBackdropDismiss(onClose); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + const focusable = () => [...(ref.current?.querySelectorAll('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled])') ?? [])]; + focusable()[0]?.focus(); + const key = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); event.stopImmediatePropagation(); closeRef.current(); + } else if (event.key === "Tab") { + const items = focusable(), first = items[0], last = items.at(-1); + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && (document.activeElement === first || !ref.current?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && (document.activeElement === last || !ref.current?.contains(document.activeElement))) { event.preventDefault(); first.focus(); } + } + }; + // Capture claims the nested dialog's Escape before outer Settings/setup. + document.addEventListener("keydown", key, true); + return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); }; + }, []); + return createPortal(
+
event.stopPropagation()} className="settings-dialog-pop mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-card p-5 shadow-xl">{children}
+
, document.body); +} + +/** Compact settings row, or setup's secondary action only. */ +export function ChatGPTConnection({ setup = false }: { setup?: boolean }) { + 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
+
+ {!setup && active && ChatGPT} + {active && !setup ? <> + + + : } +
+ {ui.inlineError &&

{ui.inlineError}

} + {ui.warning &&
{ui.warning}
} +
; +} + +/** Contextual consent discovery belongs beside background model choices only. */ +export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) { + const ui = useContext(Context); + const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); + if (provider !== "chatgpt" || !account || ui.status.allowBackgroundUse) return null; + return ; +} diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 3e1c21a..f1f2652 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -15,8 +15,8 @@ * a paste box on a key the UI cannot make live would mislead. * - OpenRouter is NOT special: one provider among 33, a row only when * stored, otherwise it lives in the Add dropdown like everyone else. - * - Tools & services (Deepgram, the Google pair) keep their own group, - * same look, same interaction. + * - Providers groups API keys, subscriptions, dictation and search. + * Unknown variables stay masked/read-only at the end of Model API keys. * * API: GET /api/keys (masked statuses + the providers array), PUT * /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take @@ -26,6 +26,7 @@ import { useEffect, useRef, useState } from "react"; import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; +import { ChatGPTConnection } from "./chatgpt-connection.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; @@ -412,7 +413,7 @@ export function KeysSection({ return ( <> - + {providerRows.map((status) => ( void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
)} + {unknownRows.length > 0 &&
+ {unknownRows.map((status) =>
+ {status.id} + {status.masked} +
)} +
} - - {toolRows.map((status) => ( + +
+
+ + {(["Dictation", "Search"] as const).map((label) => + {toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => ( ))} - +
)} - {unknownRows.length > 0 && ( - - {unknownRows.map((status) => ( -
- {status.id} - {status.masked} -
- ))} -
- )} ); } diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 4e3d55e..8f46238 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -126,7 +126,7 @@ export function ModelMenuList({ {m.name} - {priceLabel(m.cost)} + {m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)} diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index e3979b4..f0cca9f 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -49,17 +49,18 @@ import { KeysSection } from "./keys-section.tsx"; import { Hint } from "@/components/ui/hint"; import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; import { KeyboardSection } from "./keyboard-section.tsx"; +import { ChatGPTUIProvider } from "./chatgpt-connection.tsx"; /** Owner (2026-09-20): hints earn their place — mechanics-explaining * prose is out; only the keyboard note stays (storage location is a * real question users ask). */ const SECTIONS = [ - { id: "general", label: "General" }, - { id: "keys", label: "Keys" }, + { id: "keys", label: "Providers" }, { id: "agent", label: "Agent" }, { id: "appearance", label: "Appearance" }, { id: "graph", label: "Graph" }, { id: "keyboard", label: "Keyboard", hint: "Stored on this device" }, + { id: "general", label: "General" }, ] as const; type SectionId = (typeof SECTIONS)[number]["id"]; @@ -83,20 +84,20 @@ export function SettingsDialogProvider({ children: React.ReactNode; }) { const [show, setShow] = useState(false); - const [landOn, setLandOn] = useState("general"); + const [landOn, setLandOn] = useState("keys"); const [landOnRow, setLandOnRow] = useState(null); const open = useCallback((section?: string, row?: string) => { - if (section) setLandOn(section as SectionId); + setLandOn(section ? section as SectionId : "keys"); setLandOnRow(row ?? null); setShow(true); }, []); return ( - + {children} {show && ( setShow(false)} initialSection={landOn} initialRow={landOnRow} /> )} - + ); } @@ -133,7 +134,7 @@ function SettingsDialog({ * (portals render nothing under renderToStaticMarkup). */ export function SettingsDialogBody({ onClose, - initialSection = "general", + initialSection = "keys", initialRow = null, }: { onClose: () => void; @@ -376,7 +377,7 @@ export function SettingsDialogBody({ )} {active === "keys" && ( - + )} diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index b97a816..de59b98 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -15,12 +15,10 @@ * its own save), the same re-read pattern chat.tsx uses for "models". */ -import { useCallback, useEffect, useState } from "react"; import type { AuthKeyStatus } 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 { useSettingsSaved } from "@/components/settings-signal"; /** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses * classifyKeys (the keys section's own classification — no drift): @@ -40,22 +38,8 @@ export function hasProviderKey( * failed read claiming "zero keys" would be a lie the CTA flow then * leans on). Re-reads on the keys signal. */ export function useZeroKeys(): boolean { - const [zeroKeys, setZeroKeys] = useState(false); - const read = useCallback(() => { - fetch("/api/keys") - .then((r) => r.json()) - .then((body: { keys?: AuthKeyStatus[]; providers?: ProviderKeyInfo[] }) => { - setZeroKeys(!hasProviderKey(body.keys ?? [], body.providers)); - }) - .catch(() => {}); - }, []); - useEffect(() => { - read(); - }, [read]); - useSettingsSaved((section) => { - if (section === "keys") read(); - }); - return zeroKeys; + const { catalog } = useModelCatalog(); + return catalog ? !catalog.providers.some((provider) => provider.keyPresent) : false; } /** The pill itself — the models CTA's chrome and placement, the keys @@ -93,9 +77,6 @@ export function strongKeyless(catalog: ModelCatalogResponse | undefined): boolea * (a provider key saved or deleted) and "models" (the strong slot * re-pinned). Live-updates with the page, never mid-send. */ export function useStrongKeyless(): boolean { - const { catalog, refresh } = useModelCatalog(); - useSettingsSaved((section) => { - if (section === "keys" || section === "models") refresh(); - }); + const { catalog } = useModelCatalog(); return strongKeyless(catalog); } diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 5b902b7..2372d01 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -44,6 +44,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig import { Hint } from "@/components/ui/hint"; import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; +import { ChatGPTBackgroundAccess, ChatGPTConnection } from "@/components/settings/chatgpt-connection"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; import { ThinkingMenuList } from "@/components/settings/model-menu"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; @@ -147,7 +148,6 @@ export function SetupKeysBody({ }) { return (
-

Add a provider key.

Choose your AI provider and paste its API key. You’ll pick your models next.

@@ -211,6 +211,7 @@ export function SetupKeysBody({

)}
+
); } @@ -265,16 +266,19 @@ export function SetupDialog({ // 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[] }) => setKeyProviders(b.providers ?? [])) - .catch(() => setKeyProviders([])); + .then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); }) + .catch(() => { if (!cancelled) setKeyProviders([]); }); fetch("/api/models") - .then((r) => r.json()) + .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 @@ -292,7 +296,8 @@ export function SetupDialog({ } setLevels(preLevels); }) - .catch(() => setFailed(true)); + .catch(() => { if (!cancelled) setFailed(true); }); + return () => { cancelled = true; }; }, [open, keysTick]); // Esc + outside-click — layered dismissal, closable WITHOUT @@ -472,6 +477,7 @@ export function SetupDialog({ )} {SLOT_META[slot].roster} + {slot !== "strong" && }