@hienlh/ppm 0.20.1 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +1 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +5 -1
- package/dist/web/assets/accounts-settings-section-DzDmIsPe.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BLctP1Pr.js → ai-resource-editor-D7C9wmwA.js} +1 -1
- package/dist/web/assets/{ai-settings-section-0hSDp7xx.js → ai-settings-section-BW5a07-Z.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CTEU2uGa.js → ai-settings-section-DZuwllLy.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-BkcoSV0E.js → appearance-settings-section-RfH2N1rj.js} +1 -1
- package/dist/web/assets/chat-tab-Bz0pk0_E.js +15 -0
- package/dist/web/assets/{code-editor-C4kKhamq.js → code-editor-C4o1SyuO.js} +2 -2
- package/dist/web/assets/{conflict-editor-BEULKwHk.js → conflict-editor-Dk65mBIe.js} +1 -1
- package/dist/web/assets/{csv-preview-k3eM8nFf.js → csv-preview-DENi3CFz.js} +1 -1
- package/dist/web/assets/{database-viewer-Cslir1dr.js → database-viewer-BiBC7ulB.js} +1 -1
- package/dist/web/assets/{diff-viewer-DzAIvBbn.js → diff-viewer-CSvZJHak.js} +1 -1
- package/dist/web/assets/{explorer-body-D6H-4cz9.js → explorer-body-QXS0h4AF.js} +1 -1
- package/dist/web/assets/{explorer-window-content-C5W_JzCz.js → explorer-window-content-CQgqoRsj.js} +1 -1
- package/dist/web/assets/{extension-manager-section-DwicI3CA.js → extension-manager-section-CyC7wX35.js} +1 -1
- package/dist/web/assets/{extension-webview-Z3HhC8Pd.js → extension-webview-Da8SIlRa.js} +1 -1
- package/dist/web/assets/{files-settings-section-ByV7bRbB.js → files-settings-section-D8cebM51.js} +1 -1
- package/dist/web/assets/{general-settings-section-nLksKHur.js → general-settings-section-1HVTvlIw.js} +1 -1
- package/dist/web/assets/{git-log-panel-iUXltiTB.js → git-log-panel-C7a4rp8f.js} +1 -1
- package/dist/web/assets/{glide-data-grid-C1QB4DHt.js → glide-data-grid-BT9p_4AN.js} +1 -1
- package/dist/web/assets/{group-chat-tab-ezXKVVpg.js → group-chat-tab-BUHGnUyW.js} +1 -1
- package/dist/web/assets/index-CuYtJ5xF.css +2 -0
- package/dist/web/assets/{index-CJWbFurD.js → index-kGmcC9cb.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-DwARwskB.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
- package/dist/web/assets/{keybindings-store-S7YLGd8B.js → keybindings-store-DjJum4OS.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-BoUDvdDy.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DKdfiSIX.js → markdown-renderer-dHeak2mE.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DYFmMC_8.js → markdown-renderer-iBw1fCI_.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CMdqLCV-.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
- package/dist/web/assets/{notification-store-DpSYFj36.js → notification-store-DVi6Ds71.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-CIf3-3xd.js → notifications-settings-section-shQ-ugKh.js} +1 -1
- package/dist/web/assets/{pdf-preview-C-7IdkIK.js → pdf-preview-DB26KSzm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BYXFYlLg.js → postgres-viewer-CJe2meyF.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-B9dXvQ6p.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
- package/dist/web/assets/{process-table-B6NeTWt8.js → process-table-PXwLsMuG.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-CUqq7pdU.js → proxy-settings-section-BHBhK6HC.js} +1 -1
- package/dist/web/assets/{query-audit-section-C1TYrPIp.js → query-audit-section-BD5ZVqDz.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-DuKwKe5F.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-DxMTtsa6.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-BwjfLWqg.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-BIl4N5Hn.js → remote-desktop-window-content-X324gYbW.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-C0qOb7GI.js → schedules-settings-section-a4-h6THc.js} +1 -1
- package/dist/web/assets/{settings-body-qbrJXX_A.js → settings-body-Fq7BVDw2.js} +2 -2
- package/dist/web/assets/{settings-tab-BKRgYVxf.js → settings-tab-CVLQy-ie.js} +1 -1
- package/dist/web/assets/{settings-window-content-DWsxJDu-.js → settings-window-content-Bi7M0iaZ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-CsZOpCW3.js → sql-query-editor-D1r265fl.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DUrnCaP7.js → sqlite-viewer-DHkIotRl.js} +1 -1
- package/dist/web/assets/{system-monitor-body-C5cMpg7I.js → system-monitor-body-y6VUkYTL.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-C66ywbVZ.js → system-monitor-tab-DRAOejhk.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-CENaXYJ-.js → system-monitor-window-content-B5i57xHD.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-HViABGoS.js → tab-host-window-content-SkyPFFlc.js} +1 -1
- package/dist/web/assets/{team-member-sheet-CK6eyBRL.js → team-member-sheet-BB63u-Lf.js} +2 -2
- package/dist/web/assets/{team-member-window-content-D6wxg37P.js → team-member-window-content-D6tVTXuV.js} +1 -1
- package/dist/web/assets/{terminal-tab-BmyTawmN.js → terminal-tab-BYaHAkI5.js} +1 -1
- package/dist/web/assets/{tool-cards-y-XJcJHo.js → tool-cards-DRJu20sL.js} +2 -2
- package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
- package/dist/web/assets/{use-monaco-theme-BkM5rMgL.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-BT2r1td1.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
- package/dist/web/assets/{use-websocket-BvVTwMjI.js → use-websocket-BC_EGf5q.js} +1 -1
- package/dist/web/assets/{video-preview-BEaofjN7.js → video-preview-BUb43zWb.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/deployment-guide.md +10 -0
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
- package/src/providers/codex-app-server/codex-history.ts +118 -45
- package/src/providers/codex-app-server/codex-provider.ts +277 -15
- package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
- package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
- package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
- package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
- package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
- package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
- package/src/server/routes/chat.ts +23 -2
- package/src/server/routes/codex-accounts.ts +32 -3
- package/src/server/ws/chat.ts +15 -11
- package/src/services/codex-account-cooldown.ts +56 -0
- package/src/services/codex-account-login.ts +109 -34
- package/src/services/codex-account.service.ts +56 -10
- package/src/services/codex-login-callback.ts +57 -0
- package/src/web/components/chat/chat-tab.tsx +8 -2
- package/src/web/components/chat/codex-usage-panel.tsx +129 -165
- package/src/web/components/chat/usage-badge.tsx +38 -129
- package/src/web/components/chat/usage-panel-shell.tsx +153 -0
- package/src/web/components/settings/accounts/account-card.tsx +20 -3
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
- package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
- package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
- package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
- package/src/web/components/ui/tooltip.tsx +14 -1
- package/src/web/hooks/use-usage.ts +13 -1
- package/dist/web/assets/accounts-settings-section-_sG1AqtH.js +0 -1
- package/dist/web/assets/chat-tab-CJi4uYb9.js +0 -15
- package/dist/web/assets/index-CoaVwAF6.css +0 -2
- package/dist/web/assets/use-accounts-data-BbOb2QNP.js +0 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chrome shared by the chat header's usage panels.
|
|
3
|
+
*
|
|
4
|
+
* Both providers' panels answer the same two questions — how much quota is left, and which
|
|
5
|
+
* account should serve this chat — so they are the same panel with a different source of
|
|
6
|
+
* accounts behind them. Keeping the frame here is what stops that from drifting: the Codex
|
|
7
|
+
* panel used to draw its own header and stack its cards vertically, so comparing two
|
|
8
|
+
* accounts meant scrolling past the first, while the Claude panel had had a sideways strip
|
|
9
|
+
* and a fullscreen grid for exactly that reason.
|
|
10
|
+
*
|
|
11
|
+
* Fullscreen lives here rather than in either caller because the layout it switches to is a
|
|
12
|
+
* property of the frame, and the cards only need to be told which shape to take.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState, type ReactNode } from "react";
|
|
16
|
+
import { ExternalLink, Maximize2, Minimize2, RefreshCw, X } from "lucide-react";
|
|
17
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
18
|
+
import { openSettings } from "@/components/settings/open-settings";
|
|
19
|
+
|
|
20
|
+
export interface UsagePanelShellProps {
|
|
21
|
+
title: string;
|
|
22
|
+
/** A note beside the title — when the numbers were last read, typically. */
|
|
23
|
+
meta?: ReactNode;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
onReload?: () => void;
|
|
26
|
+
reloading?: boolean;
|
|
27
|
+
/** One strip for anything that went wrong in the panel. Two messages in two places
|
|
28
|
+
* would be harder to notice, not clearer. */
|
|
29
|
+
error?: string | null;
|
|
30
|
+
onDismissError?: () => void;
|
|
31
|
+
/** How many cards `children` will render. Zero shows `fallback` and hides fullscreen,
|
|
32
|
+
* which has nothing to lay out. */
|
|
33
|
+
cardCount: number;
|
|
34
|
+
/** The cards, told which shape to take. */
|
|
35
|
+
children: (layout: "strip" | "grid") => ReactNode;
|
|
36
|
+
/** Shown in place of the cards when there are none. */
|
|
37
|
+
fallback?: ReactNode;
|
|
38
|
+
/** Anything below the cards — costs, hints. Hidden in fullscreen, which is for comparing. */
|
|
39
|
+
footer?: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function UsagePanelShell({
|
|
43
|
+
title, meta, onClose, onReload, reloading, error, onDismissError,
|
|
44
|
+
cardCount, children, fallback, footer,
|
|
45
|
+
}: UsagePanelShellProps) {
|
|
46
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
47
|
+
|
|
48
|
+
// Roughly square, so the cards fill the viewport instead of leaving a long empty column.
|
|
49
|
+
const fsCount = cardCount || 1;
|
|
50
|
+
const fsCols = Math.ceil(Math.sqrt(fsCount));
|
|
51
|
+
const fsRows = Math.ceil(fsCount / fsCols);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
className={`relative border-b border-border bg-surface px-3 py-2.5 ${
|
|
56
|
+
isFullscreen
|
|
57
|
+
? "fixed inset-0 z-50 flex flex-col gap-2.5 overflow-hidden"
|
|
58
|
+
: "space-y-2.5 max-h-[350px] overflow-y-auto"
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
<div className="flex items-center justify-between shrink-0">
|
|
62
|
+
<div className="flex items-center gap-2">
|
|
63
|
+
<span className="text-xs font-semibold text-text-primary">{title}</span>
|
|
64
|
+
{meta}
|
|
65
|
+
</div>
|
|
66
|
+
<div className="flex items-center gap-1">
|
|
67
|
+
<Tooltip>
|
|
68
|
+
<TooltipTrigger asChild>
|
|
69
|
+
<button
|
|
70
|
+
onClick={() => openSettings("accounts")}
|
|
71
|
+
className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
72
|
+
>
|
|
73
|
+
Manage accounts <ExternalLink className="size-3" />
|
|
74
|
+
</button>
|
|
75
|
+
</TooltipTrigger>
|
|
76
|
+
<TooltipContent side="top">Add, remove or rotate accounts</TooltipContent>
|
|
77
|
+
</Tooltip>
|
|
78
|
+
{cardCount > 0 && (
|
|
79
|
+
<Tooltip>
|
|
80
|
+
<TooltipTrigger asChild>
|
|
81
|
+
<button
|
|
82
|
+
onClick={() => setIsFullscreen((v) => !v)}
|
|
83
|
+
className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
84
|
+
aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
|
|
85
|
+
>
|
|
86
|
+
{isFullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
|
|
87
|
+
</button>
|
|
88
|
+
</TooltipTrigger>
|
|
89
|
+
<TooltipContent side="top">{isFullscreen ? "Exit fullscreen" : "Fullscreen view"}</TooltipContent>
|
|
90
|
+
</Tooltip>
|
|
91
|
+
)}
|
|
92
|
+
{onReload && (
|
|
93
|
+
<Tooltip>
|
|
94
|
+
<TooltipTrigger asChild>
|
|
95
|
+
<button
|
|
96
|
+
onClick={onReload}
|
|
97
|
+
disabled={reloading}
|
|
98
|
+
className="text-xs text-text-subtle hover:text-text-primary px-1 disabled:opacity-50 cursor-pointer"
|
|
99
|
+
aria-label="Refresh usage"
|
|
100
|
+
>
|
|
101
|
+
<RefreshCw className={`size-3 ${reloading ? "animate-spin" : ""}`} />
|
|
102
|
+
</button>
|
|
103
|
+
</TooltipTrigger>
|
|
104
|
+
<TooltipContent side="top">Refresh</TooltipContent>
|
|
105
|
+
</Tooltip>
|
|
106
|
+
)}
|
|
107
|
+
<button
|
|
108
|
+
onClick={() => { setIsFullscreen(false); onClose(); }}
|
|
109
|
+
className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
110
|
+
aria-label="Close usage panel"
|
|
111
|
+
>
|
|
112
|
+
<X className="size-3" />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* The server distinguishes a login it rejected from a provider it could not reach, and
|
|
118
|
+
the difference decides what the user should do. Showing its words verbatim is the
|
|
119
|
+
only way that survives to them. */}
|
|
120
|
+
{error && (
|
|
121
|
+
<div className="shrink-0 flex items-start gap-2 rounded border border-error/40 bg-error/10 px-2 py-1.5 text-[11px] text-error">
|
|
122
|
+
<span className="flex-1">{error}</span>
|
|
123
|
+
{onDismissError && (
|
|
124
|
+
<button
|
|
125
|
+
onClick={onDismissError}
|
|
126
|
+
className="shrink-0 text-error/70 hover:text-error cursor-pointer"
|
|
127
|
+
aria-label="Dismiss"
|
|
128
|
+
>
|
|
129
|
+
<X className="size-3" />
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{cardCount > 0 ? (
|
|
136
|
+
<div
|
|
137
|
+
className={isFullscreen
|
|
138
|
+
? "flex-1 min-h-0 grid gap-2 overflow-hidden"
|
|
139
|
+
// Same classes as AccountCardRow, with the panel's wider padding to clear.
|
|
140
|
+
: "flex gap-2 overflow-x-auto pb-1 -mx-3 px-3 snap-x snap-mandatory scrollbar-thin"}
|
|
141
|
+
style={isFullscreen ? {
|
|
142
|
+
gridTemplateColumns: `repeat(${fsCols}, minmax(0, 1fr))`,
|
|
143
|
+
gridTemplateRows: `repeat(${fsRows}, minmax(0, 1fr))`,
|
|
144
|
+
} : undefined}
|
|
145
|
+
>
|
|
146
|
+
{children(isFullscreen ? "grid" : "strip")}
|
|
147
|
+
</div>
|
|
148
|
+
) : fallback}
|
|
149
|
+
|
|
150
|
+
{footer && !isFullscreen && footer}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -51,6 +51,10 @@ export interface AccountCardProps {
|
|
|
51
51
|
/** `list` fills the width, `strip` is a fixed-width card in a sideways scroller, `grid`
|
|
52
52
|
* fills a cell of the fullscreen grid. */
|
|
53
53
|
layout?: "list" | "strip" | "grid";
|
|
54
|
+
/** The subscription tier this account is on, when the provider reports one. Codex names
|
|
55
|
+
* a plan ("plus", "team") and that decides which quota windows the account even has, so
|
|
56
|
+
* it belongs on the card; Claude reports none and the badge simply does not appear. */
|
|
57
|
+
planLabel?: string | null;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// Fixed widths so a row scrolls instead of squeezing. Two of them: a read-only card holds a
|
|
@@ -60,7 +64,7 @@ const STRIP_WIDTH = { readOnly: "min-w-[220px]", withActions: "min-w-[300px]" }
|
|
|
60
64
|
|
|
61
65
|
export function AccountCard({
|
|
62
66
|
entry, isActive, accountInfo, onToggle, toggling, onDelete, onExport, onViewProfile, flash,
|
|
63
|
-
onSelect, unselectableReason, selecting,
|
|
67
|
+
onSelect, unselectableReason, selecting, planLabel,
|
|
64
68
|
layout = "list",
|
|
65
69
|
}: AccountCardProps) {
|
|
66
70
|
const { usage } = entry;
|
|
@@ -83,8 +87,13 @@ export function AccountCard({
|
|
|
83
87
|
// restates what the sign-in chip beside it implies. That leaves the two states nothing
|
|
84
88
|
// else announces: a temporary token with no way to renew, and one that has lapsed but
|
|
85
89
|
// still has a refresh token behind it.
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
//
|
|
91
|
+
// Gated on there being an `accountInfo` at all. Without one `tokenStatus` reports
|
|
92
|
+
// "unknown", which reads as a warning about this account's token — and a Codex card has
|
|
93
|
+
// no token to warn about, because its credentials live in a CODEX_HOME the server
|
|
94
|
+
// authenticates against rather than in a grant with an expiry.
|
|
95
|
+
const showTokenStatus = !!accountInfo && (ts.label === "temp" || ts.label === "unknown"
|
|
96
|
+
|| (ts.label === "expired" && !isExpired && !needsReauth));
|
|
88
97
|
const grantExpiresOn = grantExpiresAtMs
|
|
89
98
|
? new Date(grantExpiresAtMs).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
|
|
90
99
|
: null;
|
|
@@ -173,6 +182,14 @@ export function AccountCard({
|
|
|
173
182
|
API key
|
|
174
183
|
</AccountHint>
|
|
175
184
|
)}
|
|
185
|
+
{planLabel && (
|
|
186
|
+
<AccountHint
|
|
187
|
+
className="text-[10px] uppercase tracking-wide text-text-subtle shrink-0"
|
|
188
|
+
hint="The subscription this account is on. It decides which quota windows the account has — some plans have a weekly limit and no shorter one."
|
|
189
|
+
>
|
|
190
|
+
{planLabel}
|
|
191
|
+
</AccountHint>
|
|
192
|
+
)}
|
|
176
193
|
|
|
177
194
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
178
195
|
{!isExpired && onViewProfile && accountInfo?.profileData && (
|
|
@@ -19,7 +19,7 @@ import { CodexUsageRows } from "./codex-usage-rows";
|
|
|
19
19
|
import { useCodexAccounts } from "./use-codex-accounts";
|
|
20
20
|
|
|
21
21
|
/** Codex multi-account management, separate from Claude accounts because codex auth is owned
|
|
22
|
-
* by the app-server per CODEX_HOME. Added by API key or ChatGPT device code. */
|
|
22
|
+
* by the app-server per CODEX_HOME. Added by API key, browser login, or ChatGPT device code. */
|
|
23
23
|
export function CodexAccountsSection() {
|
|
24
24
|
const [dialog, setDialog] = useState<"add" | "export" | "import" | "rotation" | null>(null);
|
|
25
25
|
const c = useCodexAccounts(() => setDialog(null));
|
|
@@ -120,7 +120,7 @@ export function CodexAccountsSection() {
|
|
|
120
120
|
|
|
121
121
|
<CodexAddAccountDialog
|
|
122
122
|
open={dialog === "add"}
|
|
123
|
-
onOpenChange={(v) => setDialog(v ? "add" : null)}
|
|
123
|
+
onOpenChange={(v) => { if (!v) c.cancelLogin(); setDialog(v ? "add" : null); }}
|
|
124
124
|
label={c.label}
|
|
125
125
|
onLabelChange={c.setLabel}
|
|
126
126
|
apiKey={c.apiKey}
|
|
@@ -130,6 +130,13 @@ export function CodexAccountsSection() {
|
|
|
130
130
|
deviceWaiting={c.deviceWaiting}
|
|
131
131
|
onStartDevice={() => void c.startDevice()}
|
|
132
132
|
device={c.device}
|
|
133
|
+
browser={c.browser}
|
|
134
|
+
onStartBrowser={() => void c.startBrowser()}
|
|
135
|
+
loginStarting={c.loginStarting}
|
|
136
|
+
callbackUrl={c.callbackUrl}
|
|
137
|
+
onCallbackUrlChange={c.setCallbackUrl}
|
|
138
|
+
submittingCallback={c.submittingCallback}
|
|
139
|
+
onSubmitCallback={() => void c.submitCallback()}
|
|
133
140
|
error={c.err}
|
|
134
141
|
/>
|
|
135
142
|
<CodexBackupDialog
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Add a Codex account: sign in with ChatGPT, or paste an API key.
|
|
3
|
-
*
|
|
4
|
-
* Presentational only — every piece of state and each handler stays in the pane. The
|
|
5
|
-
* device-code login owns a server-side app-server that has to be released if the user walks
|
|
6
|
-
* away, and the pane's unmount effect is what does that; moving the flow's state in here
|
|
7
|
-
* would tie that release to closing a dialog instead.
|
|
8
|
-
*
|
|
9
|
-
* Mirrors the Claude add dialog: recommended sign-in first, divider, manual key below.
|
|
10
|
-
*/
|
|
1
|
+
/** Add a Codex account through browser login, device code, or an API key. */
|
|
11
2
|
|
|
12
3
|
import { ExternalLink, KeyRound, Loader2, MonitorSmartphone } from "lucide-react";
|
|
13
4
|
import {
|
|
@@ -25,6 +16,7 @@ export interface CodexDevicePrompt {
|
|
|
25
16
|
export function CodexAddAccountDialog({
|
|
26
17
|
open, onOpenChange, label, onLabelChange, apiKey, onApiKeyChange,
|
|
27
18
|
adding, onAddApiKey, deviceWaiting, onStartDevice, device, error,
|
|
19
|
+
browser, onStartBrowser, loginStarting, callbackUrl, onCallbackUrlChange, submittingCallback, onSubmitCallback,
|
|
28
20
|
}: {
|
|
29
21
|
open: boolean;
|
|
30
22
|
onOpenChange: (open: boolean) => void;
|
|
@@ -38,29 +30,66 @@ export function CodexAddAccountDialog({
|
|
|
38
30
|
onStartDevice: () => void;
|
|
39
31
|
device: CodexDevicePrompt | null;
|
|
40
32
|
error: string | null;
|
|
33
|
+
browser: { authUrl: string } | null;
|
|
34
|
+
onStartBrowser: () => void;
|
|
35
|
+
loginStarting: boolean;
|
|
36
|
+
callbackUrl: string;
|
|
37
|
+
onCallbackUrlChange: (value: string) => void;
|
|
38
|
+
submittingCallback: boolean;
|
|
39
|
+
onSubmitCallback: () => void;
|
|
41
40
|
}) {
|
|
41
|
+
const loginBusy = loginStarting || deviceWaiting || !!browser || adding;
|
|
42
42
|
return (
|
|
43
43
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
44
|
-
<DialogContent className="sm:max-w-md">
|
|
44
|
+
<DialogContent className="sm:max-w-md max-h-[90dvh] overflow-y-auto">
|
|
45
45
|
<DialogHeader>
|
|
46
46
|
<DialogTitle className="text-sm">Add Codex Account</DialogTitle>
|
|
47
47
|
<DialogDescription className="text-xs leading-relaxed">
|
|
48
|
-
|
|
48
|
+
Choose browser login, a device code, or an OpenAI API key.
|
|
49
49
|
</DialogDescription>
|
|
50
50
|
</DialogHeader>
|
|
51
51
|
|
|
52
52
|
<div className="space-y-3">
|
|
53
|
+
<div className="space-y-1.5">
|
|
54
|
+
<Label htmlFor="codex-label" className="text-xs">Label (optional)</Label>
|
|
55
|
+
<Input id="codex-label" placeholder="e.g. Personal, Work" value={label}
|
|
56
|
+
onChange={(e) => onLabelChange(e.target.value)} disabled={loginBusy} className="text-xs" />
|
|
57
|
+
</div>
|
|
58
|
+
<div className="rounded-md border p-3 space-y-2">
|
|
59
|
+
<p className="text-[11px] font-medium">Browser login</p>
|
|
60
|
+
<Button size="sm" className="w-full cursor-pointer gap-1.5" onClick={onStartBrowser} disabled={loginBusy}>
|
|
61
|
+
<ExternalLink className="size-4" /> Sign in with browser
|
|
62
|
+
</Button>
|
|
63
|
+
{browser && (
|
|
64
|
+
<div className="space-y-2 text-xs">
|
|
65
|
+
<a href={browser.authUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
|
|
66
|
+
<ExternalLink className="size-3" /> Open ChatGPT sign-in
|
|
67
|
+
</a>
|
|
68
|
+
<p>Sign in, then copy the full localhost callback URL from the address bar and paste it below. On a phone, copy it even if the page fails to load.</p>
|
|
69
|
+
<Label htmlFor="codex-callback-url">Callback URL</Label>
|
|
70
|
+
<Input id="codex-callback-url" type="password" autoComplete="off" spellCheck={false}
|
|
71
|
+
placeholder="http://localhost:.../auth/callback?..." value={callbackUrl}
|
|
72
|
+
onChange={(e) => onCallbackUrlChange(e.target.value)} disabled={submittingCallback} />
|
|
73
|
+
<Button size="sm" onClick={onSubmitCallback} disabled={!callbackUrl.trim() || submittingCallback}>
|
|
74
|
+
{submittingCallback && <Loader2 className="size-3 animate-spin" />} Submit callback URL
|
|
75
|
+
</Button>
|
|
76
|
+
<p role="status" className="flex items-center gap-1.5 text-muted-foreground">
|
|
77
|
+
<Loader2 className="size-3 animate-spin" /> Waiting for authorization. This may complete automatically on this computer.
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
53
82
|
<div className="rounded-md border p-3 space-y-2">
|
|
54
|
-
<p className="text-[11px] font-medium">
|
|
83
|
+
<p className="text-[11px] font-medium">Device code</p>
|
|
55
84
|
<Button
|
|
56
85
|
size="sm"
|
|
57
86
|
className="w-full cursor-pointer gap-1.5"
|
|
58
87
|
onClick={onStartDevice}
|
|
59
|
-
disabled={
|
|
88
|
+
disabled={loginBusy}
|
|
60
89
|
>
|
|
61
90
|
{deviceWaiting
|
|
62
91
|
? <><Loader2 className="size-4 animate-spin" /> Waiting for authorization...</>
|
|
63
|
-
: <><MonitorSmartphone className="size-4" /> Sign in with
|
|
92
|
+
: <><MonitorSmartphone className="size-4" /> Sign in with device code</>}
|
|
64
93
|
</Button>
|
|
65
94
|
|
|
66
95
|
{device && (
|
|
@@ -96,19 +125,10 @@ export function CodexAddAccountDialog({
|
|
|
96
125
|
className="text-xs font-mono"
|
|
97
126
|
/>
|
|
98
127
|
</div>
|
|
99
|
-
<div className="space-y-1.5">
|
|
100
|
-
<Label htmlFor="codex-label" className="text-xs">Label (optional)</Label>
|
|
101
|
-
<Input
|
|
102
|
-
id="codex-label"
|
|
103
|
-
placeholder="e.g. Personal, Work"
|
|
104
|
-
value={label}
|
|
105
|
-
onChange={(e) => onLabelChange(e.target.value)}
|
|
106
|
-
className="text-xs"
|
|
107
|
-
/>
|
|
108
|
-
</div>
|
|
109
128
|
</div>
|
|
110
129
|
|
|
111
|
-
{
|
|
130
|
+
{loginStarting && <p role="status" className="text-xs">Starting login...</p>}
|
|
131
|
+
{error && <div role="alert" className="text-[11px] p-2 rounded bg-error/10 text-error">{error}</div>}
|
|
112
132
|
|
|
113
133
|
<DialogFooter>
|
|
114
134
|
<Button size="sm" variant="outline" className="text-xs cursor-pointer" onClick={() => onOpenChange(false)}>
|
|
@@ -118,7 +138,7 @@ export function CodexAddAccountDialog({
|
|
|
118
138
|
size="sm"
|
|
119
139
|
className="text-xs cursor-pointer gap-1.5"
|
|
120
140
|
onClick={onAddApiKey}
|
|
121
|
-
disabled={!apiKey.trim() ||
|
|
141
|
+
disabled={!apiKey.trim() || loginBusy}
|
|
122
142
|
>
|
|
123
143
|
{adding ? <Loader2 className="size-3.5 animate-spin" /> : <KeyRound className="size-3.5" />}
|
|
124
144
|
Add key
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The quota rows of a Codex account card.
|
|
3
3
|
*
|
|
4
4
|
* Split out so the wiring is testable on its own: the reset clock is the part that keeps
|
|
5
5
|
* getting dropped, because the percentages render fine without it.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Which rows appear follows what the account reports, because not every plan has the same
|
|
8
|
+
* windows. A Plus account has a 5-hour quota and a weekly one; a ChatGPT Business account
|
|
9
|
+
* has the weekly quota alone, and drawing an empty "5-Hour" row for it claimed a limit that
|
|
10
|
+
* does not exist.
|
|
11
|
+
*
|
|
12
|
+
* A read that returned nothing at all is a different thing, and still shows both rows with
|
|
13
|
+
* an em dash: a card that silently loses all its height reads as a broken account rather
|
|
14
|
+
* than as missing data.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import { AccountUsageBar } from "./account-bucket-row";
|
|
@@ -17,10 +22,16 @@ import type { Usage } from "./use-codex-accounts";
|
|
|
17
22
|
function toPct(v?: number): number | null { return v != null ? Math.round(v * 100) : null; }
|
|
18
23
|
|
|
19
24
|
export function CodexUsageRows({ usage }: { usage: Usage }) {
|
|
25
|
+
// Nothing was read — show the shape of the answer rather than an empty card.
|
|
26
|
+
const unread = usage.session == null && usage.weekly == null;
|
|
20
27
|
return (
|
|
21
28
|
<div className="space-y-2">
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
{(unread || usage.session != null) && (
|
|
30
|
+
<AccountUsageBar label="5-Hour Session" pct={toPct(usage.fiveHour)} reset={formatResetTime(usage.session)} resetsAt={usage.session?.resetsAt} />
|
|
31
|
+
)}
|
|
32
|
+
{(unread || usage.weekly != null) && (
|
|
33
|
+
<AccountUsageBar label="Weekly" pct={toPct(usage.sevenDay)} reset={formatResetTime(usage.weekly)} resetsAt={usage.weekly?.resetsAt} />
|
|
34
|
+
)}
|
|
24
35
|
</div>
|
|
25
36
|
);
|
|
26
37
|
}
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* State and requests for the Codex accounts pane.
|
|
3
|
-
*
|
|
4
|
-
* Split from the pane the way `use-accounts-data` is on the Claude side, so both panes are
|
|
5
|
-
* render-only and the two providers stay comparable.
|
|
6
|
-
*
|
|
7
|
-
* The device-code login is the reason the cleanup here matters: it holds a server-side
|
|
8
|
-
* app-server until it resolves, so leaving the pane has to release it rather than let it age
|
|
9
|
-
* out. That cleanup belongs to the pane's lifetime, not to a dialog being closed.
|
|
10
|
-
*/
|
|
1
|
+
/** State and requests for the Codex accounts pane, including cancellable login flows. */
|
|
11
2
|
|
|
12
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
13
4
|
import { api, getAuthToken } from "@/lib/api-client";
|
|
@@ -28,6 +19,8 @@ export interface Usage {
|
|
|
28
19
|
weekly?: LimitBucket;
|
|
29
20
|
}
|
|
30
21
|
interface DevicePending { id: string; userCode: string; verificationUrl: string }
|
|
22
|
+
interface BrowserPending { id: string; authUrl: string }
|
|
23
|
+
type LoginMethod = "device" | "browser";
|
|
31
24
|
type DeviceStatus = { state: "pending" } | { state: "done" } | { state: "error"; error: string };
|
|
32
25
|
|
|
33
26
|
/** Gap between status polls. Each poll answers immediately, so a request lost to
|
|
@@ -48,13 +41,23 @@ export function useCodexAccounts(onDone: () => void) {
|
|
|
48
41
|
const [err, setErr] = useState<string | null>(null);
|
|
49
42
|
const [device, setDevice] = useState<DevicePending | null>(null);
|
|
50
43
|
const [deviceWaiting, setDeviceWaiting] = useState(false);
|
|
44
|
+
const [browser, setBrowser] = useState<BrowserPending | null>(null);
|
|
45
|
+
const [loginStarting, setLoginStarting] = useState(false);
|
|
46
|
+
const [callbackUrl, setCallbackUrl] = useState("");
|
|
47
|
+
const [submittingCallback, setSubmittingCallback] = useState(false);
|
|
51
48
|
const [backupPassword, setBackupPassword] = useState("");
|
|
52
49
|
const [exporting, setExporting] = useState(false);
|
|
53
50
|
const [importing, setImporting] = useState(false);
|
|
54
51
|
const [msg, setMsg] = useState<string | null>(null);
|
|
55
52
|
const stopPollRef = useRef<(() => void) | null>(null);
|
|
56
53
|
/** Id of the login still owed a result, so unmount can release it. */
|
|
57
|
-
const
|
|
54
|
+
const pendingLoginRef = useRef<{ id: string; method: LoginMethod } | null>(null);
|
|
55
|
+
const loginGenerationRef = useRef(0);
|
|
56
|
+
const startingRef = useRef(false);
|
|
57
|
+
const addingRef = useRef(false);
|
|
58
|
+
const submittingRef = useRef(false);
|
|
59
|
+
const onDoneRef = useRef(onDone);
|
|
60
|
+
onDoneRef.current = onDone;
|
|
58
61
|
|
|
59
62
|
const load = useCallback(async () => {
|
|
60
63
|
setLoading(true);
|
|
@@ -68,53 +71,95 @@ export function useCodexAccounts(onDone: () => void) {
|
|
|
68
71
|
useEffect(() => { load(); }, [load]);
|
|
69
72
|
|
|
70
73
|
const addApiKey = async () => {
|
|
71
|
-
if (!apiKey.trim()) return;
|
|
74
|
+
if (!apiKey.trim() || addingRef.current || startingRef.current || pendingLoginRef.current) return;
|
|
75
|
+
addingRef.current = true;
|
|
72
76
|
setAdding(true); setErr(null);
|
|
73
77
|
try {
|
|
74
78
|
await api.post("/api/codex-accounts/api-key", { apiKey: apiKey.trim(), label: label.trim() || undefined });
|
|
75
79
|
setApiKey(""); setLabel(""); onDone(); await load();
|
|
76
|
-
} catch (e) { setErr((e as Error).message); } finally { setAdding(false); }
|
|
80
|
+
} catch (e) { setErr((e as Error).message); } finally { addingRef.current = false; setAdding(false); }
|
|
77
81
|
};
|
|
78
82
|
|
|
79
|
-
const
|
|
80
|
-
|
|
83
|
+
const releaseLogin = useCallback(() => {
|
|
84
|
+
loginGenerationRef.current += 1;
|
|
85
|
+
startingRef.current = false;
|
|
86
|
+
submittingRef.current = false;
|
|
87
|
+
stopPollRef.current?.();
|
|
88
|
+
stopPollRef.current = null;
|
|
89
|
+
const pending = pendingLoginRef.current;
|
|
90
|
+
pendingLoginRef.current = null;
|
|
91
|
+
if (pending) void api.del(`/api/codex-accounts/${pending.method}-login/${pending.id}`).catch(() => {});
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const cancelLogin = useCallback(() => {
|
|
95
|
+
releaseLogin();
|
|
96
|
+
setDevice(null); setDeviceWaiting(false); setBrowser(null);
|
|
97
|
+
setLoginStarting(false); setCallbackUrl(""); setSubmittingCallback(false);
|
|
98
|
+
}, [releaseLogin]);
|
|
99
|
+
|
|
100
|
+
const pollLogin = useCallback((id: string, method: LoginMethod) => {
|
|
101
|
+
const deadline = Date.now() + (method === "browser" ? 610_000 : DEVICE_POLL_DEADLINE_MS);
|
|
81
102
|
let stopped = false;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
stopped = true; stopPollRef.current = null; devicePendingIdRef.current = null;
|
|
85
|
-
if (message) setErr(message);
|
|
86
|
-
setDevice(null); setDeviceWaiting(false);
|
|
87
|
-
};
|
|
103
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
stopPollRef.current = () => { stopped = true; clearTimeout(timer); };
|
|
88
105
|
const tick = async () => {
|
|
89
106
|
if (stopped) return;
|
|
90
|
-
let
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
try { s = await api.get<DeviceStatus>(`/api/codex-accounts/device-login/${id}/status`); } catch { /* retry */ }
|
|
107
|
+
let status: DeviceStatus | null = null;
|
|
108
|
+
// Transient network failures must not discard an authorization in progress.
|
|
109
|
+
try { status = await api.get<DeviceStatus>(`/api/codex-accounts/${method}-login/${id}/status`); } catch { /* retry */ }
|
|
94
110
|
if (stopped) return;
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
111
|
+
if (status?.state === "done") {
|
|
112
|
+
cancelLogin(); setLabel(""); onDoneRef.current(); await load(); return;
|
|
113
|
+
}
|
|
114
|
+
if (status?.state === "error" || Date.now() > deadline) {
|
|
115
|
+
cancelLogin();
|
|
116
|
+
setErr(status?.state === "error" ? status.error : `${method === "browser" ? "Browser" : "Device"} login timed out.`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
timer = setTimeout(tick, DEVICE_POLL_MS);
|
|
99
120
|
};
|
|
100
121
|
void tick();
|
|
101
|
-
}, [load]);
|
|
122
|
+
}, [cancelLogin, load]);
|
|
102
123
|
|
|
103
|
-
|
|
104
|
-
// server-side app-server instead of leaving it to age out.
|
|
105
|
-
useEffect(() => () => {
|
|
106
|
-
stopPollRef.current?.();
|
|
107
|
-
const id = devicePendingIdRef.current;
|
|
108
|
-
if (id) api.del(`/api/codex-accounts/device-login/${id}`).catch(() => {});
|
|
109
|
-
}, []);
|
|
124
|
+
useEffect(() => releaseLogin, [releaseLogin]);
|
|
110
125
|
|
|
111
|
-
const
|
|
112
|
-
|
|
126
|
+
const startLogin = async (method: LoginMethod) => {
|
|
127
|
+
if (startingRef.current || pendingLoginRef.current || addingRef.current) return;
|
|
128
|
+
startingRef.current = true;
|
|
129
|
+
const generation = ++loginGenerationRef.current;
|
|
130
|
+
setLoginStarting(true); setErr(null);
|
|
113
131
|
try {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
const result = await api.post<DevicePending | BrowserPending>(`/api/codex-accounts/${method}-login`, { label: label.trim() || undefined });
|
|
133
|
+
// Closing the dialog while startup is in flight still owes the server a cancel.
|
|
134
|
+
if (generation !== loginGenerationRef.current) {
|
|
135
|
+
void api.del(`/api/codex-accounts/${method}-login/${result.id}`).catch(() => {});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
pendingLoginRef.current = { id: result.id, method };
|
|
139
|
+
if (method === "device") { setDevice(result as DevicePending); setDeviceWaiting(true); }
|
|
140
|
+
else setBrowser(result as BrowserPending);
|
|
141
|
+
pollLogin(result.id, method);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (generation === loginGenerationRef.current) setErr((e as Error).message);
|
|
144
|
+
} finally {
|
|
145
|
+
if (generation === loginGenerationRef.current) { startingRef.current = false; setLoginStarting(false); }
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const submitCallback = async () => {
|
|
150
|
+
const pending = pendingLoginRef.current;
|
|
151
|
+
if (pending?.method !== "browser" || !callbackUrl.trim() || submittingRef.current) return;
|
|
152
|
+
const generation = loginGenerationRef.current;
|
|
153
|
+
submittingRef.current = true; setSubmittingCallback(true); setErr(null);
|
|
154
|
+
const submittedUrl = callbackUrl.trim();
|
|
155
|
+
setCallbackUrl("");
|
|
156
|
+
try {
|
|
157
|
+
await api.post(`/api/codex-accounts/browser-login/${pending.id}/callback`, { callbackUrl: submittedUrl });
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (generation === loginGenerationRef.current) setErr((e as Error).message);
|
|
160
|
+
} finally {
|
|
161
|
+
if (generation === loginGenerationRef.current) { submittingRef.current = false; setSubmittingCallback(false); }
|
|
162
|
+
}
|
|
118
163
|
};
|
|
119
164
|
|
|
120
165
|
const remove = async (id: string) => { await api.del(`/api/codex-accounts/${id}`); await load(); };
|
|
@@ -179,6 +224,7 @@ export function useCodexAccounts(onDone: () => void) {
|
|
|
179
224
|
err, setErr, device, deviceWaiting, backupPassword, setBackupPassword,
|
|
180
225
|
exporting, importing, msg, setMsg,
|
|
181
226
|
toggling,
|
|
182
|
-
load, addApiKey, startDevice
|
|
227
|
+
load, addApiKey, startDevice: () => startLogin("device"), startBrowser: () => startLogin("browser"),
|
|
228
|
+
browser, loginStarting, callbackUrl, setCallbackUrl, submittingCallback, submitCallback, cancelLogin, remove, toggle, changeStrategy, doExport, doImport,
|
|
183
229
|
};
|
|
184
230
|
}
|
|
@@ -19,10 +19,23 @@ function TooltipProvider({
|
|
|
19
19
|
)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Carries its own provider, so a tooltip works wherever it is rendered.
|
|
24
|
+
*
|
|
25
|
+
* Radix throws outright — not a warning, a thrown error — when a Tooltip has no provider
|
|
26
|
+
* above it. That made any component holding one unrenderable outside the app shell: three
|
|
27
|
+
* component tests went red the moment a shared usage bar gained a tooltip, and nothing about
|
|
28
|
+
* the failure pointed at the tooltip. Nesting is cheap and supported; an outer provider still
|
|
29
|
+
* governs timing for everything beneath it.
|
|
30
|
+
*/
|
|
22
31
|
function Tooltip({
|
|
23
32
|
...props
|
|
24
33
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
|
25
|
-
return
|
|
34
|
+
return (
|
|
35
|
+
<TooltipProvider>
|
|
36
|
+
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
37
|
+
</TooltipProvider>
|
|
38
|
+
)
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
function TooltipTrigger({
|
|
@@ -10,6 +10,16 @@ interface UseUsageReturn {
|
|
|
10
10
|
/** ISO timestamp from BE — when usage was actually fetched. */
|
|
11
11
|
lastFetchedAt: string | null;
|
|
12
12
|
refreshUsage: () => void;
|
|
13
|
+
/**
|
|
14
|
+
* Re-read without forcing the provider to go back to its source.
|
|
15
|
+
*
|
|
16
|
+
* For the caller that has just changed WHICH account this session reports on, rather than
|
|
17
|
+
* wanting fresher numbers for the same one. The new account's quota is already cached —
|
|
18
|
+
* it is what the account panel was showing a moment ago — so `refresh=1` would drop every
|
|
19
|
+
* account's cached value and re-read them all to display one figure that was already
|
|
20
|
+
* known.
|
|
21
|
+
*/
|
|
22
|
+
reloadUsage: () => void;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/**
|
|
@@ -61,8 +71,10 @@ export function useUsage(projectName: string, providerId = "claude", sessionId?:
|
|
|
61
71
|
|
|
62
72
|
/** Manual refresh — asks BE for fresh usage. */
|
|
63
73
|
const refreshUsage = useCallback(() => doFetch(true), [doFetch]);
|
|
74
|
+
/** Re-read the cached value, for when the session's account changed underneath it. */
|
|
75
|
+
const reloadUsage = useCallback(() => doFetch(), [doFetch]);
|
|
64
76
|
|
|
65
77
|
// Hide the previous scope synchronously, before the new effect runs.
|
|
66
78
|
const current = snapshot?.scope === scope ? snapshot : null;
|
|
67
|
-
return { usageInfo: current?.usage ?? {}, usageLoading, lastFetchedAt: current?.fetchedAt ?? null, refreshUsage };
|
|
79
|
+
return { usageInfo: current?.usage ?? {}, usageLoading, lastFetchedAt: current?.fetchedAt ?? null, refreshUsage, reloadUsage };
|
|
68
80
|
}
|