// Session-header Codex control: the same ChatGPT mark that identifies the Codex // rail doubles as the account switcher trigger (not a second icon). Menu shows // active subscription, status/usage, and pin switching. Host-credit: null. import { useCodexAccounts } from "../../../hooks/use-codex-accounts"; import type { CodexAccount, SessionEvent } from "@opengeni/sdk"; import { useState } from "react"; import { BillingClassMark } from "../billing-class-mark"; import { ChatGptMark } from "../chatgpt-mark"; import { SubscriptionAccountMenu } from "./subscription-account-menu"; import { Skeleton } from "../../../ui/skeleton"; // Canonical value: @opengeni/codex CODEX_MODEL_ID_PREFIX. Inlined to avoid a new // web dependency on the server-only codex package. const CODEX_MODEL_ID_PREFIX = "codex/"; const AUTO = "auto"; // Reopening the menu reuses live readings this recent instead of asking the // provider again; older ones stay on screen while the menu re-checks. const LIVE_USAGE_REUSE_MS = 30_000; function accountLabel(account: CodexAccount | undefined | null): string { if (!account) return "—"; return ( account.label ?? account.email ?? account.plan ?? account.chatgptAccountId ?? "Codex account" ); } function tighterRemaining(account: CodexAccount | undefined | null): number | null { if (!account) return null; const remainings = [account.fiveHour?.remaining, account.weekly?.remaining].filter( (value): value is number => typeof value === "number", ); return remainings.length > 0 ? Math.min(...remainings) : null; } function statusTone(account: CodexAccount | undefined | null): "ok" | "warn" | "bad" { if (!account) return "ok"; if (account.status === "needs_relogin" || account.status === "error") return "bad"; const remaining = tighterRemaining(account); if (remaining != null && remaining <= 10) return "warn"; return "ok"; } export function CodexAccountIndicator({ workspaceId: _workspaceId, sessionId, model, modelReady = true, events, }: { workspaceId: string; sessionId: string; model: string; /** False while the session's admitted model identity is still loading. */ modelReady?: boolean; events?: SessionEvent[]; }) { const isCodexSession = model.startsWith(CODEX_MODEL_ID_PREFIX); const codex = useCodexAccounts({ sessionId, pollIntervalMs: 30_000, enabled: modelReady && isCodexSession, ...(events !== undefined ? { events } : {}), }); const [checkingUsage, setCheckingUsage] = useState(false); const [usageChecked, setUsageChecked] = useState(false); const [usageCheckFailed, setUsageCheckFailed] = useState(false); // Live readings from an earlier check (this menu, another session's, or // Settings) stay visible while a new check runs instead of flashing away. const hasLiveUsage = codex.liveUsageUpdatedAt !== null; const awaitingFirstUsage = checkingUsage && !hasLiveUsage; if (!modelReady) { return ( ); } if (!isCodexSession) { return null; } const current = codex.currentSelection; const effective = (current ? codex.currentAccount : null) ?? codex.accounts.find( (account) => account.id === (current ? current.credentialId : codex.pinnedAccountId), ) ?? null; const selectionLabel = current ? current.waiting ? "Limit reached" : "Current account" : "Account selection"; const displayName = effective ? accountLabel(effective) : current?.credentialId ? "Account unavailable" : current && !current.waiting ? "Selecting account" : "Automatic selection"; const accountsPending = codex.loading && !effective; if (accountsPending) { return ( ); } const tone = statusTone(effective); const ariaLabel = `Switch Codex account · ${selectionLabel} · ${displayName}`; const liveStatus = effective ? codex.liveUsage[effective.id]?.status : null; const usageUnavailable = usageCheckFailed || (usageChecked && !liveStatus) || liveStatus === "error" || liveStatus === "no-data"; return ( } headerMark={} selectionLabel={selectionLabel} displayName={displayName} metadata={ [effective?.plan, effective?.status === "active" ? null : effective?.status] .filter(Boolean) .join(" · ") || "Codex subscription" } tone={tone} usage={{ checking: awaitingFirstUsage, unavailable: usageUnavailable, windows: [ ...(effective?.fiveHour ? [{ label: "5h", remaining: effective.fiveHour.remaining }] : []), ...(effective?.weekly ? [{ label: "Week", remaining: effective.weekly.remaining }] : []), ], }} accounts={codex.accounts.map((account) => ({ id: account.id, label: accountLabel(account), plan: account.plan, status: account.status === "active" ? null : account.status === "needs_relogin" ? "relogin" : account.status, remaining: account.id !== effective?.id && !awaitingFirstUsage && !usageCheckFailed && (!usageChecked || codex.liveUsage[account.id]) && codex.liveUsage[account.id]?.status !== "error" && codex.liveUsage[account.id]?.status !== "no-data" ? tighterRemaining(account) : null, }))} selectedId={codex.pinnedAccountId} automaticSelected={codex.pinnedAccountId === null} choosing={codex.pinning} choosingTarget={codex.pinningTarget === AUTO ? null : (codex.pinningTarget ?? undefined)} actionLabel={current?.waiting ? "Retry with" : "Use for next turn"} automaticLabel={current?.waiting ? "Auto" : "Auto (workspace default)"} emptyMessage={codex.error ? "Accounts unavailable." : "No Codex subscriptions connected."} error={codex.mutationError ? `Switch failed: ${codex.mutationError.message}` : null} note={ codex.switchAppliedTo === "waiting_turn" ? "Selection saved. Capacity is being rechecked." : codex.switchAppliedTo === "next_turn" ? "Selection saved for the next turn." : current?.waiting ? "Changing accounts retries the waiting turn." : "Applies next turn. The current turn keeps its account." } onChoose={(id) => { if (codex.pinnedAccountId === id && !current?.waiting) return; void codex.pin(id ?? AUTO); }} onOpenChange={(open) => { if (!open) return; setCheckingUsage(true); setUsageChecked(false); setUsageCheckFailed(false); void codex.refreshUsage({ maxAgeMs: LIVE_USAGE_REUSE_MS }).then( (ok) => { setUsageChecked(ok); setUsageCheckFailed(!ok); setCheckingUsage(false); }, () => { setUsageCheckFailed(true); setCheckingUsage(false); }, ); }} /> ); }