@hienlh/ppm 0.7.16 → 0.7.18

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 (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bunfig.toml +2 -0
  3. package/dist/web/assets/chat-tab-CXg6B4pP.js +7 -0
  4. package/dist/web/assets/{code-editor-DXqocnye.js → code-editor-hi_D7gq-.js} +1 -1
  5. package/dist/web/assets/{database-viewer-ChX5vA56.js → database-viewer-CbB7uUNK.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-8RNfSVOl.js → diff-viewer-ahy-4aRe.js} +1 -1
  7. package/dist/web/assets/git-graph-Dm8O90yN.js +1 -0
  8. package/dist/web/assets/index-Bw-o6Ji3.js +28 -0
  9. package/dist/web/assets/index-D6GLlwUx.css +2 -0
  10. package/dist/web/assets/keybindings-store-D-ezVO2O.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-BOHSi1fK.js → markdown-renderer-Dok83ft3.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-DRo3924t.js → postgres-viewer-BlESHVhJ.js} +1 -1
  13. package/dist/web/assets/settings-tab-2RCHMDfQ.js +1 -0
  14. package/dist/web/assets/{sqlite-viewer-0iVQjCmF.js → sqlite-viewer-LZsr5d7e.js} +1 -1
  15. package/dist/web/assets/switch-PAf5UhcN.js +1 -0
  16. package/dist/web/assets/{terminal-tab-Cuznr8Lg.js → terminal-tab-CCQsVaE6.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/server/routes/accounts.ts +60 -6
  21. package/src/services/account.service.ts +180 -17
  22. package/src/services/claude-usage.service.ts +9 -2
  23. package/src/services/db.service.ts +25 -3
  24. package/src/web/components/chat/chat-history-bar.tsx +2 -1
  25. package/src/web/components/chat/message-list.tsx +4 -2
  26. package/src/web/components/chat/usage-badge.tsx +128 -26
  27. package/src/web/components/settings/accounts-settings-section.tsx +268 -33
  28. package/src/web/lib/api-settings.ts +49 -0
  29. package/src/web/styles/globals.css +7 -0
  30. package/test-claude-oauth-v2.mjs +165 -0
  31. package/test-claude-oauth.mjs +175 -0
  32. package/test-verify-oat.mjs +106 -0
  33. package/dist/web/assets/ai-settings-section-BxCMGg-I.js +0 -1
  34. package/dist/web/assets/chat-tab-DtIaMWNT.js +0 -7
  35. package/dist/web/assets/git-graph-DYbWcg6M.js +0 -1
  36. package/dist/web/assets/index-BzhcIgja.js +0 -28
  37. package/dist/web/assets/index-sMxUHxFZ.css +0 -2
  38. package/dist/web/assets/keybindings-store-DBQQ_pTh.js +0 -1
  39. package/dist/web/assets/settings-tab-6ytjTMb9.js +0 -1
@@ -141,8 +141,10 @@ function MessageBubble({ message, isStreaming, projectName, onFork }: { message:
141
141
  <MarkdownContent content={message.content} projectName={projectName} />
142
142
  </div>
143
143
  )}
144
- {message.accountLabel && (
145
- <p className="text-[11px] text-text-tertiary/60 select-none">via {message.accountLabel}</p>
144
+ {!isStreaming && message.accountLabel && (
145
+ <p className="text-[10px] select-none" style={{ color: "var(--color-text-subtle)" }}>
146
+ via {message.accountLabel}
147
+ </p>
146
148
  )}
147
149
  </div>
148
150
  );
@@ -1,7 +1,17 @@
1
1
  import { useState, useEffect } from "react";
2
- import { Activity, RefreshCw } from "lucide-react";
2
+ import { Activity, RefreshCw, Eye, ShieldCheck, Loader2, X } from "lucide-react";
3
+ import { Switch } from "@/components/ui/switch";
3
4
  import type { UsageInfo, LimitBucket } from "../../../types/chat";
4
- import { getAllAccountUsages, type AccountUsageEntry } from "../../lib/api-settings";
5
+ import {
6
+ getAccounts,
7
+ getActiveAccount,
8
+ getAllAccountUsages,
9
+ patchAccount,
10
+ verifyAccount,
11
+ type AccountInfo,
12
+ type AccountUsageEntry,
13
+ type OAuthProfileData,
14
+ } from "../../lib/api-settings";
5
15
 
6
16
  interface UsageBadgeProps {
7
17
  usage: UsageInfo;
@@ -111,19 +121,30 @@ function formatLastUpdated(ts: number | null | undefined): string | null {
111
121
  if (secs < 5) return "just now";
112
122
  if (secs < 60) return `${secs}s ago`;
113
123
  const mins = Math.floor(secs / 60);
114
- return `${mins}m ago`;
124
+ if (mins < 60) return `${mins}m ago`;
125
+ const hrs = Math.floor(mins / 60);
126
+ const remainMins = mins % 60;
127
+ if (hrs < 24) return remainMins > 0 ? `${hrs}h ${remainMins}m ago` : `${hrs}h ago`;
128
+ const days = Math.floor(hrs / 24);
129
+ return `${days}d ago`;
115
130
  }
116
131
 
117
- function AccountUsageCard({ entry, isActive }: {
132
+ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onVerify, verifyingId, onViewProfile }: {
118
133
  entry: AccountUsageEntry;
119
134
  isActive: boolean;
135
+ accountInfo?: AccountInfo;
136
+ onToggle?: (id: string, status: string) => void;
137
+ onVerify?: (id: string) => void;
138
+ verifyingId?: string | null;
139
+ onViewProfile?: (profile: OAuthProfileData) => void;
120
140
  }) {
121
141
  const { usage } = entry;
122
142
  const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
143
+ const status = accountInfo?.status ?? entry.accountStatus;
123
144
 
124
145
  return (
125
146
  <div className={`rounded-md border p-2 space-y-1.5 ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
126
- <div className="flex items-center gap-1.5 flex-wrap">
147
+ <div className="flex items-center gap-1.5">
127
148
  <span className="text-xs font-medium truncate flex-1 min-w-0">
128
149
  {entry.accountLabel ?? entry.accountId.slice(0, 8)}
129
150
  </span>
@@ -133,9 +154,36 @@ function AccountUsageCard({ entry, isActive }: {
133
154
  {!entry.isOAuth && (
134
155
  <span className="text-[9px] text-text-subtle shrink-0">API key</span>
135
156
  )}
136
- {entry.accountStatus === "disabled" && (
137
- <span className="text-[9px] text-text-subtle shrink-0">disabled</span>
138
- )}
157
+ {/* Account controls */}
158
+ <div className="flex items-center gap-0.5 shrink-0">
159
+ {onViewProfile && accountInfo?.profileData && (
160
+ <button
161
+ className="p-1 rounded cursor-pointer text-text-subtle hover:text-foreground hover:bg-surface-elevated transition-colors"
162
+ onClick={() => onViewProfile(accountInfo.profileData!)}
163
+ title="View profile"
164
+ >
165
+ <Eye className="size-3" />
166
+ </button>
167
+ )}
168
+ {onVerify && (
169
+ <button
170
+ className="p-1 rounded cursor-pointer text-text-subtle hover:text-green-600 hover:bg-surface-elevated transition-colors"
171
+ onClick={() => onVerify(entry.accountId)}
172
+ disabled={verifyingId === entry.accountId}
173
+ title="Verify token"
174
+ >
175
+ {verifyingId === entry.accountId ? <Loader2 className="size-3 animate-spin" /> : <ShieldCheck className="size-3" />}
176
+ </button>
177
+ )}
178
+ {onToggle && (
179
+ <Switch
180
+ checked={status !== "disabled"}
181
+ onCheckedChange={() => onToggle(entry.accountId, status)}
182
+ disabled={status === "cooldown"}
183
+ className="scale-[0.6] cursor-pointer"
184
+ />
185
+ )}
186
+ </div>
139
187
  </div>
140
188
  {hasBuckets ? (
141
189
  <div className="space-y-1.5">
@@ -160,27 +208,57 @@ function AccountUsageCard({ entry, isActive }: {
160
208
 
161
209
  export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, lastFetchedAt }: UsageDetailPanelProps) {
162
210
  const [allUsages, setAllUsages] = useState<AccountUsageEntry[]>([]);
163
- const [loadingAll, setLoadingAll] = useState(false);
211
+ const [accounts, setAccounts] = useState<AccountInfo[]>([]);
212
+ const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
213
+ const [loadingAll, setLoadingAll] = useState(true);
214
+ const [verifyingId, setVerifyingId] = useState<string | null>(null);
215
+ const [profileView, setProfileView] = useState<OAuthProfileData | null>(null);
216
+
217
+ async function loadAll() {
218
+ setLoadingAll(true);
219
+ const [usages, accs, active] = await Promise.allSettled([
220
+ getAllAccountUsages(), getAccounts(), getActiveAccount(),
221
+ ]);
222
+ if (usages.status === "fulfilled") setAllUsages(usages.value);
223
+ if (accs.status === "fulfilled") setAccounts(accs.value);
224
+ if (active.status === "fulfilled") setActiveAccountId(active.value?.id ?? null);
225
+ setLoadingAll(false);
226
+ }
164
227
 
165
228
  useEffect(() => {
166
229
  if (!visible) return;
167
- setLoadingAll(true);
168
- getAllAccountUsages()
169
- .then(setAllUsages)
170
- .catch(() => {})
171
- .finally(() => setLoadingAll(false));
230
+ loadAll();
172
231
  }, [visible]);
173
232
 
233
+ // Re-fetch account usages after parent refreshes from Anthropic API
234
+ useEffect(() => {
235
+ if (!visible || !lastFetchedAt) return;
236
+ loadAll();
237
+ }, [lastFetchedAt]); // eslint-disable-line react-hooks/exhaustive-deps
238
+
174
239
  if (!visible) return null;
175
240
 
241
+ const accountMap = new Map(accounts.map((a) => [a.id, a]));
176
242
  const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
177
243
  const hasMultipleAccounts = allUsages.length > 0;
178
244
 
245
+ async function handleToggle(id: string, status: string) {
246
+ await patchAccount(id, { status: status === "disabled" ? "active" : "disabled" });
247
+ loadAll();
248
+ onReload?.();
249
+ }
250
+
251
+ async function handleVerify(id: string) {
252
+ setVerifyingId(id);
253
+ try { await verifyAccount(id); loadAll(); } catch { /* silent */ }
254
+ setVerifyingId(null);
255
+ }
256
+
179
257
  return (
180
258
  <div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
181
259
  <div className="flex items-center justify-between">
182
260
  <div className="flex items-center gap-2">
183
- <span className="text-xs font-semibold text-text-primary">Usage Limits</span>
261
+ <span className="text-xs font-semibold text-text-primary">Usage & Accounts</span>
184
262
  {lastFetchedAt && (
185
263
  <span className="text-[10px] text-text-subtle">{formatLastUpdated(new Date(lastFetchedAt).getTime())}</span>
186
264
  )}
@@ -188,39 +266,43 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
188
266
  <div className="flex items-center gap-1">
189
267
  {onReload && (
190
268
  <button
191
- onClick={onReload}
192
- disabled={loading}
193
- className="text-xs text-text-subtle hover:text-text-primary px-1 disabled:opacity-50"
194
- title="Refresh usage data"
269
+ onClick={() => { setLoadingAll(true); onReload(); }}
270
+ disabled={loading || loadingAll}
271
+ className="text-xs text-text-subtle hover:text-text-primary px-1 disabled:opacity-50 cursor-pointer"
272
+ title="Refresh"
195
273
  >
196
- <RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
274
+ <RefreshCw className={`size-3 ${(loading || loadingAll) ? "animate-spin" : ""}`} />
197
275
  </button>
198
276
  )}
199
277
  <button
200
278
  onClick={onClose}
201
- className="text-xs text-text-subtle hover:text-text-primary px-1"
279
+ className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
202
280
  >
203
-
281
+ <X className="size-3" />
204
282
  </button>
205
283
  </div>
206
284
  </div>
207
285
 
208
- {hasMultipleAccounts ? (
286
+ {(hasMultipleAccounts || loadingAll) ? (
209
287
  <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-1.5">
210
288
  {loadingAll ? (
211
- <p className="text-[10px] text-text-subtle">Loading accounts...</p>
289
+ <p className="text-[10px] text-text-subtle">Loading...</p>
212
290
  ) : (
213
291
  allUsages.map((entry) => (
214
292
  <AccountUsageCard
215
293
  key={entry.accountId}
216
294
  entry={entry}
217
- isActive={entry.accountId === usage.activeAccountId}
295
+ isActive={entry.accountId === (activeAccountId ?? usage.activeAccountId)}
296
+ accountInfo={accountMap.get(entry.accountId)}
297
+ onToggle={handleToggle}
298
+ onVerify={handleVerify}
299
+ verifyingId={verifyingId}
300
+ onViewProfile={setProfileView}
218
301
  />
219
302
  ))
220
303
  )}
221
304
  </div>
222
305
  ) : (
223
- // Fallback: single-account view (legacy or no accounts configured)
224
306
  <>
225
307
  {usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet ? (
226
308
  <div className="space-y-2.5">
@@ -255,6 +337,26 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
255
337
  )}
256
338
  </div>
257
339
  )}
340
+
341
+ {/* Inline profile popup */}
342
+ {profileView && (
343
+ <div className="border-t border-border pt-2">
344
+ <div className="flex items-center justify-between mb-1">
345
+ <span className="text-[10px] font-medium text-text-subtle">Profile</span>
346
+ <button className="text-text-subtle hover:text-foreground cursor-pointer" onClick={() => setProfileView(null)}>
347
+ <X className="size-3" />
348
+ </button>
349
+ </div>
350
+ <div className="grid grid-cols-[70px_1fr] gap-x-2 gap-y-0.5 text-[10px]">
351
+ {profileView.account?.display_name && <><span className="text-text-subtle">Name</span><span>{profileView.account.display_name}</span></>}
352
+ {profileView.account?.email && <><span className="text-text-subtle">Email</span><span>{profileView.account.email}</span></>}
353
+ {profileView.organization?.name && <><span className="text-text-subtle">Org</span><span>{profileView.organization.name}</span></>}
354
+ {profileView.organization?.organization_type && <><span className="text-text-subtle">Type</span><span>{profileView.organization.organization_type}</span></>}
355
+ {profileView.organization?.rate_limit_tier && <><span className="text-text-subtle">Tier</span><span>{profileView.organization.rate_limit_tier}</span></>}
356
+ {profileView.organization?.subscription_status && <><span className="text-text-subtle">Status</span><span>{profileView.organization.subscription_status}</span></>}
357
+ </div>
358
+ </div>
359
+ )}
258
360
  </div>
259
361
  );
260
362
  }