feat: add verified desktop ChatGPT subscription connection
This commit is contained in:
@@ -5,7 +5,7 @@ import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon,
|
||||
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 { formatTokens, planCostLabel } from "@/components/usage-format";
|
||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
|
||||
/**
|
||||
@@ -348,7 +348,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)">
|
||||
{totalCost === null ? "—" : formatUsd(totalCost)}
|
||||
{planCostLabel(totalCost, blobs.some((blob) => blob.model?.startsWith("chatgpt:")))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -423,7 +423,7 @@ function ActivityBlobRow({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
||||
<span data-testid="activity-blob-cost">{blob.cost === null ? "—" : formatUsd(blob.cost)}</span>
|
||||
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}</span>
|
||||
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -462,7 +462,7 @@ function ActivityBlobRow({
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Cost</span>
|
||||
<span className="tabular-nums text-card-foreground">
|
||||
{blob.cost === null ? "—" : formatUsd(blob.cost)}
|
||||
{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
|
||||
@@ -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<GraphCostSummary, "stages">): 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") : `$${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)}
|
||||
</button>
|
||||
</Hint>
|
||||
{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 (
|
||||
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
|
||||
<div className="mb-2 flex items-baseline justify-between">
|
||||
@@ -386,7 +394,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
||||
Graph pipeline costs
|
||||
</span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
${totalDollars.toFixed(2)}
|
||||
{graphUsageLabel(costs)}
|
||||
{unpricedRuns > 0 && (
|
||||
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span>
|
||||
)}
|
||||
@@ -397,8 +405,8 @@ 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 === 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) && (
|
||||
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
@@ -422,7 +430,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars)}</span>
|
||||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, (run.subscriptionRuns ?? 0) > 0)}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+30
-40
@@ -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<ModelCatalogResponse> | undefined;
|
||||
|
||||
function fetchCatalog(force = false): Promise<ModelCatalogResponse> {
|
||||
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<ModelCatalogResponse>;
|
||||
})
|
||||
.then((c) => {
|
||||
catalogCache = c;
|
||||
return c;
|
||||
})
|
||||
.catch((err) => {
|
||||
catalogInflight = undefined; // retry on next mount/open
|
||||
throw err;
|
||||
});
|
||||
return catalogInflight;
|
||||
let catalogInflight: Promise<void> | undefined;
|
||||
let catalogGeneration = 0;
|
||||
let stopCatalogSignals: (() => void) | undefined;
|
||||
const catalogReaders = new Set<() => void>();
|
||||
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
|
||||
function fetchCatalog(revalidate = false): void {
|
||||
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
|
||||
if (catalogInflight) return;
|
||||
const generation = catalogGeneration;
|
||||
catalogInflight = fetch("/api/models")
|
||||
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
|
||||
.then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } })
|
||||
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
|
||||
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
|
||||
}
|
||||
|
||||
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
|
||||
const [catalog, setCatalog] = useState(catalogCache);
|
||||
useEffect(() => {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -322,6 +311,7 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
|
||||
{effectiveProvider === "chatgpt" && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="text-xs text-muted-foreground underline-offset-2 hover:underline">Using ChatGPT plan</a>}
|
||||
<Hint
|
||||
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
|
||||
// — the long sentence was way too much. ⌘K opens this same
|
||||
|
||||
@@ -344,18 +344,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
|
||||
@@ -364,6 +369,7 @@ export function AgentSection({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
modelsMounted.current = true;
|
||||
readModels();
|
||||
fetch("/api/settings/prompts")
|
||||
.then((r) => r.json())
|
||||
@@ -380,6 +386,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 —
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ChatGPTStatus } from "@/src/core";
|
||||
|
||||
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally 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.";
|
||||
const empty: ChatGPTStatus = { available: false, pending: false, activeId: null, accounts: [], error: null };
|
||||
|
||||
/** Status/control surface only; OAuth URLs, codes, and tokens never reach React. */
|
||||
export function ChatGPTConnection() {
|
||||
const [status, setStatus] = useState<ChatGPTStatus>(empty);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const bridge = window.lh?.chatgpt;
|
||||
if (!bridge) return;
|
||||
let mounted = true;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const next = await bridge.status();
|
||||
if (!mounted) return;
|
||||
setStatus(next);
|
||||
setSelected((old) => old || next.activeId || "");
|
||||
} catch { if (mounted) setError("The desktop connection is unavailable. Retry when the app is ready."); }
|
||||
};
|
||||
void refresh();
|
||||
const off = bridge.onChange(() => void refresh());
|
||||
return () => { mounted = false; off(); };
|
||||
}, []);
|
||||
const account = status.accounts.find((a) => a.id === selected);
|
||||
async function control(action: () => Promise<ChatGPTStatus>) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await action();
|
||||
if (typeof next.available === "boolean") setStatus(next);
|
||||
setError(next.error ?? null);
|
||||
} catch { setError("The ChatGPT connection could not be changed. Try again."); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
|
||||
return (
|
||||
<div className="flex flex-col gap-3" data-testid="chatgpt-connection">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-ui text-xs text-foreground">ChatGPT</span>
|
||||
<span className="text-xs text-muted-foreground" role="status">{status.pending ? "Waiting for browser sign-in…" : status.accounts.some((a) => a.id === status.activeId && a.connected) ? "ChatGPT plan connection" : "Not connected"}</span>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
||||
<div className="flex flex-wrap 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>
|
||||
</div>
|
||||
{status.accounts.length > 0 && (
|
||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
Saved ChatGPT account / registration
|
||||
<select aria-label="Saved ChatGPT account" value={selected} onChange={(event) => setSelected(event.target.value)} disabled={busy || status.pending} className="rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
|
||||
<option value="">Add another account or workspace</option>
|
||||
{status.accounts.map((item) => <option key={item.id} value={item.id}>{item.email} · registration {item.registration}{item.id === status.activeId ? " · active" : ""}</option>)}
|
||||
</select>
|
||||
<span>Registrations can belong to different workspaces. An email does not identify a workspace.</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Official black/white format and unmodified OpenAI-provided mark. */}
|
||||
<button type="button" data-testid="chatgpt-login" disabled={!status.available || busy || status.pending} onClick={() => bridge && void control(() => bridge.login(selected || undefined))} className="inline-flex min-h-10 items-center justify-center gap-2 rounded-md border border-white/20 bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-40">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/siwc/chatgpt-logo-white.svg" width="21" height="21" alt="" aria-hidden="true" />
|
||||
Continue with ChatGPT
|
||||
</button>
|
||||
{status.pending && <button type="button" onClick={() => bridge && void control(() => bridge.cancel())} className="text-xs underline underline-offset-2">Cancel sign-in</button>}
|
||||
{account?.connected && selected !== status.activeId && <button type="button" disabled={busy || status.pending} onClick={() => bridge && void control(() => bridge.select(selected))} className="text-xs underline underline-offset-2">Use this account</button>}
|
||||
{account && <button type="button" disabled={busy} onClick={() => bridge && void control(() => bridge.logout(selected))} className="text-xs underline underline-offset-2">Disconnect</button>}
|
||||
</div>
|
||||
{!status.available && <p className="text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>}
|
||||
{account && (
|
||||
<label className="flex items-start gap-2 text-xs text-foreground">
|
||||
<input type="checkbox" checked={account.background} disabled={busy || !status.available || status.pending} onChange={(event) => bridge && void control(() => bridge.consent(account.id, event.target.checked))} className="mt-0.5" />
|
||||
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">Allow extraction, compaction, delegated jobs, and automatic follow-up work to use this ChatGPT connection.</span></span>
|
||||
</label>
|
||||
)}
|
||||
{(error || status.error || status.catalogError) && <p role="alert" className="text-xs text-red-400">{error || status.error || status.catalogError}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +413,7 @@ export function KeysSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup label="ChatGPT" testid="group-chatgpt"><ChatGPTConnection /></SettingsGroup>
|
||||
<SettingsGroup label="Model providers" testid="group-provider-keys">
|
||||
{providerRows.map((status) => (
|
||||
<KeyTableRow
|
||||
|
||||
@@ -126,7 +126,7 @@ 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">
|
||||
{priceLabel(m.cost)}
|
||||
{m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
|
||||
@@ -55,7 +55,7 @@ import { KeyboardSection } from "./keyboard-section.tsx";
|
||||
* real question users ask). */
|
||||
const SECTIONS = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "keys", label: "Keys" },
|
||||
{ id: "keys", label: "Connections" },
|
||||
{ id: "agent", label: "Agent" },
|
||||
{ id: "appearance", label: "Appearance" },
|
||||
{ id: "graph", label: "Graph" },
|
||||
@@ -376,7 +376,7 @@ export function SettingsDialogBody({
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "keys" && (
|
||||
<SettingsSection title="Keys" testid="section-keys">
|
||||
<SettingsSection title="Connections" testid="section-keys">
|
||||
<KeysSection initialRow={initialRow ?? undefined} />
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 { 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,8 @@ export function SetupKeysBody({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
||||
<p className="text-sm text-foreground">Add a provider key.</p>
|
||||
<ChatGPTConnection />
|
||||
<p className="border-t border-border pt-3 text-sm text-foreground">Or add an API key.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your AI provider and paste its API key. You’ll pick your models next.
|
||||
</p>
|
||||
@@ -265,16 +267,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 +297,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
|
||||
|
||||
@@ -21,6 +21,7 @@ import { fuzzyScore } from "@/src/core";
|
||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
import type { ChatListItem } from "@/src/core";
|
||||
import { kbdEventForAction } from "@/src/core";
|
||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||
|
||||
/**
|
||||
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
|
||||
@@ -47,6 +48,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
||||
*
|
||||
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
||||
*/
|
||||
import type { ChatGPTControlBridge } from "@/src/core";
|
||||
const SIDEBAR_AUTOCLOSE = 928;
|
||||
|
||||
// Module scope, guarded for SSR (the server has no navigator).
|
||||
@@ -58,6 +60,7 @@ declare global {
|
||||
lh?: {
|
||||
/** Read-only host OS; determines the native-control gutter. */
|
||||
platform?: string;
|
||||
chatgpt?: ChatGPTControlBridge;
|
||||
expandWindow: () => void;
|
||||
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
||||
onZoom?: (cb: (factor: number) => void) => () => void;
|
||||
@@ -79,6 +82,13 @@ export function useSidebar() {
|
||||
}
|
||||
|
||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const bridge = window.lh?.chatgpt;
|
||||
if (!bridge) return;
|
||||
const off = bridge.onChange(() => notifySettingsSaved("keys"));
|
||||
notifySettingsSaved("keys"); // cover changes missed before this root-lifetime subscription
|
||||
return off;
|
||||
}, []);
|
||||
// Start closed so server and client first-render agree (no hydration
|
||||
// mismatch); the stored value is applied after mount.
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -83,11 +83,11 @@ export function StatsBar({
|
||||
const { catalog } = useModelCatalog();
|
||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
||||
const ctx = contextWindow({ turns }, limits);
|
||||
const { cost, unpriced } = sessionCost({ turns });
|
||||
const { cost, unpriced, subscription = 0 } = sessionCost({ turns });
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
// otherwise "—" (never a fake $0.0000).
|
||||
const pricedCount = turns.length - unpriced;
|
||||
const costLabel = pricedCount > 0 ? formatUsd(cost) : "—";
|
||||
const pricedCount = turns.length - unpriced - subscription;
|
||||
const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—";
|
||||
const lastModel = turns.at(-1)?.model;
|
||||
|
||||
return (
|
||||
@@ -143,9 +143,10 @@ export function StatsBar({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Total cost</span>
|
||||
<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>}
|
||||
{unpriced > 0 && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Unpriced turns</span>
|
||||
@@ -168,7 +169,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>{c === null ? "—" : formatUsd(c)}</span>
|
||||
<span>{turn.model.startsWith("chatgpt:") ? "ChatGPT plan" : c === null ? "—" : formatUsd(c)}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,11 @@ 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);
|
||||
}
|
||||
|
||||
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
|
||||
|
||||
Reference in New Issue
Block a user