Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.15.115",
"version": "0.15.116",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/main/agent-dashboard-design-system-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
loadMeteredUsageRows,
type MeteredUsageRow,
} from "./reconciliation-worker.js";
import type { SessionPageRequest } from "../shared/agent-db-contract.js";
import { detectBillingMode } from "./billing-mode-detector.js";
import { openAgentDatabase, type AgentDatabase } from "./database/index.js";
import { coerceDbId } from "./database/ipc-validation.js";
Expand All @@ -14,7 +15,10 @@ import { isAgentMonitorHooksEnabled } from "./agent-monitor-hooks.js";

const DESIGN_SYSTEM_DB_IPC_CHANNELS = [
"desktop:db:get-sessions",
"desktop:db:get-sessions-page",
"desktop:db:get-kanban-pages",
"desktop:db:get-session",
"desktop:db:get-session-details",
"desktop:db:get-agents",
"desktop:db:get-events",
"desktop:db:get-dashboard-summary",
Expand Down Expand Up @@ -153,12 +157,28 @@ export function createAgentDashboardDesignSystemRuntime(
function registerDesignSystemDbIpcHandlers(agentDatabase: AgentDatabase): void {
ipcMain.handle("desktop:db:get-sessions", () => agentDatabase.sessions.getAll());

ipcMain.handle("desktop:db:get-sessions-page", (_event, request: unknown) =>
agentDatabase.sessions.getPage(coerceSessionPageRequest(request)),
);

ipcMain.handle("desktop:db:get-kanban-pages", (_event, statuses: unknown, limit: unknown) => {
const safeStatuses = Array.isArray(statuses) ? statuses.filter((s): s is string => typeof s === "string") : [];
const safeLimit = typeof limit === "number" && Number.isInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 25;
return agentDatabase.sessions.getKanbanPages(safeStatuses, safeLimit);
});

ipcMain.handle("desktop:db:get-session", (_event, id: unknown) => {
const sessionId = coerceDbId(id);
if (sessionId === null) return undefined;
return agentDatabase.sessions.getById(sessionId);
});

ipcMain.handle("desktop:db:get-session-details", (_event, id: unknown) => {
const sessionId = coerceDbId(id);
if (sessionId === null) return undefined;
return agentDatabase.sessions.getDetailsById(sessionId);
});

ipcMain.handle("desktop:db:get-agents", (_event, sessionId: unknown) => {
const id = coerceDbId(sessionId);
if (id === null) return [];
Expand Down Expand Up @@ -224,3 +244,16 @@ function unregisterDesignSystemDbIpcHandlers(): void {
ipcMain.removeHandler(channel);
}
}

function coerceSessionPageRequest(value: unknown): SessionPageRequest | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const raw = value as Record<string, unknown>;
return {
limit: typeof raw.limit === "number" ? raw.limit : undefined,
offset: typeof raw.offset === "number" ? raw.offset : undefined,
status: typeof raw.status === "string" ? raw.status : undefined,
q: typeof raw.q === "string" ? raw.q : undefined,
};
}
16 changes: 15 additions & 1 deletion apps/desktop/src/main/database/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const CURRENT_SCHEMA_VERSION = 6;
export const CURRENT_SCHEMA_VERSION = 7;

/**
* Each migration runs against the DB when user_version < CURRENT_SCHEMA_VERSION.
Comment thread
mikeangstadt marked this conversation as resolved.
Expand Down Expand Up @@ -132,5 +132,19 @@ ALTER TABLE sessions ADD COLUMN organization_id TEXT;

CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_organization_id ON sessions(organization_id) WHERE organization_id IS NOT NULL;
`,

// Version 6 -> 7: keep the in-process dashboard waiting-session page bounded
// to the sessions index instead of scanning large historical databases.
`
CREATE INDEX IF NOT EXISTS idx_sessions_waiting_started_at
ON sessions(started_at DESC)
WHERE awaiting_input_since IS NOT NULL
AND status NOT IN ('completed', 'abandoned', 'error');

CREATE INDEX IF NOT EXISTS idx_sessions_running_started_at
ON sessions(started_at DESC)
WHERE awaiting_input_since IS NULL
AND status NOT IN ('completed', 'abandoned', 'error');
`,
];
126 changes: 124 additions & 2 deletions apps/desktop/src/main/database/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
import type { DatabaseSync } from "node:sqlite";
import type { SessionRow, SessionWithAgents } from "../../shared/agent-db-contract.js";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type {
KanbanPages,
SessionPage,
SessionPageRequest,
SessionRow,
SessionWithAgents,
} from "../../shared/agent-db-contract.js";

// Terminal session statuses (vendor + canonical AgentSession vocabulary). A
// session not in this set is treated as active. Writes are owned by
// `lifecycle.ts`; this store is read-only.
const TERMINAL_STATUSES = "('completed', 'abandoned', 'error')";
Comment thread
mikeangstadt marked this conversation as resolved.
const TERMINAL_STATUS_SET = new Set(["completed", "abandoned", "error"]);
const MAX_SESSION_PAGE_LIMIT = 100;
const DEFAULT_SESSION_PAGE_LIMIT = 25;
// Correlated subqueries — efficient for bounded queries (LIMIT/single-row).
const SESSION_DETAIL_SELECT = `
SELECT
s.*,
(SELECT COUNT(*) FROM agents a WHERE a.session_id = s.id) as agent_count,
(SELECT COUNT(*) FROM events e WHERE e.session_id = s.id) as event_count,
(
SELECT COALESCE(SUM(COALESCE(t.input_tokens, 0) + COALESCE(t.output_tokens, 0)), 0)
FROM token_usage t
WHERE t.session_id = s.id
) as total_tokens
FROM sessions s
`;
// CTE-based join — efficient for unbounded multi-row queries (active/historical).
const SESSION_DETAILS_CTES = `
WITH agent_counts AS (
SELECT session_id, COUNT(*) as agent_count
Expand All @@ -32,6 +54,10 @@ export function createSessionStore(db: DatabaseSync) {
const getActiveStmt = db.prepare(
`SELECT * FROM sessions WHERE status NOT IN ${TERMINAL_STATUSES} ORDER BY started_at DESC`,
);
const getDetailsByIdStmt = db.prepare(`
${SESSION_DETAIL_SELECT}
WHERE s.id = ?
`);

const getActiveWithDetailsStmt = db.prepare(`
${SESSION_DETAILS_CTES}
Expand Down Expand Up @@ -99,6 +125,70 @@ export function createSessionStore(db: DatabaseSync) {
});
}

// Statement cache for dynamically-built page queries. Avoids re-preparing
// on every IPC call while keeping the WHERE clause flexible.
const stmtCache = new Map<string, ReturnType<typeof db.prepare>>();
function getOrPrepare(key: string, sql: string) {
let stmt = stmtCache.get(key);
if (!stmt) {
stmt = db.prepare(sql);
stmtCache.set(key, stmt);
}
return stmt;
}

function coercePageRequest(request: SessionPageRequest | undefined): {
limit: number;
offset: number;
status: string | null;
q: string | null;
} {
const requestedLimit = request?.limit;
const limit = typeof requestedLimit === "number" && Number.isInteger(requestedLimit)
? Math.min(Math.max(requestedLimit, 1), MAX_SESSION_PAGE_LIMIT)
: DEFAULT_SESSION_PAGE_LIMIT;
const requestedOffset = request?.offset;
const offset = typeof requestedOffset === "number" && Number.isInteger(requestedOffset)
? Math.max(requestedOffset, 0)
: 0;
const status =
typeof request?.status === "string" && request.status.length > 0
? request.status
: null;
const q =
typeof request?.q === "string" && request.q.trim().length > 0
? request.q.trim()
: null;
return { limit, offset, status, q };
}

function pageWhereClause(status: string | null, q: string | null): {
whereSql: string;
params: SQLInputValue[];
} {
const where: string[] = [];
const params: SQLInputValue[] = [];
if (status === "waiting") {
where.push("s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NOT NULL");
Comment thread
mikeangstadt marked this conversation as resolved.
} else if (status === "running") {
where.push("s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NULL");
Comment thread
mikeangstadt marked this conversation as resolved.
} else if (status && status !== "all") {
where.push("s.status = ?");
params.push(status);
}
if (q) {
// Escape SQLite LIKE wildcards so user search for "%" or "_" does literal matching
const escaped = q.replace(/[%_]/g, (ch) => `\\${ch}`);
const like = `%${escaped}%`;
where.push("(s.id LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\' OR s.model LIKE ? ESCAPE '\\')");
params.push(like, like, like, like);
}
return {
whereSql: where.length > 0 ? `WHERE ${where.join(" AND ")}` : "",
params,
};
}

return {
getById(id: string): SessionRow | undefined {
return toRow(getByIdStmt.get(id) as Record<string, unknown> | undefined);
Expand All @@ -112,6 +202,11 @@ export function createSessionStore(db: DatabaseSync) {
return rowsToList(getActiveStmt.all() as Record<string, unknown>[]);
},

getDetailsById(id: string): SessionWithAgents | undefined {
const row = getDetailsByIdStmt.get(id) as Record<string, unknown> | undefined;
return row ? detailRowsToList([row])[0] : undefined;
},

getActiveWithDetails(): SessionWithAgents[] {
return detailRowsToList(
getActiveWithDetailsStmt.all() as Record<string, unknown>[],
Expand All @@ -135,6 +230,33 @@ export function createSessionStore(db: DatabaseSync) {
];
},

getPage(request?: SessionPageRequest): SessionPage {
const { limit, offset, status, q } = coercePageRequest(request);
const { whereSql, params } = pageWhereClause(status, q);
const countStmt = getOrPrepare(`page-count:${whereSql}`,
`SELECT COUNT(*) as count FROM sessions s ${whereSql}`);
const selectStmt = getOrPrepare(`page-select:${whereSql}`,
`${SESSION_DETAIL_SELECT} ${whereSql} ORDER BY s.started_at DESC, s.id DESC LIMIT ? OFFSET ?`);

const totalRow = countStmt.get(...params) as { count: number };
const rows = selectStmt.all(...params, limit, offset) as Record<string, unknown>[];

return {
Comment thread
mikeangstadt marked this conversation as resolved.
sessions: detailRowsToList(rows),
total: totalRow.count,
limit,
offset,
};
},

getKanbanPages(statuses: string[], limit: number): KanbanPages {
const result: KanbanPages = {};
for (const status of statuses) {
result[status] = this.getPage({ limit, status });
}
return result;
},

invalidateHistoricalDetails(): void {
historicalDetailsCache = null;
},
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/preload-design-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import type {
EventCountByType,
EventRow,
EventWithSession,
KanbanPages,
SessionPage,
SessionPageRequest,
SessionRow,
SessionWithAgents,
TokenAnalytics,
Expand All @@ -18,10 +21,13 @@ const designSystemDashboardApi = {
db: {
getSessions: () => ipcRenderer.invoke("desktop:db:get-sessions") as Promise<SessionRow[]>,
getSession: (id: string) => ipcRenderer.invoke("desktop:db:get-session", id) as Promise<SessionRow | undefined>,
getSessionDetails: (id: string) => ipcRenderer.invoke("desktop:db:get-session-details", id) as Promise<SessionWithAgents | undefined>,
getAgents: (sessionId: string) => ipcRenderer.invoke("desktop:db:get-agents", sessionId) as Promise<AgentRow[]>,
getEvents: (sessionId: string, agentId?: string) => ipcRenderer.invoke("desktop:db:get-events", sessionId, agentId) as Promise<EventRow[]>,
getDashboardSummary: () => ipcRenderer.invoke("desktop:db:get-dashboard-summary") as Promise<DashboardSummary>,
getSessionsWithDetails: () => ipcRenderer.invoke("desktop:db:get-sessions-with-details") as Promise<SessionWithAgents[]>,
getSessionsPage: (request?: SessionPageRequest) => ipcRenderer.invoke("desktop:db:get-sessions-page", request) as Promise<SessionPage>,
getKanbanPages: (statuses: string[], limit: number) => ipcRenderer.invoke("desktop:db:get-kanban-pages", statuses, limit) as Promise<KanbanPages>,
getEventFeed: () => ipcRenderer.invoke("desktop:db:get-event-feed") as Promise<EventWithSession[]>,
getEventsWithSession: (sessionId: string) => ipcRenderer.invoke("desktop:db:get-events-with-session", sessionId) as Promise<EventWithSession[]>,
getEventCountByType: () => ipcRenderer.invoke("desktop:db:get-event-count-by-type") as Promise<EventCountByType[]>,
Expand Down
60 changes: 22 additions & 38 deletions apps/desktop/src/renderer/components/kanban/KanbanView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useState } from "react";
import { KanbanBoardLayout, KanbanColumn, KanbanCardFrame } from "@closedloop-ai/design-system/components/ui/layout/kanban-board";
import { Badge } from "@closedloop-ai/design-system/components/ui/badge";
import { useQueryCache } from "../../hooks/useQueryCache";
import type { SessionWithAgents } from "../../../shared/agent-db-contract";
import type { KanbanPages, SessionWithAgents } from "../../../shared/agent-db-contract";

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

const COLUMNS = [
{ key: "running", label: "Running", icon: PlayIcon(), color: "text-blue-400" },
{ key: "waiting", label: "Waiting", icon: ClockIcon(), color: "text-yellow-400" },
{ key: "completed", label: "Completed", icon: CheckIcon(), color: "text-green-400" },
{ key: "failed", label: "Failed", icon: XIcon(), color: "text-red-400" },
{ key: "stopped", label: "Stopped", icon: StopIcon(), color: "text-zinc-400" },
{ key: "running", label: "Running", status: "running", icon: PlayIcon(), color: "text-blue-400" },
{ key: "waiting", label: "Waiting", status: "waiting", icon: ClockIcon(), color: "text-yellow-400" },
{ key: "completed", label: "Completed", status: "completed", icon: CheckIcon(), color: "text-green-400" },
{ key: "failed", label: "Failed", status: "error", icon: XIcon(), color: "text-red-400" },
{ key: "stopped", label: "Stopped", status: "abandoned", icon: StopIcon(), color: "text-zinc-400" },
];
const KANBAN_COLUMN_LIMIT = 25;

const KANBAN_STATUSES = COLUMNS.map((c) => c.status);

export function KanbanView() {
Comment thread
mikeangstadt marked this conversation as resolved.
const { data: sessions, loading } = useQueryCache<SessionWithAgents[]>(
"db:sessions-details",
() => window.desktopApi.db.getSessionsWithDetails() as Promise<SessionWithAgents[]>,
const { data: pages, loading } = useQueryCache<KanbanPages>(
"db:kanban-session-pages",
() => window.desktopApi.db.getKanbanPages(KANBAN_STATUSES, KANBAN_COLUMN_LIMIT),
);
const [selectedId, setSelectedId] = useState<string | null>(null);

if (loading || !sessions) {
if (loading || !pages) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-[var(--muted-foreground)]">Loading...</p>
</div>
);
}

const grouped: Record<string, SessionWithAgents[]> = {};
for (const col of COLUMNS) {
grouped[col.key] = sessions.filter((s) => s.status === col.key);
}

const uncategorized = sessions.filter(
(s) => !COLUMNS.some((c) => c.key === s.status),
);

return (
<div className="p-6 h-full flex flex-col">
<div className="mb-4">
<h1 className="text-xl font-bold text-[var(--foreground)]">My Tasks</h1>

<p className="text-sm text-[var(--muted-foreground)]">
Sessions grouped by status
Recent sessions grouped by status
</p>
</div>

<div className="flex-1 overflow-auto">
<KanbanBoardLayout>
{COLUMNS.map((col) => {
const items = grouped[col.key] ?? [];
const page = pages[col.key];
const items: SessionWithAgents[] = page?.sessions ?? [];
return (
<KanbanColumn
key={col.key}
title={col.label}
count={items.length}
count={page?.total ?? items.length}
icon={col.icon}
emptyState={
<div className="py-6 text-center text-xs text-[var(--muted-foreground)]">
Expand Down Expand Up @@ -96,24 +90,14 @@ export function KanbanView() {
</button>
</KanbanCardFrame>
))}
{(page?.total ?? 0) > items.length ? (
<div className="px-2 py-3 text-center text-xs text-[var(--muted-foreground)]">
Showing latest {items.length} of {page?.total}
</div>
) : null}
</KanbanColumn>
);
})}

{uncategorized.length > 0 && (
<KanbanColumn
title="Other"
count={uncategorized.length}
emptyState={null}
>
{uncategorized.map((session) => (
<KanbanCardFrame key={session.id}>
<p className="truncate text-sm font-medium">{session.name ?? "Unnamed"}</p>
<Badge variant="secondary">{session.status}</Badge>
</KanbanCardFrame>
))}
</KanbanColumn>
)}
</KanbanBoardLayout>
</div>
</div>
Expand Down
Loading
Loading