@hienlh/ppm 0.9.57 → 0.9.59

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 (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/web/assets/api-settings-CgBII8jW.js +1 -0
  3. package/dist/web/assets/chat-tab-GSn-Itse.js +10 -0
  4. package/dist/web/assets/{code-editor-DAZvtAlT.js → code-editor-Bjh4LrGQ.js} +1 -1
  5. package/dist/web/assets/{database-viewer-C5fco1jm.js → database-viewer-v3PVSVgo.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-ShRSPvsf.js → diff-viewer-BGSt0dzB.js} +1 -1
  7. package/dist/web/assets/{extension-webview-CWJRMPfV.js → extension-webview-aIzLDxoc.js} +1 -1
  8. package/dist/web/assets/{git-graph-h0QmXMdZ.js → git-graph-D_7WjkSI.js} +1 -1
  9. package/dist/web/assets/{index-CDlrGSwd.js → index-B23bElOE.js} +6 -6
  10. package/dist/web/assets/index-r64nXcCm.css +2 -0
  11. package/dist/web/assets/keybindings-store-DbtSlUnk.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-CSEmmMWt.js → markdown-renderer-DoqEXzyK.js} +1 -1
  13. package/dist/web/assets/{port-forwarding-tab-Cts6tMFn.js → port-forwarding-tab-DCfENtZd.js} +1 -1
  14. package/dist/web/assets/{postgres-viewer-CiQC1sf9.js → postgres-viewer-E0_ojaz2.js} +1 -1
  15. package/dist/web/assets/{settings-tab-CQx6aHtO.js → settings-tab-Ba9P0f9D.js} +1 -1
  16. package/dist/web/assets/{sqlite-viewer-FQfCkjU6.js → sqlite-viewer-DvlMWCLX.js} +1 -1
  17. package/dist/web/assets/{terminal-tab-C2SnOqxn.js → terminal-tab-wFhiLTfY.js} +1 -1
  18. package/dist/web/assets/{use-monaco-theme-VPgvhMpB.js → use-monaco-theme-Dk_fE15d.js} +1 -1
  19. package/dist/web/index.html +3 -3
  20. package/dist/web/sw.js +1 -1
  21. package/package.json +1 -1
  22. package/src/providers/claude-agent-sdk.ts +28 -2
  23. package/src/server/routes/accounts.ts +9 -1
  24. package/src/server/routes/chat.ts +32 -5
  25. package/src/services/account.service.ts +34 -0
  26. package/src/services/chat.service.ts +3 -3
  27. package/src/services/db.service.ts +9 -0
  28. package/src/types/chat.ts +6 -1
  29. package/src/web/components/chat/chat-history-bar.tsx +110 -74
  30. package/src/web/components/chat/chat-history-panel.tsx +2 -2
  31. package/src/web/components/chat/chat-welcome.tsx +2 -2
  32. package/src/web/components/chat/session-picker.tsx +2 -2
  33. package/src/web/components/chat/usage-badge.tsx +12 -10
  34. package/src/web/components/chat/usage-pattern-chart.tsx +203 -0
  35. package/src/web/components/layout/editor-panel.tsx +2 -2
  36. package/src/web/lib/api-settings.ts +14 -0
  37. package/dist/web/assets/api-settings-Bid0NHuI.js +0 -1
  38. package/dist/web/assets/chat-tab-SfXtOm9d.js +0 -10
  39. package/dist/web/assets/index-DVuSY0BZ.css +0 -2
  40. package/dist/web/assets/keybindings-store-wbHg-S_v.js +0 -1
@@ -8,7 +8,7 @@ import { AISettingsSection } from "@/components/settings/ai-settings-section";
8
8
  import { UsageDetailPanel } from "./usage-badge";
9
9
  import { TeamActivityPanel } from "./team-activity-panel";
10
10
  import { ProviderBadge } from "./provider-selector";
11
- import type { SessionInfo } from "../../../types/chat";
11
+ import type { SessionInfo, SessionListResponse } from "../../../types/chat";
12
12
  import type { UsageInfo } from "../../../types/chat";
13
13
  import type { TeamMessageItem } from "@/hooks/use-chat";
14
14
 
@@ -107,8 +107,11 @@ export function ChatHistoryBar({
107
107
  const [searchQuery, setSearchQuery] = useState("");
108
108
  const [editingId, setEditingId] = useState<string | null>(null);
109
109
  const [editingTitle, setEditingTitle] = useState("");
110
+ const [hasMore, setHasMore] = useState(false);
111
+ const [loadingMore, setLoadingMore] = useState(false);
110
112
  const editInputRef = useRef<HTMLInputElement>(null);
111
113
  const openTab = useTabStore((s) => s.openTab);
114
+ const PAGE_SIZE = 50;
112
115
 
113
116
  const togglePanel = (panel: PanelType) => {
114
117
  setActivePanel((prev) => prev === panel ? null : panel);
@@ -118,8 +121,9 @@ export function ChatHistoryBar({
118
121
  if (!projectName) return;
119
122
  setLoading(true);
120
123
  try {
121
- const data = await api.get<SessionInfo[]>(`${projectUrl(projectName)}/chat/sessions`);
122
- setSessions(data);
124
+ const data = await api.get<SessionListResponse>(`${projectUrl(projectName)}/chat/sessions?limit=${PAGE_SIZE}&offset=0`);
125
+ setSessions(data.sessions);
126
+ setHasMore(data.hasMore);
123
127
  } catch {
124
128
  // silent
125
129
  } finally {
@@ -127,6 +131,26 @@ export function ChatHistoryBar({
127
131
  }
128
132
  }, [projectName]);
129
133
 
134
+ const loadMore = useCallback(async () => {
135
+ if (!projectName || loadingMore || !hasMore) return;
136
+ setLoadingMore(true);
137
+ try {
138
+ // Offset by count of non-pinned sessions (pinned are injected separately by backend)
139
+ const unpinnedCount = sessions.filter((s) => !s.pinned).length;
140
+ const data = await api.get<SessionListResponse>(`${projectUrl(projectName)}/chat/sessions?limit=${PAGE_SIZE}&offset=${unpinnedCount}`);
141
+ setSessions((prev) => {
142
+ const existingIds = new Set(prev.map((s) => s.id));
143
+ const newSessions = data.sessions.filter((s) => !existingIds.has(s.id));
144
+ return [...prev, ...newSessions];
145
+ });
146
+ setHasMore(data.hasMore);
147
+ } catch {
148
+ // silent
149
+ } finally {
150
+ setLoadingMore(false);
151
+ }
152
+ }, [projectName, loadingMore, hasMore, sessions]);
153
+
130
154
  // Load sessions when history panel opens
131
155
  useEffect(() => {
132
156
  if (activePanel === "history" && sessions.length === 0) load();
@@ -367,78 +391,90 @@ export function ChatHistoryBar({
367
391
  {searchQuery ? "No matching sessions" : "No sessions yet"}
368
392
  </div>
369
393
  ) : (
370
- filteredSessions.map((session) => (
371
- <div
372
- key={session.id}
373
- className="flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-surface-elevated transition-colors group"
374
- >
375
- <ProviderBadge providerId={session.providerId} />
376
- {editingId === session.id ? (
377
- <form
378
- className="flex items-center gap-1 flex-1 min-w-0"
379
- onSubmit={(e) => { e.preventDefault(); saveTitle(); }}
380
- >
381
- <input
382
- ref={editInputRef}
383
- value={editingTitle}
384
- onChange={(e) => setEditingTitle(e.target.value)}
385
- onBlur={saveTitle}
386
- onKeyDown={(e) => { if (e.key === "Escape") cancelEditing(); }}
387
- className="flex-1 min-w-0 bg-surface-elevated text-[11px] text-text-primary px-1 py-0.5 rounded border border-border outline-none focus:border-primary"
388
- autoFocus
389
- />
390
- <button type="submit" className="p-0.5 text-green-500 hover:text-green-400" onClick={(e) => e.stopPropagation()}>
391
- <Check className="size-3" />
392
- </button>
393
- <button type="button" className="p-0.5 text-text-subtle hover:text-text-secondary" onClick={(e) => { e.stopPropagation(); cancelEditing(); }}>
394
- <X className="size-3" />
395
- </button>
396
- </form>
397
- ) : (
398
- <>
399
- <button
400
- onClick={() => openSession(session)}
401
- className="text-[11px] truncate flex-1 text-left flex items-center gap-1"
402
- >
403
- {session.title?.startsWith("[PPM]") && (
404
- <Bot className="size-3 text-muted-foreground shrink-0" />
405
- )}
406
- {session.title?.startsWith("[PPM]")
407
- ? session.title.slice(7)
408
- : session.title || "Untitled"}
409
- </button>
410
- <button
411
- onClick={(e) => togglePin(e, session)}
412
- className={`p-0.5 rounded transition-all ${
413
- session.pinned
414
- ? "text-primary hover:text-primary/70"
415
- : "text-text-subtle hover:text-text-secondary can-hover:opacity-0 can-hover:group-hover:opacity-100"
416
- }`}
417
- title={session.pinned ? "Unpin session" : "Pin session"}
418
- >
419
- {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
420
- </button>
421
- <button
422
- onClick={(e) => startEditing(session, e)}
423
- className="p-0.5 rounded text-text-subtle hover:text-text-secondary can-hover:opacity-0 can-hover:group-hover:opacity-100 transition-opacity"
424
- title="Rename session"
425
- >
426
- <Pencil className="size-3" />
427
- </button>
428
- <button
429
- onClick={(e) => deleteSession(e, session)}
430
- className="p-0.5 rounded text-text-subtle hover:text-red-400 hover:bg-red-500/20 can-hover:opacity-0 can-hover:group-hover:opacity-100 transition-opacity"
431
- title="Delete session"
394
+ <>
395
+ {filteredSessions.map((session) => (
396
+ <div
397
+ key={session.id}
398
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-surface-elevated transition-colors group"
399
+ >
400
+ <ProviderBadge providerId={session.providerId} />
401
+ {editingId === session.id ? (
402
+ <form
403
+ className="flex items-center gap-1 flex-1 min-w-0"
404
+ onSubmit={(e) => { e.preventDefault(); saveTitle(); }}
432
405
  >
433
- <Trash2 className="size-3" />
434
- </button>
435
- </>
436
- )}
437
- {editingId !== session.id && session.updatedAt && (
438
- <span className="text-[10px] text-text-subtle shrink-0 w-10 text-right">{formatDate(session.updatedAt)}</span>
439
- )}
440
- </div>
441
- ))
406
+ <input
407
+ ref={editInputRef}
408
+ value={editingTitle}
409
+ onChange={(e) => setEditingTitle(e.target.value)}
410
+ onBlur={saveTitle}
411
+ onKeyDown={(e) => { if (e.key === "Escape") cancelEditing(); }}
412
+ className="flex-1 min-w-0 bg-surface-elevated text-[11px] text-text-primary px-1 py-0.5 rounded border border-border outline-none focus:border-primary"
413
+ autoFocus
414
+ />
415
+ <button type="submit" className="p-0.5 text-green-500 hover:text-green-400" onClick={(e) => e.stopPropagation()}>
416
+ <Check className="size-3" />
417
+ </button>
418
+ <button type="button" className="p-0.5 text-text-subtle hover:text-text-secondary" onClick={(e) => { e.stopPropagation(); cancelEditing(); }}>
419
+ <X className="size-3" />
420
+ </button>
421
+ </form>
422
+ ) : (
423
+ <>
424
+ <button
425
+ onClick={() => openSession(session)}
426
+ className="text-[11px] truncate flex-1 text-left flex items-center gap-1"
427
+ >
428
+ {session.title?.startsWith("[PPM]") && (
429
+ <Bot className="size-3 text-muted-foreground shrink-0" />
430
+ )}
431
+ {session.title?.startsWith("[PPM]")
432
+ ? session.title.slice(7)
433
+ : session.title || "Untitled"}
434
+ </button>
435
+ <button
436
+ onClick={(e) => togglePin(e, session)}
437
+ className={`p-0.5 rounded transition-all ${
438
+ session.pinned
439
+ ? "text-primary hover:text-primary/70"
440
+ : "text-text-subtle hover:text-text-secondary can-hover:opacity-0 can-hover:group-hover:opacity-100"
441
+ }`}
442
+ title={session.pinned ? "Unpin session" : "Pin session"}
443
+ >
444
+ {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
445
+ </button>
446
+ <button
447
+ onClick={(e) => startEditing(session, e)}
448
+ className="p-0.5 rounded text-text-subtle hover:text-text-secondary can-hover:opacity-0 can-hover:group-hover:opacity-100 transition-opacity"
449
+ title="Rename session"
450
+ >
451
+ <Pencil className="size-3" />
452
+ </button>
453
+ <button
454
+ onClick={(e) => deleteSession(e, session)}
455
+ className="p-0.5 rounded text-text-subtle hover:text-red-400 hover:bg-red-500/20 can-hover:opacity-0 can-hover:group-hover:opacity-100 transition-opacity"
456
+ title="Delete session"
457
+ >
458
+ <Trash2 className="size-3" />
459
+ </button>
460
+ </>
461
+ )}
462
+ {editingId !== session.id && session.updatedAt && (
463
+ <span className="text-[10px] text-text-subtle shrink-0 w-10 text-right">{formatDate(session.updatedAt)}</span>
464
+ )}
465
+ </div>
466
+ ))}
467
+ {hasMore && !searchQuery && (
468
+ <button
469
+ onClick={loadMore}
470
+ disabled={loadingMore}
471
+ className="flex items-center justify-center gap-1 w-full py-1.5 text-[11px] text-text-subtle hover:text-text-secondary hover:bg-surface-elevated transition-colors"
472
+ >
473
+ {loadingMore ? <Loader2 className="size-3 animate-spin" /> : null}
474
+ {loadingMore ? "Loading..." : "Load more"}
475
+ </button>
476
+ )}
477
+ </>
442
478
  )}
443
479
  </div>
444
480
  </div>
@@ -27,8 +27,8 @@ export function ChatHistoryPanel({ projectName }: ChatHistoryPanelProps) {
27
27
  setLoading(true);
28
28
  setError(null);
29
29
  try {
30
- const data = await api.get<SessionInfo[]>(`${projectUrl(projectName)}/chat/sessions`);
31
- setSessions(data);
30
+ const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions`);
31
+ setSessions(data.sessions);
32
32
  } catch (e) {
33
33
  setError(e instanceof Error ? e.message : "Failed to load sessions");
34
34
  } finally {
@@ -38,8 +38,8 @@ export function ChatWelcome({ projectName, onSelectSession }: ChatWelcomeProps)
38
38
  if (!projectName) return;
39
39
  setLoading(true);
40
40
  try {
41
- const data = await api.get<SessionInfo[]>(`${projectUrl(projectName)}/chat/sessions`);
42
- setSessions(data.slice(0, FETCH_SESSIONS_LIMIT));
41
+ const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions?limit=${FETCH_SESSIONS_LIMIT}`);
42
+ setSessions(data.sessions.slice(0, FETCH_SESSIONS_LIMIT));
43
43
  } catch {
44
44
  // silently ignore
45
45
  } finally {
@@ -25,8 +25,8 @@ export function SessionPicker({
25
25
  if (!projectName) return;
26
26
  setLoading(true);
27
27
  try {
28
- const data = await api.get<SessionInfo[]>(`${projectUrl(projectName)}/chat/sessions`);
29
- setSessions(data);
28
+ const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions`);
29
+ setSessions(data.sessions);
30
30
  } catch {
31
31
  // Silently fail — sessions list is non-critical
32
32
  } finally {
@@ -14,6 +14,7 @@ import {
14
14
  } from "../../lib/api-settings";
15
15
  import { AddAccountDialog, ExportAccountsDialog, ImportAccountsDialog } from "./account-dialogs";
16
16
  import { AccountRotationSettings } from "./account-rotation-settings";
17
+ import { UsagePatternChart } from "./usage-pattern-chart";
17
18
 
18
19
  interface UsageBadgeProps {
19
20
  usage: UsageInfo;
@@ -160,7 +161,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onDelete, on
160
161
  onToggle?: (id: string, status: string) => void;
161
162
  onDelete?: (id: string, display: string) => void;
162
163
  onExport?: (id: string) => void;
163
- onViewProfile?: (profile: OAuthProfileData) => void;
164
+ onViewProfile?: (profile: OAuthProfileData, accountId: string) => void;
164
165
  flash?: boolean;
165
166
  fullscreen?: boolean;
166
167
  }) {
@@ -187,7 +188,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onDelete, on
187
188
  {!isExpired && onViewProfile && accountInfo?.profileData && (
188
189
  <button
189
190
  className="p-1 rounded cursor-pointer text-text-subtle hover:text-foreground hover:bg-surface-elevated transition-colors"
190
- onClick={() => onViewProfile(accountInfo.profileData!)}
191
+ onClick={() => onViewProfile(accountInfo.profileData!, entry.accountId)}
191
192
  title="View profile"
192
193
  >
193
194
  <Eye className="size-3" />
@@ -259,7 +260,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
259
260
  const [initialLoading, setInitialLoading] = useState(true);
260
261
  const [refreshing, setRefreshing] = useState(false);
261
262
  const [flashIds, setFlashIds] = useState<Set<string>>(new Set());
262
- const [profileView, setProfileView] = useState<OAuthProfileData | null>(null);
263
+ const [profileView, setProfileView] = useState<{ profile: OAuthProfileData; accountId: string } | null>(null);
263
264
  const [showAddDialog, setShowAddDialog] = useState(false);
264
265
  const [showExportDialog, setShowExportDialog] = useState(false);
265
266
  const [showImportDialog, setShowImportDialog] = useState(false);
@@ -441,7 +442,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
441
442
  onToggle={handleToggle}
442
443
  onDelete={(id, display) => setDeleteTarget({ id, display })}
443
444
  onExport={(id) => { setExportPreselect(id); setShowExportDialog(true); }}
444
- onViewProfile={setProfileView}
445
+ onViewProfile={(profile, accountId) => setProfileView({ profile, accountId })}
445
446
  flash={flashIds.has(entry.accountId)}
446
447
  fullscreen={isFullscreen}
447
448
  />
@@ -494,13 +495,14 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
494
495
  </button>
495
496
  </div>
496
497
  <div className="grid grid-cols-[70px_1fr] gap-x-2 gap-y-0.5 text-[10px]">
497
- {profileView.account?.display_name && <><span className="text-text-subtle">Name</span><span>{profileView.account.display_name}</span></>}
498
- {profileView.account?.email && <><span className="text-text-subtle">Email</span><span>{profileView.account.email}</span></>}
499
- {profileView.organization?.name && <><span className="text-text-subtle">Org</span><span>{profileView.organization.name}</span></>}
500
- {profileView.organization?.organization_type && <><span className="text-text-subtle">Type</span><span>{profileView.organization.organization_type}</span></>}
501
- {profileView.organization?.rate_limit_tier && <><span className="text-text-subtle">Tier</span><span>{profileView.organization.rate_limit_tier}</span></>}
502
- {profileView.organization?.subscription_status && <><span className="text-text-subtle">Status</span><span>{profileView.organization.subscription_status}</span></>}
498
+ {profileView.profile.account?.display_name && <><span className="text-text-subtle">Name</span><span>{profileView.profile.account.display_name}</span></>}
499
+ {profileView.profile.account?.email && <><span className="text-text-subtle">Email</span><span>{profileView.profile.account.email}</span></>}
500
+ {profileView.profile.organization?.name && <><span className="text-text-subtle">Org</span><span>{profileView.profile.organization.name}</span></>}
501
+ {profileView.profile.organization?.organization_type && <><span className="text-text-subtle">Type</span><span>{profileView.profile.organization.organization_type}</span></>}
502
+ {profileView.profile.organization?.rate_limit_tier && <><span className="text-text-subtle">Tier</span><span>{profileView.profile.organization.rate_limit_tier}</span></>}
503
+ {profileView.profile.organization?.subscription_status && <><span className="text-text-subtle">Status</span><span>{profileView.profile.organization.subscription_status}</span></>}
503
504
  </div>
505
+ <UsagePatternChart accountId={profileView.accountId} />
504
506
  </div>
505
507
  )}
506
508
 
@@ -0,0 +1,203 @@
1
+ import { useState, useEffect, useMemo } from "react";
2
+ import { Loader2 } from "lucide-react";
3
+ import { getUsageHistory, type UsageSnapshot } from "../../lib/api-settings";
4
+
5
+ const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
6
+ const HOUR_LABELS = Array.from({ length: 24 }, (_, i) => i);
7
+
8
+ type ViewMode = "5h" | "weekly";
9
+
10
+ interface AggregatedCell {
11
+ sum: number;
12
+ count: number;
13
+ avg: number;
14
+ }
15
+
16
+ /** Aggregate snapshots into a 7×24 grid (day-of-week × hour-of-day) */
17
+ function buildHeatmap(snapshots: UsageSnapshot[], mode: ViewMode): AggregatedCell[][] {
18
+ // grid[dayOfWeek 0-6][hour 0-23]
19
+ const grid: AggregatedCell[][] = Array.from({ length: 7 }, () =>
20
+ Array.from({ length: 24 }, () => ({ sum: 0, count: 0, avg: 0 })),
21
+ );
22
+
23
+ for (const snap of snapshots) {
24
+ const val = mode === "5h" ? snap.five_hour_util : snap.weekly_util;
25
+ if (val == null) continue;
26
+ const d = new Date(snap.recorded_at + (snap.recorded_at.endsWith("Z") ? "" : "Z"));
27
+ const dow = (d.getDay() + 6) % 7; // Monday=0
28
+ const hour = d.getHours();
29
+ grid[dow]![hour]!.sum += val;
30
+ grid[dow]![hour]!.count += 1;
31
+ }
32
+
33
+ // Compute averages
34
+ for (const row of grid) {
35
+ for (const cell of row) {
36
+ cell.avg = cell.count > 0 ? cell.sum / cell.count : 0;
37
+ }
38
+ }
39
+ return grid;
40
+ }
41
+
42
+ /** Aggregate snapshots by day-of-week (average utilization) */
43
+ function buildDayAvg(grid: AggregatedCell[][]): number[] {
44
+ return grid.map((row) => {
45
+ const totalSum = row.reduce((s, c) => s + c.sum, 0);
46
+ const totalCount = row.reduce((s, c) => s + c.count, 0);
47
+ return totalCount > 0 ? totalSum / totalCount : 0;
48
+ });
49
+ }
50
+
51
+ /** Aggregate snapshots by hour-of-day (average utilization) */
52
+ function buildHourAvg(grid: AggregatedCell[][]): number[] {
53
+ return HOUR_LABELS.map((h) => {
54
+ let sum = 0, count = 0;
55
+ for (const row of grid) {
56
+ sum += row[h]!.sum;
57
+ count += row[h]!.count;
58
+ }
59
+ return count > 0 ? sum / count : 0;
60
+ });
61
+ }
62
+
63
+ function cellColor(val: number): string {
64
+ if (val === 0) return "bg-surface-elevated";
65
+ if (val < 0.3) return "bg-green-500/30";
66
+ if (val < 0.5) return "bg-green-500/60";
67
+ if (val < 0.7) return "bg-amber-500/50";
68
+ if (val < 0.9) return "bg-amber-500/80";
69
+ return "bg-red-500/80";
70
+ }
71
+
72
+ function barColor(val: number): string {
73
+ if (val < 0.3) return "bg-green-500";
74
+ if (val < 0.7) return "bg-amber-500";
75
+ return "bg-red-500";
76
+ }
77
+
78
+ export function UsagePatternChart({ accountId }: { accountId: string }) {
79
+ const [snapshots, setSnapshots] = useState<UsageSnapshot[] | null>(null);
80
+ const [loading, setLoading] = useState(true);
81
+ const [mode, setMode] = useState<ViewMode>("5h");
82
+
83
+ useEffect(() => {
84
+ setLoading(true);
85
+ getUsageHistory(accountId)
86
+ .then(setSnapshots)
87
+ .catch(() => setSnapshots([]))
88
+ .finally(() => setLoading(false));
89
+ }, [accountId]);
90
+
91
+ const grid = useMemo(() => snapshots ? buildHeatmap(snapshots, mode) : null, [snapshots, mode]);
92
+ const dayAvg = useMemo(() => grid ? buildDayAvg(grid) : [], [grid]);
93
+ const hourAvg = useMemo(() => grid ? buildHourAvg(grid) : [], [grid]);
94
+ const maxDay = Math.max(...dayAvg, 0.01);
95
+ const maxHour = Math.max(...hourAvg, 0.01);
96
+
97
+ if (loading) {
98
+ return (
99
+ <div className="flex items-center justify-center py-3">
100
+ <Loader2 className="size-3 animate-spin text-text-subtle" />
101
+ </div>
102
+ );
103
+ }
104
+
105
+ if (!snapshots || snapshots.length === 0) {
106
+ return (
107
+ <div className="text-[10px] text-text-subtle py-2 text-center">
108
+ No usage history yet
109
+ </div>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <div className="mt-2 space-y-2">
115
+ <div className="flex items-center justify-between">
116
+ <span className="text-[10px] font-medium text-text-subtle">Usage Pattern (7d)</span>
117
+ <div className="flex gap-0.5 text-[9px]">
118
+ <button
119
+ onClick={() => setMode("5h")}
120
+ className={`px-1.5 py-0.5 rounded cursor-pointer transition-colors ${mode === "5h" ? "bg-primary/15 text-primary" : "text-text-subtle hover:text-text-secondary"}`}
121
+ >
122
+ 5h
123
+ </button>
124
+ <button
125
+ onClick={() => setMode("weekly")}
126
+ className={`px-1.5 py-0.5 rounded cursor-pointer transition-colors ${mode === "weekly" ? "bg-primary/15 text-primary" : "text-text-subtle hover:text-text-secondary"}`}
127
+ >
128
+ Wk
129
+ </button>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Day of week bars */}
134
+ <div>
135
+ <span className="text-[9px] text-text-subtle">By Day</span>
136
+ <div className="flex flex-col gap-[2px] mt-0.5">
137
+ {DAY_LABELS.map((label, i) => {
138
+ const val = dayAvg[i] ?? 0;
139
+ return (
140
+ <div key={label} className="flex items-center gap-1">
141
+ <span className="text-[8px] text-text-subtle w-5 shrink-0 text-right tabular-nums">{label}</span>
142
+ <div className="flex-1 h-2.5 bg-surface-elevated rounded-sm overflow-hidden">
143
+ <div
144
+ className={`h-full rounded-sm transition-all ${barColor(val)}`}
145
+ style={{ width: `${Math.round((val / maxDay) * 100)}%` }}
146
+ />
147
+ </div>
148
+ <span className="text-[8px] text-text-subtle w-6 shrink-0 text-right tabular-nums">
149
+ {Math.round(val * 100)}%
150
+ </span>
151
+ </div>
152
+ );
153
+ })}
154
+ </div>
155
+ </div>
156
+
157
+ {/* Hour of day heatmap */}
158
+ <div>
159
+ <span className="text-[9px] text-text-subtle">By Hour</span>
160
+ <div className="flex gap-[1px] mt-0.5">
161
+ {HOUR_LABELS.map((h) => {
162
+ const val = hourAvg[h] ?? 0;
163
+ return (
164
+ <div key={h} className="flex-1 flex flex-col items-center gap-[1px]">
165
+ <div
166
+ className={`w-full aspect-square rounded-[2px] ${cellColor(val)}`}
167
+ title={`${h}:00 — ${Math.round(val * 100)}%`}
168
+ />
169
+ {h % 6 === 0 && (
170
+ <span className="text-[7px] text-text-subtle tabular-nums">{h}</span>
171
+ )}
172
+ </div>
173
+ );
174
+ })}
175
+ </div>
176
+ </div>
177
+
178
+ {/* Heatmap: day × hour grid */}
179
+ {grid && (
180
+ <div>
181
+ <span className="text-[9px] text-text-subtle">Heatmap</span>
182
+ <div className="flex flex-col gap-[1px] mt-0.5">
183
+ {DAY_LABELS.map((label, d) => (
184
+ <div key={label} className="flex items-center gap-[1px]">
185
+ <span className="text-[7px] text-text-subtle w-4 shrink-0 text-right">{label.charAt(0)}</span>
186
+ {HOUR_LABELS.map((h) => {
187
+ const cell = grid[d]![h]!;
188
+ return (
189
+ <div
190
+ key={h}
191
+ className={`flex-1 aspect-square rounded-[1px] ${cellColor(cell.avg)}`}
192
+ title={`${label} ${h}:00 — ${cell.count > 0 ? Math.round(cell.avg * 100) + "%" : "no data"}`}
193
+ />
194
+ );
195
+ })}
196
+ </div>
197
+ ))}
198
+ </div>
199
+ </div>
200
+ )}
201
+ </div>
202
+ );
203
+ }
@@ -108,8 +108,8 @@ function EmptyPanel({ panelId }: { panelId: string }) {
108
108
  if (!activeProject?.name) return;
109
109
  setLoadingSessions(true);
110
110
  try {
111
- const data = await api.get<SessionInfo[]>(`${projectUrl(activeProject.name)}/chat/sessions`);
112
- setSessions(data.slice(0, FETCH_SESSIONS_LIMIT));
111
+ const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(activeProject.name)}/chat/sessions?limit=${FETCH_SESSIONS_LIMIT}`);
112
+ setSessions(data.sessions.slice(0, FETCH_SESSIONS_LIMIT));
113
113
  } catch {
114
114
  // silently ignore — empty state still functional without sessions
115
115
  } finally {
@@ -115,6 +115,20 @@ export function getAllAccountUsages(): Promise<AccountUsageEntry[]> {
115
115
  return api.get<AccountUsageEntry[]>("/api/accounts/usage");
116
116
  }
117
117
 
118
+ export interface UsageSnapshot {
119
+ id: number;
120
+ account_id: string | null;
121
+ five_hour_util: number | null;
122
+ weekly_util: number | null;
123
+ weekly_opus_util: number | null;
124
+ weekly_sonnet_util: number | null;
125
+ recorded_at: string;
126
+ }
127
+
128
+ export function getUsageHistory(accountId: string): Promise<UsageSnapshot[]> {
129
+ return api.get<UsageSnapshot[]>(`/api/accounts/${accountId}/usage-history`);
130
+ }
131
+
118
132
  export function importAccounts(params: { data: string; password: string }): Promise<{ imported: number; refreshed: number }> {
119
133
  return api.post<{ imported: number; refreshed: number }>("/api/accounts/import", params);
120
134
  }
@@ -1 +0,0 @@
1
- import{r as e}from"./chunk-CFjPhJqf.js";import{t}from"./api-client-BKIT_Qeg.js";var n=e({addAccount:()=>a,deleteAccount:()=>o,exchangeOAuthCode:()=>d,getAISettings:()=>h,getAccountSettings:()=>c,getAccounts:()=>r,getActiveAccount:()=>i,getAllAccountUsages:()=>f,getOAuthUrl:()=>u,getProxySettings:()=>_,importAccounts:()=>p,patchAccount:()=>s,updateAISettings:()=>g,updateAccountSettings:()=>l,updateDeviceName:()=>m,updateProxySettings:()=>v});function r(){return t.get(`/api/accounts`)}function i(){return t.get(`/api/accounts/active`)}function a(e){return t.post(`/api/accounts`,e)}function o(e){return t.del(`/api/accounts/${e}`)}function s(e,n){return t.patch(`/api/accounts/${e}`,n)}function c(){return t.get(`/api/accounts/settings`)}function l(e){return t.put(`/api/accounts/settings`,e)}function u(){return t.get(`/api/accounts/oauth/url`)}function d(e,n){return t.post(`/api/accounts/oauth/exchange`,{code:e,state:n})}function f(){return t.get(`/api/accounts/usage`)}function p(e){return t.post(`/api/accounts/import`,e)}function m(e){return t.put(`/api/settings/device-name`,{device_name:e})}function h(){return t.get(`/api/settings/ai`)}function g(e){return t.put(`/api/settings/ai`,e)}function _(){return t.get(`/api/settings/proxy`)}function v(e){return t.put(`/api/settings/proxy`,e)}export{h as a,i as c,_ as d,p as f,v as g,l as h,d as i,f as l,g as m,n,c as o,s as p,o as r,r as s,a as t,u};