@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.
- package/CHANGELOG.md +13 -0
- package/dist/web/assets/api-settings-CgBII8jW.js +1 -0
- package/dist/web/assets/chat-tab-GSn-Itse.js +10 -0
- package/dist/web/assets/{code-editor-DAZvtAlT.js → code-editor-Bjh4LrGQ.js} +1 -1
- package/dist/web/assets/{database-viewer-C5fco1jm.js → database-viewer-v3PVSVgo.js} +1 -1
- package/dist/web/assets/{diff-viewer-ShRSPvsf.js → diff-viewer-BGSt0dzB.js} +1 -1
- package/dist/web/assets/{extension-webview-CWJRMPfV.js → extension-webview-aIzLDxoc.js} +1 -1
- package/dist/web/assets/{git-graph-h0QmXMdZ.js → git-graph-D_7WjkSI.js} +1 -1
- package/dist/web/assets/{index-CDlrGSwd.js → index-B23bElOE.js} +6 -6
- package/dist/web/assets/index-r64nXcCm.css +2 -0
- package/dist/web/assets/keybindings-store-DbtSlUnk.js +1 -0
- package/dist/web/assets/{markdown-renderer-CSEmmMWt.js → markdown-renderer-DoqEXzyK.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-Cts6tMFn.js → port-forwarding-tab-DCfENtZd.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CiQC1sf9.js → postgres-viewer-E0_ojaz2.js} +1 -1
- package/dist/web/assets/{settings-tab-CQx6aHtO.js → settings-tab-Ba9P0f9D.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-FQfCkjU6.js → sqlite-viewer-DvlMWCLX.js} +1 -1
- package/dist/web/assets/{terminal-tab-C2SnOqxn.js → terminal-tab-wFhiLTfY.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-VPgvhMpB.js → use-monaco-theme-Dk_fE15d.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +28 -2
- package/src/server/routes/accounts.ts +9 -1
- package/src/server/routes/chat.ts +32 -5
- package/src/services/account.service.ts +34 -0
- package/src/services/chat.service.ts +3 -3
- package/src/services/db.service.ts +9 -0
- package/src/types/chat.ts +6 -1
- package/src/web/components/chat/chat-history-bar.tsx +110 -74
- package/src/web/components/chat/chat-history-panel.tsx +2 -2
- package/src/web/components/chat/chat-welcome.tsx +2 -2
- package/src/web/components/chat/session-picker.tsx +2 -2
- package/src/web/components/chat/usage-badge.tsx +12 -10
- package/src/web/components/chat/usage-pattern-chart.tsx +203 -0
- package/src/web/components/layout/editor-panel.tsx +2 -2
- package/src/web/lib/api-settings.ts +14 -0
- package/dist/web/assets/api-settings-Bid0NHuI.js +0 -1
- package/dist/web/assets/chat-tab-SfXtOm9d.js +0 -10
- package/dist/web/assets/index-DVuSY0BZ.css +0 -2
- 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<
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
<
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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};
|