diff --git a/.github/workflows/notify-website.yml b/.github/workflows/notify-website.yml new file mode 100644 index 0000000..cbf11f6 --- /dev/null +++ b/.github/workflows/notify-website.yml @@ -0,0 +1,49 @@ +name: Notify website of public desktop release + +on: + release: + types: [published] + +# Use only the separately scoped website token; no app token or checkout is needed. +permissions: {} + +jobs: + notify-website: + if: >- + github.repository == 'amosblomqvist/learnharness' && + !github.event.release.draft && !github.event.release.prerelease + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Queue the website update for the published tag + env: + WEBSITE_DISPATCH_TOKEN: ${{ secrets.WEBSITE_DISPATCH_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + shell: bash + run: | + set -euo pipefail + if [[ -z "$WEBSITE_DISPATCH_TOKEN" ]]; then + echo '::error::Missing WEBSITE_DISPATCH_TOKEN.' + exit 1 + fi + if [[ -z "$RELEASE_TAG" ]]; then + echo '::error::The published release has no tag.' + exit 1 + fi + payload=$(jq -cn --arg tag "$RELEASE_TAG" \ + '{event_type:"desktop-release-published",client_payload:{tag:$tag}}') + status=$(curl --fail-with-body --silent --show-error \ + --connect-timeout 15 --max-time 60 \ + --request POST \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer $WEBSITE_DISPATCH_TOKEN" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + --header 'Content-Type: application/json' \ + --data "$payload" \ + --output /dev/null --write-out '%{http_code}' \ + https://api.github.com/repos/amosblomqvist/learnharness-web/dispatches) + if [[ "$status" != '204' ]]; then + printf '::error::Expected dispatch HTTP 204, received %s.\n' "$status" + exit 1 + fi + echo 'Website update queued. This does not confirm website deployment.' diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 32d7469..41f456e 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -4,7 +4,7 @@ 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 } from "@/src/core"; +import { isModelsRefusal, isProviderKeyRefusal, subscriptionLabel, subscriptionLabels } from "@/src/core"; import { formatTokens, planCostLabel } from "@/components/usage-format"; import { useServerEvent, useServerResync } from "@/components/server-events"; @@ -350,7 +350,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { Background agents {blobs.length > 0 && ( - {planCostLabel(totalCost, blobs.some((blob) => blob.model?.startsWith("chatgpt:")))} + {planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))} )} @@ -425,7 +425,7 @@ function ActivityBlobRow({ )} - {planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)} + {planCostLabel(blob.cost, subscriptionLabel(blob.model))} {formatElapsed(now - blob.startedAt)} @@ -464,7 +464,7 @@ function ActivityBlobRow({
Cost - {planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)} + {planCostLabel(blob.cost, subscriptionLabel(blob.model))}
diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 293bf44..9cde6cf 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -68,6 +68,7 @@ import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx import Link from "next/link"; import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core"; +import { planCostLabel } from "@/components/usage-format"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; import { usePopoverDismiss } from "@/components/popover-dismiss"; import { useActivity } from "@/components/activity-dock"; @@ -294,16 +295,18 @@ 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, subscription = false): string { - if (subscription) return dollars === null ? "ChatGPT plan" : `$${dollars.toFixed(3)} API + ChatGPT plan`; - return dollars === null ? "—" : `$${dollars.toFixed(3)}`; +function fmtRunCost(dollars: number | null, providers: readonly string[] = []): string { + return planCostLabel(dollars, providers, (value) => `$${value.toFixed(3)}`); +} +function graphProviders(value: { subscriptionRuns?: number; subscriptionProviders?: string[] }): string[] { + return value.subscriptionProviders ?? ((value.subscriptionRuns ?? 0) > 0 ? ["ChatGPT"] : []); } function graphUsageLabel(costs: Pick): string { const stages = [costs.stages.extraction, costs.stages.audit]; - const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0); + const providers = [...new Set(stages.flatMap(graphProviders))]; 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)}` : "—"; + return planCostLabel(priced ? dollars : null, providers, (value) => `$${value.toFixed(2)}`); } /** @@ -405,7 +408,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
{label} - {fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, (stage.subscriptionRuns ?? 0) > 0)} + {fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, graphProviders(stage))} {stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && ( +{stage.unpricedRuns} unpriced )} @@ -430,7 +433,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { )} - {fmtRunCost(run.dollars, (run.subscriptionRuns ?? 0) > 0)}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""} + {fmtRunCost(run.dollars, graphProviders(run))}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}
))}
diff --git a/components/session-core.ts b/components/session-core.ts index 2d765fa..657d0da 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -227,7 +227,10 @@ export class SessionModel { /** The in-flight assistant message's client id (see applyWireEvent). */ private streamingMessageId: string | null = null; private statusValue: SessionStatus = "ready"; + /** Request/loop failures are transient; provider failures derive from messages. */ private errorValue: { message: string } | null = null; + /** 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; @@ -258,12 +261,22 @@ export class SessionModel { this.statusValue === "submitted" || this.statusValue === "streaming" ? { 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 providerError = + latest?.role === "assistant" && latest.stopReason === "error" && + !this.incompleteUsageIds.has(latest.id) && (!live || latest.id === this.liveFailureId) + ? { message: latest.errorMessage ?? "An error occurred." } + : null; return { - messages: projectView([...this.base, ...this.tail], live), - nativeMessages: [...this.base, ...this.tail], - contextTokens: measuredContextTokens([...this.base, ...this.tail], this.incompleteUsageIds), + messages: projectView(messages, live), + nativeMessages: messages, + contextTokens: measuredContextTokens(messages, this.incompleteUsageIds), status: this.statusValue, - error: this.errorValue, + error: this.errorValue ?? providerError, steerQueue: this.steerQueueValue, }; } @@ -279,6 +292,7 @@ export class SessionModel { } private setStatus(status: SessionStatus): void { + if (status === "submitted") this.liveFailureId = null; this.statusValue = status; this.readyRef = status === "ready"; this.onChange?.(); @@ -369,8 +383,15 @@ 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_end") { + this.incompleteUsageIds.delete(id); + if (message.role === "assistant" && message.stopReason === "error") { + this.liveFailureId = id; + // A newer provider failure replaces an earlier connection/loop + // error, just as the live message_end did before restoration. + this.errorValue = null; + } + } else this.incompleteUsageIds.add(id); if (agent.type === "message_start") { this.streamingMessageId = id; this.streamingIds.add(id); @@ -385,15 +406,6 @@ export class SessionModel { } if (agent.type === "message_end") { this.streamingMessageId = null; // slot done — next start mints fresh - // (fallthrough: returns true below — the message landed) - const stop = (message as { stopReason?: string }).stopReason; - if (stop === "error") { - // Provider error, verbatim (owner ruling) — the record - // persists the same message; no masking anywhere. - this.setError( - String((message as { errorMessage?: string }).errorMessage ?? "An error occurred."), - ); - } } return true; // a message event is view work, even empty content } @@ -433,17 +445,18 @@ 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): Promise { + async consumeStream(res: Response, controller = this.consumeAbort): Promise { if (!res.body) throw new Error("The turn stream body is empty."); const reader = res.body.getReader(); let sawEvent = false; try { for await (const event of sseEvents(reader)) { + if (this.consumeAbort !== controller) return; 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(() => {}); - this.onReattachSettled?.(); + if (this.consumeAbort === controller) this.onReattachSettled?.(); return; } const acted = this.applyWireEvent(event); @@ -453,9 +466,13 @@ export class SessionModel { } } } finally { - this.streamingIds = new Set(); - this.executions = new Map(); - if (this.statusValue !== "error") this.setStatus("ready"); + // 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"); + } } } @@ -505,8 +522,10 @@ export class SessionModel { const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal, }); + if (this.consumeAbort !== controller) return; if (recordRes.ok) { const record = (await recordRes.json()) as { messages?: LhMessage[] }; + if (this.consumeAbort !== controller) return; if (Array.isArray(record.messages)) { this.mutate(() => { this.base = record.messages!; @@ -527,10 +546,11 @@ export class SessionModel { const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, { signal: controller.signal, }); + 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); + await this.consumeStream(res, controller); } catch (err) { - if ((err as Error).name === "AbortError") return; // a newer consume won + if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won this.setError((err as Error).message); this.setStatus("error"); } diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 5395eec..bd7c810 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -73,6 +73,7 @@ import { cn } from "@/lib/utils"; import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx"; import { ModelMenuList, ThinkingMenuList } from "./model-menu"; import { ChatGPTBackgroundAccess } from "./chatgpt-connection"; +import { NativeSubscriptionBackgroundAccess } from "./subscription-connections"; /** 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). */ @@ -648,7 +649,7 @@ export function AgentSection({ ref={thisMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2" > - {slot !== "strong" && } + {slot !== "strong" && <>} a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean")); + 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") && (a.canSignInAgain === undefined || typeof a.canSignInAgain === "boolean")); } function replyError(value: unknown): string { return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR; @@ -138,11 +137,11 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { 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() { + function openConnect(accountId = "") { if (operation.current) return; setBackground(statusRef.current.allowBackgroundUse); setBackgroundDirty(false); - setRegistration(""); + setRegistration(accountId); setError(null); setDismissedError(status.error); showDialog({ kind: "connect" }); @@ -255,12 +254,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { }; return {children} - {dialog && + {dialog && +

ChatGPT

{dialog.kind === "warning" ? <>

{REVOCATION_WARNING}

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

{CHATGPT_DISCONNECT_COPY.title}

+

{CHATGPT_DISCONNECT_COPY.title}

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

{CHATGPT_DISCONNECT_COPY.body}

{context.error &&

{context.error}

} @@ -270,7 +270,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
: <> {dialog.kind === "connect" ? <> -

Continue with ChatGPT

{status.accounts.length > 0 &&
context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5"> - {status.accounts.map((a) => )} - : {managed.email || "ChatGPT account"}} -
-
- -
- } + : null}
button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background"> { if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); } @@ -296,7 +286,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { }} /> {BACKGROUND_LABEL}{CHATGPT_BACKGROUND_HINT}
- {dialog.kind === "manage" &&

{CHATGPT_NOTICE}

}
OpenAI privacy Manage usage @@ -304,11 +293,21 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { {!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 === "manage" && managed &&
+
+ {status.accounts.length > 1 && managedPresent && !busy ? : {managed.email || "ChatGPT account"}} +
+ +
} + + +
}> + {dialog.kind === "manage" && managedPresent && managed && !managed.connected && managed.canSignInAgain === true && status.available && } + {dialog.kind === "connect" && void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />} -
+ }
}
; @@ -324,48 +323,20 @@ function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onCli } 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); + return {children}; } -/** Compact settings row, or setup's secondary action only. */ -export function ChatGPTConnection({ setup = false }: { setup?: boolean }) { +/** Shared compact settings/setup row; account selection precedes the quiet action. */ +export function ChatGPTConnection(_props: { 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 ? <> - - - : } -
+ active ? ui.manage(active.id) : ui.connect()}> + {active && } + {ui.inlineError &&

{ui.inlineError}

} {ui.warning &&
{ui.warning}
}
; diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx new file mode 100644 index 0000000..395d604 --- /dev/null +++ b/components/settings/connection-controls.tsx @@ -0,0 +1,45 @@ +"use client"; +import { useEffect, useRef } from "react"; +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 }) { + return
+ {label} + {children} + +
; +} + +export function ConnectionFooter({ leading, children, testid }: { leading?: React.ReactNode; children: React.ReactNode; testid?: string }) { + return
+ {leading} +
{children}
+
; +} + +export function ConnectionDialog({ children, onClose, busy, label, testid, backdropTestid }: { children: React.ReactNode; onClose: () => void; busy: boolean; label: string; testid: string; backdropTestid: string }) { + const ref = useRef(null), closeRef = useRef(onClose); + closeRef.current = onClose; + const backdropDismiss = useBackdropDismiss(() => { if (!busy) 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(); } + } + }; + 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); +} diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index f1f2652..3269ca8 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -26,7 +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 { SubscriptionConnections } from "./subscription-connections.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; @@ -456,7 +456,7 @@ export function KeysSection({ -
+
{(["Dictation", "Search"] as const).map((label) => diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 8f46238..19697b9 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -21,7 +21,7 @@ import { useEffect, useRef, useState } from "react"; import type { ModelCatalogEntry } from "@/src/core"; -import { fuzzyScore } from "@/src/core"; +import { fuzzyScore, subscriptionLabel } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; function priceLabel(cost: ModelCatalogEntry["cost"]): string { @@ -125,12 +125,12 @@ export function ModelMenuList({ > {m.name} - - {m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)} + + {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} - {m.provider} · {m.id} + {subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id} {formatTokens(m.contextWindow)} diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index f0cca9f..1142bdf 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -50,6 +50,7 @@ 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"; +import { NativeSubscriptionUIProvider } from "./subscription-connections.tsx"; /** Owner (2026-09-20): hints earn their place — mechanics-explaining * prose is out; only the keyboard note stays (storage location is a @@ -92,12 +93,12 @@ export function SettingsDialogProvider({ setShow(true); }, []); return ( - + {children} {show && ( setShow(false)} initialSection={landOn} initialRow={landOnRow} /> )} - + ); } diff --git a/components/settings/subscription-connections.tsx b/components/settings/subscription-connections.tsx new file mode 100644 index 0000000..0120200 --- /dev/null +++ b/components/settings/subscription-connections.tsx @@ -0,0 +1,138 @@ +"use client"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core"; +import { Button } from "@/components/ui/button"; +import { notifySettingsSaved } from "@/components/settings-signal"; +import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls"; +import { ChatGPTConnection } from "./chatgpt-connection"; +import { Toggle } from "./controls"; + +export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply."; +export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection."; +export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; } +export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; } +export function 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 [statuses, setStatuses] = useState([]); + const [dialog, setDialog] = useState(null); + const [view, setView] = useState<"notice" | "connection" | "manage">("notice"); + const [confirm, setConfirm] = useState(false), [background, setBackground] = useState(true), [backgroundDirty, setBackgroundDirty] = useState(false), [promptValue, setPromptValue] = useState(""); + const [operation, setOperation] = useState<"login" | "control" | null>(null), [error, setError] = useState(null); + const mounted = useRef(false), sequence = useRef(0), generation = useRef(0), statusesRef = useRef(statuses); + const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === dialog); + const status = statuses.find((s) => s.providerId === dialog) ?? (dialog ? emptySubscriptionStatus(dialog) : undefined); + const interaction = status?.interaction, prompt = interaction?.prompt; + const management = view === "manage"; + const busy = operation === "control" || status?.committing === true; + async function refresh() { + const request = ++sequence.current; + try { + const values = await window.lh?.subscriptions?.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); + return next && (!old || next.revision >= old.revision) ? next : old ?? emptySubscriptionStatus(providerId); + }); + statusesRef.current = accepted; setStatuses(accepted); + } catch { if (mounted.current && request === sequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); } + } + useEffect(() => { + mounted.current = true; void refresh(); + const off = window.lh?.subscriptions?.onChange(() => { void refresh(); notifySettingsSaved("keys"); }); + return () => { mounted.current = false; generation.current++; off?.(); }; + // The root-lifetime subscriber reads current state through refs. + }, []); + useEffect(() => { setPromptValue(""); }, [prompt?.id]); + function open(providerId: NativeSubscriptionProviderId) { + if (operation || statusesRef.current.some((s) => s.pending)) return; + generation.current++; setError(null); setConfirm(false); setBackgroundDirty(false); + const current = statusesRef.current.find((s) => s.providerId === providerId); + setBackground(current?.allowBackgroundUse ?? true); setView(current?.connectionId ? "manage" : "notice"); setDialog(providerId); + } + async function control(action: () => Promise, close = false) { + if (busy) return; + const owner = ++generation.current; setOperation("control"); setError(null); + try { await action(); await refresh(); if (mounted.current && owner === generation.current && close) setDialog(null); } + catch { if (mounted.current && owner === generation.current) setError(CONTROL_ERROR); } + finally { if (mounted.current && owner === generation.current) setOperation(null); } + } + 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); + } + function beginSignIn() { + if (operation || busy || !status) return; + 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 + {children} + {dialog && descriptor && status && +

{descriptor.label}

+ {confirm ? <> +

Disconnect {descriptor.label}?

+

{subscriptionDisconnectCopy(descriptor.label)}

+ : view === "notice" ? <> +

{subscriptionNotice(descriptor.label)}

+ {dialog === "github-copilot" &&

{COPILOT_POLICY_NOTICE}

} + : <> +
+ { setBackground(enabled); if (management && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} /> + Allow background use{SUBSCRIPTION_BACKGROUND_HINT} +
+ {!status.available &&

Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.

} + {view === "connection" && interaction &&
+ {interaction.progress &&

{interaction.progress}

} + {interaction.userCode &&

Device code: {interaction.userCode}

} + {interaction.browser && } + {prompt &&
{ event.preventDefault(); if (bridge) void bridge.respond(dialog, interaction.attemptId, prompt.id, promptValue).catch(() => setError(CONTROL_ERROR)); }}> + + {prompt.type === "select" ? : setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5" />} + +
} +
} + } + {(error || status.error) &&

{error || status.error}

} + setConfirm(true)}>Disconnect}> + {!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <> + {status.error && } + + } + + {confirm ? : view === "notice" ? : view === "connection" && } + +
} +
; +} + +export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) { + const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!; + const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId); + return
+ s.pending)} onAction={() => ui.open(providerId)} /> + {status.error &&

{status.error}

} +
; +} +export function SubscriptionConnections({ setup = false }: { setup?: boolean }) { + return
{NATIVE_SUBSCRIPTIONS.map((p) => )}
; +} +export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) { + const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider); + const status = ui.statuses.find((s) => s.providerId === descriptor?.providerId); + if (!descriptor || !status?.connected || status.allowBackgroundUse) return null; + return ; +} diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 2372d01..461e507 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -44,7 +44,8 @@ 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 { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection"; +import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections"; 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"; @@ -211,7 +212,7 @@ export function SetupKeysBody({

)} -
+

Subscriptions

); } @@ -477,7 +478,7 @@ export function SetupDialog({ )}
{SLOT_META[slot].roster} - {slot !== "strong" && } + {slot !== "strong" && <>}