@hienlh/ppm 0.20.0 → 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 +21 -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-BgYIPGVP.js → ai-resource-editor-D7C9wmwA.js} +1 -1
- package/dist/web/assets/{ai-settings-section-iBRCQvBR.js → ai-settings-section-BW5a07-Z.js} +1 -1
- package/dist/web/assets/{ai-settings-section-Cywmtnps.js → ai-settings-section-DZuwllLy.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-mhu9T6Fk.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-Cv_pzHo7.js → code-editor-C4o1SyuO.js} +2 -2
- package/dist/web/assets/{conflict-editor-BnWaMSEa.js → conflict-editor-Dk65mBIe.js} +1 -1
- package/dist/web/assets/{csv-preview-DG4YAabr.js → csv-preview-DENi3CFz.js} +1 -1
- package/dist/web/assets/{database-viewer-B-wA2qT-.js → database-viewer-BiBC7ulB.js} +1 -1
- package/dist/web/assets/{diff-viewer-DRtzdfXk.js → diff-viewer-CSvZJHak.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-6tzdk7Pn.js → edit-diff-preview-xdDQvPhs.js} +1 -1
- package/dist/web/assets/{explorer-body-uh8oJp6J.js → explorer-body-QXS0h4AF.js} +1 -1
- package/dist/web/assets/{explorer-window-content-DLn_4egF.js → explorer-window-content-CQgqoRsj.js} +1 -1
- package/dist/web/assets/{extension-manager-section-C_ZGYvWf.js → extension-manager-section-CyC7wX35.js} +1 -1
- package/dist/web/assets/{extension-webview-DbFWxJpj.js → extension-webview-Da8SIlRa.js} +1 -1
- package/dist/web/assets/{files-settings-section-BrUbJglA.js → files-settings-section-D8cebM51.js} +1 -1
- package/dist/web/assets/{general-settings-section-B9ZPZVso.js → general-settings-section-1HVTvlIw.js} +1 -1
- package/dist/web/assets/{git-log-panel-C0jnhmgC.js → git-log-panel-C7a4rp8f.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CZVKIum-.js → glide-data-grid-BT9p_4AN.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DnPtmbPc.js → group-chat-tab-BUHGnUyW.js} +1 -1
- package/dist/web/assets/index-CuYtJ5xF.css +2 -0
- package/dist/web/assets/{index-CHQAPZIX.js → index-kGmcC9cb.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-Bjldyhru.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
- package/dist/web/assets/{keybindings-store-DBVXZKp8.js → keybindings-store-DjJum4OS.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-D7jaCPrv.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
- package/dist/web/assets/{markdown-renderer-B4B5oxQS.js → markdown-renderer-dHeak2mE.js} +2 -2
- package/dist/web/assets/{markdown-renderer-DwHh-jd_.js → markdown-renderer-iBw1fCI_.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-JJxZYWkV.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
- package/dist/web/assets/{notification-store-CCWNUHsS.js → notification-store-DVi6Ds71.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-DbbX7-Qp.js → notifications-settings-section-shQ-ugKh.js} +1 -1
- package/dist/web/assets/{pdf-preview-DxNjWa1v.js → pdf-preview-DB26KSzm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BScvk5ev.js → postgres-viewer-CJe2meyF.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-jFA9Ncgo.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
- package/dist/web/assets/{process-table-BexeYWQm.js → process-table-PXwLsMuG.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-C_seKq56.js → proxy-settings-section-BHBhK6HC.js} +1 -1
- package/dist/web/assets/{query-audit-section-BWXJmPDV.js → query-audit-section-BD5ZVqDz.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-B--PeL5i.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-jgn8nmQa.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-CjDth-W_.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CrHAUA03.js → remote-desktop-window-content-X324gYbW.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-DMFn3mYL.js → schedules-settings-section-a4-h6THc.js} +1 -1
- package/dist/web/assets/{settings-body-Dz2vPTEP.js → settings-body-Fq7BVDw2.js} +2 -2
- package/dist/web/assets/{settings-tab-B5QbsvS4.js → settings-tab-CVLQy-ie.js} +1 -1
- package/dist/web/assets/{settings-window-content-iM2bPoX5.js → settings-window-content-Bi7M0iaZ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-C0mGgLfX.js → sql-query-editor-D1r265fl.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Bs3RKf1X.js → sqlite-viewer-DHkIotRl.js} +1 -1
- package/dist/web/assets/{system-monitor-body-DvUmEdNe.js → system-monitor-body-y6VUkYTL.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-CHZ5F_ZE.js → system-monitor-tab-DRAOejhk.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-DjzqoeCN.js → system-monitor-window-content-B5i57xHD.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-DKeAr5mD.js → tab-host-window-content-SkyPFFlc.js} +1 -1
- package/dist/web/assets/{team-member-sheet-Cl9ZB3dl.js → team-member-sheet-BB63u-Lf.js} +2 -2
- package/dist/web/assets/{team-member-window-content-DfP7WdFD.js → team-member-window-content-D6tVTXuV.js} +1 -1
- package/dist/web/assets/{terminal-tab-J1Fqa_rm.js → terminal-tab-BYaHAkI5.js} +1 -1
- package/dist/web/assets/{tool-cards-DQghSuKX.js → tool-cards-DRJu20sL.js} +3 -3
- package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
- package/dist/web/assets/{use-monaco-theme-aa4-wjKQ.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-B-TpQ-Ua.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
- package/dist/web/assets/{use-websocket-BBslmv3D.js → use-websocket-BC_EGf5q.js} +1 -1
- package/dist/web/assets/{video-preview-Bd0lcK3d.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/edit-diff-preview.tsx +14 -7
- 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/shared/markdown-code-block.tsx +10 -5
- 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-BQyru31C.js +0 -1
- package/dist/web/assets/chat-tab-Bs9OD6ss.js +0 -15
- package/dist/web/assets/index-CoaVwAF6.css +0 -2
- package/dist/web/assets/use-accounts-data-KWcMgRlc.js +0 -1
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { useState } from "react";
|
|
20
|
-
import { Activity,
|
|
20
|
+
import { Activity, RefreshCw } from "lucide-react";
|
|
21
21
|
import type { UsageInfo } from "../../../types/chat";
|
|
22
22
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
23
|
-
import { openSettings } from "@/components/settings/open-settings";
|
|
24
23
|
import { AccountCard } from "@/components/settings/accounts/account-card";
|
|
25
24
|
import { AccountBucketRow } from "@/components/settings/accounts/account-bucket-row";
|
|
26
25
|
import { useAccountsData } from "@/components/settings/accounts/use-accounts-data";
|
|
27
26
|
import { formatLastUpdated, formatResetTime, pctColor } from "@/components/settings/accounts/account-usage-format";
|
|
27
|
+
import { UsagePanelShell } from "./usage-panel-shell";
|
|
28
28
|
|
|
29
29
|
/** Matches the whole-percent figure the usage bars show, so a refusal agrees with the card. */
|
|
30
30
|
function atCap(util: number | null | undefined): boolean {
|
|
@@ -91,7 +91,6 @@ export function UsageDetailPanel({
|
|
|
91
91
|
// One strip for anything that went wrong in this panel — a refused switch or a refused
|
|
92
92
|
// toggle. Two separate messages in two places would be harder to notice, not clearer.
|
|
93
93
|
const [panelError, setPanelError] = useState<string | null>(null);
|
|
94
|
-
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
95
94
|
|
|
96
95
|
if (!visible) return null;
|
|
97
96
|
|
|
@@ -158,131 +157,21 @@ export function UsageDetailPanel({
|
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
|
|
161
|
-
const hasPerAccountUsage = usages.length > 0;
|
|
162
|
-
|
|
163
|
-
// Roughly square, so the cards fill the viewport instead of leaving a long empty column.
|
|
164
|
-
const fsCount = usages.length || 1;
|
|
165
|
-
const fsCols = Math.ceil(Math.sqrt(fsCount));
|
|
166
|
-
const fsRows = Math.ceil(fsCount / fsCols);
|
|
167
160
|
|
|
168
161
|
return (
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
: "space-y-2.5 max-h-[350px] overflow-y-auto"
|
|
174
|
-
}`}
|
|
175
|
-
>
|
|
176
|
-
<div className="flex items-center justify-between shrink-0">
|
|
177
|
-
<div className="flex items-center gap-2">
|
|
178
|
-
<span className="text-xs font-semibold text-text-primary">Usage</span>
|
|
179
|
-
{lastFetchedAt && (
|
|
180
|
-
<span className="text-[10px] text-text-subtle">{formatLastUpdated(new Date(lastFetchedAt).getTime())}</span>
|
|
181
|
-
)}
|
|
182
|
-
</div>
|
|
183
|
-
<div className="flex items-center gap-1">
|
|
184
|
-
<Tooltip>
|
|
185
|
-
<TooltipTrigger asChild>
|
|
186
|
-
<button
|
|
187
|
-
onClick={() => openSettings("accounts")}
|
|
188
|
-
className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
189
|
-
>
|
|
190
|
-
Manage accounts <ExternalLink className="size-3" />
|
|
191
|
-
</button>
|
|
192
|
-
</TooltipTrigger>
|
|
193
|
-
<TooltipContent side="top">Add, remove or rotate accounts</TooltipContent>
|
|
194
|
-
</Tooltip>
|
|
195
|
-
{hasPerAccountUsage && (
|
|
196
|
-
<Tooltip>
|
|
197
|
-
<TooltipTrigger asChild>
|
|
198
|
-
<button
|
|
199
|
-
onClick={() => setIsFullscreen((v) => !v)}
|
|
200
|
-
className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
201
|
-
aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
|
|
202
|
-
>
|
|
203
|
-
{isFullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
|
|
204
|
-
</button>
|
|
205
|
-
</TooltipTrigger>
|
|
206
|
-
<TooltipContent side="top">{isFullscreen ? "Exit fullscreen" : "Fullscreen view"}</TooltipContent>
|
|
207
|
-
</Tooltip>
|
|
208
|
-
)}
|
|
209
|
-
{onReload && (
|
|
210
|
-
<Tooltip>
|
|
211
|
-
<TooltipTrigger asChild>
|
|
212
|
-
<button
|
|
213
|
-
onClick={() => { onReload(); void reload(); }}
|
|
214
|
-
disabled={loading || refreshing}
|
|
215
|
-
className="text-xs text-text-subtle hover:text-text-primary px-1 disabled:opacity-50 cursor-pointer"
|
|
216
|
-
aria-label="Refresh usage"
|
|
217
|
-
>
|
|
218
|
-
<RefreshCw className={`size-3 ${(loading || refreshing) ? "animate-spin" : ""}`} />
|
|
219
|
-
</button>
|
|
220
|
-
</TooltipTrigger>
|
|
221
|
-
<TooltipContent side="top">Refresh</TooltipContent>
|
|
222
|
-
</Tooltip>
|
|
223
|
-
)}
|
|
224
|
-
<button
|
|
225
|
-
onClick={() => { setIsFullscreen(false); onClose(); }}
|
|
226
|
-
className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
|
|
227
|
-
aria-label="Close usage panel"
|
|
228
|
-
>
|
|
229
|
-
<X className="size-3" />
|
|
230
|
-
</button>
|
|
231
|
-
</div>
|
|
232
|
-
</div>
|
|
233
|
-
|
|
234
|
-
{/* The server distinguishes "this login was rejected, sign in again" from "could not
|
|
235
|
-
reach Anthropic, try shortly", and the difference decides what the user should do.
|
|
236
|
-
Showing its words verbatim is the only way that survives to them. */}
|
|
237
|
-
{panelError && (
|
|
238
|
-
<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">
|
|
239
|
-
<span className="flex-1">{panelError}</span>
|
|
240
|
-
<button
|
|
241
|
-
onClick={() => setPanelError(null)}
|
|
242
|
-
className="shrink-0 text-error/70 hover:text-error cursor-pointer"
|
|
243
|
-
aria-label="Dismiss"
|
|
244
|
-
>
|
|
245
|
-
<X className="size-3" />
|
|
246
|
-
</button>
|
|
247
|
-
</div>
|
|
162
|
+
<UsagePanelShell
|
|
163
|
+
title="Usage"
|
|
164
|
+
meta={lastFetchedAt && (
|
|
165
|
+
<span className="text-[10px] text-text-subtle">{formatLastUpdated(new Date(lastFetchedAt).getTime())}</span>
|
|
248
166
|
)}
|
|
249
|
-
|
|
250
|
-
{
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
gridTemplateColumns: `repeat(${fsCols}, minmax(0, 1fr))`,
|
|
258
|
-
gridTemplateRows: `repeat(${fsRows}, minmax(0, 1fr))`,
|
|
259
|
-
} : undefined}
|
|
260
|
-
>
|
|
261
|
-
{initialLoading ? (
|
|
262
|
-
<p className="text-[10px] text-text-subtle">Loading...</p>
|
|
263
|
-
) : (
|
|
264
|
-
usages.map((entry) => {
|
|
265
|
-
const info = accountMap.get(entry.accountId);
|
|
266
|
-
return (
|
|
267
|
-
<AccountCard
|
|
268
|
-
key={entry.accountId}
|
|
269
|
-
entry={entry}
|
|
270
|
-
// What is serving THIS chat, not what the rotation would pick next —
|
|
271
|
-
// those differ the moment a session is bound to an account.
|
|
272
|
-
isActive={entry.accountId === (selectedAccountId ?? usage.activeAccountId ?? activeAccountId)}
|
|
273
|
-
accountInfo={info}
|
|
274
|
-
flash={flashIds.has(entry.accountId)}
|
|
275
|
-
layout={isFullscreen ? "grid" : "strip"}
|
|
276
|
-
onSelect={onSelectAccount ? handleSelect : undefined}
|
|
277
|
-
unselectableReason={unselectableReason(info, entry)}
|
|
278
|
-
selecting={selectingId === entry.accountId}
|
|
279
|
-
onToggle={handleToggle}
|
|
280
|
-
toggling={togglingId === entry.accountId}
|
|
281
|
-
/>
|
|
282
|
-
);
|
|
283
|
-
})
|
|
284
|
-
)}
|
|
285
|
-
</div>
|
|
167
|
+
onClose={onClose}
|
|
168
|
+
onReload={onReload ? () => { onReload(); void reload(); } : undefined}
|
|
169
|
+
reloading={loading || refreshing}
|
|
170
|
+
error={panelError}
|
|
171
|
+
onDismissError={() => setPanelError(null)}
|
|
172
|
+
cardCount={usages.length}
|
|
173
|
+
fallback={initialLoading ? (
|
|
174
|
+
<p className="text-[10px] text-text-subtle">Loading...</p>
|
|
286
175
|
) : usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet ? (
|
|
287
176
|
<div className="space-y-2.5">
|
|
288
177
|
<AccountBucketRow label="5-Hour Session" bucket={usage.session} />
|
|
@@ -293,8 +182,7 @@ export function UsageDetailPanel({
|
|
|
293
182
|
) : (
|
|
294
183
|
<p className="text-xs text-text-subtle">No usage data available</p>
|
|
295
184
|
)}
|
|
296
|
-
|
|
297
|
-
{hasCost && (
|
|
185
|
+
footer={hasCost ? (
|
|
298
186
|
<div className="border-t border-border pt-2 space-y-1">
|
|
299
187
|
{usage.queryCostUsd != null && (
|
|
300
188
|
<div className="flex items-center justify-between text-xs">
|
|
@@ -313,7 +201,28 @@ export function UsageDetailPanel({
|
|
|
313
201
|
</div>
|
|
314
202
|
)}
|
|
315
203
|
</div>
|
|
316
|
-
)}
|
|
317
|
-
|
|
204
|
+
) : undefined}
|
|
205
|
+
>
|
|
206
|
+
{(layout) => usages.map((entry) => {
|
|
207
|
+
const info = accountMap.get(entry.accountId);
|
|
208
|
+
return (
|
|
209
|
+
<AccountCard
|
|
210
|
+
key={entry.accountId}
|
|
211
|
+
entry={entry}
|
|
212
|
+
// What is serving THIS chat, not what the rotation would pick next —
|
|
213
|
+
// those differ the moment a session is bound to an account.
|
|
214
|
+
isActive={entry.accountId === (selectedAccountId ?? usage.activeAccountId ?? activeAccountId)}
|
|
215
|
+
accountInfo={info}
|
|
216
|
+
flash={flashIds.has(entry.accountId)}
|
|
217
|
+
layout={layout}
|
|
218
|
+
onSelect={onSelectAccount ? handleSelect : undefined}
|
|
219
|
+
unselectableReason={unselectableReason(info, entry)}
|
|
220
|
+
selecting={selectingId === entry.accountId}
|
|
221
|
+
onToggle={handleToggle}
|
|
222
|
+
toggling={togglingId === entry.accountId}
|
|
223
|
+
/>
|
|
224
|
+
);
|
|
225
|
+
})}
|
|
226
|
+
</UsagePanelShell>
|
|
318
227
|
);
|
|
319
228
|
}
|
|
@@ -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
|
}
|