Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 34a3830

Browse files
authored
Merge pull request #276 from closedloop-ai/perf-design-dashboard-pagination
PERF: Paginate design dashboard sessions
2 parents 84d0c78 + 2774b54 commit 34a3830

13 files changed

Lines changed: 568 additions & 88 deletions

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.15.115",
3+
"version": "0.15.116",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/main/agent-dashboard-design-system-runtime.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
loadMeteredUsageRows,
77
type MeteredUsageRow,
88
} from "./reconciliation-worker.js";
9+
import type { SessionPageRequest } from "../shared/agent-db-contract.js";
910
import { detectBillingMode } from "./billing-mode-detector.js";
1011
import { openAgentDatabase, type AgentDatabase } from "./database/index.js";
1112
import { coerceDbId } from "./database/ipc-validation.js";
@@ -14,7 +15,10 @@ import { isAgentMonitorHooksEnabled } from "./agent-monitor-hooks.js";
1415

1516
const DESIGN_SYSTEM_DB_IPC_CHANNELS = [
1617
"desktop:db:get-sessions",
18+
"desktop:db:get-sessions-page",
19+
"desktop:db:get-kanban-pages",
1720
"desktop:db:get-session",
21+
"desktop:db:get-session-details",
1822
"desktop:db:get-agents",
1923
"desktop:db:get-events",
2024
"desktop:db:get-dashboard-summary",
@@ -153,12 +157,28 @@ export function createAgentDashboardDesignSystemRuntime(
153157
function registerDesignSystemDbIpcHandlers(agentDatabase: AgentDatabase): void {
154158
ipcMain.handle("desktop:db:get-sessions", () => agentDatabase.sessions.getAll());
155159

160+
ipcMain.handle("desktop:db:get-sessions-page", (_event, request: unknown) =>
161+
agentDatabase.sessions.getPage(coerceSessionPageRequest(request)),
162+
);
163+
164+
ipcMain.handle("desktop:db:get-kanban-pages", (_event, statuses: unknown, limit: unknown) => {
165+
const safeStatuses = Array.isArray(statuses) ? statuses.filter((s): s is string => typeof s === "string") : [];
166+
const safeLimit = typeof limit === "number" && Number.isInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 25;
167+
return agentDatabase.sessions.getKanbanPages(safeStatuses, safeLimit);
168+
});
169+
156170
ipcMain.handle("desktop:db:get-session", (_event, id: unknown) => {
157171
const sessionId = coerceDbId(id);
158172
if (sessionId === null) return undefined;
159173
return agentDatabase.sessions.getById(sessionId);
160174
});
161175

176+
ipcMain.handle("desktop:db:get-session-details", (_event, id: unknown) => {
177+
const sessionId = coerceDbId(id);
178+
if (sessionId === null) return undefined;
179+
return agentDatabase.sessions.getDetailsById(sessionId);
180+
});
181+
162182
ipcMain.handle("desktop:db:get-agents", (_event, sessionId: unknown) => {
163183
const id = coerceDbId(sessionId);
164184
if (id === null) return [];
@@ -224,3 +244,16 @@ function unregisterDesignSystemDbIpcHandlers(): void {
224244
ipcMain.removeHandler(channel);
225245
}
226246
}
247+
248+
function coerceSessionPageRequest(value: unknown): SessionPageRequest | undefined {
249+
if (typeof value !== "object" || value === null) {
250+
return undefined;
251+
}
252+
const raw = value as Record<string, unknown>;
253+
return {
254+
limit: typeof raw.limit === "number" ? raw.limit : undefined,
255+
offset: typeof raw.offset === "number" ? raw.offset : undefined,
256+
status: typeof raw.status === "string" ? raw.status : undefined,
257+
q: typeof raw.q === "string" ? raw.q : undefined,
258+
};
259+
}

apps/desktop/src/main/database/schema.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const CURRENT_SCHEMA_VERSION = 6;
1+
export const CURRENT_SCHEMA_VERSION = 7;
22

33
/**
44
* Each migration runs against the DB when user_version < CURRENT_SCHEMA_VERSION.
@@ -132,5 +132,19 @@ ALTER TABLE sessions ADD COLUMN organization_id TEXT;
132132
133133
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id) WHERE user_id IS NOT NULL;
134134
CREATE INDEX IF NOT EXISTS idx_sessions_organization_id ON sessions(organization_id) WHERE organization_id IS NOT NULL;
135+
`,
136+
137+
// Version 6 -> 7: keep the in-process dashboard waiting-session page bounded
138+
// to the sessions index instead of scanning large historical databases.
139+
`
140+
CREATE INDEX IF NOT EXISTS idx_sessions_waiting_started_at
141+
ON sessions(started_at DESC)
142+
WHERE awaiting_input_since IS NOT NULL
143+
AND status NOT IN ('completed', 'abandoned', 'error');
144+
145+
CREATE INDEX IF NOT EXISTS idx_sessions_running_started_at
146+
ON sessions(started_at DESC)
147+
WHERE awaiting_input_since IS NULL
148+
AND status NOT IN ('completed', 'abandoned', 'error');
135149
`,
136150
];

apps/desktop/src/main/database/sessions.ts

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,33 @@
1-
import type { DatabaseSync } from "node:sqlite";
2-
import type { SessionRow, SessionWithAgents } from "../../shared/agent-db-contract.js";
1+
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
2+
import type {
3+
KanbanPages,
4+
SessionPage,
5+
SessionPageRequest,
6+
SessionRow,
7+
SessionWithAgents,
8+
} from "../../shared/agent-db-contract.js";
39

410
// Terminal session statuses (vendor + canonical AgentSession vocabulary). A
511
// session not in this set is treated as active. Writes are owned by
612
// `lifecycle.ts`; this store is read-only.
713
const TERMINAL_STATUSES = "('completed', 'abandoned', 'error')";
814
const TERMINAL_STATUS_SET = new Set(["completed", "abandoned", "error"]);
15+
const MAX_SESSION_PAGE_LIMIT = 100;
16+
const DEFAULT_SESSION_PAGE_LIMIT = 25;
17+
// Correlated subqueries — efficient for bounded queries (LIMIT/single-row).
18+
const SESSION_DETAIL_SELECT = `
19+
SELECT
20+
s.*,
21+
(SELECT COUNT(*) FROM agents a WHERE a.session_id = s.id) as agent_count,
22+
(SELECT COUNT(*) FROM events e WHERE e.session_id = s.id) as event_count,
23+
(
24+
SELECT COALESCE(SUM(COALESCE(t.input_tokens, 0) + COALESCE(t.output_tokens, 0)), 0)
25+
FROM token_usage t
26+
WHERE t.session_id = s.id
27+
) as total_tokens
28+
FROM sessions s
29+
`;
30+
// CTE-based join — efficient for unbounded multi-row queries (active/historical).
931
const SESSION_DETAILS_CTES = `
1032
WITH agent_counts AS (
1133
SELECT session_id, COUNT(*) as agent_count
@@ -32,6 +54,10 @@ export function createSessionStore(db: DatabaseSync) {
3254
const getActiveStmt = db.prepare(
3355
`SELECT * FROM sessions WHERE status NOT IN ${TERMINAL_STATUSES} ORDER BY started_at DESC`,
3456
);
57+
const getDetailsByIdStmt = db.prepare(`
58+
${SESSION_DETAIL_SELECT}
59+
WHERE s.id = ?
60+
`);
3561

3662
const getActiveWithDetailsStmt = db.prepare(`
3763
${SESSION_DETAILS_CTES}
@@ -99,6 +125,70 @@ export function createSessionStore(db: DatabaseSync) {
99125
});
100126
}
101127

128+
// Statement cache for dynamically-built page queries. Avoids re-preparing
129+
// on every IPC call while keeping the WHERE clause flexible.
130+
const stmtCache = new Map<string, ReturnType<typeof db.prepare>>();
131+
function getOrPrepare(key: string, sql: string) {
132+
let stmt = stmtCache.get(key);
133+
if (!stmt) {
134+
stmt = db.prepare(sql);
135+
stmtCache.set(key, stmt);
136+
}
137+
return stmt;
138+
}
139+
140+
function coercePageRequest(request: SessionPageRequest | undefined): {
141+
limit: number;
142+
offset: number;
143+
status: string | null;
144+
q: string | null;
145+
} {
146+
const requestedLimit = request?.limit;
147+
const limit = typeof requestedLimit === "number" && Number.isInteger(requestedLimit)
148+
? Math.min(Math.max(requestedLimit, 1), MAX_SESSION_PAGE_LIMIT)
149+
: DEFAULT_SESSION_PAGE_LIMIT;
150+
const requestedOffset = request?.offset;
151+
const offset = typeof requestedOffset === "number" && Number.isInteger(requestedOffset)
152+
? Math.max(requestedOffset, 0)
153+
: 0;
154+
const status =
155+
typeof request?.status === "string" && request.status.length > 0
156+
? request.status
157+
: null;
158+
const q =
159+
typeof request?.q === "string" && request.q.trim().length > 0
160+
? request.q.trim()
161+
: null;
162+
return { limit, offset, status, q };
163+
}
164+
165+
function pageWhereClause(status: string | null, q: string | null): {
166+
whereSql: string;
167+
params: SQLInputValue[];
168+
} {
169+
const where: string[] = [];
170+
const params: SQLInputValue[] = [];
171+
if (status === "waiting") {
172+
where.push("s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NOT NULL");
173+
} else if (status === "running") {
174+
where.push("s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NULL");
175+
} else if (status && status !== "all") {
176+
where.push("s.status = ?");
177+
params.push(status);
178+
}
179+
if (q) {
180+
// Escape SQLite LIKE wildcards so user search for "%" or "_" does literal matching
181+
const escaped = q.replace(/[%_]/g, (ch) => `\\${ch}`);
182+
const like = `%${escaped}%`;
183+
where.push("(s.id LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\' OR s.model LIKE ? ESCAPE '\\')");
184+
params.push(like, like, like, like);
185+
}
186+
return {
187+
whereSql: where.length > 0 ? `WHERE ${where.join(" AND ")}` : "",
188+
params,
189+
};
190+
}
191+
102192
return {
103193
getById(id: string): SessionRow | undefined {
104194
return toRow(getByIdStmt.get(id) as Record<string, unknown> | undefined);
@@ -112,6 +202,11 @@ export function createSessionStore(db: DatabaseSync) {
112202
return rowsToList(getActiveStmt.all() as Record<string, unknown>[]);
113203
},
114204

205+
getDetailsById(id: string): SessionWithAgents | undefined {
206+
const row = getDetailsByIdStmt.get(id) as Record<string, unknown> | undefined;
207+
return row ? detailRowsToList([row])[0] : undefined;
208+
},
209+
115210
getActiveWithDetails(): SessionWithAgents[] {
116211
return detailRowsToList(
117212
getActiveWithDetailsStmt.all() as Record<string, unknown>[],
@@ -135,6 +230,33 @@ export function createSessionStore(db: DatabaseSync) {
135230
];
136231
},
137232

233+
getPage(request?: SessionPageRequest): SessionPage {
234+
const { limit, offset, status, q } = coercePageRequest(request);
235+
const { whereSql, params } = pageWhereClause(status, q);
236+
const countStmt = getOrPrepare(`page-count:${whereSql}`,
237+
`SELECT COUNT(*) as count FROM sessions s ${whereSql}`);
238+
const selectStmt = getOrPrepare(`page-select:${whereSql}`,
239+
`${SESSION_DETAIL_SELECT} ${whereSql} ORDER BY s.started_at DESC, s.id DESC LIMIT ? OFFSET ?`);
240+
241+
const totalRow = countStmt.get(...params) as { count: number };
242+
const rows = selectStmt.all(...params, limit, offset) as Record<string, unknown>[];
243+
244+
return {
245+
sessions: detailRowsToList(rows),
246+
total: totalRow.count,
247+
limit,
248+
offset,
249+
};
250+
},
251+
252+
getKanbanPages(statuses: string[], limit: number): KanbanPages {
253+
const result: KanbanPages = {};
254+
for (const status of statuses) {
255+
result[status] = this.getPage({ limit, status });
256+
}
257+
return result;
258+
},
259+
138260
invalidateHistoricalDetails(): void {
139261
historicalDetailsCache = null;
140262
},

apps/desktop/src/main/preload-design-system.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import type {
77
EventCountByType,
88
EventRow,
99
EventWithSession,
10+
KanbanPages,
11+
SessionPage,
12+
SessionPageRequest,
1013
SessionRow,
1114
SessionWithAgents,
1215
TokenAnalytics,
@@ -18,10 +21,13 @@ const designSystemDashboardApi = {
1821
db: {
1922
getSessions: () => ipcRenderer.invoke("desktop:db:get-sessions") as Promise<SessionRow[]>,
2023
getSession: (id: string) => ipcRenderer.invoke("desktop:db:get-session", id) as Promise<SessionRow | undefined>,
24+
getSessionDetails: (id: string) => ipcRenderer.invoke("desktop:db:get-session-details", id) as Promise<SessionWithAgents | undefined>,
2125
getAgents: (sessionId: string) => ipcRenderer.invoke("desktop:db:get-agents", sessionId) as Promise<AgentRow[]>,
2226
getEvents: (sessionId: string, agentId?: string) => ipcRenderer.invoke("desktop:db:get-events", sessionId, agentId) as Promise<EventRow[]>,
2327
getDashboardSummary: () => ipcRenderer.invoke("desktop:db:get-dashboard-summary") as Promise<DashboardSummary>,
2428
getSessionsWithDetails: () => ipcRenderer.invoke("desktop:db:get-sessions-with-details") as Promise<SessionWithAgents[]>,
29+
getSessionsPage: (request?: SessionPageRequest) => ipcRenderer.invoke("desktop:db:get-sessions-page", request) as Promise<SessionPage>,
30+
getKanbanPages: (statuses: string[], limit: number) => ipcRenderer.invoke("desktop:db:get-kanban-pages", statuses, limit) as Promise<KanbanPages>,
2531
getEventFeed: () => ipcRenderer.invoke("desktop:db:get-event-feed") as Promise<EventWithSession[]>,
2632
getEventsWithSession: (sessionId: string) => ipcRenderer.invoke("desktop:db:get-events-with-session", sessionId) as Promise<EventWithSession[]>,
2733
getEventCountByType: () => ipcRenderer.invoke("desktop:db:get-event-count-by-type") as Promise<EventCountByType[]>,

apps/desktop/src/renderer/components/kanban/KanbanView.tsx

Lines changed: 22 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useState } from "react";
22
import { KanbanBoardLayout, KanbanColumn, KanbanCardFrame } from "@closedloop-ai/design-system/components/ui/layout/kanban-board";
3-
import { Badge } from "@closedloop-ai/design-system/components/ui/badge";
43
import { useQueryCache } from "../../hooks/useQueryCache";
5-
import type { SessionWithAgents } from "../../../shared/agent-db-contract";
4+
import type { KanbanPages, SessionWithAgents } from "../../../shared/agent-db-contract";
65

76
function PlayIcon() { return <span className="text-blue-400 text-xs">&#9654;</span>; }
87
function ClockIcon() { return <span className="text-yellow-400 text-xs">&#9201;</span>; }
@@ -11,56 +10,51 @@ function XIcon() { return <span className="text-red-400 text-xs">&#10007;</span>
1110
function StopIcon() { return <span className="text-zinc-400 text-xs">&#9632;</span>; }
1211

1312
const COLUMNS = [
14-
{ key: "running", label: "Running", icon: PlayIcon(), color: "text-blue-400" },
15-
{ key: "waiting", label: "Waiting", icon: ClockIcon(), color: "text-yellow-400" },
16-
{ key: "completed", label: "Completed", icon: CheckIcon(), color: "text-green-400" },
17-
{ key: "failed", label: "Failed", icon: XIcon(), color: "text-red-400" },
18-
{ key: "stopped", label: "Stopped", icon: StopIcon(), color: "text-zinc-400" },
13+
{ key: "running", label: "Running", status: "running", icon: PlayIcon(), color: "text-blue-400" },
14+
{ key: "waiting", label: "Waiting", status: "waiting", icon: ClockIcon(), color: "text-yellow-400" },
15+
{ key: "completed", label: "Completed", status: "completed", icon: CheckIcon(), color: "text-green-400" },
16+
{ key: "failed", label: "Failed", status: "error", icon: XIcon(), color: "text-red-400" },
17+
{ key: "stopped", label: "Stopped", status: "abandoned", icon: StopIcon(), color: "text-zinc-400" },
1918
];
19+
const KANBAN_COLUMN_LIMIT = 25;
20+
21+
const KANBAN_STATUSES = COLUMNS.map((c) => c.status);
2022

2123
export function KanbanView() {
22-
const { data: sessions, loading } = useQueryCache<SessionWithAgents[]>(
23-
"db:sessions-details",
24-
() => window.desktopApi.db.getSessionsWithDetails() as Promise<SessionWithAgents[]>,
24+
const { data: pages, loading } = useQueryCache<KanbanPages>(
25+
"db:kanban-session-pages",
26+
() => window.desktopApi.db.getKanbanPages(KANBAN_STATUSES, KANBAN_COLUMN_LIMIT),
2527
);
2628
const [selectedId, setSelectedId] = useState<string | null>(null);
2729

28-
if (loading || !sessions) {
30+
if (loading || !pages) {
2931
return (
3032
<div className="flex items-center justify-center h-full">
3133
<p className="text-sm text-[var(--muted-foreground)]">Loading...</p>
3234
</div>
3335
);
3436
}
3537

36-
const grouped: Record<string, SessionWithAgents[]> = {};
37-
for (const col of COLUMNS) {
38-
grouped[col.key] = sessions.filter((s) => s.status === col.key);
39-
}
40-
41-
const uncategorized = sessions.filter(
42-
(s) => !COLUMNS.some((c) => c.key === s.status),
43-
);
44-
4538
return (
4639
<div className="p-6 h-full flex flex-col">
4740
<div className="mb-4">
4841
<h1 className="text-xl font-bold text-[var(--foreground)]">My Tasks</h1>
4942

5043
<p className="text-sm text-[var(--muted-foreground)]">
51-
Sessions grouped by status
44+
Recent sessions grouped by status
5245
</p>
5346
</div>
5447

5548
<div className="flex-1 overflow-auto">
5649
<KanbanBoardLayout>
5750
{COLUMNS.map((col) => {
58-
const items = grouped[col.key] ?? [];
51+
const page = pages[col.key];
52+
const items: SessionWithAgents[] = page?.sessions ?? [];
5953
return (
6054
<KanbanColumn
6155
key={col.key}
6256
title={col.label}
63-
count={items.length}
57+
count={page?.total ?? items.length}
6458
icon={col.icon}
6559
emptyState={
6660
<div className="py-6 text-center text-xs text-[var(--muted-foreground)]">
@@ -96,24 +90,14 @@ export function KanbanView() {
9690
</button>
9791
</KanbanCardFrame>
9892
))}
93+
{(page?.total ?? 0) > items.length ? (
94+
<div className="px-2 py-3 text-center text-xs text-[var(--muted-foreground)]">
95+
Showing latest {items.length} of {page?.total}
96+
</div>
97+
) : null}
9998
</KanbanColumn>
10099
);
101100
})}
102-
103-
{uncategorized.length > 0 && (
104-
<KanbanColumn
105-
title="Other"
106-
count={uncategorized.length}
107-
emptyState={null}
108-
>
109-
{uncategorized.map((session) => (
110-
<KanbanCardFrame key={session.id}>
111-
<p className="truncate text-sm font-medium">{session.name ?? "Unnamed"}</p>
112-
<Badge variant="secondary">{session.status}</Badge>
113-
</KanbanCardFrame>
114-
))}
115-
</KanbanColumn>
116-
)}
117101
</KanbanBoardLayout>
118102
</div>
119103
</div>

0 commit comments

Comments
 (0)