@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
@@ -15,6 +15,7 @@ import { getPpmDir } from "./ppm-dir.ts";
15
15
  import { configService } from "./config.service.ts";
16
16
  import { encrypt, decrypt } from "../lib/account-crypto.ts";
17
17
  import { getOrFetchUsage } from "./provider-usage/usage-registry.ts";
18
+ import { isCodexAccountUsageLimited } from "./codex-account-cooldown.ts";
18
19
  import type { UsageInfo } from "../providers/provider.interface.ts";
19
20
 
20
21
  export type CodexStrategy = "round-robin" | "fill-first" | "lowest-usage";
@@ -162,6 +163,22 @@ export function setCodexStrategy(strategy: CodexStrategy): void {
162
163
  /** Five-hour utilisation at or above which an account is skipped while others have room. */
163
164
  const FIVE_HOUR_SKIP_THRESHOLD = 0.95;
164
165
 
166
+ /**
167
+ * The utilisation an account should be judged on, as a 0-1 fraction.
168
+ *
169
+ * Not every plan has a short window. A ChatGPT Business account reports a weekly quota and
170
+ * nothing else, so reading `fiveHour` alone finds nothing there and reports the account as
171
+ * unreadable — which sends it to the back of `lowest-usage` and exempts it from the cap
172
+ * skip, for an account whose usage is in fact perfectly well known. Falling through to the
173
+ * weekly figure judges it on the only limit it actually has.
174
+ *
175
+ * `Infinity` still means genuinely unreadable: a failed fetch de-prioritises the account
176
+ * rather than reading as a flattering 0%.
177
+ */
178
+ export function codexUsageLevel(usage?: UsageInfo): number {
179
+ return usage?.fiveHour ?? usage?.sevenDay ?? Number.POSITIVE_INFINITY;
180
+ }
181
+
165
182
  /**
166
183
  * Drop accounts with no five-hour room left, unless that would leave nothing.
167
184
  *
@@ -183,6 +200,20 @@ function withFiveHourRoom(
183
200
  return room.length > 0 ? room : accts;
184
201
  }
185
202
 
203
+ /**
204
+ * Drop accounts Codex itself refused for an exhausted quota, unless that would leave nothing.
205
+ *
206
+ * Complements the five-hour skip rather than repeating it. That one reads a usage figure
207
+ * served from a two-minute cache, so an account that hit its cap moments ago still looks
208
+ * like it has room; this one reads the refusal Codex just gave us, which is current by
209
+ * definition. Soft for the same reason as the skip above: a park is a guess at when the
210
+ * quota returns, and handing back a parked account beats handing back nothing.
211
+ */
212
+ function withQuotaLeft(accts: CodexAccount[]): CodexAccount[] {
213
+ const usable = accts.filter((a) => !isCodexAccountUsageLimited(a.id));
214
+ return usable.length > 0 ? usable : accts;
215
+ }
216
+
186
217
  /**
187
218
  * Pick an account by strategy. `usageOf` (optional, from P4) enables lowest-usage;
188
219
  * without it lowest-usage falls back to round-robin.
@@ -190,16 +221,20 @@ function withFiveHourRoom(
190
221
  * `usageOf` also drives the five-hour skip, so a caller that supplies it gets an account
191
222
  * with room rather than merely the least-used one among several that are all capped.
192
223
  */
193
- export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?: (id: string) => number }): CodexAccount | null {
224
+ export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?: (id: string) => number; exclude?: string[] }): CodexAccount | null {
194
225
  // Disabled is a hard exclusion with no fallback, unlike the five-hour skip below. Being
195
226
  // capped means a slower turn; being switched off is the user saying don't use this, and
196
227
  // handing it back anyway because nothing else was left would ignore them.
197
- const all = listCodexAccounts().filter((a) => a.status !== "disabled");
228
+ //
229
+ // `exclude` is hard for the same reason: its only caller is the rotation away from an
230
+ // account that just refused the turn, and falling back to it would rotate in a circle.
231
+ const excluded = new Set(opts?.exclude ?? []);
232
+ const all = listCodexAccounts().filter((a) => a.status !== "disabled" && !excluded.has(a.id));
198
233
  if (all.length === 0) return null;
199
234
  if (all.length === 1) return all[0]!;
200
235
  const strategy = opts?.strategy ?? getCodexStrategy();
201
236
  const usageOf = opts?.usageOf;
202
- const accts = withFiveHourRoom(all, usageOf);
237
+ const accts = withFiveHourRoom(withQuotaLeft(all), usageOf);
203
238
  if (accts.length === 1) return accts[0]!;
204
239
  if (strategy === "fill-first") return accts[0]!;
205
240
  if (strategy === "lowest-usage" && usageOf) {
@@ -225,9 +260,10 @@ export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?:
225
260
  * null rather than guessing at one.
226
261
  */
227
262
  export function peekCodexAccount(): CodexAccount | null {
228
- // Status is the one thing this can filter on without going async, so it does. Usage lives
229
- // behind a fetch and stays out of reach here; the consuming pick endpoint reads it instead.
230
- const accts = listCodexAccounts().filter((a) => a.status !== "disabled");
263
+ // Status and the quota park are the two things this can filter on without going async, so
264
+ // it does both. Usage lives behind a fetch and stays out of reach here; the consuming pick
265
+ // endpoint reads it instead.
266
+ const accts = withQuotaLeft(listCodexAccounts().filter((a) => a.status !== "disabled"));
231
267
  if (accts.length === 0) return null;
232
268
  if (accts.length === 1) return accts[0]!;
233
269
  return getCodexStrategy() === "fill-first" ? accts[0]! : null;
@@ -240,9 +276,19 @@ export async function resolveCodexAccountForSession(sessionId?: string): Promise
240
276
  const sticky = sessionId ? getSessionCodexAccount(sessionId) : null;
241
277
  // A binding is held for the prompt cache, not honoured unconditionally: an account the
242
278
  // user has switched off has to let go of the sessions sitting on it, or turning it off
243
- // would do nothing for exactly the conversations already using it.
244
- if (sticky) { const a = getCodexAccount(sticky); if (a && a.status !== "disabled") return a; }
245
- if (listCodexAccounts().filter((a) => a.status !== "disabled").length <= 1) return selectCodexAccount();
279
+ // would do nothing for exactly the conversations already using it. An account parked for
280
+ // an exhausted quota lets go for a different reason it would refuse the turn, and
281
+ // spending one to rediscover that is worse than starting on an account that can answer.
282
+ if (sticky) {
283
+ const a = getCodexAccount(sticky);
284
+ if (a && a.status !== "disabled" && !isCodexAccountUsageLimited(a.id)) return a;
285
+ }
286
+ // Usage is only worth fetching when it can change the answer, and with at most one
287
+ // account able to serve it cannot. Parked accounts count as unable for the same reason
288
+ // they are skipped below, so a pool of two where one is out of quota resolves without a
289
+ // network read at all.
290
+ const usable = listCodexAccounts().filter((a) => a.status !== "disabled" && !isCodexAccountUsageLimited(a.id));
291
+ if (usable.length <= 1) return selectCodexAccount();
246
292
  // Usage is read for every strategy, not just lowest-usage: it is also what lets the
247
293
  // selector skip an account with no five-hour room left. Reading it only for lowest-usage
248
294
  // left that skip inert under round-robin, which is the default — sessions kept landing on
@@ -252,7 +298,7 @@ export async function resolveCodexAccountForSession(sessionId?: string): Promise
252
298
  // A failed usage fetch (broken/expired account) yields {} → treat as +Infinity so
253
299
  // lowest-usage de-prioritizes it instead of preferring it as "0% used".
254
300
  const usages = await getAllCodexUsages();
255
- return selectCodexAccount({ usageOf: (id) => usages[id]?.fiveHour ?? Number.POSITIVE_INFINITY });
301
+ return selectCodexAccount({ usageOf: (id) => codexUsageLevel(usages[id]) });
256
302
  }
257
303
 
258
304
  // ── Usage (per account) ──
@@ -0,0 +1,57 @@
1
+ import { createServer } from "node:net";
2
+
3
+ export interface BrowserLoginCallback {
4
+ redirectUri: string;
5
+ state: string;
6
+ submitted: boolean;
7
+ }
8
+
9
+ /** Codex may cancel an existing listener on its preferred port when starting.
10
+ * Refuse an occupied port instead of interrupting another application's login. */
11
+ export async function checkCodexLoginPort(): Promise<void> {
12
+ await new Promise<void>((resolve, reject) => {
13
+ const server = createServer();
14
+ server.once("error", () => reject(new Error("Codex login port 1455 is busy. Finish the other browser login or use device code.")));
15
+ server.listen(1455, "127.0.0.1", () => server.close(() => resolve()));
16
+ });
17
+ }
18
+
19
+ export function parseBrowserLogin(authUrl: string): BrowserLoginCallback {
20
+ const auth = new URL(authUrl);
21
+ if (auth.protocol !== "https:" || !["auth.openai.com", "chatgpt.com"].includes(auth.hostname) || auth.username || auth.password) {
22
+ throw new Error("Codex returned an unsupported login URL.");
23
+ }
24
+ const redirect = new URL(auth.searchParams.get("redirect_uri") ?? "");
25
+ if (redirect.protocol !== "http:" || redirect.hostname !== "localhost" || !["1455", "1457"].includes(redirect.port)
26
+ || redirect.pathname !== "/auth/callback" || redirect.search || redirect.hash || redirect.username || redirect.password) {
27
+ throw new Error("Codex returned an unsupported callback URL.");
28
+ }
29
+ const state = auth.searchParams.get("state");
30
+ if (!state) throw new Error("Codex returned a login URL without state.");
31
+ return { redirectUri: redirect.href, state, submitted: false };
32
+ }
33
+
34
+ /** Never fetch the user-supplied URL. Validate it against this pending login,
35
+ * then rebuild a request to the fixed loopback listener using only code/state. */
36
+ export function buildCodexCallback(callbackUrl: string, login: BrowserLoginCallback): URL {
37
+ if (typeof callbackUrl !== "string" || callbackUrl.length > 16_384) throw new Error("Paste the full localhost callback URL.");
38
+ let url: URL;
39
+ try { url = new URL(callbackUrl.trim()); } catch { throw new Error("Paste the full localhost callback URL."); }
40
+ const expected = new URL(login.redirectUri);
41
+ if (url.origin !== expected.origin || url.pathname !== expected.pathname || url.username || url.password || url.hash) {
42
+ throw new Error("Use the localhost callback URL from this Codex login.");
43
+ }
44
+ const state = url.searchParams.get("state");
45
+ // Codex also accepts this exact onboarding suffix; it still binds to the original state.
46
+ if (url.searchParams.getAll("state").length !== 1 || (state !== login.state && state !== `${login.state}.onboarding_entrypoint=life_sciences`)) {
47
+ throw new Error("This URL belongs to a different login. Use the latest sign-in link.");
48
+ }
49
+ const code = url.searchParams.get("code");
50
+ if (url.searchParams.has("error") || !code || url.searchParams.getAll("code").length !== 1) {
51
+ throw new Error("This URL has no authorization code. Complete ChatGPT sign-in first.");
52
+ }
53
+ expected.hostname = "127.0.0.1";
54
+ expected.searchParams.set("code", code);
55
+ expected.searchParams.set("state", state!);
56
+ return expected;
57
+ }
@@ -89,7 +89,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
89
89
 
90
90
  // Usage runs independently — auto-refreshes on interval. Scoped to this session so the
91
91
  // account shown is the one bound to it, not whichever session ran most recently.
92
- const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
92
+ const { usageInfo, usageLoading, lastFetchedAt, refreshUsage, reloadUsage } =
93
93
  useUsage(projectName, providerId, sessionId ?? undefined);
94
94
 
95
95
  // Draft auto-save/restore
@@ -183,6 +183,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
183
183
  // other source is the usage endpoint on a two-minute poll — so without this the badge
184
184
  // sits on the old account long after the switch, which reads as the button not working.
185
185
  setServingAccount({ id: accountId, label });
186
+ // The header chip reports the account BOUND to this session, and that binding has
187
+ // just changed — so re-read it now instead of leaving the chip on the previous
188
+ // account's figures for up to the two-minute poll. Before the first message a
189
+ // session has no binding at all, so those figures were blank, and the chip sat at
190
+ // "--%" beside a panel already showing the chosen account's quota.
191
+ void reloadUsage();
186
192
  // Switching costs a full prompt-cache write, so it is worth saying out loud rather
187
193
  // than letting the chip quietly change.
188
194
  toast.success("This chat will use the selected account from the next message.");
@@ -198,7 +204,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
198
204
  metadata: { ...metadata, pickedAccountId: accountId, pickedAccountLabel: label, pickedAccountProvider: providerId },
199
205
  });
200
206
  return null;
201
- }, [sessionId, projectName, tabId, metadata, providerId, updateTab]); // eslint-disable-line react-hooks/exhaustive-deps
207
+ }, [sessionId, projectName, tabId, metadata, providerId, updateTab, reloadUsage]); // eslint-disable-line react-hooks/exhaustive-deps
202
208
 
203
209
  /**
204
210
  * The account this chat is on, as far as the UI can tell.
@@ -1,11 +1,27 @@
1
+ /**
2
+ * The chat header's Codex usage panel.
3
+ *
4
+ * Deliberately the same panel as the Claude one, not a lookalike: the frame comes from
5
+ * `UsagePanelShell` and every card is the `AccountCard` that Settings renders. It used to
6
+ * draw both itself, which is how the two ended up with cards of different shapes stacked in
7
+ * different directions — the drift the shared Settings layout exists to stop.
8
+ *
9
+ * What stays Codex-specific is only what genuinely differs: its accounts come from
10
+ * `/api/codex-accounts` rather than the Claude pool, an account is a CODEX_HOME rather than
11
+ * an OAuth grant so there is no token to expire, and a plan may have no 5-hour window at
12
+ * all — so the quota rows follow what the account actually reports.
13
+ */
14
+
1
15
  import { useState, useEffect, useCallback } from "react";
2
- import { X, Loader2, RefreshCw } from "lucide-react";
16
+ import { Loader2 } from "lucide-react";
3
17
  import { api } from "@/lib/api-client";
4
- import { Switch } from "@/components/ui/switch";
5
- import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
6
- import { AccountUsageBar } from "@/components/settings/accounts/account-bucket-row";
18
+ import { AccountCard } from "@/components/settings/accounts/account-card";
19
+ import { AccountCardShell } from "@/components/settings/accounts/accounts-pane-header";
20
+ import { AccountBucketRow } from "@/components/settings/accounts/account-bucket-row";
7
21
  import { formatResetTime } from "@/components/settings/accounts/account-usage-format";
22
+ import type { AccountUsageEntry } from "@/lib/api-settings";
8
23
  import type { UsageInfo } from "../../../types/chat";
24
+ import { UsagePanelShell } from "./usage-panel-shell";
9
25
 
10
26
  interface CodexAccount { id: string; label: string; type: string; planType?: string | null; status?: "active" | "disabled" }
11
27
  type Usage = Pick<UsageInfo, "fiveHour" | "sevenDay" | "session" | "weekly">;
@@ -15,20 +31,23 @@ function atCap(util: number | null | undefined): boolean {
15
31
  return Math.round((util ?? 0) * 100) >= 100;
16
32
  }
17
33
 
18
- function UsageBar({ label, frac, bucket }: {
19
- label: string;
20
- frac?: number;
21
- bucket?: UsageInfo["session"];
22
- }) {
23
- const pct = frac != null ? Math.round(frac * 100) : null;
24
- return <AccountUsageBar label={label} pct={pct} reset={formatResetTime(bucket)} resetsAt={bucket?.resetsAt} />;
34
+ /**
35
+ * A Codex account in the shape every account card speaks.
36
+ *
37
+ * `isOAuth` carries the account kind rather than an OAuth fact: a `chatgpt` account is a
38
+ * sign-in and an `apiKey` one is a static key, which is exactly the distinction the card's
39
+ * "API key" badge already draws.
40
+ */
41
+ function toEntry(account: CodexAccount, usage: Usage): AccountUsageEntry {
42
+ return {
43
+ accountId: account.id,
44
+ accountLabel: account.label,
45
+ accountStatus: account.status ?? "active",
46
+ isOAuth: account.type !== "apiKey",
47
+ usage: { session: usage.session, weekly: usage.weekly },
48
+ };
25
49
  }
26
50
 
27
- /** Read-only usage panel opened from the chat toolbar badge (Claude parity).
28
- * Shows every managed Codex account's 5h/weekly utilization. When no managed
29
- * accounts exist, chats run on the ambient ~/.codex login — its usage comes
30
- * from the session `usage` prop. Login/management lives in Settings → AI
31
- * Provider → Codex. */
32
51
  export function CodexUsagePanel({ onClose, usage, onReload, onSelectAccount, selectedAccountId }: {
33
52
  onClose: () => void;
34
53
  usage: UsageInfo;
@@ -44,43 +63,7 @@ export function CodexUsagePanel({ onClose, usage, onReload, onSelectAccount, sel
44
63
  const [loading, setLoading] = useState(false);
45
64
  const [selectingId, setSelectingId] = useState<string | null>(null);
46
65
  const [togglingId, setTogglingId] = useState<string | null>(null);
47
- const [toggleError, setToggleError] = useState<string | null>(null);
48
-
49
- // This panel draws its own cards rather than reusing Settings' AccountCard, so the
50
- // selection control is rebuilt here. Kept visually the same as the Claude panel's on
51
- // purpose — the two sub-tabs drifting apart is the thing the shared Settings layout was
52
- // introduced to stop, and a differently-shaped button here would start it again.
53
- const select = useCallback(async (id: string, label: string, refused: string | null) => {
54
- if (!onSelectAccount) return;
55
- // Refuse here rather than leaving the button inert: the user pressed something and is
56
- // owed the reason. The server checks again, which catches an account that became
57
- // unusable between this render and the click.
58
- if (refused) { setToggleError(`Cannot switch to this account — ${refused}`); return; }
59
- setSelectingId(id);
60
- setToggleError(null);
61
- try { setToggleError((await onSelectAccount(id, label)) ?? null); } finally { setSelectingId(null); }
62
- }, [onSelectAccount]);
63
-
64
- /**
65
- * Switch a Codex account on or off without leaving the chat.
66
- *
67
- * No pending-token dance like the Claude side: the Codex route answers immediately because
68
- * there is no refresh token to prove. The pending id is still tracked so the switch cannot
69
- * be double-fired on a slow connection.
70
- */
71
- const toggle = useCallback(async (id: string, status: string) => {
72
- setTogglingId(id);
73
- setToggleError(null);
74
- try {
75
- await api.patch(`/api/codex-accounts/${id}`, { status: status === "disabled" ? "active" : "disabled" });
76
- const d = await api.get<{ accounts: CodexAccount[] }>("/api/codex-accounts");
77
- setAccounts(d.accounts);
78
- } catch (e) {
79
- setToggleError((e as Error).message || "Could not change the account");
80
- } finally {
81
- setTogglingId(null);
82
- }
83
- }, []);
66
+ const [panelError, setPanelError] = useState<string | null>(null);
84
67
 
85
68
  const load = useCallback(async () => {
86
69
  setLoading(true);
@@ -104,126 +87,107 @@ export function CodexUsagePanel({ onClose, usage, onReload, onSelectAccount, sel
104
87
  } catch { /* retain the last successful reading */ } finally { setLoading(false); }
105
88
  }, [onReload]);
106
89
 
107
- return (
108
- <div className="border-t border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
109
- <div className="flex items-center justify-between">
110
- <span className="text-xs font-semibold text-text-primary">Codex Usage</span>
111
- <div className="flex items-center gap-1">
112
- <Tooltip>
113
- <TooltipTrigger asChild>
114
- <button
115
- onClick={reload}
116
- disabled={loading}
117
- className="text-text-subtle hover:text-text-primary px-1 cursor-pointer disabled:opacity-50"
118
- aria-label="Refresh usage"
119
- >
120
- <RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
121
- </button>
122
- </TooltipTrigger>
123
- <TooltipContent side="top">Refresh</TooltipContent>
124
- </Tooltip>
125
- <Tooltip>
126
- <TooltipTrigger asChild>
127
- <button
128
- onClick={onClose}
129
- className="text-text-subtle hover:text-text-primary px-1 cursor-pointer"
130
- aria-label="Close usage panel"
131
- >
132
- <X className="size-3" />
133
- </button>
134
- </TooltipTrigger>
135
- <TooltipContent side="top">Close</TooltipContent>
136
- </Tooltip>
137
- </div>
138
- </div>
90
+ /**
91
+ * Why an account cannot take this chat, in the words the card shows.
92
+ *
93
+ * Reached, not approaching: an account at 96% still answers, one at 100% does not. Both
94
+ * windows are checked because a plan may have only one of them, and whichever it has is
95
+ * the one that refuses the turn.
96
+ */
97
+ const unselectableReason = useCallback((account: CodexAccount, u: Usage): string | null => {
98
+ if (account.status === "disabled") return "Disabled";
99
+ if (atCap(u.fiveHour)) return `5-hour limit reached${u.session?.resetsAt ? ` — resets ${formatResetTime(u.session)}` : ""}`;
100
+ if (atCap(u.sevenDay)) return `Weekly limit reached${u.weekly?.resetsAt ? ` — resets ${formatResetTime(u.weekly)}` : ""}`;
101
+ return null;
102
+ }, []);
139
103
 
140
- {toggleError && (
141
- <div className="flex items-start gap-2 rounded border border-error/40 bg-error/10 px-2 py-1.5 text-[11px] text-error">
142
- <span className="flex-1">{toggleError}</span>
143
- <button onClick={() => setToggleError(null)} className="shrink-0 text-error/70 hover:text-error cursor-pointer" aria-label="Dismiss">
144
- <X className="size-3" />
145
- </button>
146
- </div>
147
- )}
104
+ const handleSelect = useCallback(async (accountId: string) => {
105
+ if (!onSelectAccount) return;
106
+ const account = accounts.find((a) => a.id === accountId);
107
+ if (!account) return;
108
+ // Refuse here rather than leaving the button inert: the user pressed something and is
109
+ // owed the reason. The server checks again, which catches an account that became
110
+ // unusable between this render and the click.
111
+ const refused = unselectableReason(account, usages[accountId] ?? {});
112
+ if (refused) { setPanelError(`Cannot switch to this account — ${refused.toLowerCase()}.`); return; }
113
+ setSelectingId(accountId);
114
+ setPanelError(null);
115
+ try { setPanelError((await onSelectAccount(accountId, account.label)) ?? null); }
116
+ finally { setSelectingId(null); }
117
+ }, [onSelectAccount, accounts, usages, unselectableReason]);
148
118
 
149
- {loading && accounts.length === 0 && (
150
- <div className="text-xs text-text-subtle flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Loading…</div>
151
- )}
119
+ /**
120
+ * Switch a Codex account on or off without leaving the chat.
121
+ *
122
+ * No pending-token dance like the Claude side: the Codex route answers immediately because
123
+ * there is no refresh token to prove. The pending id is still tracked so the switch cannot
124
+ * be double-fired on a slow connection.
125
+ */
126
+ const handleToggle = useCallback(async (id: string, status: string) => {
127
+ setTogglingId(id);
128
+ setPanelError(null);
129
+ try {
130
+ await api.patch(`/api/codex-accounts/${id}`, { status: status === "disabled" ? "active" : "disabled" });
131
+ const d = await api.get<{ accounts: CodexAccount[] }>("/api/codex-accounts");
132
+ setAccounts(d.accounts);
133
+ } catch (e) {
134
+ setPanelError((e as Error).message || "Could not change the account");
135
+ } finally {
136
+ setTogglingId(null);
137
+ }
138
+ }, []);
152
139
 
153
- {/* No managed accounts → chat runs on the ambient ~/.codex login; show its usage */}
154
- {!loading && accounts.length === 0 && (
155
- <>
156
- <div className="rounded-md border border-border/50 bg-surface/40 p-2.5 space-y-2">
157
- <div className="flex items-center gap-2">
158
- <span className="text-sm text-text-primary truncate flex-1 min-w-0">{usage.activeAccountLabel || "Default login"}</span>
159
- <span className="text-[10px] uppercase tracking-wide text-text-subtle border border-border rounded px-1">~/.codex</span>
160
- </div>
161
- <UsageBar label="5-Hour" frac={usage.fiveHour} bucket={usage.session} />
162
- <UsageBar label="Weekly" frac={usage.sevenDay} bucket={usage.weekly} />
163
- </div>
164
- <p className="text-[11px] text-text-subtle">Using your default <code>~/.codex</code> login. Add managed accounts in Settings → AI Provider → Codex.</p>
165
- </>
166
- )}
140
+ /* No managed accounts → chats run on the ambient ~/.codex login, whose usage arrives on
141
+ the session prop rather than from the accounts endpoint. */
142
+ const ambient = loading && accounts.length === 0 ? (
143
+ <div className="text-xs text-text-subtle flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Loading…</div>
144
+ ) : (
145
+ <>
146
+ <AccountCardShell active={false}>
147
+ <div className="flex items-center gap-2">
148
+ <span className="text-sm font-medium text-text-primary truncate flex-1 min-w-0">{usage.activeAccountLabel || "Default login"}</span>
149
+ <span className="text-[10px] uppercase tracking-wide text-text-subtle border border-border rounded px-1 shrink-0">~/.codex</span>
150
+ </div>
151
+ <div className="space-y-2">
152
+ <AccountBucketRow label="5-Hour Session" bucket={usage.session} />
153
+ <AccountBucketRow label="Weekly" bucket={usage.weekly} />
154
+ {!usage.session && !usage.weekly && <p className="text-xs text-text-subtle">No usage data yet</p>}
155
+ </div>
156
+ </AccountCardShell>
157
+ <p className="text-[11px] text-text-subtle">Using your default <code>~/.codex</code> login. Add managed accounts in Settings → AI Provider → Codex.</p>
158
+ </>
159
+ );
167
160
 
168
- {accounts.map((a) => {
161
+ return (
162
+ <UsagePanelShell
163
+ title="Codex Usage"
164
+ onClose={onClose}
165
+ onReload={onReload ? () => void reload() : undefined}
166
+ reloading={loading}
167
+ error={panelError}
168
+ onDismissError={() => setPanelError(null)}
169
+ cardCount={accounts.length}
170
+ fallback={ambient}
171
+ >
172
+ {(layout) => accounts.map((a) => {
169
173
  const u = usages[a.id] ?? {};
170
- const isServing = a.id === (selectedAccountId ?? usage.activeAccountId);
171
- // Reached, not approaching: an account at 96% still answers, one at 100% does not.
172
- const refused = a.status === "disabled"
173
- ? "it is switched off."
174
- : atCap(u.fiveHour)
175
- ? "it has reached its 5-hour limit."
176
- : atCap(u.sevenDay)
177
- ? "it has reached its weekly limit."
178
- : null;
179
174
  return (
180
- <div
175
+ <AccountCard
181
176
  key={a.id}
182
- className={`rounded-md border bg-surface/40 p-2.5 space-y-2 ${isServing ? "border-primary/30 bg-primary/5" : "border-border/50"}`}
183
- >
184
- <div className="flex items-center gap-2">
185
- <span className="text-sm text-text-primary truncate flex-1 min-w-0">{a.label}</span>
186
- {isServing && <span className="text-[10px] text-primary shrink-0 font-medium">Active</span>}
187
- {/* Same slot as the Active badge, so every card keeps one header row and the
188
- panel does not grow taller just to carry a button. */}
189
- {onSelectAccount && !isServing && (
190
- <Tooltip>
191
- <TooltipTrigger asChild>
192
- <button
193
- type="button"
194
- onClick={() => select(a.id, a.label, refused)}
195
- disabled={selectingId === a.id}
196
- className={[
197
- "shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors cursor-pointer disabled:cursor-wait",
198
- refused
199
- ? "text-text-subtle hover:text-error hover:bg-error/10"
200
- : "text-text-secondary hover:text-foreground hover:bg-surface-elevated",
201
- ].join(" ")}
202
- >
203
- {selectingId === a.id ? "Switching…" : "Use"}
204
- </button>
205
- </TooltipTrigger>
206
- <TooltipContent side="top">
207
- {refused ? `Cannot use this account — ${refused}` : "Use this account for this chat"}
208
- </TooltipContent>
209
- </Tooltip>
210
- )}
211
- {a.status === "disabled" && <span className="text-[10px] text-text-subtle shrink-0">Off</span>}
212
- <span className="text-[10px] uppercase tracking-wide text-text-subtle border border-border rounded px-1">{a.type}</span>
213
- {a.planType && <span className="text-[10px] text-text-subtle">{a.planType}</span>}
214
- <Switch
215
- checked={a.status !== "disabled"}
216
- onCheckedChange={() => void toggle(a.id, a.status ?? "active")}
217
- disabled={togglingId === a.id}
218
- aria-label={a.status === "disabled" ? "Enable account" : "Disable account"}
219
- className="cursor-pointer shrink-0"
220
- />
221
- </div>
222
- <UsageBar label="5-Hour" frac={u.fiveHour} bucket={u.session} />
223
- <UsageBar label="Weekly" frac={u.sevenDay} bucket={u.weekly} />
224
- </div>
177
+ entry={toEntry(a, u)}
178
+ // What is serving THIS chat, not what the rotation would pick next —
179
+ // those differ the moment a session is bound to an account.
180
+ isActive={a.id === (selectedAccountId ?? usage.activeAccountId)}
181
+ layout={layout}
182
+ planLabel={a.planType}
183
+ onSelect={onSelectAccount ? handleSelect : undefined}
184
+ unselectableReason={unselectableReason(a, u)}
185
+ selecting={selectingId === a.id}
186
+ onToggle={handleToggle}
187
+ toggling={togglingId === a.id}
188
+ />
225
189
  );
226
190
  })}
227
- </div>
191
+ </UsagePanelShell>
228
192
  );
229
193
  }
@@ -22,16 +22,23 @@ export default function EditDiffPreview({ oldStr, newStr, filePath, maxLines = 1
22
22
  [oldStr, newStr, maxLines],
23
23
  );
24
24
 
25
+ // The `{ __html }` objects live inside the memo on purpose. React 19 compares that prop by
26
+ // identity, not by the string inside, so an inline `{{ __html: html }}` re-parses and
27
+ // rebuilds every line's subtree on every render of this card — and a card re-renders on
28
+ // each flush of the streaming turn it sits in. Measured at ~20 identical rewrites per line
29
+ // per turn (tests/e2e/chat-dom-leak-trace.mjs), all of it garbage for the DOM allocator.
25
30
  const lines = useMemo(
26
31
  () =>
27
32
  rows.map((row) => ({
28
33
  row,
29
- html: renderLine(
30
- row.text,
31
- language,
32
- row.kind === "equal" ? [] : row.ranges,
33
- row.kind === "del" ? "bg-diff-removed-word" : "bg-diff-added-word",
34
- ),
34
+ html: {
35
+ __html: renderLine(
36
+ row.text,
37
+ language,
38
+ row.kind === "equal" ? [] : row.ranges,
39
+ row.kind === "del" ? "bg-diff-removed-word" : "bg-diff-added-word",
40
+ ),
41
+ },
35
42
  })),
36
43
  [rows, language],
37
44
  );
@@ -51,7 +58,7 @@ export default function EditDiffPreview({ oldStr, newStr, filePath, maxLines = 1
51
58
  <span className={`select-none shrink-0 w-3 pl-1.5 ${s.gutter}`}>{s.prefix}</span>
52
59
  <code
53
60
  className="whitespace-pre-wrap break-all text-text-primary pr-1.5"
54
- dangerouslySetInnerHTML={{ __html: html }}
61
+ dangerouslySetInnerHTML={html}
55
62
  />
56
63
  </div>
57
64
  );