@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +16 -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-BLctP1Pr.js → ai-resource-editor-D7C9wmwA.js} +1 -1
  7. package/dist/web/assets/{ai-settings-section-0hSDp7xx.js → ai-settings-section-BW5a07-Z.js} +1 -1
  8. package/dist/web/assets/{ai-settings-section-CTEU2uGa.js → ai-settings-section-DZuwllLy.js} +1 -1
  9. package/dist/web/assets/{appearance-settings-section-BkcoSV0E.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-C4kKhamq.js → code-editor-C4o1SyuO.js} +2 -2
  12. package/dist/web/assets/{conflict-editor-BEULKwHk.js → conflict-editor-Dk65mBIe.js} +1 -1
  13. package/dist/web/assets/{csv-preview-k3eM8nFf.js → csv-preview-DENi3CFz.js} +1 -1
  14. package/dist/web/assets/{database-viewer-Cslir1dr.js → database-viewer-BiBC7ulB.js} +1 -1
  15. package/dist/web/assets/{diff-viewer-DzAIvBbn.js → diff-viewer-CSvZJHak.js} +1 -1
  16. package/dist/web/assets/{explorer-body-D6H-4cz9.js → explorer-body-QXS0h4AF.js} +1 -1
  17. package/dist/web/assets/{explorer-window-content-C5W_JzCz.js → explorer-window-content-CQgqoRsj.js} +1 -1
  18. package/dist/web/assets/{extension-manager-section-DwicI3CA.js → extension-manager-section-CyC7wX35.js} +1 -1
  19. package/dist/web/assets/{extension-webview-Z3HhC8Pd.js → extension-webview-Da8SIlRa.js} +1 -1
  20. package/dist/web/assets/{files-settings-section-ByV7bRbB.js → files-settings-section-D8cebM51.js} +1 -1
  21. package/dist/web/assets/{general-settings-section-nLksKHur.js → general-settings-section-1HVTvlIw.js} +1 -1
  22. package/dist/web/assets/{git-log-panel-iUXltiTB.js → git-log-panel-C7a4rp8f.js} +1 -1
  23. package/dist/web/assets/{glide-data-grid-C1QB4DHt.js → glide-data-grid-BT9p_4AN.js} +1 -1
  24. package/dist/web/assets/{group-chat-tab-ezXKVVpg.js → group-chat-tab-BUHGnUyW.js} +1 -1
  25. package/dist/web/assets/index-CuYtJ5xF.css +2 -0
  26. package/dist/web/assets/{index-CJWbFurD.js → index-kGmcC9cb.js} +5 -5
  27. package/dist/web/assets/{jira-watcher-section-DwARwskB.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
  28. package/dist/web/assets/{keybindings-store-S7YLGd8B.js → keybindings-store-DjJum4OS.js} +1 -1
  29. package/dist/web/assets/{keyboard-shortcuts-section-BoUDvdDy.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
  30. package/dist/web/assets/{markdown-renderer-DKdfiSIX.js → markdown-renderer-dHeak2mE.js} +1 -1
  31. package/dist/web/assets/{markdown-renderer-DYFmMC_8.js → markdown-renderer-iBw1fCI_.js} +1 -1
  32. package/dist/web/assets/{mobile-explorer-sheet-CMdqLCV-.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
  33. package/dist/web/assets/{notification-store-DpSYFj36.js → notification-store-DVi6Ds71.js} +1 -1
  34. package/dist/web/assets/{notifications-settings-section-CIf3-3xd.js → notifications-settings-section-shQ-ugKh.js} +1 -1
  35. package/dist/web/assets/{pdf-preview-C-7IdkIK.js → pdf-preview-DB26KSzm.js} +1 -1
  36. package/dist/web/assets/{postgres-viewer-BYXFYlLg.js → postgres-viewer-CJe2meyF.js} +1 -1
  37. package/dist/web/assets/{ppmbot-settings-section-B9dXvQ6p.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
  38. package/dist/web/assets/{process-table-B6NeTWt8.js → process-table-PXwLsMuG.js} +1 -1
  39. package/dist/web/assets/{proxy-settings-section-CUqq7pdU.js → proxy-settings-section-BHBhK6HC.js} +1 -1
  40. package/dist/web/assets/{query-audit-section-C1TYrPIp.js → query-audit-section-BD5ZVqDz.js} +1 -1
  41. package/dist/web/assets/{remote-desktop-mobile-sheet-DuKwKe5F.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
  42. package/dist/web/assets/{remote-desktop-mobile-view-DxMTtsa6.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
  43. package/dist/web/assets/{remote-desktop-readiness-gate-BwjfLWqg.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
  44. package/dist/web/assets/{remote-desktop-window-content-BIl4N5Hn.js → remote-desktop-window-content-X324gYbW.js} +1 -1
  45. package/dist/web/assets/{schedules-settings-section-C0qOb7GI.js → schedules-settings-section-a4-h6THc.js} +1 -1
  46. package/dist/web/assets/{settings-body-qbrJXX_A.js → settings-body-Fq7BVDw2.js} +2 -2
  47. package/dist/web/assets/{settings-tab-BKRgYVxf.js → settings-tab-CVLQy-ie.js} +1 -1
  48. package/dist/web/assets/{settings-window-content-DWsxJDu-.js → settings-window-content-Bi7M0iaZ.js} +1 -1
  49. package/dist/web/assets/{sql-query-editor-CsZOpCW3.js → sql-query-editor-D1r265fl.js} +1 -1
  50. package/dist/web/assets/{sqlite-viewer-DUrnCaP7.js → sqlite-viewer-DHkIotRl.js} +1 -1
  51. package/dist/web/assets/{system-monitor-body-C5cMpg7I.js → system-monitor-body-y6VUkYTL.js} +2 -2
  52. package/dist/web/assets/{system-monitor-tab-C66ywbVZ.js → system-monitor-tab-DRAOejhk.js} +1 -1
  53. package/dist/web/assets/{system-monitor-window-content-CENaXYJ-.js → system-monitor-window-content-B5i57xHD.js} +1 -1
  54. package/dist/web/assets/{tab-host-window-content-HViABGoS.js → tab-host-window-content-SkyPFFlc.js} +1 -1
  55. package/dist/web/assets/{team-member-sheet-CK6eyBRL.js → team-member-sheet-BB63u-Lf.js} +2 -2
  56. package/dist/web/assets/{team-member-window-content-D6wxg37P.js → team-member-window-content-D6tVTXuV.js} +1 -1
  57. package/dist/web/assets/{terminal-tab-BmyTawmN.js → terminal-tab-BYaHAkI5.js} +1 -1
  58. package/dist/web/assets/{tool-cards-y-XJcJHo.js → tool-cards-DRJu20sL.js} +2 -2
  59. package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
  60. package/dist/web/assets/{use-monaco-theme-BkM5rMgL.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
  61. package/dist/web/assets/{use-remote-desktop-display-choice-BT2r1td1.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
  62. package/dist/web/assets/{use-websocket-BvVTwMjI.js → use-websocket-BC_EGf5q.js} +1 -1
  63. package/dist/web/assets/{video-preview-BEaofjN7.js → video-preview-BUb43zWb.js} +1 -1
  64. package/dist/web/index.html +2 -2
  65. package/dist/web/sw.js +1 -1
  66. package/docs/deployment-guide.md +10 -0
  67. package/package.json +1 -1
  68. package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
  69. package/src/providers/codex-app-server/codex-history.ts +118 -45
  70. package/src/providers/codex-app-server/codex-provider.ts +277 -15
  71. package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
  72. package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
  73. package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
  74. package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
  75. package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
  76. package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
  77. package/src/server/routes/chat.ts +23 -2
  78. package/src/server/routes/codex-accounts.ts +32 -3
  79. package/src/server/ws/chat.ts +15 -11
  80. package/src/services/codex-account-cooldown.ts +56 -0
  81. package/src/services/codex-account-login.ts +109 -34
  82. package/src/services/codex-account.service.ts +56 -10
  83. package/src/services/codex-login-callback.ts +57 -0
  84. package/src/web/components/chat/chat-tab.tsx +8 -2
  85. package/src/web/components/chat/codex-usage-panel.tsx +129 -165
  86. package/src/web/components/chat/usage-badge.tsx +38 -129
  87. package/src/web/components/chat/usage-panel-shell.tsx +153 -0
  88. package/src/web/components/settings/accounts/account-card.tsx +20 -3
  89. package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
  90. package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
  91. package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
  92. package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
  93. package/src/web/components/ui/tooltip.tsx +14 -1
  94. package/src/web/hooks/use-usage.ts +13 -1
  95. package/dist/web/assets/accounts-settings-section-_sG1AqtH.js +0 -1
  96. package/dist/web/assets/chat-tab-CJi4uYb9.js +0 -15
  97. package/dist/web/assets/index-CoaVwAF6.css +0 -2
  98. 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
- 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
  }
@@ -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 devicePendingIdRef = useRef<string | null>(null);
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 pollDevice = useCallback((id: string) => {
80
- const deadline = Date.now() + DEVICE_POLL_DEADLINE_MS;
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
- stopPollRef.current = () => { stopped = true; };
83
- const finish = (message?: string) => {
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 s: DeviceStatus | null = null;
91
- // A failed poll is not a failed login the outcome is held server-side,
92
- // so keep asking until the deadline instead of aborting the flow.
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 (s?.state === "done") { finish(); setLabel(""); onDone(); await load(); return; }
96
- if (s?.state === "error") { finish(s.error); return; }
97
- if (Date.now() > deadline) { finish("Device login timed out."); return; }
98
- setTimeout(tick, DEVICE_POLL_MS);
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
- // Abandoning the panel mid-login stops the polling and releases the
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 startDevice = async () => {
112
- setErr(null);
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 d = await api.post<DevicePending>("/api/codex-accounts/device-login", { label: label.trim() || undefined });
115
- setDevice(d); setDeviceWaiting(true); devicePendingIdRef.current = d.id;
116
- pollDevice(d.id);
117
- } catch (e) { setErr((e as Error).message); }
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, remove, toggle, changeStrategy, doExport, doImport,
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 <TooltipPrimitive.Root data-slot="tooltip" {...props} />
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
  }