Merge native Copilot and Grok subscription connections
This commit is contained in:
@@ -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 && (
|
||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
||||
{planCostLabel(totalCost, blobs.some((blob) => blob.model?.startsWith("chatgpt:")))}
|
||||
{planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -425,7 +425,7 @@ function ActivityBlobRow({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
||||
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}</span>
|
||||
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, subscriptionLabel(blob.model))}</span>
|
||||
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -464,7 +464,7 @@ function ActivityBlobRow({
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Cost</span>
|
||||
<span className="tabular-nums text-card-foreground">
|
||||
{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
|
||||
{planCostLabel(blob.cost, subscriptionLabel(blob.model))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
|
||||
@@ -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<GraphCostSummary, "stages">): 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 }) {
|
||||
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 text-right tabular-nums">
|
||||
{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) && (
|
||||
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
||||
)}
|
||||
@@ -430,7 +433,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, (run.subscriptionRuns ?? 0) > 0)}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
|
||||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, graphProviders(run))}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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" && <ChatGPTBackgroundAccess provider={coord?.provider} />}
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="—"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"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 { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toggle } from "./controls";
|
||||
|
||||
@@ -22,7 +21,7 @@ function isStatus(value: unknown): value is ChatGPTStatus {
|
||||
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"));
|
||||
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 <Context.Provider value={context}>
|
||||
{children}
|
||||
{dialog && <AccountDialog key={dialog.kind} onClose={close} busy={busy && operation.current !== "login"}>
|
||||
{dialog && <AccountDialog key={`${dialog.kind}:${dialog.kind === "manage" && !!dialog.confirm}`} onClose={close} busy={busy && operation.current !== "login"}>
|
||||
<h2 className="text-sm font-semibold text-foreground">ChatGPT</h2>
|
||||
{dialog.kind === "warning" ? <>
|
||||
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
|
||||
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
|
||||
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
|
||||
<h2 className="text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</h2>
|
||||
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
|
||||
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
|
||||
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||
@@ -270,7 +270,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</div> : <>
|
||||
{dialog.kind === "connect" ? <>
|
||||
<h2 className="text-sm font-semibold text-foreground">Continue with ChatGPT</h2>
|
||||
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
|
||||
<option value="">Add another account or workspace</option>
|
||||
@@ -279,16 +278,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
|
||||
</div>}
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
||||
</> : managed && <>
|
||||
<div data-testid="chatgpt-managed-identity" className="flex items-center gap-2 text-xs text-foreground">
|
||||
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => 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) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||
</select> : <span>{managed.email || "ChatGPT account"}</span>}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center" data-testid="chatgpt-account-actions">
|
||||
<Button variant="ghost" size="xs" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => dialog.kind === "manage" && showDialog({ ...dialog, confirm: true })}>Disconnect account</Button>
|
||||
</div>
|
||||
</>}
|
||||
</> : null}
|
||||
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
||||
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
||||
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
|
||||
@@ -296,7 +286,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
}} />
|
||||
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
|
||||
</div>
|
||||
{dialog.kind === "manage" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
|
||||
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
|
||||
@@ -304,11 +293,21 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.</p>}
|
||||
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
|
||||
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||
<div className="mt-4 flex items-center justify-end gap-2" data-testid="chatgpt-account-footer">
|
||||
{dialog.kind === "manage" && <Button variant="outline" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer disabled:cursor-not-allowed" onClick={openConnect} disabled={busy || status.pending || !status.available}>Add accounts</Button>}
|
||||
<Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
|
||||
{dialog.kind === "manage" && managed && <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid="chatgpt-account-strip">
|
||||
<div data-testid="chatgpt-managed-identity" className="flex min-w-0 items-center gap-2 text-xs text-foreground">
|
||||
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => 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) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||
</select> : <span className="break-all">{managed.email || "ChatGPT account"}</span>}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer text-muted-foreground disabled:cursor-not-allowed" onClick={() => openConnect()} disabled={busy || status.pending || !status.available}>Add accounts</Button>
|
||||
</div>}
|
||||
<ConnectionFooter testid="chatgpt-account-footer" leading={dialog.kind === "manage" && <div data-testid="chatgpt-account-actions">
|
||||
<Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => showDialog({ ...dialog, confirm: true })}>Disconnect</Button>
|
||||
</div>}>
|
||||
{dialog.kind === "manage" && managedPresent && managed && !managed.connected && managed.canSignInAgain === true && status.available && <Button variant="outline" size="sm" disabled={busy || status.pending || !status.available} onClick={() => openConnect(managed.id)}>Sign in again</Button>}
|
||||
<Button variant={dialog.kind === "manage" ? "outline" : "ghost"} size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
|
||||
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
|
||||
</div>
|
||||
</ConnectionFooter>
|
||||
</>}
|
||||
</AccountDialog>}
|
||||
</Context.Provider>;
|
||||
@@ -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<HTMLDivElement>(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<HTMLElement>('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(<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background/80" data-testid="chatgpt-dialog-backdrop" {...backdropDismiss}>
|
||||
<div ref={ref} role="dialog" aria-modal="true" aria-label="ChatGPT account" aria-busy={busy} data-testid="chatgpt-account-dialog" onClick={(event) => 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}</div>
|
||||
</div>, document.body);
|
||||
return <ConnectionDialog label="ChatGPT account" testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>;
|
||||
}
|
||||
|
||||
/** 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 <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{!setup && active && <span className="font-ui text-xs text-foreground">ChatGPT</span>}
|
||||
{active && !setup ? <>
|
||||
<select aria-label="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 || "ChatGPT account"}</option>)}
|
||||
</select>
|
||||
<Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.manage(active.id)}>Manage…</Button>
|
||||
</> : <ContinueButton onClick={ui.connect} disabled={!ui.status.available || ui.busy || ui.status.pending} />}
|
||||
</div>
|
||||
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
|
||||
{active && <select aria-label="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 || "ChatGPT account"}</option>)}
|
||||
</select>}
|
||||
</ConnectionRow>
|
||||
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>}
|
||||
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>Dismiss</button></div>}
|
||||
</div>;
|
||||
|
||||
@@ -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 <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
||||
<span className="font-ui text-xs text-foreground">{label}</span>
|
||||
{children}
|
||||
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ConnectionFooter({ leading, children, testid }: { leading?: React.ReactNode; children: React.ReactNode; testid?: string }) {
|
||||
return <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid={testid}>
|
||||
{leading}
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">{children}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(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<HTMLElement>('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(<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background/80" data-testid={backdropTestid} {...backdropDismiss}>
|
||||
<div ref={ref} role="dialog" aria-modal="true" aria-label={label} aria-busy={busy} data-testid={testid} onClick={(event) => 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}</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -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({
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
|
||||
<div data-testid="group-chatgpt"><ChatGPTConnection /></div>
|
||||
<SubscriptionConnections />
|
||||
</SettingsGroup>
|
||||
|
||||
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<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="USD per 1M tokens in/out">
|
||||
{m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "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">{m.provider} · {m.id}</span>
|
||||
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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 (
|
||||
<ChatGPTUIProvider><DialogContext.Provider value={{ open }}>
|
||||
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
|
||||
{children}
|
||||
{show && (
|
||||
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
||||
)}
|
||||
</DialogContext.Provider></ChatGPTUIProvider>
|
||||
</DialogContext.Provider></NativeSubscriptionUIProvider></ChatGPTUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NativeSubscriptionStatus[]>([]);
|
||||
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(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<string | null>(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<void>, 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 <Context.Provider value={{ statuses, busy: !!operation, open }}>
|
||||
{children}
|
||||
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
|
||||
{confirm ? <>
|
||||
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p>
|
||||
</> : view === "notice" ? <>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</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="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); }} />
|
||||
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{SUBSCRIPTION_BACKGROUND_HINT}</span></span>
|
||||
</div>
|
||||
{!status.available && <p className="mt-3 text-xs text-muted-foreground">Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.</p>}
|
||||
{view === "connection" && interaction && <div className="mt-3 space-y-3 text-xs" data-testid="subscription-login-interaction">
|
||||
{interaction.progress && <p role="status" className="text-muted-foreground">{interaction.progress}</p>}
|
||||
{interaction.userCode && <p>Device code: <code className="select-text font-mono">{interaction.userCode}</code></p>}
|
||||
{interaction.browser && <Button variant="outline" size="sm" disabled={busy} onClick={() => bridge && void bridge.open(dialog, interaction.attemptId, interaction.browser!.id).catch(() => setError("The browser could not be opened. Try again."))}>Open browser</Button>}
|
||||
{prompt && <form className="space-y-2" onSubmit={(event) => { event.preventDefault(); if (bridge) void bridge.respond(dialog, interaction.attemptId, prompt.id, promptValue).catch(() => setError(CONTROL_ERROR)); }}>
|
||||
<label className="block" htmlFor="subscription-auth-prompt">{prompt.message}</label>
|
||||
{prompt.type === "select" ? <select id="subscription-auth-prompt" value={promptValue} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5"><option value="" disabled>Select…</option>{prompt.options?.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}</select> : <input id="subscription-auth-prompt" type={prompt.type === "secret" ? "password" : "text"} autoComplete="off" value={promptValue} placeholder={prompt.placeholder} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5" />}
|
||||
<Button type="submit" variant="outline" size="sm" disabled={busy || (prompt.type === "select" && !promptValue)}>Continue</Button>
|
||||
</form>}
|
||||
</div>}
|
||||
</>}
|
||||
{(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{error || status.error}</p>}
|
||||
<ConnectionFooter testid="subscription-connection-footer" leading={!confirm && management && status.connectionId && !status.pending && <Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" disabled={busy || !!operation} onClick={() => setConfirm(true)}>Disconnect</Button>}>
|
||||
{!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <>
|
||||
{status.error && <Button variant="outline" size="sm" disabled={busy || !!operation} onClick={() => bridge && void control(() => bridge.retry(dialog))}>Retry</Button>}
|
||||
<Button variant="outline" size="sm" disabled={!status.available || busy || !!operation} onClick={beginSignIn}>Sign in again</Button>
|
||||
</>}
|
||||
<Button variant={!confirm && management ? "outline" : "ghost"} size="sm" disabled={busy} onClick={close}>{confirm || !management ? "Cancel" : "Close"}</Button>
|
||||
{confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>Disconnect</Button> : view === "notice" ? <Button variant="outline" size="sm" disabled={!status.available || !!operation} onClick={() => setView("connection")}>Continue</Button> : view === "connection" && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>Connect</Button>}
|
||||
</ConnectionFooter>
|
||||
</ConnectionDialog>}
|
||||
</Context.Provider>;
|
||||
}
|
||||
|
||||
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 <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
|
||||
<ConnectionRow label={descriptor.label} action={status.connectionId ? "Manage…" : "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">{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>;
|
||||
}
|
||||
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 <Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.open(descriptor.providerId)}>Enable background use</Button>;
|
||||
}
|
||||
@@ -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({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-border/60 pt-4"><ChatGPTConnection setup /></div>
|
||||
<div className="mt-2 border-t border-border/60 pt-4"><h3 className="mb-3 font-ui text-xs text-muted-foreground">Subscriptions</h3><SubscriptionConnections setup /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -477,7 +478,7 @@ export function SetupDialog({
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
||||
{slot !== "strong" && <ChatGPTBackgroundAccess provider={entry?.provider} />}
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<button
|
||||
|
||||
@@ -48,7 +48,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
||||
*
|
||||
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
||||
*/
|
||||
import type { ChatGPTControlBridge } from "@/src/core";
|
||||
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
||||
const SIDEBAR_AUTOCLOSE = 928;
|
||||
|
||||
// Module scope, guarded for SSR (the server has no navigator).
|
||||
@@ -61,6 +61,7 @@ declare global {
|
||||
/** Read-only host OS; determines the native-control gutter. */
|
||||
platform?: string;
|
||||
chatgpt?: ChatGPTControlBridge;
|
||||
subscriptions?: NativeSubscriptionControlBridge;
|
||||
expandWindow: () => void;
|
||||
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
||||
onZoom?: (cb: (factor: number) => void) => () => void;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { SessionPricing, TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
|
||||
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||
|
||||
export function formatContextMeasurement(tokens: number | null, limit: number | null): string {
|
||||
@@ -89,7 +89,8 @@ export function StatsBar({
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
// otherwise "—" (never a fake $0.0000).
|
||||
const pricedCount = turns.length - unpriced - subscription;
|
||||
const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—";
|
||||
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -147,7 +148,7 @@ export function StatsBar({
|
||||
<span>{subscription ? "Usage" : "Total cost"}</span>
|
||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||
</div>
|
||||
{subscription > 0 && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage ChatGPT usage</a>}
|
||||
{providers.includes("ChatGPT") && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage ChatGPT usage</a>}
|
||||
{unpriced > 0 && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Unpriced turns</span>
|
||||
@@ -170,7 +171,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} steps</span>
|
||||
<span>{turn.model.startsWith("chatgpt:") ? "ChatGPT plan" : c === null ? "—" : formatUsd(c)}</span>
|
||||
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,9 @@ export function formatUsd(c: number): string {
|
||||
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function planCostLabel(dollars: number | null, subscription: boolean, format = formatUsd): string {
|
||||
if (subscription) return dollars === null ? "ChatGPT plan" : `${format(dollars)} API + ChatGPT plan`;
|
||||
return dollars === null ? "—" : format(dollars);
|
||||
export function planCostLabel(dollars: number | null, subscription: boolean | string | readonly string[] | undefined, format = formatUsd): string {
|
||||
const labels = subscription === true ? ["ChatGPT"] : typeof subscription === "string" ? [subscription] : Array.isArray(subscription) ? [...new Set(subscription)] : [];
|
||||
return [...(dollars === null ? [] : [format(dollars)]), ...labels].join(" + ") || "—";
|
||||
}
|
||||
|
||||
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
||||
|
||||
Reference in New Issue
Block a user