@hienlh/ppm 0.9.57 → 0.9.58

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 (30) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/web/assets/{chat-tab-SfXtOm9d.js → chat-tab-ZEtP5nL5.js} +6 -6
  3. package/dist/web/assets/{code-editor-DAZvtAlT.js → code-editor-Dn9HRaE5.js} +1 -1
  4. package/dist/web/assets/{database-viewer-C5fco1jm.js → database-viewer-DlXfniq_.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-ShRSPvsf.js → diff-viewer-CwFFFtPi.js} +1 -1
  6. package/dist/web/assets/{extension-webview-CWJRMPfV.js → extension-webview-l_-lzKIZ.js} +1 -1
  7. package/dist/web/assets/{git-graph-h0QmXMdZ.js → git-graph-DB-2iYCl.js} +1 -1
  8. package/dist/web/assets/{index-CDlrGSwd.js → index-ClA7szqI.js} +3 -3
  9. package/dist/web/assets/keybindings-store-Cp6d8e0H.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-CSEmmMWt.js → markdown-renderer-CmqCn-DR.js} +1 -1
  11. package/dist/web/assets/{port-forwarding-tab-Cts6tMFn.js → port-forwarding-tab-Bi5W7N36.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-CiQC1sf9.js → postgres-viewer-DQ0Pl0Wt.js} +1 -1
  13. package/dist/web/assets/{settings-tab-CQx6aHtO.js → settings-tab-DFQhx0tb.js} +1 -1
  14. package/dist/web/assets/{sqlite-viewer-FQfCkjU6.js → sqlite-viewer-CyR-W32c.js} +1 -1
  15. package/dist/web/assets/{terminal-tab-C2SnOqxn.js → terminal-tab-B-jucNv9.js} +1 -1
  16. package/dist/web/assets/{use-monaco-theme-VPgvhMpB.js → use-monaco-theme-i8C5MCBM.js} +1 -1
  17. package/dist/web/index.html +1 -1
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/providers/claude-agent-sdk.ts +28 -2
  21. package/src/server/routes/chat.ts +32 -5
  22. package/src/services/account.service.ts +34 -0
  23. package/src/services/chat.service.ts +3 -3
  24. package/src/types/chat.ts +6 -1
  25. package/src/web/components/chat/chat-history-bar.tsx +110 -74
  26. package/src/web/components/chat/chat-history-panel.tsx +2 -2
  27. package/src/web/components/chat/chat-welcome.tsx +2 -2
  28. package/src/web/components/chat/session-picker.tsx +2 -2
  29. package/src/web/components/layout/editor-panel.tsx +2 -2
  30. package/dist/web/assets/keybindings-store-wbHg-S_v.js +0 -1
@@ -75,17 +75,44 @@ chatRoutes.get("/sessions", async (c) => {
75
75
  try {
76
76
  const projectPath = c.get("projectPath");
77
77
  const providerId = c.req.query("providerId");
78
- const sessions = await chatService.listSessions(providerId, projectPath);
79
- // Enrich with pin status
78
+ const limit = Math.min(parseInt(c.req.query("limit") ?? "50", 10) || 50, 200);
79
+ const offset = parseInt(c.req.query("offset") ?? "0", 10) || 0;
80
+
81
+ const sessions = await chatService.listSessions(providerId, projectPath, { limit, offset });
80
82
  const pinnedIds = getPinnedSessionIds();
81
- const enriched = sessions.map((s) => ({ ...s, pinned: pinnedIds.has(s.id) }));
82
- // Sort: pinned first (by pinned_at implicit via Set order), then unpinned by createdAt
83
+
84
+ // On first page, fetch pinned sessions that may be outside the current page
85
+ let pinnedSessions: typeof sessions = [];
86
+ if (offset === 0 && pinnedIds.size > 0) {
87
+ const pageIds = new Set(sessions.map((s) => s.id));
88
+ const missingPinnedIds = [...pinnedIds].filter((id) => !pageIds.has(id));
89
+ if (missingPinnedIds.length > 0) {
90
+ // Fetch individual pinned sessions by ID via SDK
91
+ const claudeProvider = providerRegistry.get("claude") as any;
92
+ if (claudeProvider?.getSessionInfoById) {
93
+ const results = await Promise.all(
94
+ missingPinnedIds.map((id) => claudeProvider.getSessionInfoById(id, projectPath)),
95
+ );
96
+ pinnedSessions = results.filter((s: any): s is NonNullable<typeof s> => s != null);
97
+ }
98
+ }
99
+ }
100
+
101
+ // Merge and enrich with pin status
102
+ const merged = [...pinnedSessions, ...sessions];
103
+ const seen = new Set<string>();
104
+ const deduped = merged.filter((s) => { if (seen.has(s.id)) return false; seen.add(s.id); return true; });
105
+ const enriched = deduped.map((s) => ({ ...s, pinned: pinnedIds.has(s.id) }));
106
+
107
+ // Sort: pinned first, then by createdAt desc
83
108
  enriched.sort((a, b) => {
84
109
  if (a.pinned && !b.pinned) return -1;
85
110
  if (!a.pinned && b.pinned) return 1;
86
111
  return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
87
112
  });
88
- return c.json(ok(enriched));
113
+
114
+ const hasMore = sessions.length >= limit;
115
+ return c.json(ok({ sessions: enriched, hasMore }));
89
116
  } catch (e) {
90
117
  return c.json(err((e as Error).message), 500);
91
118
  }
@@ -77,6 +77,8 @@ const acctHotState = ((globalThis as any)[ACCT_HOT_KEY] ??= {
77
77
 
78
78
  class AccountService {
79
79
  private pendingStates = new Map<string, { verifier: string; createdAt: number }>();
80
+ /** Per-account mutex: dedup concurrent refresh calls so only one OAuth request fires at a time. */
81
+ private pendingRefreshes = new Map<string, Promise<void>>();
80
82
 
81
83
  private toAccount(row: AccountRow): Account {
82
84
  let profileData: OAuthProfileData | null = null;
@@ -517,16 +519,42 @@ class AccountService {
517
519
 
518
520
  /**
519
521
  * Refresh an OAuth access token using the stored refresh token.
522
+ * Uses a per-account mutex to prevent concurrent refresh calls from racing
523
+ * (Anthropic rotates refresh tokens — only one call per token is valid).
524
+ * Also skips the OAuth call if the DB token was already refreshed by another session.
520
525
  * @param disableOnFail - if true, disable the account when refresh fails (default: true).
521
526
  * Background/startup refresh should pass false to avoid disabling accounts prematurely.
522
527
  */
523
528
  async refreshAccessToken(accountId: string, disableOnFail = true): Promise<void> {
529
+ // Dedup: if a refresh is already in progress for this account, wait for it instead of racing
530
+ const pending = this.pendingRefreshes.get(accountId);
531
+ if (pending) {
532
+ console.log(`[accounts] Refresh already in progress for ${accountId} — waiting for it`);
533
+ return pending;
534
+ }
535
+
536
+ const promise = this._doRefreshAccessToken(accountId, disableOnFail);
537
+ this.pendingRefreshes.set(accountId, promise);
538
+ try {
539
+ await promise;
540
+ } finally {
541
+ this.pendingRefreshes.delete(accountId);
542
+ }
543
+ }
544
+
545
+ private async _doRefreshAccessToken(accountId: string, disableOnFail: boolean): Promise<void> {
524
546
  const account = this.getWithTokens(accountId);
525
547
  if (!account) throw new Error(`Account ${accountId} not found`);
526
548
  // Skip refresh for temporary accounts (no refresh token)
527
549
  if (!account.refreshToken || account.refreshToken === "") {
528
550
  throw new Error(`Account ${accountId} has no refresh token (temporary account)`);
529
551
  }
552
+ // Skip if token was already refreshed by another session (still fresh)
553
+ const nowS = Math.floor(Date.now() / 1000);
554
+ if (account.expiresAt && account.expiresAt - nowS > 60) {
555
+ console.log(`[accounts] Token for ${account.email ?? accountId} is already fresh (expires in ${account.expiresAt - nowS}s) — skipping OAuth refresh`);
556
+ return;
557
+ }
530
558
  const res = await fetch(OAUTH_TOKEN_URL, {
531
559
  method: "POST",
532
560
  headers: { "Content-Type": "application/json" },
@@ -542,6 +570,12 @@ class AccountService {
542
570
  console.error(`[accounts] Refresh failed for ${accountId}: ${res.status} ${errorBody}`);
543
571
  // invalid_grant or invalid_request = refresh token permanently dead → clear it so account becomes temporary
544
572
  if (errorBody.includes("invalid_grant") || errorBody.includes("invalid_request")) {
573
+ // Double-check: another session might have already refreshed between our read and the OAuth call
574
+ const recheckAccount = this.getWithTokens(accountId);
575
+ if (recheckAccount?.expiresAt && recheckAccount.expiresAt - Math.floor(Date.now() / 1000) > 60) {
576
+ console.log(`[accounts] Refresh failed with invalid_grant but DB token is now fresh — another session refreshed it`);
577
+ return;
578
+ }
545
579
  console.log(`[accounts] Clearing invalid refresh token for ${account.email ?? accountId} — account is now temporary`);
546
580
  updateAccount(accountId, { refresh_token: encrypt("") });
547
581
  }
@@ -29,12 +29,12 @@ class ChatService {
29
29
  return provider.resumeSession(sessionId);
30
30
  }
31
31
 
32
- async listSessions(providerId?: string, dir?: string): Promise<SessionInfo[]> {
32
+ async listSessions(providerId?: string, dir?: string, opts?: { limit?: number; offset?: number }): Promise<SessionInfo[]> {
33
33
  if (providerId) {
34
34
  const provider = providerRegistry.get(providerId);
35
35
  if (!provider) throw new Error(`Provider "${providerId}" not found`);
36
36
  if (dir && provider.listSessionsByDir) {
37
- return provider.listSessionsByDir(dir);
37
+ return provider.listSessionsByDir(dir, opts);
38
38
  }
39
39
  return provider.listSessions();
40
40
  }
@@ -44,7 +44,7 @@ class ChatService {
44
44
  const provider = providerRegistry.get(info.id);
45
45
  if (provider) {
46
46
  if (dir && provider.listSessionsByDir) {
47
- all.push(...await provider.listSessionsByDir(dir));
47
+ all.push(...await provider.listSessionsByDir(dir, opts));
48
48
  } else {
49
49
  all.push(...await provider.listSessions());
50
50
  }
package/src/types/chat.ts CHANGED
@@ -26,7 +26,7 @@ export interface AIProvider {
26
26
  onToolApproval?: (callback: ToolApprovalHandler) => void;
27
27
  abortQuery?(sessionId: string): void;
28
28
  getMessages?(sessionId: string): Promise<ChatMessage[]>;
29
- listSessionsByDir?(dir: string): Promise<SessionInfo[]>;
29
+ listSessionsByDir?(dir: string, opts?: { limit?: number; offset?: number }): Promise<SessionInfo[]>;
30
30
  ensureProjectPath?(sessionId: string, path: string): void;
31
31
  setForkSource?(sessionId: string, sourceSessionId: string): void;
32
32
  forkAtMessage?(sessionId: string, messageId: string, opts?: { title?: string; dir?: string }): Promise<{ sessionId: string }>;
@@ -66,6 +66,11 @@ export interface SessionInfo {
66
66
  pinned?: boolean;
67
67
  }
68
68
 
69
+ export interface SessionListResponse {
70
+ sessions: SessionInfo[];
71
+ hasMore: boolean;
72
+ }
73
+
69
74
  export interface LimitBucket {
70
75
  utilization: number;
71
76
  resetsAt: string;
@@ -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 {
@@ -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 {
@@ -1 +0,0 @@
1
- import"./react-nm2Ru1Pt.js";import"./api-client-BKIT_Qeg.js";import{W as e}from"./index-CDlrGSwd.js";export{e as useKeybindingsStore};