@hienlh/ppm 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +1 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +5 -1
- package/dist/web/assets/accounts-settings-section-DzDmIsPe.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BgYIPGVP.js → ai-resource-editor-D7C9wmwA.js} +1 -1
- package/dist/web/assets/{ai-settings-section-iBRCQvBR.js → ai-settings-section-BW5a07-Z.js} +1 -1
- package/dist/web/assets/{ai-settings-section-Cywmtnps.js → ai-settings-section-DZuwllLy.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-mhu9T6Fk.js → appearance-settings-section-RfH2N1rj.js} +1 -1
- package/dist/web/assets/chat-tab-Bz0pk0_E.js +15 -0
- package/dist/web/assets/{code-editor-Cv_pzHo7.js → code-editor-C4o1SyuO.js} +2 -2
- package/dist/web/assets/{conflict-editor-BnWaMSEa.js → conflict-editor-Dk65mBIe.js} +1 -1
- package/dist/web/assets/{csv-preview-DG4YAabr.js → csv-preview-DENi3CFz.js} +1 -1
- package/dist/web/assets/{database-viewer-B-wA2qT-.js → database-viewer-BiBC7ulB.js} +1 -1
- package/dist/web/assets/{diff-viewer-DRtzdfXk.js → diff-viewer-CSvZJHak.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-6tzdk7Pn.js → edit-diff-preview-xdDQvPhs.js} +1 -1
- package/dist/web/assets/{explorer-body-uh8oJp6J.js → explorer-body-QXS0h4AF.js} +1 -1
- package/dist/web/assets/{explorer-window-content-DLn_4egF.js → explorer-window-content-CQgqoRsj.js} +1 -1
- package/dist/web/assets/{extension-manager-section-C_ZGYvWf.js → extension-manager-section-CyC7wX35.js} +1 -1
- package/dist/web/assets/{extension-webview-DbFWxJpj.js → extension-webview-Da8SIlRa.js} +1 -1
- package/dist/web/assets/{files-settings-section-BrUbJglA.js → files-settings-section-D8cebM51.js} +1 -1
- package/dist/web/assets/{general-settings-section-B9ZPZVso.js → general-settings-section-1HVTvlIw.js} +1 -1
- package/dist/web/assets/{git-log-panel-C0jnhmgC.js → git-log-panel-C7a4rp8f.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CZVKIum-.js → glide-data-grid-BT9p_4AN.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DnPtmbPc.js → group-chat-tab-BUHGnUyW.js} +1 -1
- package/dist/web/assets/index-CuYtJ5xF.css +2 -0
- package/dist/web/assets/{index-CHQAPZIX.js → index-kGmcC9cb.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-Bjldyhru.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
- package/dist/web/assets/{keybindings-store-DBVXZKp8.js → keybindings-store-DjJum4OS.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-D7jaCPrv.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
- package/dist/web/assets/{markdown-renderer-B4B5oxQS.js → markdown-renderer-dHeak2mE.js} +2 -2
- package/dist/web/assets/{markdown-renderer-DwHh-jd_.js → markdown-renderer-iBw1fCI_.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-JJxZYWkV.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
- package/dist/web/assets/{notification-store-CCWNUHsS.js → notification-store-DVi6Ds71.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-DbbX7-Qp.js → notifications-settings-section-shQ-ugKh.js} +1 -1
- package/dist/web/assets/{pdf-preview-DxNjWa1v.js → pdf-preview-DB26KSzm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BScvk5ev.js → postgres-viewer-CJe2meyF.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-jFA9Ncgo.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
- package/dist/web/assets/{process-table-BexeYWQm.js → process-table-PXwLsMuG.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-C_seKq56.js → proxy-settings-section-BHBhK6HC.js} +1 -1
- package/dist/web/assets/{query-audit-section-BWXJmPDV.js → query-audit-section-BD5ZVqDz.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-B--PeL5i.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-jgn8nmQa.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-CjDth-W_.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CrHAUA03.js → remote-desktop-window-content-X324gYbW.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-DMFn3mYL.js → schedules-settings-section-a4-h6THc.js} +1 -1
- package/dist/web/assets/{settings-body-Dz2vPTEP.js → settings-body-Fq7BVDw2.js} +2 -2
- package/dist/web/assets/{settings-tab-B5QbsvS4.js → settings-tab-CVLQy-ie.js} +1 -1
- package/dist/web/assets/{settings-window-content-iM2bPoX5.js → settings-window-content-Bi7M0iaZ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-C0mGgLfX.js → sql-query-editor-D1r265fl.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Bs3RKf1X.js → sqlite-viewer-DHkIotRl.js} +1 -1
- package/dist/web/assets/{system-monitor-body-DvUmEdNe.js → system-monitor-body-y6VUkYTL.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-CHZ5F_ZE.js → system-monitor-tab-DRAOejhk.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-DjzqoeCN.js → system-monitor-window-content-B5i57xHD.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-DKeAr5mD.js → tab-host-window-content-SkyPFFlc.js} +1 -1
- package/dist/web/assets/{team-member-sheet-Cl9ZB3dl.js → team-member-sheet-BB63u-Lf.js} +2 -2
- package/dist/web/assets/{team-member-window-content-DfP7WdFD.js → team-member-window-content-D6tVTXuV.js} +1 -1
- package/dist/web/assets/{terminal-tab-J1Fqa_rm.js → terminal-tab-BYaHAkI5.js} +1 -1
- package/dist/web/assets/{tool-cards-DQghSuKX.js → tool-cards-DRJu20sL.js} +3 -3
- package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
- package/dist/web/assets/{use-monaco-theme-aa4-wjKQ.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-B-TpQ-Ua.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
- package/dist/web/assets/{use-websocket-BBslmv3D.js → use-websocket-BC_EGf5q.js} +1 -1
- package/dist/web/assets/{video-preview-Bd0lcK3d.js → video-preview-BUb43zWb.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/deployment-guide.md +10 -0
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
- package/src/providers/codex-app-server/codex-history.ts +118 -45
- package/src/providers/codex-app-server/codex-provider.ts +277 -15
- package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
- package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
- package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
- package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
- package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
- package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
- package/src/server/routes/chat.ts +23 -2
- package/src/server/routes/codex-accounts.ts +32 -3
- package/src/server/ws/chat.ts +15 -11
- package/src/services/codex-account-cooldown.ts +56 -0
- package/src/services/codex-account-login.ts +109 -34
- package/src/services/codex-account.service.ts +56 -10
- package/src/services/codex-login-callback.ts +57 -0
- package/src/web/components/chat/chat-tab.tsx +8 -2
- package/src/web/components/chat/codex-usage-panel.tsx +129 -165
- package/src/web/components/chat/edit-diff-preview.tsx +14 -7
- package/src/web/components/chat/usage-badge.tsx +38 -129
- package/src/web/components/chat/usage-panel-shell.tsx +153 -0
- package/src/web/components/settings/accounts/account-card.tsx +20 -3
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
- package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
- package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
- package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
- package/src/web/components/shared/markdown-code-block.tsx +10 -5
- package/src/web/components/ui/tooltip.tsx +14 -1
- package/src/web/hooks/use-usage.ts +13 -1
- package/dist/web/assets/accounts-settings-section-BQyru31C.js +0 -1
- package/dist/web/assets/chat-tab-Bs9OD6ss.js +0 -15
- package/dist/web/assets/index-CoaVwAF6.css +0 -2
- package/dist/web/assets/use-accounts-data-KWcMgRlc.js +0 -1
|
@@ -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
|
-
|
|
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
|
|
229
|
-
// behind a fetch and stays out of reach here; the consuming pick
|
|
230
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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]
|
|
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 {
|
|
16
|
+
import { Loader2 } from "lucide-react";
|
|
3
17
|
import { api } from "@/lib/api-client";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 [
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
175
|
+
<AccountCard
|
|
181
176
|
key={a.id}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
</
|
|
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:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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={
|
|
61
|
+
dangerouslySetInnerHTML={html}
|
|
55
62
|
/>
|
|
56
63
|
</div>
|
|
57
64
|
);
|