@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CLAUDE.md +1 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/http-api.md +5 -1
  5. package/dist/web/assets/accounts-settings-section-DzDmIsPe.js +1 -0
  6. package/dist/web/assets/{ai-resource-editor-BgYIPGVP.js → ai-resource-editor-D7C9wmwA.js} +1 -1
  7. package/dist/web/assets/{ai-settings-section-iBRCQvBR.js → ai-settings-section-BW5a07-Z.js} +1 -1
  8. package/dist/web/assets/{ai-settings-section-Cywmtnps.js → ai-settings-section-DZuwllLy.js} +1 -1
  9. package/dist/web/assets/{appearance-settings-section-mhu9T6Fk.js → appearance-settings-section-RfH2N1rj.js} +1 -1
  10. package/dist/web/assets/chat-tab-Bz0pk0_E.js +15 -0
  11. package/dist/web/assets/{code-editor-Cv_pzHo7.js → code-editor-C4o1SyuO.js} +2 -2
  12. package/dist/web/assets/{conflict-editor-BnWaMSEa.js → conflict-editor-Dk65mBIe.js} +1 -1
  13. package/dist/web/assets/{csv-preview-DG4YAabr.js → csv-preview-DENi3CFz.js} +1 -1
  14. package/dist/web/assets/{database-viewer-B-wA2qT-.js → database-viewer-BiBC7ulB.js} +1 -1
  15. package/dist/web/assets/{diff-viewer-DRtzdfXk.js → diff-viewer-CSvZJHak.js} +1 -1
  16. package/dist/web/assets/{edit-diff-preview-6tzdk7Pn.js → edit-diff-preview-xdDQvPhs.js} +1 -1
  17. package/dist/web/assets/{explorer-body-uh8oJp6J.js → explorer-body-QXS0h4AF.js} +1 -1
  18. package/dist/web/assets/{explorer-window-content-DLn_4egF.js → explorer-window-content-CQgqoRsj.js} +1 -1
  19. package/dist/web/assets/{extension-manager-section-C_ZGYvWf.js → extension-manager-section-CyC7wX35.js} +1 -1
  20. package/dist/web/assets/{extension-webview-DbFWxJpj.js → extension-webview-Da8SIlRa.js} +1 -1
  21. package/dist/web/assets/{files-settings-section-BrUbJglA.js → files-settings-section-D8cebM51.js} +1 -1
  22. package/dist/web/assets/{general-settings-section-B9ZPZVso.js → general-settings-section-1HVTvlIw.js} +1 -1
  23. package/dist/web/assets/{git-log-panel-C0jnhmgC.js → git-log-panel-C7a4rp8f.js} +1 -1
  24. package/dist/web/assets/{glide-data-grid-CZVKIum-.js → glide-data-grid-BT9p_4AN.js} +1 -1
  25. package/dist/web/assets/{group-chat-tab-DnPtmbPc.js → group-chat-tab-BUHGnUyW.js} +1 -1
  26. package/dist/web/assets/index-CuYtJ5xF.css +2 -0
  27. package/dist/web/assets/{index-CHQAPZIX.js → index-kGmcC9cb.js} +5 -5
  28. package/dist/web/assets/{jira-watcher-section-Bjldyhru.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
  29. package/dist/web/assets/{keybindings-store-DBVXZKp8.js → keybindings-store-DjJum4OS.js} +1 -1
  30. package/dist/web/assets/{keyboard-shortcuts-section-D7jaCPrv.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
  31. package/dist/web/assets/{markdown-renderer-B4B5oxQS.js → markdown-renderer-dHeak2mE.js} +2 -2
  32. package/dist/web/assets/{markdown-renderer-DwHh-jd_.js → markdown-renderer-iBw1fCI_.js} +1 -1
  33. package/dist/web/assets/{mobile-explorer-sheet-JJxZYWkV.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
  34. package/dist/web/assets/{notification-store-CCWNUHsS.js → notification-store-DVi6Ds71.js} +1 -1
  35. package/dist/web/assets/{notifications-settings-section-DbbX7-Qp.js → notifications-settings-section-shQ-ugKh.js} +1 -1
  36. package/dist/web/assets/{pdf-preview-DxNjWa1v.js → pdf-preview-DB26KSzm.js} +1 -1
  37. package/dist/web/assets/{postgres-viewer-BScvk5ev.js → postgres-viewer-CJe2meyF.js} +1 -1
  38. package/dist/web/assets/{ppmbot-settings-section-jFA9Ncgo.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
  39. package/dist/web/assets/{process-table-BexeYWQm.js → process-table-PXwLsMuG.js} +1 -1
  40. package/dist/web/assets/{proxy-settings-section-C_seKq56.js → proxy-settings-section-BHBhK6HC.js} +1 -1
  41. package/dist/web/assets/{query-audit-section-BWXJmPDV.js → query-audit-section-BD5ZVqDz.js} +1 -1
  42. package/dist/web/assets/{remote-desktop-mobile-sheet-B--PeL5i.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
  43. package/dist/web/assets/{remote-desktop-mobile-view-jgn8nmQa.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
  44. package/dist/web/assets/{remote-desktop-readiness-gate-CjDth-W_.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
  45. package/dist/web/assets/{remote-desktop-window-content-CrHAUA03.js → remote-desktop-window-content-X324gYbW.js} +1 -1
  46. package/dist/web/assets/{schedules-settings-section-DMFn3mYL.js → schedules-settings-section-a4-h6THc.js} +1 -1
  47. package/dist/web/assets/{settings-body-Dz2vPTEP.js → settings-body-Fq7BVDw2.js} +2 -2
  48. package/dist/web/assets/{settings-tab-B5QbsvS4.js → settings-tab-CVLQy-ie.js} +1 -1
  49. package/dist/web/assets/{settings-window-content-iM2bPoX5.js → settings-window-content-Bi7M0iaZ.js} +1 -1
  50. package/dist/web/assets/{sql-query-editor-C0mGgLfX.js → sql-query-editor-D1r265fl.js} +1 -1
  51. package/dist/web/assets/{sqlite-viewer-Bs3RKf1X.js → sqlite-viewer-DHkIotRl.js} +1 -1
  52. package/dist/web/assets/{system-monitor-body-DvUmEdNe.js → system-monitor-body-y6VUkYTL.js} +2 -2
  53. package/dist/web/assets/{system-monitor-tab-CHZ5F_ZE.js → system-monitor-tab-DRAOejhk.js} +1 -1
  54. package/dist/web/assets/{system-monitor-window-content-DjzqoeCN.js → system-monitor-window-content-B5i57xHD.js} +1 -1
  55. package/dist/web/assets/{tab-host-window-content-DKeAr5mD.js → tab-host-window-content-SkyPFFlc.js} +1 -1
  56. package/dist/web/assets/{team-member-sheet-Cl9ZB3dl.js → team-member-sheet-BB63u-Lf.js} +2 -2
  57. package/dist/web/assets/{team-member-window-content-DfP7WdFD.js → team-member-window-content-D6tVTXuV.js} +1 -1
  58. package/dist/web/assets/{terminal-tab-J1Fqa_rm.js → terminal-tab-BYaHAkI5.js} +1 -1
  59. package/dist/web/assets/{tool-cards-DQghSuKX.js → tool-cards-DRJu20sL.js} +3 -3
  60. package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
  61. package/dist/web/assets/{use-monaco-theme-aa4-wjKQ.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
  62. package/dist/web/assets/{use-remote-desktop-display-choice-B-TpQ-Ua.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
  63. package/dist/web/assets/{use-websocket-BBslmv3D.js → use-websocket-BC_EGf5q.js} +1 -1
  64. package/dist/web/assets/{video-preview-Bd0lcK3d.js → video-preview-BUb43zWb.js} +1 -1
  65. package/dist/web/index.html +2 -2
  66. package/dist/web/sw.js +1 -1
  67. package/docs/deployment-guide.md +10 -0
  68. package/package.json +1 -1
  69. package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
  70. package/src/providers/codex-app-server/codex-history.ts +118 -45
  71. package/src/providers/codex-app-server/codex-provider.ts +277 -15
  72. package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
  73. package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
  74. package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
  75. package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
  76. package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
  77. package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
  78. package/src/server/routes/chat.ts +23 -2
  79. package/src/server/routes/codex-accounts.ts +32 -3
  80. package/src/server/ws/chat.ts +15 -11
  81. package/src/services/codex-account-cooldown.ts +56 -0
  82. package/src/services/codex-account-login.ts +109 -34
  83. package/src/services/codex-account.service.ts +56 -10
  84. package/src/services/codex-login-callback.ts +57 -0
  85. package/src/web/components/chat/chat-tab.tsx +8 -2
  86. package/src/web/components/chat/codex-usage-panel.tsx +129 -165
  87. package/src/web/components/chat/edit-diff-preview.tsx +14 -7
  88. package/src/web/components/chat/usage-badge.tsx +38 -129
  89. package/src/web/components/chat/usage-panel-shell.tsx +153 -0
  90. package/src/web/components/settings/accounts/account-card.tsx +20 -3
  91. package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
  92. package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
  93. package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
  94. package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
  95. package/src/web/components/shared/markdown-code-block.tsx +10 -5
  96. package/src/web/components/ui/tooltip.tsx +14 -1
  97. package/src/web/hooks/use-usage.ts +13 -1
  98. package/dist/web/assets/accounts-settings-section-BQyru31C.js +0 -1
  99. package/dist/web/assets/chat-tab-Bs9OD6ss.js +0 -15
  100. package/dist/web/assets/index-CoaVwAF6.css +0 -2
  101. 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, ExternalLink, Maximize2, Minimize2, RefreshCw, X } from "lucide-react";
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
- <div
170
- className={`relative border-b border-border bg-surface px-3 py-2.5 ${
171
- isFullscreen
172
- ? "fixed inset-0 z-50 flex flex-col gap-2.5 overflow-hidden"
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
- {hasPerAccountUsage || initialLoading ? (
251
- <div
252
- className={isFullscreen
253
- ? "flex-1 min-h-0 grid gap-2 overflow-hidden"
254
- // Same classes as AccountCardRow, with the panel's wider padding to clear.
255
- : "flex gap-2 overflow-x-auto pb-1 -mx-3 px-3 snap-x snap-mandatory scrollbar-thin"}
256
- style={isFullscreen ? {
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
- </div>
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
- const showTokenStatus = ts.label === "temp" || ts.label === "unknown"
87
- || (ts.label === "expired" && !isExpired && !needsReauth);
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
- Each account keeps its own login (its own <code>CODEX_HOME</code>).
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">Recommended: sign in with ChatGPT</p>
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={deviceWaiting}
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 ChatGPT</>}
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
- {error && <div className="text-[11px] p-2 rounded bg-error/10 text-error">{error}</div>}
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() || adding}
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 two quota rows of a Codex account card.
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
- * Unlike the Claude card's `AccountBucketRow`, a row here stays on screen with an em dash
8
- * when the quota read failed instead of disappearing a card that silently loses half its
9
- * height reads as a broken account rather than as missing data.
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
- <AccountUsageBar label="5-Hour Session" pct={toPct(usage.fiveHour)} reset={formatResetTime(usage.session)} resetsAt={usage.session?.resetsAt} />
23
- <AccountUsageBar label="Weekly" pct={toPct(usage.sevenDay)} reset={formatResetTime(usage.weekly)} resetsAt={usage.weekly?.resetsAt} />
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
  }