@hienlh/ppm 0.20.1 → 0.20.3

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 (99) 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-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/docs/lessons-learned.md +14 -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/usage-badge.tsx +38 -129
  88. package/src/web/components/chat/usage-panel-shell.tsx +153 -0
  89. package/src/web/components/settings/accounts/account-card.tsx +20 -3
  90. package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
  91. package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
  92. package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
  93. package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
  94. package/src/web/components/ui/tooltip.tsx +14 -1
  95. package/src/web/hooks/use-usage.ts +13 -1
  96. package/dist/web/assets/accounts-settings-section-_sG1AqtH.js +0 -1
  97. package/dist/web/assets/chat-tab-CJi4uYb9.js +0 -15
  98. package/dist/web/assets/index-CoaVwAF6.css +0 -2
  99. package/dist/web/assets/use-accounts-data-BbOb2QNP.js +0 -1
@@ -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
  }
@@ -17,14 +17,14 @@
17
17
  */
18
18
 
19
19
  import { useState } from "react";
20
- import { Activity, ExternalLink, Maximize2, Minimize2, RefreshCw, X } from "lucide-react";
20
+ import { Activity, RefreshCw } from "lucide-react";
21
21
  import type { UsageInfo } from "../../../types/chat";
22
22
  import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
23
- import { openSettings } from "@/components/settings/open-settings";
24
23
  import { AccountCard } from "@/components/settings/accounts/account-card";
25
24
  import { AccountBucketRow } from "@/components/settings/accounts/account-bucket-row";
26
25
  import { useAccountsData } from "@/components/settings/accounts/use-accounts-data";
27
26
  import { formatLastUpdated, formatResetTime, pctColor } from "@/components/settings/accounts/account-usage-format";
27
+ import { UsagePanelShell } from "./usage-panel-shell";
28
28
 
29
29
  /** Matches the whole-percent figure the usage bars show, so a refusal agrees with the card. */
30
30
  function atCap(util: number | null | undefined): boolean {
@@ -91,7 +91,6 @@ export function UsageDetailPanel({
91
91
  // One strip for anything that went wrong in this panel — a refused switch or a refused
92
92
  // toggle. Two separate messages in two places would be harder to notice, not clearer.
93
93
  const [panelError, setPanelError] = useState<string | null>(null);
94
- const [isFullscreen, setIsFullscreen] = useState(false);
95
94
 
96
95
  if (!visible) return null;
97
96
 
@@ -158,131 +157,21 @@ export function UsageDetailPanel({
158
157
  }
159
158
 
160
159
  const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
161
- const hasPerAccountUsage = usages.length > 0;
162
-
163
- // Roughly square, so the cards fill the viewport instead of leaving a long empty column.
164
- const fsCount = usages.length || 1;
165
- const fsCols = Math.ceil(Math.sqrt(fsCount));
166
- const fsRows = Math.ceil(fsCount / fsCols);
167
160
 
168
161
  return (
169
- <div
170
- className={`relative border-b border-border bg-surface px-3 py-2.5 ${
171
- isFullscreen
172
- ? "fixed inset-0 z-50 flex flex-col gap-2.5 overflow-hidden"
173
- : "space-y-2.5 max-h-[350px] overflow-y-auto"
174
- }`}
175
- >
176
- <div className="flex items-center justify-between shrink-0">
177
- <div className="flex items-center gap-2">
178
- <span className="text-xs font-semibold text-text-primary">Usage</span>
179
- {lastFetchedAt && (
180
- <span className="text-[10px] text-text-subtle">{formatLastUpdated(new Date(lastFetchedAt).getTime())}</span>
181
- )}
182
- </div>
183
- <div className="flex items-center gap-1">
184
- <Tooltip>
185
- <TooltipTrigger asChild>
186
- <button
187
- onClick={() => openSettings("accounts")}
188
- className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-primary px-1 cursor-pointer"
189
- >
190
- Manage accounts <ExternalLink className="size-3" />
191
- </button>
192
- </TooltipTrigger>
193
- <TooltipContent side="top">Add, remove or rotate accounts</TooltipContent>
194
- </Tooltip>
195
- {hasPerAccountUsage && (
196
- <Tooltip>
197
- <TooltipTrigger asChild>
198
- <button
199
- onClick={() => setIsFullscreen((v) => !v)}
200
- className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
201
- aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
202
- >
203
- {isFullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
204
- </button>
205
- </TooltipTrigger>
206
- <TooltipContent side="top">{isFullscreen ? "Exit fullscreen" : "Fullscreen view"}</TooltipContent>
207
- </Tooltip>
208
- )}
209
- {onReload && (
210
- <Tooltip>
211
- <TooltipTrigger asChild>
212
- <button
213
- onClick={() => { onReload(); void reload(); }}
214
- disabled={loading || refreshing}
215
- className="text-xs text-text-subtle hover:text-text-primary px-1 disabled:opacity-50 cursor-pointer"
216
- aria-label="Refresh usage"
217
- >
218
- <RefreshCw className={`size-3 ${(loading || refreshing) ? "animate-spin" : ""}`} />
219
- </button>
220
- </TooltipTrigger>
221
- <TooltipContent side="top">Refresh</TooltipContent>
222
- </Tooltip>
223
- )}
224
- <button
225
- onClick={() => { setIsFullscreen(false); onClose(); }}
226
- className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
227
- aria-label="Close usage panel"
228
- >
229
- <X className="size-3" />
230
- </button>
231
- </div>
232
- </div>
233
-
234
- {/* The server distinguishes "this login was rejected, sign in again" from "could not
235
- reach Anthropic, try shortly", and the difference decides what the user should do.
236
- Showing its words verbatim is the only way that survives to them. */}
237
- {panelError && (
238
- <div className="shrink-0 flex items-start gap-2 rounded border border-error/40 bg-error/10 px-2 py-1.5 text-[11px] text-error">
239
- <span className="flex-1">{panelError}</span>
240
- <button
241
- onClick={() => setPanelError(null)}
242
- className="shrink-0 text-error/70 hover:text-error cursor-pointer"
243
- aria-label="Dismiss"
244
- >
245
- <X className="size-3" />
246
- </button>
247
- </div>
162
+ <UsagePanelShell
163
+ title="Usage"
164
+ meta={lastFetchedAt && (
165
+ <span className="text-[10px] text-text-subtle">{formatLastUpdated(new Date(lastFetchedAt).getTime())}</span>
248
166
  )}
249
-
250
- {hasPerAccountUsage || initialLoading ? (
251
- <div
252
- className={isFullscreen
253
- ? "flex-1 min-h-0 grid gap-2 overflow-hidden"
254
- // Same classes as AccountCardRow, with the panel's wider padding to clear.
255
- : "flex gap-2 overflow-x-auto pb-1 -mx-3 px-3 snap-x snap-mandatory scrollbar-thin"}
256
- style={isFullscreen ? {
257
- gridTemplateColumns: `repeat(${fsCols}, minmax(0, 1fr))`,
258
- gridTemplateRows: `repeat(${fsRows}, minmax(0, 1fr))`,
259
- } : undefined}
260
- >
261
- {initialLoading ? (
262
- <p className="text-[10px] text-text-subtle">Loading...</p>
263
- ) : (
264
- usages.map((entry) => {
265
- const info = accountMap.get(entry.accountId);
266
- return (
267
- <AccountCard
268
- key={entry.accountId}
269
- entry={entry}
270
- // What is serving THIS chat, not what the rotation would pick next —
271
- // those differ the moment a session is bound to an account.
272
- isActive={entry.accountId === (selectedAccountId ?? usage.activeAccountId ?? activeAccountId)}
273
- accountInfo={info}
274
- flash={flashIds.has(entry.accountId)}
275
- layout={isFullscreen ? "grid" : "strip"}
276
- onSelect={onSelectAccount ? handleSelect : undefined}
277
- unselectableReason={unselectableReason(info, entry)}
278
- selecting={selectingId === entry.accountId}
279
- onToggle={handleToggle}
280
- toggling={togglingId === entry.accountId}
281
- />
282
- );
283
- })
284
- )}
285
- </div>
167
+ onClose={onClose}
168
+ onReload={onReload ? () => { onReload(); void reload(); } : undefined}
169
+ reloading={loading || refreshing}
170
+ error={panelError}
171
+ onDismissError={() => setPanelError(null)}
172
+ cardCount={usages.length}
173
+ fallback={initialLoading ? (
174
+ <p className="text-[10px] text-text-subtle">Loading...</p>
286
175
  ) : usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet ? (
287
176
  <div className="space-y-2.5">
288
177
  <AccountBucketRow label="5-Hour Session" bucket={usage.session} />
@@ -293,8 +182,7 @@ export function UsageDetailPanel({
293
182
  ) : (
294
183
  <p className="text-xs text-text-subtle">No usage data available</p>
295
184
  )}
296
-
297
- {hasCost && (
185
+ footer={hasCost ? (
298
186
  <div className="border-t border-border pt-2 space-y-1">
299
187
  {usage.queryCostUsd != null && (
300
188
  <div className="flex items-center justify-between text-xs">
@@ -313,7 +201,28 @@ export function UsageDetailPanel({
313
201
  </div>
314
202
  )}
315
203
  </div>
316
- )}
317
- </div>
204
+ ) : undefined}
205
+ >
206
+ {(layout) => usages.map((entry) => {
207
+ const info = accountMap.get(entry.accountId);
208
+ return (
209
+ <AccountCard
210
+ key={entry.accountId}
211
+ entry={entry}
212
+ // What is serving THIS chat, not what the rotation would pick next —
213
+ // those differ the moment a session is bound to an account.
214
+ isActive={entry.accountId === (selectedAccountId ?? usage.activeAccountId ?? activeAccountId)}
215
+ accountInfo={info}
216
+ flash={flashIds.has(entry.accountId)}
217
+ layout={layout}
218
+ onSelect={onSelectAccount ? handleSelect : undefined}
219
+ unselectableReason={unselectableReason(info, entry)}
220
+ selecting={selectingId === entry.accountId}
221
+ onToggle={handleToggle}
222
+ toggling={togglingId === entry.accountId}
223
+ />
224
+ );
225
+ })}
226
+ </UsagePanelShell>
318
227
  );
319
228
  }