From 0dc926308d8eb980e43d19a6d31b5267bab03455 Mon Sep 17 00:00:00 2001 From: Orhan Biyiklioglu Date: Tue, 7 Apr 2026 16:01:45 +0300 Subject: [PATCH 1/4] Add activity dashboard with transcript stats Introduce a top-bar dashboard view that surfaces provider, project, session, message, bookmark, token, tool-call, model, and recent-activity aggregates in a new renderer layout. Adds hero metrics, provider throughput, category composition, activity skyline, and top project/model sections while fitting the existing app shell patterns. Add a dashboard:getStats IPC contract and query-service aggregation path that combines SQLite-backed session metrics with bookmark totals and provider activity rollups. Wire the request through bootstrap, preload, the shared bridge, and renderer types so the desktop app can fetch the dashboard through the existing typed boundary. Cover the new behavior with query-service, IPC-contract, App-shell, focus restoration, TopBar, and DashboardView tests, including top-bar navigation and dashboard refresh/error handling. --- apps/desktop/src/main/bootstrap.ts | 1 + apps/desktop/src/main/data/bookmarkStore.ts | 6 + .../src/main/data/queryService.test.ts | 218 +++++++ apps/desktop/src/main/data/queryService.ts | 253 +++++++++ apps/desktop/src/main/ipc.test.ts | 64 +++ apps/desktop/src/preload/index.ts | 1 + .../renderer/App.focusRestoration.test.tsx | 37 ++ apps/desktop/src/renderer/App.test.tsx | 18 + apps/desktop/src/renderer/App.tsx | 44 +- apps/desktop/src/renderer/app/types.ts | 3 +- .../src/renderer/components/ToolbarIcon.tsx | 3 + .../src/renderer/components/TopBar.test.tsx | 8 + .../src/renderer/components/TopBar.tsx | 31 +- .../renderer/features/DashboardView.test.tsx | 186 ++++++ .../src/renderer/features/DashboardView.tsx | 388 +++++++++++++ .../features/useDashboardController.ts | 87 +++ .../src/renderer/lib/paneFocusController.tsx | 3 +- apps/desktop/src/renderer/styles.css | 532 ++++++++++++++++++ .../src/renderer/test/appTestFixtures.ts | 138 +++++ apps/desktop/src/shared/codetrailBridge.ts | 4 + packages/core/src/contracts/ipc.test.ts | 41 ++ packages/core/src/contracts/ipc.ts | 58 ++ 22 files changed, 2113 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/renderer/features/DashboardView.test.tsx create mode 100644 apps/desktop/src/renderer/features/DashboardView.tsx create mode 100644 apps/desktop/src/renderer/features/useDashboardController.ts diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index c3e4930..3093480 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -592,6 +592,7 @@ export async function bootstrapMainProcess( options.onCommandStateChanged?.(payload); return { ok: true }; }, + "dashboard:getStats": () => queryService.getDashboardStats(), "indexer:refresh": async (payload) => { invalidateAllowedRootsCache(); if (payload.projectId && !payload.force) { diff --git a/apps/desktop/src/main/data/bookmarkStore.ts b/apps/desktop/src/main/data/bookmarkStore.ts index 8ec53af..ea589a6 100644 --- a/apps/desktop/src/main/data/bookmarkStore.ts +++ b/apps/desktop/src/main/data/bookmarkStore.ts @@ -85,6 +85,7 @@ export type BookmarkStore = { searchMode?: SearchMode, ) => Record; countProjectBookmarksByProjectIds?: (projectIds: string[]) => Record; + countAllBookmarks?: () => number; countSessionBookmarks: (projectId: string, sessionId: string) => number; countSessionBookmarksBySessionIds?: ( projectId: string, @@ -132,6 +133,7 @@ export function createBookmarkStore(bookmarksDbPath: string): BookmarkStore { WHERE project_id = ? AND message_id = ?`, ); const countStmt = db.prepare("SELECT COUNT(*) as cnt FROM bookmarks WHERE project_id = ?"); + const countAllStmt = db.prepare("SELECT COUNT(*) as cnt FROM bookmarks"); const countSessionStmt = db.prepare( "SELECT COUNT(*) as cnt FROM bookmarks WHERE project_id = ? AND session_id = ?", ); @@ -351,6 +353,10 @@ export function createBookmarkStore(bookmarksDbPath: string): BookmarkStore { countProjectBookmarksByProjectIds: (projectIds) => { return countBookmarksByProjectIds(db, projectIds); }, + countAllBookmarks: () => { + const row = countAllStmt.get() as { cnt: number } | undefined; + return Number(row?.cnt ?? 0); + }, countSessionBookmarks: (projectId, sessionId) => { const row = countSessionStmt.get(projectId, sessionId) as { cnt: number } | undefined; return Number(row?.cnt ?? 0); diff --git a/apps/desktop/src/main/data/queryService.test.ts b/apps/desktop/src/main/data/queryService.test.ts index 38066e4..7c481d3 100644 --- a/apps/desktop/src/main/data/queryService.test.ts +++ b/apps/desktop/src/main/data/queryService.test.ts @@ -208,6 +208,8 @@ function createBookmarkStoreMock(overrides: Partial = {}): Bookma thinking: 0, system: 0, })), + countProjectBookmarksByProjectIds: vi.fn((_projectIds: string[]) => ({})), + countAllBookmarks: vi.fn(() => 0), countSessionBookmarks: vi.fn(() => 0), getBookmark: vi.fn(() => null), upsertBookmark: vi.fn(), @@ -225,6 +227,222 @@ function createBookmarkStoreMock(overrides: Partial = {}): Bookma } describe("queryService in-memory", () => { + it("aggregates dashboard statistics across providers, categories, and projects", () => { + const db = seedQueryDb(); + const now = "2026-03-02T11:00:00.000Z"; + + db.prepare( + `INSERT INTO projects (id, provider, name, path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run("project_2", "codex", "Project Two", "/workspace/project-two", now, now); + + db.prepare( + `INSERT INTO sessions ( + id, + project_id, + provider, + file_path, + model_names, + started_at, + ended_at, + duration_ms, + git_branch, + cwd, + message_count, + token_input_total, + token_output_total + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "session_2", + "project_2", + "codex", + "/workspace/project-two/session-2.jsonl", + "codex-gpt-5", + "2026-03-02T11:00:00.000Z", + "2026-03-02T11:00:10.000Z", + 10000, + "feature/dashboard", + "/workspace/project-two", + 3, + 31, + 17, + ); + + db.prepare( + `INSERT INTO messages ( + id, + source_id, + session_id, + provider, + category, + content, + created_at, + token_input, + token_output, + operation_duration_ms, + operation_duration_source, + operation_duration_confidence + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "message_3", + "source_3", + "session_2", + "codex", + "tool_edit", + "Updated dashboard layout styles", + "2026-03-02T11:00:02.000Z", + null, + null, + null, + null, + null, + ); + db.prepare( + `INSERT INTO messages ( + id, + source_id, + session_id, + provider, + category, + content, + created_at, + token_input, + token_output, + operation_duration_ms, + operation_duration_source, + operation_duration_confidence + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "message_4", + "source_4", + "session_2", + "codex", + "tool_use", + "Read dashboard query implementation", + "2026-03-02T11:00:04.000Z", + null, + null, + null, + null, + null, + ); + db.prepare( + `INSERT INTO messages ( + id, + source_id, + session_id, + provider, + category, + content, + created_at, + token_input, + token_output, + operation_duration_ms, + operation_duration_source, + operation_duration_confidence + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "message_5", + "source_5", + "session_2", + "codex", + "tool_result", + "Dashboard query returned aggregated rows", + "2026-03-02T11:00:10.000Z", + 31, + 17, + 10000, + "native", + "high", + ); + + db.prepare( + `INSERT INTO indexed_files ( + file_path, + provider, + project_path, + session_identity, + file_size, + file_mtime_ms, + indexed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + "/workspace/project-two/session-2.jsonl", + "codex", + "/workspace/project-two", + "codex:session_2", + 1024, + Date.parse("2026-03-02T11:00:10.000Z"), + now, + ); + + db.prepare( + `INSERT INTO tool_calls ( + id, + message_id, + tool_name, + args_json, + result_json, + started_at, + completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + "tool_call_1", + "message_4", + "Read", + JSON.stringify({ filePath: "src/dashboard.tsx" }), + JSON.stringify({ ok: true }), + "2026-03-02T11:00:03.000Z", + "2026-03-02T11:00:04.000Z", + ); + + const bookmarkStore = createBookmarkStoreMock({ + countAllBookmarks: vi.fn(() => 3), + countProjectBookmarksByProjectIds: vi.fn((projectIds: string[]) => + Object.fromEntries( + projectIds.map((projectId) => [projectId, projectId === "project_2" ? 2 : 1]), + ), + ), + }); + const service = createQueryServiceFromDb(db, { + bookmarkStore, + ownsBookmarkStore: false, + }); + + const stats = service.getDashboardStats(); + + expect(stats.summary.projectCount).toBe(2); + expect(stats.summary.sessionCount).toBe(2); + expect(stats.summary.messageCount).toBe(5); + expect(stats.summary.bookmarkCount).toBe(3); + expect(stats.summary.toolCallCount).toBe(1); + expect(stats.summary.indexedFileCount).toBe(2); + expect(stats.summary.activeProviderCount).toBe(2); + expect(stats.summary.averageMessagesPerSession).toBe(2.5); + expect(stats.categoryCounts.user).toBe(1); + expect(stats.categoryCounts.assistant).toBe(1); + expect(stats.categoryCounts.tool_edit).toBe(1); + expect(stats.categoryCounts.tool_use).toBe(1); + expect(stats.categoryCounts.tool_result).toBe(1); + expect(stats.providerStats.find((provider) => provider.provider === "codex")).toMatchObject({ + projectCount: 1, + sessionCount: 1, + messageCount: 3, + toolCallCount: 1, + }); + expect(stats.topProjects[0]).toMatchObject({ + projectId: "project_2", + bookmarkCount: 2, + }); + expect(stats.topModels[0]).toMatchObject({ + modelName: "codex-gpt-5", + messageCount: 3, + }); + expect(stats.recentActivity).toHaveLength(stats.activityWindowDays); + expect(bookmarkStore.countAllBookmarks).toHaveBeenCalled(); + expect(bookmarkStore.countProjectBookmarksByProjectIds).toHaveBeenCalled(); + }); + it("serves list/detail/bookmark flows using createQueryServiceFromDb", () => { const db = seedQueryDb(); const service = createQueryServiceFromDb(db); diff --git a/apps/desktop/src/main/data/queryService.ts b/apps/desktop/src/main/data/queryService.ts index c3d6e75..7c9fb2a 100644 --- a/apps/desktop/src/main/data/queryService.ts +++ b/apps/desktop/src/main/data/queryService.ts @@ -157,6 +157,54 @@ type FocusTargetRow = { created_at: string; created_at_ms: number; }; +type DashboardSummaryRow = { + project_count: number; + session_count: number; + message_count: number; + tool_call_count: number; + indexed_file_count: number; + indexed_bytes_total: number; + token_input_total: number; + token_output_total: number; + total_duration_ms: number; + average_session_duration_ms: number; +}; +type DashboardCategoryRow = { + category: string; + count: number; +}; +type DashboardProviderRow = { + provider: Provider; + project_count: number; + session_count: number; + message_count: number; + token_input_total: number; + token_output_total: number; + last_activity: string | null; +}; +type DashboardToolCallProviderRow = { + provider: Provider; + tool_call_count: number; +}; +type DashboardActivityRow = { + date: string; + session_count: number; + message_count: number; +}; +type DashboardProjectRow = { + project_id: string; + provider: Provider; + name: string; + path: string; + session_count: number; + message_count: number; + last_activity: string | null; +}; +type DashboardModelRow = { + model_name: string; + session_count: number; + message_count: number; +}; type TurnAnchorRow = { id: string; @@ -193,6 +241,7 @@ type TurnNavigationMetadata = { }; export type QueryService = { + getDashboardStats: () => IpcResponse<"dashboard:getStats">; listProjects: (request: IpcRequest<"projects:list">) => IpcResponse<"projects:list">; getProjectById: (projectId: string) => IpcResponse<"projects:list">["projects"][number] | null; getProjectCombinedDetail: ( @@ -263,6 +312,7 @@ export function createQueryServiceFromDb( let closed = false; return { + getDashboardStats: () => getDashboardStatsWithDatabase(db, bookmarkStore), listProjects: (request) => listProjectsWithDatabase(db, bookmarkStore, request), getProjectById: (projectId) => getProjectByIdWithDatabase(db, bookmarkStore, projectId), getProjectCombinedDetail: (request) => getProjectCombinedDetailWithDatabase(db, request), @@ -290,6 +340,209 @@ export function createQueryServiceFromDb( }; } +function getDashboardStatsWithDatabase( + db: DatabaseHandle, + bookmarkStore: BookmarkStore, +): IpcResponse<"dashboard:getStats"> { + const summaryRow = db + .prepare( + `SELECT + (SELECT COUNT(*) FROM projects) AS project_count, + (SELECT COUNT(*) FROM sessions) AS session_count, + (SELECT COUNT(*) FROM messages) AS message_count, + (SELECT COUNT(*) FROM tool_calls) AS tool_call_count, + (SELECT COUNT(*) FROM indexed_files) AS indexed_file_count, + (SELECT COALESCE(SUM(file_size), 0) FROM indexed_files) AS indexed_bytes_total, + (SELECT COALESCE(SUM(token_input_total), 0) FROM sessions) AS token_input_total, + (SELECT COALESCE(SUM(token_output_total), 0) FROM sessions) AS token_output_total, + (SELECT COALESCE(SUM(duration_ms), 0) FROM sessions WHERE duration_ms IS NOT NULL) AS total_duration_ms, + (SELECT COALESCE(AVG(duration_ms), 0) FROM sessions WHERE duration_ms IS NOT NULL) AS average_session_duration_ms`, + ) + .get() as DashboardSummaryRow | undefined; + + const categoryCounts = makeEmptyCategoryCounts(); + const categoryRows = db + .prepare("SELECT category, COUNT(*) AS count FROM messages GROUP BY category") + .all() as DashboardCategoryRow[]; + for (const row of categoryRows) { + categoryCounts[normalizeMessageCategory(row.category)] = Number(row.count ?? 0); + } + + const providerCounts = createProviderRecord(() => 0); + const providerStatsByProvider = createProviderRecord((provider) => ({ + provider, + projectCount: 0, + sessionCount: 0, + messageCount: 0, + toolCallCount: 0, + tokenInputTotal: 0, + tokenOutputTotal: 0, + lastActivity: null as string | null, + })); + + const providerRows = db + .prepare( + `SELECT + provider, + COUNT(DISTINCT project_id) AS project_count, + COUNT(*) AS session_count, + COALESCE(SUM(message_count), 0) AS message_count, + COALESCE(SUM(token_input_total), 0) AS token_input_total, + COALESCE(SUM(token_output_total), 0) AS token_output_total, + MAX(activity_at) AS last_activity + FROM sessions + GROUP BY provider`, + ) + .all() as DashboardProviderRow[]; + for (const row of providerRows) { + providerCounts[row.provider] = Number(row.message_count ?? 0); + providerStatsByProvider[row.provider] = { + provider: row.provider, + projectCount: Number(row.project_count ?? 0), + sessionCount: Number(row.session_count ?? 0), + messageCount: Number(row.message_count ?? 0), + toolCallCount: 0, + tokenInputTotal: Number(row.token_input_total ?? 0), + tokenOutputTotal: Number(row.token_output_total ?? 0), + lastActivity: row.last_activity ?? null, + }; + } + + const toolCallRows = db + .prepare( + `SELECT m.provider AS provider, COUNT(*) AS tool_call_count + FROM tool_calls tc + JOIN messages m ON m.id = tc.message_id + GROUP BY m.provider`, + ) + .all() as DashboardToolCallProviderRow[]; + for (const row of toolCallRows) { + providerStatsByProvider[row.provider] = { + ...providerStatsByProvider[row.provider], + toolCallCount: Number(row.tool_call_count ?? 0), + }; + } + + const activityWindowDays = 14; + const activityStart = new Date(); + activityStart.setUTCHours(0, 0, 0, 0); + activityStart.setUTCDate(activityStart.getUTCDate() - (activityWindowDays - 1)); + const recentActivityRows = db + .prepare( + `SELECT + substr(created_at, 1, 10) AS date, + COUNT(DISTINCT session_id) AS session_count, + COUNT(*) AS message_count + FROM messages + WHERE created_at >= ? + GROUP BY substr(created_at, 1, 10) + ORDER BY date ASC`, + ) + .all(activityStart.toISOString()) as DashboardActivityRow[]; + const recentActivityByDate = new Map( + recentActivityRows.map((row) => [ + row.date, + { + date: row.date, + sessionCount: Number(row.session_count ?? 0), + messageCount: Number(row.message_count ?? 0), + }, + ]), + ); + const recentActivity = Array.from({ length: activityWindowDays }, (_, index) => { + const date = new Date(activityStart); + date.setUTCDate(activityStart.getUTCDate() + index); + const key = date.toISOString().slice(0, 10); + return ( + recentActivityByDate.get(key) ?? { + date: key, + sessionCount: 0, + messageCount: 0, + } + ); + }); + + const topProjectRows = db + .prepare( + `SELECT + p.id AS project_id, + p.provider AS provider, + p.name AS name, + p.path AS path, + COALESCE(ps.session_count, 0) AS session_count, + COALESCE(ps.message_count, 0) AS message_count, + ps.last_activity AS last_activity + FROM projects p + LEFT JOIN project_stats ps ON ps.project_id = p.id + ORDER BY COALESCE(ps.message_count, 0) DESC, COALESCE(ps.session_count, 0) DESC, p.name ASC + LIMIT 6`, + ) + .all() as DashboardProjectRow[]; + const topProjectBookmarkCounts = bookmarkStore.countProjectBookmarksByProjectIds?.( + topProjectRows.map((row) => row.project_id), + ); + const topProjects = topProjectRows.map((row) => ({ + projectId: row.project_id, + provider: row.provider, + name: row.name, + path: row.path, + sessionCount: Number(row.session_count ?? 0), + messageCount: Number(row.message_count ?? 0), + bookmarkCount: Number(topProjectBookmarkCounts?.[row.project_id] ?? 0), + lastActivity: row.last_activity ?? null, + })); + + const topModels = db + .prepare( + `SELECT + model_names AS model_name, + COUNT(*) AS session_count, + COALESCE(SUM(message_count), 0) AS message_count + FROM sessions + WHERE TRIM(model_names) <> '' + GROUP BY model_names + ORDER BY COALESCE(SUM(message_count), 0) DESC, COUNT(*) DESC, model_names ASC + LIMIT 6`, + ) + .all() as DashboardModelRow[]; + + const summary = { + projectCount: Number(summaryRow?.project_count ?? 0), + sessionCount: Number(summaryRow?.session_count ?? 0), + messageCount: Number(summaryRow?.message_count ?? 0), + bookmarkCount: Number(bookmarkStore.countAllBookmarks?.() ?? 0), + toolCallCount: Number(summaryRow?.tool_call_count ?? 0), + indexedFileCount: Number(summaryRow?.indexed_file_count ?? 0), + indexedBytesTotal: Number(summaryRow?.indexed_bytes_total ?? 0), + tokenInputTotal: Number(summaryRow?.token_input_total ?? 0), + tokenOutputTotal: Number(summaryRow?.token_output_total ?? 0), + totalDurationMs: Number(summaryRow?.total_duration_ms ?? 0), + averageMessagesPerSession: + Number(summaryRow?.session_count ?? 0) > 0 + ? Number(summaryRow?.message_count ?? 0) / Number(summaryRow?.session_count ?? 1) + : 0, + averageSessionDurationMs: Number(summaryRow?.average_session_duration_ms ?? 0), + activeProviderCount: Object.values(providerStatsByProvider).filter( + (stats) => stats.projectCount > 0 || stats.sessionCount > 0 || stats.messageCount > 0, + ).length, + }; + + return { + summary, + categoryCounts, + providerCounts, + providerStats: Object.values(providerStatsByProvider), + recentActivity, + topProjects, + topModels: topModels.map((row) => ({ + modelName: row.model_name, + sessionCount: Number(row.session_count ?? 0), + messageCount: Number(row.message_count ?? 0), + })), + activityWindowDays, + }; +} + function listRecentLiveSessionFilesWithDatabase( db: DatabaseHandle, input: { diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index a10877e..017dd9d 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -57,6 +57,38 @@ describe("registerIpcHandlers", () => { "app:setCommandState": () => ({ ok: true }), "app:getSettingsInfo": () => settingsInfo, "db:getSchemaVersion": () => ({ schemaVersion: 1 }), + "dashboard:getStats": () => ({ + summary: { + projectCount: 0, + sessionCount: 0, + messageCount: 0, + bookmarkCount: 0, + toolCallCount: 0, + indexedFileCount: 0, + indexedBytesTotal: 0, + tokenInputTotal: 0, + tokenOutputTotal: 0, + totalDurationMs: 0, + averageMessagesPerSession: 0, + averageSessionDurationMs: 0, + activeProviderCount: 0, + }, + categoryCounts: { + user: 0, + assistant: 0, + tool_use: 0, + tool_edit: 0, + tool_result: 0, + thinking: 0, + system: 0, + }, + providerCounts: createProviderRecord(() => 0), + providerStats: [], + recentActivity: [], + topProjects: [], + topModels: [], + activityWindowDays: 14, + }), "indexer:refresh": (payload) => ({ jobId: payload.force ? "force-1" : "normal-1" }), "indexer:getStatus": () => ({ running: false, @@ -293,6 +325,38 @@ describe("registerIpcHandlers", () => { "app:setCommandState": () => ({ ok: true }), "app:getSettingsInfo": () => settingsInfo, "db:getSchemaVersion": () => ({ schemaVersion: 1 }), + "dashboard:getStats": () => ({ + summary: { + projectCount: 0, + sessionCount: 0, + messageCount: 0, + bookmarkCount: 0, + toolCallCount: 0, + indexedFileCount: 0, + indexedBytesTotal: 0, + tokenInputTotal: 0, + tokenOutputTotal: 0, + totalDurationMs: 0, + averageMessagesPerSession: 0, + averageSessionDurationMs: 0, + activeProviderCount: 0, + }, + categoryCounts: { + user: 0, + assistant: 0, + tool_use: 0, + tool_edit: 0, + tool_result: 0, + thinking: 0, + system: 0, + }, + providerCounts: createProviderRecord(() => 0), + providerStats: [], + recentActivity: [], + topProjects: [], + topModels: [], + activityWindowDays: 14, + }), "indexer:refresh": () => ({ jobId: "refresh-1" }), "indexer:getStatus": () => ({ running: false, diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index abe6144..b70cb55 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -23,6 +23,7 @@ const api: CodetrailBridge = { appFlushState: (payload) => invoke("app:flushState", payload), appSetCommandState: (payload) => invoke("app:setCommandState", payload), appGetSettingsInfo: (payload) => invoke("app:getSettingsInfo", payload), + dashboardGetStats: (payload) => invoke("dashboard:getStats", payload), bookmarksGetStates: (payload) => invoke("bookmarks:getStates", payload), bookmarksListProject: (payload) => invoke("bookmarks:listProject", payload), bookmarksToggle: (payload) => invoke("bookmarks:toggle", payload), diff --git a/apps/desktop/src/renderer/App.focusRestoration.test.tsx b/apps/desktop/src/renderer/App.focusRestoration.test.tsx index ffde27e..00a547d 100644 --- a/apps/desktop/src/renderer/App.focusRestoration.test.tsx +++ b/apps/desktop/src/renderer/App.focusRestoration.test.tsx @@ -189,4 +189,41 @@ describe("App focus restoration", () => { expect(document.activeElement).toBe(container.querySelector(".list-scroll.session-list")); }); }); + + it("restores the last active history pane when exiting the dashboard with Escape", async () => { + installScrollIntoViewMock(); + const client = createAppClient(); + const { container } = renderWithClient(, client); + + await waitFor(() => { + expect(screen.getByText("Project One")).toBeInTheDocument(); + }); + + expandHistoryPanes(); + + focusPaneFromHeader(container, "project"); + await waitFor(() => { + expect(container.querySelector('[data-pane-active="true"]')).toHaveAttribute( + "data-history-pane", + "project", + ); + expect(document.activeElement).toBe(container.querySelector(".list-scroll.project-list")); + }); + + clickToolbarButton(screen.getByRole("button", { name: "Open dashboard" })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Activity Dashboard" })).toBeInTheDocument(); + }); + + fireEvent.keyDown(window, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByRole("heading", { name: "Activity Dashboard" })).toBeNull(); + expect(container.querySelector('[data-pane-active="true"]')).toHaveAttribute( + "data-history-pane", + "project", + ); + expect(document.activeElement).toBe(container.querySelector(".list-scroll.project-list")); + }); + }); }); diff --git a/apps/desktop/src/renderer/App.test.tsx b/apps/desktop/src/renderer/App.test.tsx index 581ba61..a819df0 100644 --- a/apps/desktop/src/renderer/App.test.tsx +++ b/apps/desktop/src/renderer/App.test.tsx @@ -69,6 +69,24 @@ function installDialogMock(): void { } describe("App shell", () => { + it("opens the dashboard view from the top bar and loads dashboard stats", async () => { + installScrollIntoViewMock(); + + const client = createAppClient(); + const user = userEvent.setup(); + + renderWithClient(, client); + + await user.click(screen.getByRole("button", { name: "Open dashboard" })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Activity Dashboard" })).toBeInTheDocument(); + }); + + expect(screen.getByText("Workspace telemetry")).toBeInTheDocument(); + expect(countChannelCalls(client, "dashboard:getStats")).toBeGreaterThanOrEqual(1); + }); + it("compacts large message-type pill counts while keeping the exact count in the tooltip", async () => { installDialogMock(); installScrollIntoViewMock(); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 07c5ed3..49b432e 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -29,10 +29,12 @@ import { HistoryExportProgressDialog } from "./components/HistoryExportProgressD import { SettingsView } from "./components/SettingsView"; import { ShortcutsDialog } from "./components/ShortcutsDialog"; import { TopBar } from "./components/TopBar"; +import { DashboardView } from "./features/DashboardView"; import { HistoryDetailPane } from "./features/HistoryDetailPane"; import { HistoryLayout } from "./features/HistoryLayout"; import { SearchView } from "./features/SearchView"; import { useAppearanceController } from "./features/useAppearanceController"; +import { useDashboardController } from "./features/useDashboardController"; import { type HistorySelectionDebounceOverrides, useHistoryController, @@ -202,6 +204,7 @@ export function App({ const watcherLifecycleRef = useRef(0); const watcherPendingPathCountRef = useRef(0); const watcherStatusBurstUntilRef = useRef(0); + const dashboardViewRef = useRef(null); const helpViewRef = useRef(null); const settingsViewRef = useRef(null); const viewFocusRafRef = useRef(null); @@ -263,6 +266,10 @@ export function App({ setHistoryCategories: history.setHistoryCategories, logError, }); + const dashboard = useDashboardController({ + mainView, + logError, + }); const preferredRefreshStrategy = history.preferredAutoRefreshStrategy ?? DEFAULT_PREFERRED_REFRESH_STRATEGY; const { @@ -342,6 +349,10 @@ export function App({ const searchViewTarget = mainView === "search" ? search.globalSearchInputRef.current : null; useEffect(() => { + paneFocus.registerViewTarget( + "dashboard", + mainView === "dashboard" ? dashboardViewRef.current : null, + ); paneFocus.registerViewTarget("search", searchViewTarget); paneFocus.registerViewTarget("help", mainView === "help" ? helpViewRef.current : null); paneFocus.registerViewTarget( @@ -361,6 +372,13 @@ export function App({ }, [mainView, paneFocus.activeDomain, paneFocus.lastHistoryPane]); useEffect(() => { + if (mainView === "dashboard") { + paneFocus.enterView("dashboard"); + scheduleFocusFrame(viewFocusRafRef, () => { + dashboardViewRef.current?.focus({ preventScroll: true }); + }); + return; + } if (mainView === "search") { paneFocus.enterView("search"); return; @@ -392,12 +410,20 @@ export function App({ }); const shouldReloadSearch = source === "manual" || (mainView === "search" && search.hasActiveSearchQuery); + const shouldReloadDashboard = mainView === "dashboard"; await Promise.all([ historyRefreshPromise, shouldReloadSearch ? search.reloadSearch() : Promise.resolve(), + shouldReloadDashboard ? dashboard.reloadStats() : Promise.resolve(), ]); }, - [history.handleRefreshAllData, mainView, search.hasActiveSearchQuery, search.reloadSearch], + [ + dashboard.reloadStats, + history.handleRefreshAllData, + mainView, + search.hasActiveSearchQuery, + search.reloadSearch, + ], ); useEffect(() => { reloadIndexedDataRef.current = reloadIndexedData; @@ -747,6 +773,10 @@ export function App({ search.focusGlobalSearch(); }, [search.focusGlobalSearch]); + const openDashboardView = useCallback(() => { + setMainView("dashboard"); + }, []); + const openHelpView = useCallback(() => { setMainView("help"); }, []); @@ -1271,6 +1301,9 @@ export function App({ indexing={indexing} focusMode={focusMode} focusDisabled={mainView !== "history"} + onToggleDashboard={() => + mainView === "dashboard" ? returnToHistoryWithPaneFocus() : openDashboardView() + } onToggleSearchView={() => mainView === "search" ? returnToHistoryWithPaneFocus() : focusGlobalSearch() } @@ -1349,6 +1382,15 @@ export function App({ /> ) + ) : mainView === "dashboard" ? ( +
+ void dashboard.reloadStats()} + /> +
) : mainView === "search" ? ( ; export type SearchQueryResponse = IpcResponse<"search:query">; export type SearchResult = SearchQueryResponse["results"][number]; export type SettingsInfoResponse = IpcResponse<"app:getSettingsInfo">; +export type DashboardStatsResponse = IpcResponse<"dashboard:getStats">; export type WatchStatsResponse = IpcResponse<"watcher:getStats">; export type WatchLiveStatusResponse = IpcResponse<"watcher:getLiveStatus">; export type ClaudeHookStateResponse = WatchLiveStatusResponse["claudeHookState"]; @@ -25,7 +26,7 @@ export type HistoryMessage = | SessionDetail["messages"][number] | ProjectCombinedDetail["messages"][number]; -export type MainView = "history" | "search" | "settings" | "help"; +export type MainView = "history" | "dashboard" | "search" | "settings" | "help"; export type SortDirection = "asc" | "desc"; export type ProjectViewMode = "list" | "tree"; export type ProjectSortField = "last_active" | "name"; diff --git a/apps/desktop/src/renderer/components/ToolbarIcon.tsx b/apps/desktop/src/renderer/components/ToolbarIcon.tsx index 3055937..1f81771 100644 --- a/apps/desktop/src/renderer/components/ToolbarIcon.tsx +++ b/apps/desktop/src/renderer/components/ToolbarIcon.tsx @@ -1,6 +1,7 @@ export type ToolbarIconName = | "history" | "turns" + | "dashboard" | "search" | "refresh" | "reindex" @@ -42,6 +43,7 @@ export type ToolbarIconName = const TOOLBAR_ICON_PATHS = { history: "M4 3h16v4H4zM4 10h16v4H4zM4 17h16v4H4z", turns: "M6 4h12a2 2 0 0 1 2 2v12H8a2 2 0 0 1-2-2zm0 0v10a2 2 0 0 0 2 2h10", + dashboard: "M4 19V9M10 19V5M16 19v-8M22 19V3", search: "M9 3a6 6 0 1 0 0 12a6 6 0 0 0 0-12m0 2a4 4 0 1 1 0 8a4 4 0 0 1 0-8m6.5 9.1l1.4-1.4L22 18l-1.4 1.4z", refresh: "M20 12a8 8 0 1 1-2.3-5.7M20 4v4h-4", @@ -86,6 +88,7 @@ const TOOLBAR_ICON_PATHS = { const TOOLBAR_ICON_TITLES: Record = { history: "History", turns: "Turns", + dashboard: "Dashboard", search: "Search", refresh: "Refresh", reindex: "Reindex", diff --git a/apps/desktop/src/renderer/components/TopBar.test.tsx b/apps/desktop/src/renderer/components/TopBar.test.tsx index 9ca0537..af4a534 100644 --- a/apps/desktop/src/renderer/components/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/TopBar.test.tsx @@ -17,6 +17,7 @@ describe("TopBar", () => { const onShikiThemeChange = vi.fn(); const onShikiThemePreview = vi.fn(); const onShikiThemePreviewReset = vi.fn(); + const onToggleDashboard = vi.fn(); const onIncrementalRefresh = vi.fn(); const onToggleFocus = vi.fn(); const onToggleHelp = vi.fn(); @@ -30,6 +31,7 @@ describe("TopBar", () => { indexing={false} focusMode={false} focusDisabled={false} + onToggleDashboard={onToggleDashboard} onToggleSearchView={onToggleSearchView} onThemeChange={onThemeChange} onThemePreview={onThemePreview} @@ -64,6 +66,7 @@ describe("TopBar", () => { "Toggle Settings ⌘,", ); + await user.click(screen.getByRole("button", { name: "Open dashboard" })); await user.click(screen.getByRole("button", { name: "Search" })); await user.click(screen.getByRole("button", { name: "Incremental refresh" })); await user.click(screen.getByRole("button", { name: "Enter focus mode" })); @@ -76,6 +79,7 @@ describe("TopBar", () => { await user.click(screen.getByRole("button", { name: "Light Plus" })); await user.click(screen.getByRole("button", { name: "Open settings" })); + expect(onToggleDashboard).toHaveBeenCalledTimes(1); expect(onToggleSearchView).toHaveBeenCalledTimes(1); expect(onIncrementalRefresh).toHaveBeenCalledTimes(1); expect(onToggleFocus).toHaveBeenCalledTimes(1); @@ -99,6 +103,7 @@ describe("TopBar", () => { indexing={false} focusMode={false} focusDisabled={false} + onToggleDashboard={vi.fn()} onToggleSearchView={vi.fn()} onThemeChange={onThemeChange} onThemePreview={onThemePreview} @@ -144,6 +149,7 @@ describe("TopBar", () => { indexing={false} focusMode={false} focusDisabled={false} + onToggleDashboard={vi.fn()} onToggleSearchView={vi.fn()} onThemeChange={vi.fn()} onThemePreview={vi.fn()} @@ -188,6 +194,7 @@ describe("TopBar", () => { indexing={false} focusMode={false} focusDisabled={false} + onToggleDashboard={vi.fn()} onToggleSearchView={vi.fn()} onThemeChange={vi.fn()} onThemePreview={onThemePreview} @@ -234,6 +241,7 @@ describe("TopBar", () => { indexing={false} focusMode={false} focusDisabled={false} + onToggleDashboard={vi.fn()} onToggleSearchView={vi.fn()} onThemeChange={vi.fn()} onThemePreview={vi.fn()} diff --git a/apps/desktop/src/renderer/components/TopBar.tsx b/apps/desktop/src/renderer/components/TopBar.tsx index 2913020..7d8eb49 100644 --- a/apps/desktop/src/renderer/components/TopBar.tsx +++ b/apps/desktop/src/renderer/components/TopBar.tsx @@ -591,6 +591,7 @@ export function TopBar({ indexing, focusMode, focusDisabled, + onToggleDashboard = () => undefined, onToggleSearchView, onThemeChange, onThemePreview, @@ -608,12 +609,13 @@ export function TopBar({ onToggleHelp, onToggleSettings, }: { - mainView: "history" | "search" | "settings" | "help"; + mainView: "history" | "dashboard" | "search" | "settings" | "help"; theme: ThemeMode; shikiTheme: ShikiThemeId; indexing: boolean; focusMode: boolean; focusDisabled: boolean; + onToggleDashboard?: () => void; onToggleSearchView: () => void; onThemeChange: (theme: ThemeMode) => void; onThemePreview: (theme: ThemeMode) => void; @@ -636,13 +638,15 @@ export function TopBar({ const formatTooltipLabel = useTooltipFormatter(); const preserveHistoryFocusProps = paneFocus.getPreserveHistoryFocusProps(); const activeTitleSuffix = - mainView === "search" - ? "Search" - : mainView === "settings" - ? "Settings" - : mainView === "help" - ? "Help" - : null; + mainView === "dashboard" + ? "Dashboard" + : mainView === "search" + ? "Search" + : mainView === "settings" + ? "Settings" + : mainView === "help" + ? "Help" + : null; return (
@@ -657,6 +661,17 @@ export function TopBar({
+ +
+ + {error ?
{error}
: null} + +
+
+
+

Indexed Messages

+ + {formatCompactInteger(stats.summary.messageCount)} + +

+ {formatInteger(stats.summary.activeProviderCount)} active providers,{" "} + {formatInteger(stats.summary.projectCount)} projects,{" "} + {formatInteger(stats.summary.sessionCount)} sessions +

+
+ Tokens in {formatCompactInteger(stats.summary.tokenInputTotal)} + Tokens out {formatCompactInteger(stats.summary.tokenOutputTotal)} +
+
+ + + + + + +
+ +
+
+
+
+

Message mix

+

Category Composition

+
+ {formatInteger(totalMessages)} total +
+
+
+
+ Messages + {formatCompactInteger(totalMessages)} +
+
+
+ {messageCategories.map((item) => { + const percentage = totalMessages > 0 ? (item.count / totalMessages) * 100 : 0; + return ( +
+
+ + {item.label} +
+
+ {formatCompactInteger(item.count)} + {percentage.toFixed(1)}% +
+
+ ); + })} +
+
+
+ +
+
+
+

Providers

+

Provider Throughput

+
+ + {formatInteger(stats.summary.activeProviderCount)} with activity + +
+
+ {stats.providerStats.map((provider) => { + const width = `${Math.max(8, (provider.messageCount / providerMax) * 100)}%`; + return ( +
+
+
+ {prettyProvider(provider.provider)} +

+ {formatCompactInteger(provider.projectCount)} projects,{" "} + {formatCompactInteger(provider.sessionCount)} sessions +

+
+
+ {formatCompactInteger(provider.messageCount)} + messages +
+
+
+ +
+
+ {formatCompactInteger(provider.toolCallCount)} tool calls + + {provider.lastActivity ? formatDate(provider.lastActivity) : "No activity"} + +
+
+ ); + })} +
+
+ +
+
+
+

Recent activity

+

Message Skyline

+
+ Last {stats.activityWindowDays} days +
+
+ {stats.recentActivity.map((point) => { + const height = `${Math.max(10, (point.messageCount / recentActivityMax) * 100)}%`; + return ( +
+
+ {point.date.slice(5)} +
+ ); + })} +
+
+
+ +
+
+
+
+

Top projects

+

Where the action is

+
+
+
+ {stats.topProjects.map((project, index) => ( +
+
{index + 1}
+
+
+ {project.name || "(untitled project)"} + + {prettyProvider(project.provider)} + +
+

{compactPath(project.path)}

+
+ {formatCompactInteger(project.messageCount)} messages + {formatCompactInteger(project.sessionCount)} sessions + {formatCompactInteger(project.bookmarkCount)} bookmarks +
+
+
+ ))} +
+
+ +
+
+
+

Top models

+

Most-used model signatures

+
+
+
+ {stats.topModels.map((model, index) => ( +
+
+ {model.modelName} +

+ {formatCompactInteger(model.sessionCount)} sessions,{" "} + {formatCompactInteger(model.messageCount)} messages +

+
+ {index + 1} +
+ ))} +
+
+
+ + ); +} diff --git a/apps/desktop/src/renderer/features/useDashboardController.ts b/apps/desktop/src/renderer/features/useDashboardController.ts new file mode 100644 index 0000000..6375744 --- /dev/null +++ b/apps/desktop/src/renderer/features/useDashboardController.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { EMPTY_CATEGORY_COUNTS, EMPTY_PROVIDER_COUNTS } from "../app/constants"; +import type { DashboardStatsResponse, MainView } from "../app/types"; +import { useCodetrailClient } from "../lib/codetrailClient"; +import { toErrorMessage } from "../lib/viewUtils"; + +const EMPTY_DASHBOARD_STATS: DashboardStatsResponse = { + summary: { + projectCount: 0, + sessionCount: 0, + messageCount: 0, + bookmarkCount: 0, + toolCallCount: 0, + indexedFileCount: 0, + indexedBytesTotal: 0, + tokenInputTotal: 0, + tokenOutputTotal: 0, + totalDurationMs: 0, + averageMessagesPerSession: 0, + averageSessionDurationMs: 0, + activeProviderCount: 0, + }, + categoryCounts: EMPTY_CATEGORY_COUNTS, + providerCounts: EMPTY_PROVIDER_COUNTS, + providerStats: [], + recentActivity: [], + topProjects: [], + topModels: [], + activityWindowDays: 14, +}; + +export function useDashboardController({ + mainView, + logError, +}: { + mainView: MainView; + logError: (context: string, error: unknown) => void; +}) { + const codetrail = useCodetrailClient(); + const dashboardLoadTokenRef = useRef(0); + const [stats, setStats] = useState(EMPTY_DASHBOARD_STATS); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(null); + + const reloadStats = useCallback(async () => { + const requestToken = dashboardLoadTokenRef.current + 1; + dashboardLoadTokenRef.current = requestToken; + setLoading(true); + setError(null); + try { + const response = await codetrail.invoke("dashboard:getStats", {}); + if (requestToken !== dashboardLoadTokenRef.current) { + return; + } + setStats(response); + setLoaded(true); + setError(null); + } catch (loadError) { + if (requestToken !== dashboardLoadTokenRef.current) { + return; + } + logError("Dashboard stats refresh failed", loadError); + setError(toErrorMessage(loadError)); + } finally { + if (requestToken === dashboardLoadTokenRef.current) { + setLoading(false); + } + } + }, [codetrail, logError]); + + useEffect(() => { + if (mainView !== "dashboard" || loaded) { + return; + } + void reloadStats(); + }, [loaded, mainView, reloadStats]); + + return { + stats, + loading, + loaded, + error, + reloadStats, + }; +} diff --git a/apps/desktop/src/renderer/lib/paneFocusController.tsx b/apps/desktop/src/renderer/lib/paneFocusController.tsx index 36f4b2f..9ef9d34 100644 --- a/apps/desktop/src/renderer/lib/paneFocusController.tsx +++ b/apps/desktop/src/renderer/lib/paneFocusController.tsx @@ -12,7 +12,7 @@ import { import { isEditableTarget } from "./focusTargets"; export type HistoryPaneId = "project" | "session" | "message"; -export type ViewFocusDomainId = "search" | "settings" | "help"; +export type ViewFocusDomainId = "dashboard" | "search" | "settings" | "help"; export type FocusDomain = | { kind: "history"; pane: HistoryPaneId } | { kind: ViewFocusDomainId } @@ -124,6 +124,7 @@ export function useCreatePaneFocusController(): PaneFocusController { message: { root: null, focusTarget: null }, }); const viewTargetsRef = useRef({ + dashboard: null, search: null, settings: null, help: null, diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index f1e7641..1f45bc5 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -7185,6 +7185,538 @@ mark { } } +.dashboard-view { + min-height: 100%; + padding: 28px; + display: flex; + flex-direction: column; + gap: 22px; + overflow: auto; +} + +.dashboard-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 22px 24px; + border: 1px solid var(--border); + border-radius: 22px; + background: radial-gradient( + circle at top right, + color-mix(in srgb, var(--accent-blue) 20%, transparent), + transparent 38% + ), + radial-gradient( + circle at bottom left, + color-mix(in srgb, var(--accent-purple) 14%, transparent), + transparent 34% + ), + linear-gradient( + 135deg, + color-mix(in srgb, var(--bg-surface) 84%, var(--accent-blue-dim)) 0%, + var(--bg-surface) 55%, + color-mix(in srgb, var(--bg-elevated) 88%, var(--accent-purple-dim)) 100% + ); + box-shadow: var(--shadow-card); +} + +.dashboard-hero-copy { + max-width: 720px; +} + +.dashboard-eyebrow, +.dashboard-card-eyebrow, +.dashboard-spotlight-label, +.dashboard-metric-label { + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.dashboard-title { + margin: 10px 0 8px; + font-family: "Plus Jakarta Sans", var(--font-sans); + font-size: clamp(28px, 3vw, 40px); + line-height: 1; + letter-spacing: -0.04em; +} + +.dashboard-subtitle { + margin: 0; + max-width: 60ch; + font-size: 14px; + line-height: 1.7; + color: var(--text-secondary); +} + +.dashboard-banner { + padding: 12px 14px; + border-radius: 14px; + border: 1px solid var(--accent-red-border); + background: var(--accent-red-dim); + color: var(--accent-red); +} + +.dashboard-summary-grid, +.dashboard-main-grid, +.dashboard-secondary-grid { + display: grid; + gap: 18px; +} + +.dashboard-summary-grid { + grid-template-columns: minmax(280px, 1.6fr) repeat(5, minmax(0, 1fr)); +} + +.dashboard-main-grid { + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); +} + +.dashboard-secondary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.dashboard-card, +.dashboard-metric-card, +.dashboard-spotlight-card { + position: relative; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 22px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-surface) 92%, transparent), + color-mix(in srgb, var(--bg-elevated) 90%, transparent) + ); + box-shadow: var(--shadow-card); +} + +.dashboard-card { + padding: 20px; +} + +.dashboard-card-feature { + min-height: 320px; +} + +.dashboard-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.dashboard-card-title { + margin: 6px 0 0; + font-size: 20px; + line-height: 1.1; + letter-spacing: -0.03em; +} + +.dashboard-card-meta, +.dashboard-metric-meta, +.dashboard-provider-row p, +.dashboard-ranked-main p, +.dashboard-model-row p { + margin: 0; + color: var(--text-secondary); +} + +.dashboard-card-meta { + font-size: 12px; + white-space: nowrap; +} + +.dashboard-spotlight-card { + padding: 24px; + background: radial-gradient( + circle at top right, + color-mix(in srgb, var(--accent-blue) 28%, transparent), + transparent 34% + ), + linear-gradient( + 160deg, + color-mix(in srgb, var(--tb-primary-bg) 70%, var(--bg-surface)) 0%, + color-mix(in srgb, var(--bg-surface) 88%, transparent) 100% + ); +} + +.dashboard-spotlight-orb { + position: absolute; + width: 240px; + height: 240px; + top: -120px; + right: -80px; + border-radius: 999px; + background: radial-gradient( + circle, + color-mix(in srgb, var(--accent-blue) 26%, transparent), + transparent 65% + ); + pointer-events: none; +} + +.dashboard-spotlight-value { + display: block; + margin-top: 10px; + font-size: clamp(32px, 4vw, 52px); + line-height: 0.95; + letter-spacing: -0.05em; +} + +.dashboard-spotlight-meta { + margin: 8px 0 0; + color: var(--text-secondary); +} + +.dashboard-spotlight-foot { + margin-top: 18px; + display: flex; + flex-wrap: wrap; + gap: 10px; + color: var(--text-secondary); + font-size: 12px; +} + +.dashboard-metric-card { + padding: 18px 18px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.dashboard-metric-card::after { + content: ""; + position: absolute; + inset: auto 18px 0 18px; + height: 4px; + border-radius: 999px; + background: var(--accent-blue); + opacity: 0.85; +} + +.dashboard-tone-blue::after { + background: var(--accent-blue); +} + +.dashboard-tone-green::after { + background: var(--accent-green); +} + +.dashboard-tone-orange::after { + background: var(--accent-orange); +} + +.dashboard-tone-purple::after { + background: var(--accent-purple); +} + +.dashboard-tone-teal::after { + background: var(--accent-teal); +} + +.dashboard-tone-muted::after { + background: var(--accent-muted); +} + +.dashboard-metric-value { + font-size: 24px; + line-height: 1.1; + letter-spacing: -0.04em; +} + +.dashboard-composition { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 22px; + align-items: center; + min-height: 230px; +} + +.dashboard-donut { + width: 100%; + max-width: 260px; + aspect-ratio: 1; + border-radius: 50%; + display: grid; + place-items: center; + margin: 0 auto; + box-shadow: inset 0 0 0 1px var(--border); +} + +.dashboard-donut-center { + width: 58%; + aspect-ratio: 1; + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + background: color-mix(in srgb, var(--bg-base) 82%, transparent); + box-shadow: inset 0 0 0 1px var(--border); + text-align: center; +} + +.dashboard-donut-center span { + color: var(--text-secondary); + font-size: 12px; +} + +.dashboard-donut-center strong { + font-size: 24px; + letter-spacing: -0.04em; +} + +.dashboard-legend, +.dashboard-provider-list, +.dashboard-ranked-list, +.dashboard-model-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dashboard-legend-row, +.dashboard-provider-row, +.dashboard-ranked-row, +.dashboard-model-row { + padding: 12px 14px; + border-radius: 16px; + background: color-mix(in srgb, var(--bg-surface) 80%, var(--bg-elevated)); + border: 1px solid var(--border); +} + +.dashboard-legend-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.dashboard-legend-main, +.dashboard-legend-stats, +.dashboard-ranked-title-row, +.dashboard-ranked-meta, +.dashboard-provider-head, +.dashboard-provider-foot, +.dashboard-model-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.dashboard-legend-main { + justify-content: flex-start; +} + +.dashboard-legend-swatch { + width: 10px; + height: 10px; + border-radius: 999px; +} + +.dashboard-legend-label, +.dashboard-provider-head strong, +.dashboard-ranked-main strong, +.dashboard-model-row strong { + font-size: 14px; +} + +.dashboard-legend-stats span, +.dashboard-provider-foot, +.dashboard-ranked-provider, +.dashboard-ranked-meta, +.dashboard-model-chip { + color: var(--text-secondary); + font-size: 12px; +} + +.dashboard-provider-row { + display: flex; + flex-direction: column; + gap: 10px; +} + +.dashboard-provider-counts { + text-align: right; +} + +.dashboard-provider-counts strong { + display: block; + font-size: 18px; +} + +.dashboard-provider-bar { + position: relative; + height: 10px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--bg-elevated) 86%, transparent); +} + +.dashboard-provider-bar-fill { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient( + 90deg, + var(--accent-blue), + color-mix(in srgb, var(--accent-purple) 75%, var(--accent-blue)) + ); +} + +.dashboard-skyline { + height: 250px; + display: grid; + grid-template-columns: repeat(14, minmax(0, 1fr)); + gap: 10px; + align-items: end; + padding-top: 8px; +} + +.dashboard-skyline-column { + min-width: 0; + height: 100%; + display: flex; + flex-direction: column; + justify-content: end; + align-items: center; + gap: 10px; +} + +.dashboard-skyline-bar { + width: 100%; + min-height: 10px; + border-radius: 12px 12px 4px 4px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent-teal) 72%, white), + var(--accent-blue) + ), var(--accent-blue); + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.12); +} + +.dashboard-skyline-column span { + font-size: 11px; + color: var(--text-tertiary); +} + +.dashboard-ranked-list, +.dashboard-model-list { + gap: 10px; +} + +.dashboard-ranked-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 14px; +} + +.dashboard-ranked-rank, +.dashboard-model-chip { + width: 28px; + height: 28px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated); + border: 1px solid var(--border); + font-weight: 700; +} + +.dashboard-ranked-main { + min-width: 0; +} + +.dashboard-ranked-main p { + margin-top: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-ranked-provider { + padding: 4px 8px; + border-radius: 999px; + background: var(--bg-elevated); + border: 1px solid var(--border); +} + +.dashboard-ranked-meta { + justify-content: flex-start; + flex-wrap: wrap; + margin-top: 8px; +} + +.dashboard-model-row { + gap: 16px; +} + +@media (max-width: 1400px) { + .dashboard-summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .dashboard-spotlight-card { + grid-column: 1 / -1; + } +} + +@media (max-width: 1100px) { + .dashboard-main-grid, + .dashboard-secondary-grid { + grid-template-columns: minmax(0, 1fr); + } + + .dashboard-composition { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 780px) { + .dashboard-view { + padding: 18px; + } + + .dashboard-hero { + flex-direction: column; + } + + .dashboard-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-skyline { + gap: 6px; + } +} + +@media (max-width: 560px) { + .dashboard-summary-grid { + grid-template-columns: minmax(0, 1fr); + } + + .dashboard-title { + font-size: 30px; + } + + .dashboard-ranked-title-row, + .dashboard-provider-head, + .dashboard-provider-foot, + .dashboard-model-row { + align-items: flex-start; + flex-direction: column; + } +} + @media (max-width: 1100px) { body { overflow: auto; diff --git a/apps/desktop/src/renderer/test/appTestFixtures.ts b/apps/desktop/src/renderer/test/appTestFixtures.ts index cb32990..09daaaf 100644 --- a/apps/desktop/src/renderer/test/appTestFixtures.ts +++ b/apps/desktop/src/renderer/test/appTestFixtures.ts @@ -99,6 +99,144 @@ function createRendererClient(handlers: Record) { if (channel === "app:getSettingsInfo") { return cloneValue(SETTINGS_INFO); } + if (channel === "dashboard:getStats") { + return { + summary: { + projectCount: 2, + sessionCount: 3, + messageCount: 42, + bookmarkCount: 4, + toolCallCount: 7, + indexedFileCount: 3, + indexedBytesTotal: 32_768, + tokenInputTotal: 1_200, + tokenOutputTotal: 900, + totalDurationMs: 240_000, + averageMessagesPerSession: 14, + averageSessionDurationMs: 80_000, + activeProviderCount: 2, + }, + categoryCounts: { + user: 10, + assistant: 12, + tool_use: 5, + tool_edit: 7, + tool_result: 4, + thinking: 2, + system: 2, + }, + providerCounts: { + claude: 20, + codex: 22, + gemini: 0, + cursor: 0, + copilot: 0, + }, + providerStats: [ + { + provider: "codex", + projectCount: 1, + sessionCount: 2, + messageCount: 22, + toolCallCount: 4, + tokenInputTotal: 700, + tokenOutputTotal: 500, + lastActivity: "2026-03-16T10:05:03.000Z", + }, + { + provider: "claude", + projectCount: 1, + sessionCount: 1, + messageCount: 20, + toolCallCount: 3, + tokenInputTotal: 500, + tokenOutputTotal: 400, + lastActivity: "2026-03-15T09:12:00.000Z", + }, + { + provider: "gemini", + projectCount: 0, + sessionCount: 0, + messageCount: 0, + toolCallCount: 0, + tokenInputTotal: 0, + tokenOutputTotal: 0, + lastActivity: null, + }, + { + provider: "cursor", + projectCount: 0, + sessionCount: 0, + messageCount: 0, + toolCallCount: 0, + tokenInputTotal: 0, + tokenOutputTotal: 0, + lastActivity: null, + }, + { + provider: "copilot", + projectCount: 0, + sessionCount: 0, + messageCount: 0, + toolCallCount: 0, + tokenInputTotal: 0, + tokenOutputTotal: 0, + lastActivity: null, + }, + ], + recentActivity: [ + { date: "2026-03-03", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-04", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-05", sessionCount: 1, messageCount: 3 }, + { date: "2026-03-06", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-07", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-08", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-09", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-10", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-11", sessionCount: 1, messageCount: 6 }, + { date: "2026-03-12", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-13", sessionCount: 1, messageCount: 12 }, + { date: "2026-03-14", sessionCount: 0, messageCount: 0 }, + { date: "2026-03-15", sessionCount: 1, messageCount: 9 }, + { date: "2026-03-16", sessionCount: 2, messageCount: 12 }, + ], + topProjects: [ + { + projectId: "project_1", + provider: "codex", + name: "Project One", + path: "/workspace/project-one", + sessionCount: 2, + messageCount: 22, + bookmarkCount: 3, + lastActivity: "2026-03-16T10:05:03.000Z", + }, + { + projectId: "project_2", + provider: "claude", + name: "Project Two", + path: "/workspace/project-two", + sessionCount: 1, + messageCount: 20, + bookmarkCount: 1, + lastActivity: "2026-03-15T09:12:00.000Z", + }, + ], + topModels: [ + { + modelName: "codex-gpt-5", + sessionCount: 2, + messageCount: 22, + }, + { + modelName: "claude-opus-4-1", + sessionCount: 1, + messageCount: 20, + }, + ], + activityWindowDays: 14, + }; + } if (channel === "watcher:start") { return { ok: true, watchedRoots: [], backend: "default" }; } diff --git a/apps/desktop/src/shared/codetrailBridge.ts b/apps/desktop/src/shared/codetrailBridge.ts index cdcf8db..8f88dc9 100644 --- a/apps/desktop/src/shared/codetrailBridge.ts +++ b/apps/desktop/src/shared/codetrailBridge.ts @@ -13,6 +13,9 @@ export type CodetrailBridge = { appGetSettingsInfo( payload: IpcRequestInput<"app:getSettingsInfo">, ): Promise>; + dashboardGetStats( + payload: IpcRequestInput<"dashboard:getStats">, + ): Promise>; bookmarksGetStates( payload: IpcRequestInput<"bookmarks:getStates">, ): Promise>; @@ -105,6 +108,7 @@ export const CHANNEL_TO_BRIDGE_METHOD = { "app:flushState": "appFlushState", "app:setCommandState": "appSetCommandState", "app:getSettingsInfo": "appGetSettingsInfo", + "dashboard:getStats": "dashboardGetStats", "bookmarks:getStates": "bookmarksGetStates", "bookmarks:listProject": "bookmarksListProject", "bookmarks:toggle": "bookmarksToggle", diff --git a/packages/core/src/contracts/ipc.test.ts b/packages/core/src/contracts/ipc.test.ts index bae24c6..943cf6f 100644 --- a/packages/core/src/contracts/ipc.test.ts +++ b/packages/core/src/contracts/ipc.test.ts @@ -59,6 +59,47 @@ const channelExamples: Record = { request: {}, response: { schemaVersion: 1 }, }, + "dashboard:getStats": { + request: {}, + response: { + summary: { + projectCount: 0, + sessionCount: 0, + messageCount: 0, + bookmarkCount: 0, + toolCallCount: 0, + indexedFileCount: 0, + indexedBytesTotal: 0, + tokenInputTotal: 0, + tokenOutputTotal: 0, + totalDurationMs: 0, + averageMessagesPerSession: 0, + averageSessionDurationMs: 0, + activeProviderCount: 0, + }, + categoryCounts: { + user: 0, + assistant: 0, + tool_use: 0, + tool_edit: 0, + tool_result: 0, + thinking: 0, + system: 0, + }, + providerCounts: { + claude: 0, + codex: 0, + gemini: 0, + cursor: 0, + copilot: 0, + }, + providerStats: [], + recentActivity: [], + topProjects: [], + topModels: [], + activityWindowDays: 14, + }, + }, "indexer:refresh": { request: { force: true, projectId: "project_1" }, response: { jobId: "refresh-1" }, diff --git a/packages/core/src/contracts/ipc.ts b/packages/core/src/contracts/ipc.ts index 795e184..4dbee19 100644 --- a/packages/core/src/contracts/ipc.ts +++ b/packages/core/src/contracts/ipc.ts @@ -141,6 +141,51 @@ const categoryCountsSchema = z.object({ system: z.number().int().nonnegative(), }); const providerCountsSchema = z.object(createProviderRecord(() => z.number().int().nonnegative())); +const dashboardSummarySchema = z.object({ + projectCount: z.number().int().nonnegative(), + sessionCount: z.number().int().nonnegative(), + messageCount: z.number().int().nonnegative(), + bookmarkCount: z.number().int().nonnegative(), + toolCallCount: z.number().int().nonnegative(), + indexedFileCount: z.number().int().nonnegative(), + indexedBytesTotal: z.number().int().nonnegative(), + tokenInputTotal: z.number().int().nonnegative(), + tokenOutputTotal: z.number().int().nonnegative(), + totalDurationMs: z.number().int().nonnegative(), + averageMessagesPerSession: z.number().nonnegative(), + averageSessionDurationMs: z.number().nonnegative(), + activeProviderCount: z.number().int().nonnegative(), +}); +const dashboardProviderStatSchema = z.object({ + provider: providerSchema, + projectCount: z.number().int().nonnegative(), + sessionCount: z.number().int().nonnegative(), + messageCount: z.number().int().nonnegative(), + toolCallCount: z.number().int().nonnegative(), + tokenInputTotal: z.number().int().nonnegative(), + tokenOutputTotal: z.number().int().nonnegative(), + lastActivity: z.string().nullable(), +}); +const dashboardActivityPointSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + sessionCount: z.number().int().nonnegative(), + messageCount: z.number().int().nonnegative(), +}); +const dashboardProjectStatSchema = z.object({ + projectId: z.string().min(1), + provider: providerSchema, + name: z.string(), + path: z.string(), + sessionCount: z.number().int().nonnegative(), + messageCount: z.number().int().nonnegative(), + bookmarkCount: z.number().int().nonnegative(), + lastActivity: z.string().nullable(), +}); +const dashboardModelStatSchema = z.object({ + modelName: z.string().min(1), + sessionCount: z.number().int().nonnegative(), + messageCount: z.number().int().nonnegative(), +}); const monoFontSizeSchema = z.enum([ "10px", @@ -478,6 +523,19 @@ export const ipcContractSchemas = { schemaVersion: z.number().int().positive(), }), }, + "dashboard:getStats": { + request: z.object({}), + response: z.object({ + summary: dashboardSummarySchema, + categoryCounts: categoryCountsSchema, + providerCounts: providerCountsSchema, + providerStats: z.array(dashboardProviderStatSchema), + recentActivity: z.array(dashboardActivityPointSchema), + topProjects: z.array(dashboardProjectStatSchema), + topModels: z.array(dashboardModelStatSchema), + activityWindowDays: z.number().int().positive(), + }), + }, "indexer:refresh": { request: z.object({ force: z.boolean().default(false), From f00053008620021b9c0e6601c9bbe7ca0f47c8f2 Mon Sep 17 00:00:00 2001 From: Orhan Biyiklioglu Date: Tue, 7 Apr 2026 21:36:41 +0300 Subject: [PATCH 2/4] Add AI code activity stats to the dashboard Extend the dashboard:getStats response with AI code-writing metrics derived from explicit tool_edit events. Adds a shared helper that parses stored tool-call payloads into per-file write summaries, including change type counts, file and extension rollups, line additions/deletions, and recent write activity. Expand the dashboard UI with a dedicated AI Code Activity section that surfaces write KPIs, coverage notes, write velocity, change profile, provider write throughput, top written files, and top file types while staying within the existing dashboard card and chart patterns. Refresh dashboard data whenever the view is reopened instead of loading it only once per app session. Cover the new behavior with shared helper, query-service, IPC-contract, dashboard renderer, and App-shell tests, including partial coverage, empty-state, and dashboard reopen refresh scenarios. --- .../src/main/data/queryService.test.ts | 387 +++++++++++++++++- apps/desktop/src/main/data/queryService.ts | 254 ++++++++++++ apps/desktop/src/main/ipc.test.ts | 26 ++ apps/desktop/src/renderer/App.test.tsx | 30 ++ .../renderer/features/DashboardView.test.tsx | 172 ++++++++ .../src/renderer/features/DashboardView.tsx | 292 +++++++++++++ .../features/useDashboardController.ts | 27 +- apps/desktop/src/renderer/styles.css | 151 ++++++- .../src/renderer/test/appTestFixtures.ts | 107 +++++ .../desktop/src/shared/aiCodeActivity.test.ts | 119 ++++++ apps/desktop/src/shared/aiCodeActivity.ts | 137 +++++++ packages/core/src/contracts/ipc.test.ts | 25 ++ packages/core/src/contracts/ipc.ts | 54 +++ 13 files changed, 1774 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/shared/aiCodeActivity.test.ts create mode 100644 apps/desktop/src/shared/aiCodeActivity.ts diff --git a/apps/desktop/src/main/data/queryService.test.ts b/apps/desktop/src/main/data/queryService.test.ts index 7c481d3..31da6a6 100644 --- a/apps/desktop/src/main/data/queryService.test.ts +++ b/apps/desktop/src/main/data/queryService.test.ts @@ -395,6 +395,32 @@ describe("queryService in-memory", () => { "2026-03-02T11:00:03.000Z", "2026-03-02T11:00:04.000Z", ); + db.prepare( + `INSERT INTO tool_calls ( + id, + message_id, + tool_name, + args_json, + result_json, + started_at, + completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + "tool_call_2", + "message_3", + "apply_patch", + JSON.stringify( + [ + "*** Begin Patch", + "*** Add File: src/dashboard.tsx", + "+export const Dashboard = () => null;", + "*** End Patch", + ].join("\n"), + ), + null, + "2026-03-02T11:00:01.000Z", + "2026-03-02T11:00:02.000Z", + ); const bookmarkStore = createBookmarkStoreMock({ countAllBookmarks: vi.fn(() => 3), @@ -415,7 +441,7 @@ describe("queryService in-memory", () => { expect(stats.summary.sessionCount).toBe(2); expect(stats.summary.messageCount).toBe(5); expect(stats.summary.bookmarkCount).toBe(3); - expect(stats.summary.toolCallCount).toBe(1); + expect(stats.summary.toolCallCount).toBe(2); expect(stats.summary.indexedFileCount).toBe(2); expect(stats.summary.activeProviderCount).toBe(2); expect(stats.summary.averageMessagesPerSession).toBe(2.5); @@ -428,7 +454,7 @@ describe("queryService in-memory", () => { projectCount: 1, sessionCount: 1, messageCount: 3, - toolCallCount: 1, + toolCallCount: 2, }); expect(stats.topProjects[0]).toMatchObject({ projectId: "project_2", @@ -438,11 +464,368 @@ describe("queryService in-memory", () => { modelName: "codex-gpt-5", messageCount: 3, }); + expect(stats.aiCodeStats.summary).toMatchObject({ + writeEventCount: 1, + measurableWriteEventCount: 1, + writeSessionCount: 1, + fileChangeCount: 1, + distinctFilesTouchedCount: 1, + linesAdded: 1, + linesDeleted: 0, + netLines: 1, + multiFileWriteCount: 0, + }); + expect(stats.aiCodeStats.changeTypeCounts).toEqual({ + add: 1, + update: 0, + delete: 0, + }); + expect(stats.aiCodeStats.providerStats.find((provider) => provider.provider === "codex")) + .toMatchObject({ + writeEventCount: 1, + fileChangeCount: 1, + linesAdded: 1, + linesDeleted: 0, + writeSessionCount: 1, + }); + expect(stats.aiCodeStats.topFiles[0]).toMatchObject({ + filePath: "src/dashboard.tsx", + writeEventCount: 1, + linesAdded: 1, + linesDeleted: 0, + }); expect(stats.recentActivity).toHaveLength(stats.activityWindowDays); expect(bookmarkStore.countAllBookmarks).toHaveBeenCalled(); expect(bookmarkStore.countProjectBookmarksByProjectIds).toHaveBeenCalled(); }); + it("aggregates ai code activity metrics across structured writes, multi-file patches, and unparseable events", () => { + const db = createInMemoryDatabase(); + const now = "2026-04-01T09:00:00.000Z"; + + db.prepare( + `INSERT INTO projects (id, provider, name, path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run("project_1", "claude", "Project One", "/workspace/project-one", now, now); + db.prepare( + `INSERT INTO projects (id, provider, name, path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run("project_2", "codex", "Project Two", "/workspace/project-two", now, now); + + const insertSession = db.prepare( + `INSERT INTO sessions ( + id, + project_id, + provider, + file_path, + model_names, + started_at, + ended_at, + duration_ms, + git_branch, + cwd, + message_count, + token_input_total, + token_output_total + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + insertSession.run( + "session_1", + "project_1", + "claude", + "/workspace/project-one/session-1.jsonl", + "claude-opus-4-1", + "2026-04-01T09:00:00.000Z", + "2026-04-01T09:05:00.000Z", + 300000, + "main", + "/workspace/project-one", + 1, + 0, + 0, + ); + insertSession.run( + "session_2", + "project_2", + "codex", + "/workspace/project-two/session-2.jsonl", + "codex-gpt-5", + "2026-04-02T09:00:00.000Z", + "2026-04-02T09:12:00.000Z", + 720000, + "feat/ai-stats", + "/workspace/project-two", + 4, + 0, + 0, + ); + + const insertMessage = db.prepare( + `INSERT INTO messages ( + id, + source_id, + session_id, + provider, + category, + content, + created_at, + token_input, + token_output, + operation_duration_ms, + operation_duration_source, + operation_duration_confidence + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + insertMessage.run( + "message_1", + "source_1", + "session_1", + "claude", + "tool_edit", + "Updated src/app.ts", + "2026-04-01T09:03:00.000Z", + null, + null, + null, + null, + null, + ); + insertMessage.run( + "message_2", + "source_2", + "session_2", + "codex", + "tool_edit", + "Applied multi-file patch", + "2026-04-02T09:04:00.000Z", + null, + null, + null, + null, + null, + ); + insertMessage.run( + "message_3", + "source_3", + "session_2", + "codex", + "tool_edit", + "Created src/feature.tsx", + "2026-04-03T09:08:00.000Z", + null, + null, + null, + null, + null, + ); + insertMessage.run( + "message_4", + "source_4", + "session_2", + "codex", + "tool_edit", + "Moved and deleted files", + "2026-04-04T09:09:00.000Z", + null, + null, + null, + null, + null, + ); + insertMessage.run( + "message_5", + "source_5", + "session_2", + "codex", + "tool_edit", + "Payload truncated", + "2026-04-05T09:10:00.000Z", + null, + null, + null, + null, + null, + ); + + const insertToolCall = db.prepare( + `INSERT INTO tool_calls ( + id, + message_id, + tool_name, + args_json, + result_json, + started_at, + completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + insertToolCall.run( + "tool_call_1", + "message_1", + "str_replace", + JSON.stringify({ + path: "src/app.ts", + old_string: "const value = 1;\n", + new_string: "const value = 1;\nconst next = 2;\n", + }), + null, + "2026-04-01T09:03:00.000Z", + "2026-04-01T09:03:01.000Z", + ); + insertToolCall.run( + "tool_call_2", + "message_2", + "apply_patch", + JSON.stringify( + [ + "*** Begin Patch", + "*** Add File: src/new.ts", + "+export const created = true;", + "+export const version = 1;", + "*** Update File: src/parser.ts", + "@@", + "-const value = old();", + "+const value = next();", + "+const after = true;", + "*** End Patch", + ].join("\n"), + ), + null, + "2026-04-02T09:04:00.000Z", + "2026-04-02T09:04:05.000Z", + ); + insertToolCall.run( + "tool_call_3", + "message_3", + "write_file", + JSON.stringify({ + path: "src/feature.tsx", + content: "export function Feature() {\n return
;\n}\n", + }), + null, + "2026-04-03T09:08:00.000Z", + "2026-04-03T09:08:01.000Z", + ); + insertToolCall.run( + "tool_call_4", + "message_4", + "apply_patch", + JSON.stringify( + [ + "*** Begin Patch", + "*** Delete File: src/obsolete.ts", + "@@", + "-export const obsolete = true;", + "*** Update File: src/old-name.ts", + "*** Move to: src/new-name.ts", + "@@", + "-export const before = oldName();", + "+export const after = newName();", + "*** End Patch", + ].join("\n"), + ), + null, + "2026-04-04T09:09:00.000Z", + "2026-04-04T09:09:03.000Z", + ); + insertToolCall.run( + "tool_call_5", + "message_5", + "apply_patch", + "{", + null, + "2026-04-05T09:10:00.000Z", + "2026-04-05T09:10:01.000Z", + ); + + const service = createQueryServiceFromDb(db, { + bookmarkStore: createBookmarkStoreMock(), + ownsBookmarkStore: false, + }); + + const stats = service.getDashboardStats(); + + expect(stats.aiCodeStats.summary).toMatchObject({ + writeEventCount: 5, + measurableWriteEventCount: 4, + writeSessionCount: 2, + fileChangeCount: 6, + distinctFilesTouchedCount: 6, + linesAdded: 9, + linesDeleted: 3, + netLines: 6, + multiFileWriteCount: 2, + averageFilesPerWrite: 1.5, + }); + expect(stats.aiCodeStats.changeTypeCounts).toEqual({ + add: 2, + update: 3, + delete: 1, + }); + expect(stats.aiCodeStats.providerStats.find((provider) => provider.provider === "codex")) + .toMatchObject({ + writeEventCount: 4, + fileChangeCount: 5, + linesAdded: 8, + linesDeleted: 3, + writeSessionCount: 1, + }); + expect(stats.aiCodeStats.providerStats.find((provider) => provider.provider === "claude")) + .toMatchObject({ + writeEventCount: 1, + fileChangeCount: 1, + linesAdded: 1, + linesDeleted: 0, + writeSessionCount: 1, + }); + expect(stats.aiCodeStats.topFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filePath: "src/new.ts", + writeEventCount: 1, + linesAdded: 2, + linesDeleted: 0, + }), + expect.objectContaining({ + filePath: "src/new-name.ts", + linesAdded: 1, + linesDeleted: 1, + }), + ]), + ); + expect(stats.aiCodeStats.topFileTypes).toEqual([ + { + label: ".ts", + fileChangeCount: 5, + linesAdded: 6, + linesDeleted: 3, + }, + { + label: ".tsx", + fileChangeCount: 1, + linesAdded: 3, + linesDeleted: 0, + }, + ]); + expect(stats.aiCodeStats.recentActivity).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + date: "2026-04-02", + writeEventCount: 1, + fileChangeCount: 2, + linesAdded: 4, + linesDeleted: 1, + }), + expect.objectContaining({ + date: "2026-04-05", + writeEventCount: 1, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + }), + ]), + ); + }); + it("serves list/detail/bookmark flows using createQueryServiceFromDb", () => { const db = seedQueryDb(); const service = createQueryServiceFromDb(db); diff --git a/apps/desktop/src/main/data/queryService.ts b/apps/desktop/src/main/data/queryService.ts index 7c9fb2a..73761b6 100644 --- a/apps/desktop/src/main/data/queryService.ts +++ b/apps/desktop/src/main/data/queryService.ts @@ -25,6 +25,7 @@ import { createBookmarkStore, resolveBookmarksDbPath, } from "./bookmarkStore"; +import { summarizeStoredToolEditActivity } from "../../shared/aiCodeActivity"; type DatabaseHandle = ReturnType; type OpenDatabase = typeof openDatabase; @@ -205,6 +206,16 @@ type DashboardModelRow = { session_count: number; message_count: number; }; +type DashboardAiWriteRow = { + message_id: string; + session_id: string; + provider: Provider; + created_at: string; + tool_name: string | null; + args_json: string | null; +}; + +type DashboardAiCodeStats = IpcResponse<"dashboard:getStats">["aiCodeStats"]; type TurnAnchorRow = { id: string; @@ -461,6 +472,7 @@ function getDashboardStatsWithDatabase( } ); }); + const aiCodeStats = collectDashboardAiCodeStats(db, recentActivity.map((point) => point.date)); const topProjectRows = db .prepare( @@ -539,10 +551,252 @@ function getDashboardStatsWithDatabase( sessionCount: Number(row.session_count ?? 0), messageCount: Number(row.message_count ?? 0), })), + aiCodeStats, activityWindowDays, }; } +function collectDashboardAiCodeStats( + db: DatabaseHandle, + recentDateKeys: string[], +): DashboardAiCodeStats { + const providerStatsByProvider = createProviderRecord((provider) => ({ + provider, + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + writeSessionCount: 0, + })); + const providerSessionIds = createProviderRecord(() => new Set()); + const changeTypeCounts: DashboardAiCodeStats["changeTypeCounts"] = { + add: 0, + update: 0, + delete: 0, + }; + const recentActivityByDate = new Map( + recentDateKeys.map((date) => [ + date, + { + date, + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + }, + ]), + ); + const fileStatsByPath = new Map< + string, + { + filePath: string; + writeEventCount: number; + linesAdded: number; + linesDeleted: number; + lastTouchedAt: string | null; + } + >(); + const fileTypeStatsByLabel = new Map< + string, + { + label: string; + fileChangeCount: number; + linesAdded: number; + linesDeleted: number; + } + >(); + const distinctSessionIds = new Set(); + const distinctFilesTouched = new Set(); + const writeRows = db + .prepare( + `SELECT + m.id AS message_id, + m.session_id AS session_id, + m.provider AS provider, + m.created_at AS created_at, + tc.tool_name AS tool_name, + tc.args_json AS args_json + FROM messages m + LEFT JOIN tool_calls tc ON tc.message_id = m.id + WHERE m.category = 'tool_edit' + ORDER BY m.created_at ASC, m.id ASC`, + ) + .all() as DashboardAiWriteRow[]; + + let measurableWriteEventCount = 0; + let fileChangeCount = 0; + let linesAdded = 0; + let linesDeleted = 0; + let multiFileWriteCount = 0; + + for (const row of writeRows) { + distinctSessionIds.add(row.session_id); + providerStatsByProvider[row.provider] = { + ...providerStatsByProvider[row.provider], + writeEventCount: providerStatsByProvider[row.provider].writeEventCount + 1, + }; + providerSessionIds[row.provider].add(row.session_id); + + const activityPoint = recentActivityByDate.get(row.created_at.slice(0, 10)); + if (activityPoint) { + activityPoint.writeEventCount += 1; + } + + const editSummary = summarizeStoredToolEditActivity({ + toolName: row.tool_name, + argsJson: row.args_json, + }); + if (!editSummary || editSummary.files.length === 0) { + continue; + } + + measurableWriteEventCount += 1; + if (editSummary.files.length > 1) { + multiFileWriteCount += 1; + } + + const eventTouchedPaths = new Set(); + for (const file of editSummary.files) { + fileChangeCount += 1; + linesAdded += file.linesAdded; + linesDeleted += file.linesDeleted; + distinctFilesTouched.add(file.filePath); + changeTypeCounts[file.changeType] += 1; + eventTouchedPaths.add(file.filePath); + + providerStatsByProvider[row.provider] = { + ...providerStatsByProvider[row.provider], + fileChangeCount: providerStatsByProvider[row.provider].fileChangeCount + 1, + linesAdded: providerStatsByProvider[row.provider].linesAdded + file.linesAdded, + linesDeleted: providerStatsByProvider[row.provider].linesDeleted + file.linesDeleted, + }; + + if (activityPoint) { + activityPoint.fileChangeCount += 1; + activityPoint.linesAdded += file.linesAdded; + activityPoint.linesDeleted += file.linesDeleted; + } + + const existingFileStat = fileStatsByPath.get(file.filePath); + if (existingFileStat) { + existingFileStat.linesAdded += file.linesAdded; + existingFileStat.linesDeleted += file.linesDeleted; + if (!existingFileStat.lastTouchedAt || row.created_at > existingFileStat.lastTouchedAt) { + existingFileStat.lastTouchedAt = row.created_at; + } + } else { + fileStatsByPath.set(file.filePath, { + filePath: file.filePath, + writeEventCount: 0, + linesAdded: file.linesAdded, + linesDeleted: file.linesDeleted, + lastTouchedAt: row.created_at, + }); + } + + const fileTypeLabel = inferDashboardFileTypeLabel(file.filePath); + const existingFileTypeStat = fileTypeStatsByLabel.get(fileTypeLabel); + if (existingFileTypeStat) { + existingFileTypeStat.fileChangeCount += 1; + existingFileTypeStat.linesAdded += file.linesAdded; + existingFileTypeStat.linesDeleted += file.linesDeleted; + } else { + fileTypeStatsByLabel.set(fileTypeLabel, { + label: fileTypeLabel, + fileChangeCount: 1, + linesAdded: file.linesAdded, + linesDeleted: file.linesDeleted, + }); + } + } + + for (const filePath of eventTouchedPaths) { + const fileStat = fileStatsByPath.get(filePath); + if (fileStat) { + fileStat.writeEventCount += 1; + } + } + } + + for (const provider of Object.keys(providerStatsByProvider) as Provider[]) { + providerStatsByProvider[provider] = { + ...providerStatsByProvider[provider], + writeSessionCount: providerSessionIds[provider].size, + }; + } + + return { + summary: { + writeEventCount: writeRows.length, + measurableWriteEventCount, + writeSessionCount: distinctSessionIds.size, + fileChangeCount, + distinctFilesTouchedCount: distinctFilesTouched.size, + linesAdded, + linesDeleted, + netLines: linesAdded - linesDeleted, + multiFileWriteCount, + averageFilesPerWrite: + measurableWriteEventCount > 0 ? fileChangeCount / measurableWriteEventCount : 0, + }, + changeTypeCounts, + providerStats: Object.values(providerStatsByProvider), + recentActivity: recentDateKeys.map((date) => { + return ( + recentActivityByDate.get(date) ?? { + date, + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + } + ); + }), + topFiles: Array.from(fileStatsByPath.values()) + .sort((left, right) => { + const leftTotal = left.linesAdded + left.linesDeleted; + const rightTotal = right.linesAdded + right.linesDeleted; + if (rightTotal !== leftTotal) { + return rightTotal - leftTotal; + } + if (right.writeEventCount !== left.writeEventCount) { + return right.writeEventCount - left.writeEventCount; + } + if ((right.lastTouchedAt ?? "") !== (left.lastTouchedAt ?? "")) { + return (right.lastTouchedAt ?? "").localeCompare(left.lastTouchedAt ?? ""); + } + return left.filePath.localeCompare(right.filePath); + }) + .slice(0, 6), + topFileTypes: Array.from(fileTypeStatsByLabel.values()) + .sort((left, right) => { + if (right.fileChangeCount !== left.fileChangeCount) { + return right.fileChangeCount - left.fileChangeCount; + } + const leftTotal = left.linesAdded + left.linesDeleted; + const rightTotal = right.linesAdded + right.linesDeleted; + if (rightTotal !== leftTotal) { + return rightTotal - leftTotal; + } + return left.label.localeCompare(right.label); + }) + .slice(0, 6), + }; +} + +function inferDashboardFileTypeLabel(filePath: string): string { + const normalized = filePath.replace(/\\/g, "/"); + const baseName = normalized.slice(normalized.lastIndexOf("/") + 1); + const extensionIndex = baseName.lastIndexOf("."); + if (extensionIndex <= 0 || extensionIndex === baseName.length - 1) { + if (baseName.startsWith(".") && !baseName.slice(1).includes(".")) { + return baseName.toLowerCase(); + } + return "No extension"; + } + return baseName.slice(extensionIndex).toLowerCase(); +} + function listRecentLiveSessionFilesWithDatabase( db: DatabaseHandle, input: { diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 017dd9d..082c67b 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -40,6 +40,30 @@ function createClaudeHookState(input: { installed: boolean }) { }); } +const emptyAiCodeStats = { + summary: { + writeEventCount: 0, + measurableWriteEventCount: 0, + writeSessionCount: 0, + fileChangeCount: 0, + distinctFilesTouchedCount: 0, + linesAdded: 0, + linesDeleted: 0, + netLines: 0, + multiFileWriteCount: 0, + averageFilesPerWrite: 0, + }, + changeTypeCounts: { + add: 0, + update: 0, + delete: 0, + }, + providerStats: [], + recentActivity: [], + topFiles: [], + topFileTypes: [], +} satisfies IpcResponse<"dashboard:getStats">["aiCodeStats"]; + describe("registerIpcHandlers", () => { it("validates request payloads before invoking handlers", async () => { const registry = new Map Promise>(); @@ -87,6 +111,7 @@ describe("registerIpcHandlers", () => { recentActivity: [], topProjects: [], topModels: [], + aiCodeStats: emptyAiCodeStats, activityWindowDays: 14, }), "indexer:refresh": (payload) => ({ jobId: payload.force ? "force-1" : "normal-1" }), @@ -355,6 +380,7 @@ describe("registerIpcHandlers", () => { recentActivity: [], topProjects: [], topModels: [], + aiCodeStats: emptyAiCodeStats, activityWindowDays: 14, }), "indexer:refresh": () => ({ jobId: "refresh-1" }), diff --git a/apps/desktop/src/renderer/App.test.tsx b/apps/desktop/src/renderer/App.test.tsx index a819df0..cdcb237 100644 --- a/apps/desktop/src/renderer/App.test.tsx +++ b/apps/desktop/src/renderer/App.test.tsx @@ -87,6 +87,36 @@ describe("App shell", () => { expect(countChannelCalls(client, "dashboard:getStats")).toBeGreaterThanOrEqual(1); }); + it("refreshes dashboard stats when the dashboard is reopened", async () => { + installScrollIntoViewMock(); + + const client = createAppClient(); + const user = userEvent.setup(); + + renderWithClient(, client); + + await user.click(screen.getByRole("button", { name: "Open dashboard" })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Activity Dashboard" })).toBeInTheDocument(); + }); + + const firstOpenCalls = countChannelCalls(client, "dashboard:getStats"); + + fireEvent.keyDown(window, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByRole("heading", { name: "Activity Dashboard" })).toBeNull(); + }); + + await user.click(screen.getByRole("button", { name: "Open dashboard" })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Activity Dashboard" })).toBeInTheDocument(); + expect(countChannelCalls(client, "dashboard:getStats")).toBeGreaterThan(firstOpenCalls); + }); + }); + it("compacts large message-type pill counts while keeping the exact count in the tooltip", async () => { installDialogMock(); installScrollIntoViewMock(); diff --git a/apps/desktop/src/renderer/features/DashboardView.test.tsx b/apps/desktop/src/renderer/features/DashboardView.test.tsx index fde3cc9..b5d7126 100644 --- a/apps/desktop/src/renderer/features/DashboardView.test.tsx +++ b/apps/desktop/src/renderer/features/DashboardView.test.tsx @@ -142,6 +142,113 @@ const statsFixture: DashboardStatsResponse = { messageCount: 44, }, ], + aiCodeStats: { + summary: { + writeEventCount: 5, + measurableWriteEventCount: 4, + writeSessionCount: 3, + fileChangeCount: 6, + distinctFilesTouchedCount: 5, + linesAdded: 32, + linesDeleted: 11, + netLines: 21, + multiFileWriteCount: 1, + averageFilesPerWrite: 1.5, + }, + changeTypeCounts: { + add: 2, + update: 3, + delete: 1, + }, + providerStats: [ + { + provider: "codex", + writeEventCount: 3, + fileChangeCount: 4, + linesAdded: 20, + linesDeleted: 8, + writeSessionCount: 2, + }, + { + provider: "claude", + writeEventCount: 2, + fileChangeCount: 2, + linesAdded: 12, + linesDeleted: 3, + writeSessionCount: 1, + }, + { + provider: "gemini", + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + writeSessionCount: 0, + }, + { + provider: "cursor", + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + writeSessionCount: 0, + }, + { + provider: "copilot", + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + writeSessionCount: 0, + }, + ], + recentActivity: [ + { date: "2026-03-03", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-04", writeEventCount: 1, fileChangeCount: 1, linesAdded: 4, linesDeleted: 0 }, + { date: "2026-03-05", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-06", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-07", writeEventCount: 1, fileChangeCount: 2, linesAdded: 7, linesDeleted: 2 }, + { date: "2026-03-08", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-09", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-10", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-11", writeEventCount: 1, fileChangeCount: 1, linesAdded: 8, linesDeleted: 3 }, + { date: "2026-03-12", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-13", writeEventCount: 1, fileChangeCount: 1, linesAdded: 5, linesDeleted: 1 }, + { date: "2026-03-14", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-15", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-16", writeEventCount: 1, fileChangeCount: 1, linesAdded: 8, linesDeleted: 5 }, + ], + topFiles: [ + { + filePath: "src/dashboard.tsx", + writeEventCount: 2, + linesAdded: 16, + linesDeleted: 6, + lastTouchedAt: "2026-03-16T10:05:03.000Z", + }, + { + filePath: "src/queryService.ts", + writeEventCount: 1, + linesAdded: 9, + linesDeleted: 3, + lastTouchedAt: "2026-03-11T10:05:03.000Z", + }, + ], + topFileTypes: [ + { + label: ".ts", + fileChangeCount: 4, + linesAdded: 18, + linesDeleted: 6, + }, + { + label: ".tsx", + fileChangeCount: 2, + linesAdded: 14, + linesDeleted: 5, + }, + ], + }, activityWindowDays: 14, }; @@ -153,12 +260,24 @@ describe("DashboardView", () => { expect(screen.getByRole("heading", { name: "Activity Dashboard" })).toBeInTheDocument(); expect(screen.getByText("Workspace telemetry")).toBeInTheDocument(); + expect(screen.getByText("AI Code Activity")).toBeInTheDocument(); + expect(screen.getByText("Write Velocity")).toBeInTheDocument(); + expect(screen.getByText("Change Profile")).toBeInTheDocument(); + expect(screen.getByText("Provider Write Throughput")).toBeInTheDocument(); + expect(screen.getByText("Top Written Files")).toBeInTheDocument(); + expect(screen.getByText("Top File Types")).toBeInTheDocument(); + expect(screen.getByText("Measured from 4 of 5 write events.")).toBeInTheDocument(); + expect( + screen.getByText("Some write payloads could not be fully parsed, so line totals are conservative."), + ).toBeInTheDocument(); expect(screen.getByText("Category Composition")).toBeInTheDocument(); expect(screen.getByText("Provider Throughput")).toBeInTheDocument(); expect(screen.getByText("Message Skyline")).toBeInTheDocument(); expect(screen.getByText("Where the action is")).toBeInTheDocument(); expect(screen.getByText("Most-used model signatures")).toBeInTheDocument(); expect(screen.getByText("Code Trail")).toBeInTheDocument(); + expect(screen.getByText("src/dashboard.tsx")).toBeInTheDocument(); + expect(screen.getByText(".ts")).toBeInTheDocument(); expect(screen.getAllByText("codex-gpt-5")).toHaveLength(2); expect(screen.getByText("Assistant")).toBeInTheDocument(); expect(screen.getByText("Tool Result")).toBeInTheDocument(); @@ -183,4 +302,57 @@ describe("DashboardView", () => { expect(onRefresh).toHaveBeenCalledTimes(1); }); + + it("shows an empty state when no ai write activity has been indexed", () => { + renderWithPaneFocus( + ({ + ...provider, + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + writeSessionCount: 0, + })), + recentActivity: statsFixture.aiCodeStats.recentActivity.map((point) => ({ + ...point, + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + })), + topFiles: [], + topFileTypes: [], + changeTypeCounts: { + add: 0, + update: 0, + delete: 0, + }, + }, + }} + loading={false} + error={null} + onRefresh={vi.fn()} + />, + ); + + expect(screen.getByText("No AI write activity indexed yet")).toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/renderer/features/DashboardView.tsx b/apps/desktop/src/renderer/features/DashboardView.tsx index d1df155..5436929 100644 --- a/apps/desktop/src/renderer/features/DashboardView.tsx +++ b/apps/desktop/src/renderer/features/DashboardView.tsx @@ -62,6 +62,14 @@ function formatAverage(value: number): string { return value >= 100 ? value.toFixed(0) : value.toFixed(1); } +function formatSignedInteger(value: number): string { + const rounded = Math.round(value); + if (rounded > 0) { + return `+${formatInteger(rounded)}`; + } + return formatInteger(rounded); +} + function MetricCard({ label, value, @@ -133,9 +141,63 @@ export function DashboardView({ const providerMax = useMemo(() => { return Math.max(1, ...stats.providerStats.map((provider) => provider.messageCount)); }, [stats.providerStats]); + const aiVelocityMax = useMemo(() => { + return Math.max( + 1, + ...stats.aiCodeStats.recentActivity.map((point) => point.linesAdded + point.linesDeleted), + ); + }, [stats.aiCodeStats.recentActivity]); + const aiProviderStats = useMemo(() => { + return [...stats.aiCodeStats.providerStats] + .filter((provider) => provider.writeEventCount > 0 || provider.fileChangeCount > 0) + .sort((left, right) => { + if (right.fileChangeCount !== left.fileChangeCount) { + return right.fileChangeCount - left.fileChangeCount; + } + const leftLines = left.linesAdded + left.linesDeleted; + const rightLines = right.linesAdded + right.linesDeleted; + if (rightLines !== leftLines) { + return rightLines - leftLines; + } + return left.provider.localeCompare(right.provider); + }); + }, [stats.aiCodeStats.providerStats]); + const aiProviderMax = useMemo(() => { + return Math.max(1, ...aiProviderStats.map((provider) => provider.fileChangeCount)); + }, [aiProviderStats]); + const aiChangeProfile = useMemo(() => { + const total = Math.max(1, stats.aiCodeStats.summary.fileChangeCount); + return [ + { + label: "Add", + count: stats.aiCodeStats.changeTypeCounts.add, + accent: "var(--accent-green)", + percentage: (stats.aiCodeStats.changeTypeCounts.add / total) * 100, + }, + { + label: "Update", + count: stats.aiCodeStats.changeTypeCounts.update, + accent: "var(--accent-blue)", + percentage: (stats.aiCodeStats.changeTypeCounts.update / total) * 100, + }, + { + label: "Delete", + count: stats.aiCodeStats.changeTypeCounts.delete, + accent: "var(--accent-red)", + percentage: (stats.aiCodeStats.changeTypeCounts.delete / total) * 100, + }, + ]; + }, [stats.aiCodeStats.changeTypeCounts, stats.aiCodeStats.summary.fileChangeCount]); const leadingProject = stats.topProjects[0] ?? null; const leadingModel = stats.topModels[0] ?? null; + const hasAiWriteActivity = stats.aiCodeStats.summary.writeEventCount > 0; + const hasPartialAiCoverage = + stats.aiCodeStats.summary.measurableWriteEventCount < stats.aiCodeStats.summary.writeEventCount; + const aiWriteSessionRatio = + stats.summary.sessionCount > 0 + ? (stats.aiCodeStats.summary.writeSessionCount / stats.summary.sessionCount) * 100 + : 0; return (
@@ -216,6 +278,236 @@ export function DashboardView({ /> +
+
+
+

AI code activity

+

AI Code Activity

+
+ Write-only telemetry from explicit tool edits +
+ + {!hasAiWriteActivity ? ( +
+
+
+

No write activity yet

+

No AI write activity indexed yet

+
+
+

+ Code Trail will surface write volume, file change mix, and top touched files here as + soon as indexed sessions include explicit edit tools such as patches or structured + writes. +

+
+ ) : ( + <> +
+ + + + +
+ +
+ + Measured from{" "} + {formatCompactInteger(stats.aiCodeStats.summary.measurableWriteEventCount)} of{" "} + {formatCompactInteger(stats.aiCodeStats.summary.writeEventCount)} write events. + + + {aiWriteSessionRatio.toFixed(1)}% of indexed sessions include AI writes. + + {hasPartialAiCoverage ? ( + Some write payloads could not be fully parsed, so line totals are conservative. + ) : null} +
+ +
+
+
+
+

Recent AI edits

+

Write Velocity

+
+ Last {stats.activityWindowDays} days +
+
+ {stats.aiCodeStats.recentActivity.map((point) => { + const totalLines = point.linesAdded + point.linesDeleted; + const stackHeight = `${Math.max(10, (totalLines / aiVelocityMax) * 100)}%`; + const additionShare = totalLines > 0 ? (point.linesAdded / totalLines) * 100 : 0; + const deletionShare = totalLines > 0 ? (point.linesDeleted / totalLines) * 100 : 0; + return ( +
+
+ + +
+ {point.date.slice(5)} +
+ ); + })} +
+
+ +
+
+
+
+

Change mix

+

Change Profile

+
+
+
+ {aiChangeProfile.map((item) => ( +
+
+ + {item.label} +
+
+ {formatCompactInteger(item.count)} + {item.percentage.toFixed(1)}% +
+
+ ))} +
+
+ +
+
+
+

Providers

+

Provider Write Throughput

+
+
+
+ {aiProviderStats.map((provider) => { + const width = `${Math.max(8, (provider.fileChangeCount / aiProviderMax) * 100)}%`; + return ( +
+
+
+ {prettyProvider(provider.provider)} +

+ {formatCompactInteger(provider.writeSessionCount)} sessions,{" "} + {formatCompactInteger(provider.writeEventCount)} write events +

+
+
+ {formatCompactInteger(provider.fileChangeCount)} + file changes +
+
+
+ +
+
+ +{formatCompactInteger(provider.linesAdded)} lines + -{formatCompactInteger(provider.linesDeleted)} lines +
+
+ ); + })} +
+
+
+
+ +
+
+
+
+

Top files

+

Top Written Files

+
+
+
+ {stats.aiCodeStats.topFiles.map((file, index) => ( +
+
{index + 1}
+
+
+ {compactPath(file.filePath)} +
+
+ {formatCompactInteger(file.writeEventCount)} write events + +{formatCompactInteger(file.linesAdded)} + -{formatCompactInteger(file.linesDeleted)} + + {file.lastTouchedAt ? formatDate(file.lastTouchedAt) : "No activity"} + +
+
+
+ ))} +
+
+ +
+
+
+

File types

+

Top File Types

+
+
+
+ {stats.aiCodeStats.topFileTypes.map((fileType, index) => ( +
+
+ {fileType.label} +

+ {formatCompactInteger(fileType.fileChangeCount)} file changes, + + {formatCompactInteger(fileType.linesAdded)} / - + {formatCompactInteger(fileType.linesDeleted)} +

+
+ {index + 1} +
+ ))} +
+
+
+ + )} +
+
diff --git a/apps/desktop/src/renderer/features/useDashboardController.ts b/apps/desktop/src/renderer/features/useDashboardController.ts index 6375744..9db6f11 100644 --- a/apps/desktop/src/renderer/features/useDashboardController.ts +++ b/apps/desktop/src/renderer/features/useDashboardController.ts @@ -27,6 +27,29 @@ const EMPTY_DASHBOARD_STATS: DashboardStatsResponse = { recentActivity: [], topProjects: [], topModels: [], + aiCodeStats: { + summary: { + writeEventCount: 0, + measurableWriteEventCount: 0, + writeSessionCount: 0, + fileChangeCount: 0, + distinctFilesTouchedCount: 0, + linesAdded: 0, + linesDeleted: 0, + netLines: 0, + multiFileWriteCount: 0, + averageFilesPerWrite: 0, + }, + changeTypeCounts: { + add: 0, + update: 0, + delete: 0, + }, + providerStats: [], + recentActivity: [], + topFiles: [], + topFileTypes: [], + }, activityWindowDays: 14, }; @@ -71,11 +94,11 @@ export function useDashboardController({ }, [codetrail, logError]); useEffect(() => { - if (mainView !== "dashboard" || loaded) { + if (mainView !== "dashboard") { return; } void reloadStats(); - }, [loaded, mainView, reloadStats]); + }, [mainView, reloadStats]); return { stats, diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 1f45bc5..c52100e 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -7263,7 +7263,9 @@ mark { .dashboard-summary-grid, .dashboard-main-grid, -.dashboard-secondary-grid { +.dashboard-secondary-grid, +.dashboard-ai-main-grid, +.dashboard-ai-secondary-grid { display: grid; gap: 18px; } @@ -7280,6 +7282,62 @@ mark { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.dashboard-ai-section { + display: flex; + flex-direction: column; + gap: 18px; +} + +.dashboard-section-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 14px; +} + +.dashboard-section-title { + margin: 6px 0 0; + font-size: 24px; + line-height: 1.05; + letter-spacing: -0.04em; +} + +.dashboard-ai-metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px; +} + +.dashboard-ai-note { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--border); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-surface) 92%, transparent), + color-mix(in srgb, var(--bg-elevated) 96%, transparent) + ); + color: var(--text-secondary); + font-size: 12px; +} + +.dashboard-ai-main-grid { + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.95fr); +} + +.dashboard-ai-side-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 18px; +} + +.dashboard-ai-secondary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .dashboard-card, .dashboard-metric-card, .dashboard-spotlight-card { @@ -7608,6 +7666,77 @@ mark { color: var(--text-tertiary); } +.dashboard-ai-empty-state { + min-height: 180px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.dashboard-ai-empty-copy { + margin: 0; + max-width: 62ch; + color: var(--text-secondary); + line-height: 1.7; +} + +.dashboard-ai-velocity { + height: 260px; + display: grid; + grid-template-columns: repeat(14, minmax(0, 1fr)); + gap: 10px; + align-items: end; + padding-top: 8px; +} + +.dashboard-ai-velocity-column { + min-width: 0; + height: 100%; + display: flex; + flex-direction: column; + justify-content: end; + align-items: center; + gap: 10px; +} + +.dashboard-ai-velocity-column span { + font-size: 11px; + color: var(--text-tertiary); +} + +.dashboard-ai-velocity-stack { + width: 100%; + min-height: 12px; + display: flex; + flex-direction: column; + justify-content: end; + overflow: hidden; + border-radius: 12px 12px 4px 4px; + background: color-mix(in srgb, var(--bg-elevated) 82%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 85%, transparent); +} + +.dashboard-ai-velocity-bar { + display: block; + width: 100%; +} + +.dashboard-ai-velocity-bar-add { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent-green) 78%, white), + var(--accent-teal) + ); +} + +.dashboard-ai-velocity-bar-delete { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent-red) 84%, white), + color-mix(in srgb, var(--accent-orange) 92%, var(--accent-red)) + ); +} + .dashboard-ranked-list, .dashboard-model-list { gap: 10px; @@ -7668,11 +7797,17 @@ mark { .dashboard-spotlight-card { grid-column: 1 / -1; } + + .dashboard-ai-metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 1100px) { .dashboard-main-grid, - .dashboard-secondary-grid { + .dashboard-secondary-grid, + .dashboard-ai-main-grid, + .dashboard-ai-secondary-grid { grid-template-columns: minmax(0, 1fr); } @@ -7694,9 +7829,15 @@ mark { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .dashboard-skyline { + .dashboard-skyline, + .dashboard-ai-velocity { gap: 6px; } + + .dashboard-section-heading { + align-items: flex-start; + flex-direction: column; + } } @media (max-width: 560px) { @@ -7704,6 +7845,10 @@ mark { grid-template-columns: minmax(0, 1fr); } + .dashboard-ai-metric-grid { + grid-template-columns: minmax(0, 1fr); + } + .dashboard-title { font-size: 30px; } diff --git a/apps/desktop/src/renderer/test/appTestFixtures.ts b/apps/desktop/src/renderer/test/appTestFixtures.ts index 09daaaf..f00927e 100644 --- a/apps/desktop/src/renderer/test/appTestFixtures.ts +++ b/apps/desktop/src/renderer/test/appTestFixtures.ts @@ -234,6 +234,113 @@ function createRendererClient(handlers: Record) { messageCount: 20, }, ], + aiCodeStats: { + summary: { + writeEventCount: 4, + measurableWriteEventCount: 3, + writeSessionCount: 2, + fileChangeCount: 5, + distinctFilesTouchedCount: 4, + linesAdded: 41, + linesDeleted: 13, + netLines: 28, + multiFileWriteCount: 1, + averageFilesPerWrite: 1.67, + }, + changeTypeCounts: { + add: 2, + update: 2, + delete: 1, + }, + providerStats: [ + { + provider: "codex", + writeEventCount: 3, + fileChangeCount: 4, + linesAdded: 30, + linesDeleted: 9, + writeSessionCount: 2, + }, + { + provider: "claude", + writeEventCount: 1, + fileChangeCount: 1, + linesAdded: 11, + linesDeleted: 4, + writeSessionCount: 1, + }, + { + provider: "gemini", + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + writeSessionCount: 0, + }, + { + provider: "cursor", + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + writeSessionCount: 0, + }, + { + provider: "copilot", + writeEventCount: 0, + fileChangeCount: 0, + linesAdded: 0, + linesDeleted: 0, + writeSessionCount: 0, + }, + ], + recentActivity: [ + { date: "2026-03-03", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-04", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-05", writeEventCount: 1, fileChangeCount: 1, linesAdded: 4, linesDeleted: 0 }, + { date: "2026-03-06", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-07", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-08", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-09", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-10", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-11", writeEventCount: 1, fileChangeCount: 2, linesAdded: 12, linesDeleted: 3 }, + { date: "2026-03-12", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-13", writeEventCount: 1, fileChangeCount: 1, linesAdded: 9, linesDeleted: 2 }, + { date: "2026-03-14", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-15", writeEventCount: 0, fileChangeCount: 0, linesAdded: 0, linesDeleted: 0 }, + { date: "2026-03-16", writeEventCount: 1, fileChangeCount: 1, linesAdded: 16, linesDeleted: 8 }, + ], + topFiles: [ + { + filePath: "src/dashboard.tsx", + writeEventCount: 2, + linesAdded: 18, + linesDeleted: 7, + lastTouchedAt: "2026-03-16T10:05:03.000Z", + }, + { + filePath: "src/queryService.ts", + writeEventCount: 1, + linesAdded: 12, + linesDeleted: 3, + lastTouchedAt: "2026-03-11T09:22:00.000Z", + }, + ], + topFileTypes: [ + { + label: ".ts", + fileChangeCount: 3, + linesAdded: 17, + linesDeleted: 5, + }, + { + label: ".tsx", + fileChangeCount: 2, + linesAdded: 24, + linesDeleted: 8, + }, + ], + }, activityWindowDays: 14, }; } diff --git a/apps/desktop/src/shared/aiCodeActivity.test.ts b/apps/desktop/src/shared/aiCodeActivity.test.ts new file mode 100644 index 0000000..92ea5cc --- /dev/null +++ b/apps/desktop/src/shared/aiCodeActivity.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { summarizeStoredToolEditActivity } from "./aiCodeActivity"; + +describe("summarizeStoredToolEditActivity", () => { + it("extracts multi-file apply_patch edits and line counts", () => { + const patch = [ + "*** Begin Patch", + "*** Add File: src/new.ts", + "+export const created = true;", + "+export const version = 1;", + "*** Update File: src/parser.ts", + "@@", + "-const value = old();", + "+const value = next();", + "*** End Patch", + ].join("\n"); + + const summary = summarizeStoredToolEditActivity({ + toolName: "apply_patch", + argsJson: JSON.stringify(patch), + }); + + expect(summary).toEqual({ + files: [ + { + filePath: "src/new.ts", + changeType: "add", + linesAdded: 2, + linesDeleted: 0, + }, + { + filePath: "src/parser.ts", + changeType: "update", + linesAdded: 1, + linesDeleted: 1, + }, + ], + }); + }); + + it("derives structured write metrics when no diff is present", () => { + const summary = summarizeStoredToolEditActivity({ + toolName: "str_replace", + argsJson: JSON.stringify({ + path: "src/app.ts", + old_string: "const a = 1;\nconst b = 2;\n", + new_string: "const a = 2;\nconst b = 2;\nconst c = 3;\n", + }), + }); + + expect(summary).toEqual({ + files: [ + { + filePath: "src/app.ts", + changeType: "update", + linesAdded: 2, + linesDeleted: 1, + }, + ], + }); + }); + + it("falls back to text line counts for add and delete writes without a diff", () => { + expect( + summarizeStoredToolEditActivity({ + toolName: "write_file", + argsJson: JSON.stringify({ + path: "src/new.ts", + content: "line one\nline two\n", + }), + }), + ).toEqual({ + files: [ + { + filePath: "src/new.ts", + changeType: "add", + linesAdded: 2, + linesDeleted: 0, + }, + ], + }); + + expect( + summarizeStoredToolEditActivity({ + toolName: "delete_file", + argsJson: JSON.stringify({ + path: "src/old.ts", + old_string: "line one\nline two\nline three\n", + }), + }), + ).toEqual({ + files: [ + { + filePath: "src/old.ts", + changeType: "delete", + linesAdded: 0, + linesDeleted: 3, + }, + ], + }); + }); + + it("returns null for unparseable or non-measurable payloads", () => { + expect( + summarizeStoredToolEditActivity({ + toolName: "apply_patch", + argsJson: "{", + }), + ).toBeNull(); + + expect( + summarizeStoredToolEditActivity({ + toolName: "unknown", + argsJson: JSON.stringify({ raw: "*** Begin Patch\n..." }), + }), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/shared/aiCodeActivity.ts b/apps/desktop/src/shared/aiCodeActivity.ts new file mode 100644 index 0000000..a9a9ff7 --- /dev/null +++ b/apps/desktop/src/shared/aiCodeActivity.ts @@ -0,0 +1,137 @@ +import { + buildUnifiedDiffFromTextPair, + parseToolEditPayload, + type ParsedToolEditFile, +} from "./toolParsing"; + +export type ToolEditActivityFile = { + filePath: string; + changeType: ParsedToolEditFile["changeType"]; + linesAdded: number; + linesDeleted: number; +}; + +export type ToolEditActivitySummary = { + files: ToolEditActivityFile[]; +}; + +export function summarizeStoredToolEditActivity(args: { + toolName: string | null; + argsJson: string | null; +}): ToolEditActivitySummary | null { + if (!args.argsJson) { + return null; + } + + let parsedArgs: unknown; + try { + parsedArgs = JSON.parse(args.argsJson) as unknown; + } catch { + return null; + } + + const wrappedPayload = + parsedArgs && typeof parsedArgs === "object" && !Array.isArray(parsedArgs) + ? { name: args.toolName ?? "unknown", ...(parsedArgs as Record) } + : { name: args.toolName ?? "unknown", input: parsedArgs }; + + const parsedEdit = parseToolEditPayload(JSON.stringify(wrappedPayload)); + if (!parsedEdit || parsedEdit.files.length === 0) { + return null; + } + + return { + files: parsedEdit.files + .map((file) => summarizeParsedToolEditFile(file)) + .filter((file): file is ToolEditActivityFile => file !== null), + }; +} + +function summarizeParsedToolEditFile(file: ParsedToolEditFile): ToolEditActivityFile | null { + const filePath = file.filePath.trim(); + if (filePath.length === 0) { + return null; + } + + const lineCounts = countToolEditFileLines(file); + return { + filePath, + changeType: file.changeType, + linesAdded: lineCounts.linesAdded, + linesDeleted: lineCounts.linesDeleted, + }; +} + +function countToolEditFileLines(file: ParsedToolEditFile): { + linesAdded: number; + linesDeleted: number; +} { + if (file.diff) { + return countUnifiedDiffLineChanges(file.diff); + } + + if (file.oldText !== null && file.newText !== null) { + return countUnifiedDiffLineChanges( + buildUnifiedDiffFromTextPair({ + oldText: file.oldText, + newText: file.newText, + filePath: file.filePath, + }), + ); + } + + if (file.changeType === "add" && file.newText !== null) { + return { linesAdded: countTextLines(file.newText), linesDeleted: 0 }; + } + + if (file.changeType === "delete" && file.oldText !== null) { + return { linesAdded: 0, linesDeleted: countTextLines(file.oldText) }; + } + + return { linesAdded: 0, linesDeleted: 0 }; +} + +function countUnifiedDiffLineChanges(diff: string): { + linesAdded: number; + linesDeleted: number; +} { + let linesAdded = 0; + let linesDeleted = 0; + + for (const line of diff.split(/\r?\n/)) { + if ( + line.startsWith("diff --git") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("@@") + ) { + continue; + } + if (line.startsWith("+")) { + linesAdded += 1; + continue; + } + if (line.startsWith("-")) { + linesDeleted += 1; + } + } + + return { linesAdded, linesDeleted }; +} + +function countTextLines(text: string): number { + if (text.length === 0) { + return 0; + } + const normalized = text.replace(/\r\n/g, "\n"); + let lineCount = 1; + for (const char of normalized) { + if (char === "\n") { + lineCount += 1; + } + } + if (normalized.endsWith("\n")) { + lineCount -= 1; + } + return Math.max(lineCount, 0); +} diff --git a/packages/core/src/contracts/ipc.test.ts b/packages/core/src/contracts/ipc.test.ts index 943cf6f..19493e1 100644 --- a/packages/core/src/contracts/ipc.test.ts +++ b/packages/core/src/contracts/ipc.test.ts @@ -25,6 +25,30 @@ type ChannelExample = { response: unknown; }; +const emptyAiCodeStats = { + summary: { + writeEventCount: 0, + measurableWriteEventCount: 0, + writeSessionCount: 0, + fileChangeCount: 0, + distinctFilesTouchedCount: 0, + linesAdded: 0, + linesDeleted: 0, + netLines: 0, + multiFileWriteCount: 0, + averageFilesPerWrite: 0, + }, + changeTypeCounts: { + add: 0, + update: 0, + delete: 0, + }, + providerStats: [], + recentActivity: [], + topFiles: [], + topFileTypes: [], +}; + function createClaudeHookStateExample(input: { installed: boolean }) { return createClaudeHookStateFixture({ settingsPath: "/home/user/.claude/settings.json", @@ -97,6 +121,7 @@ const channelExamples: Record = { recentActivity: [], topProjects: [], topModels: [], + aiCodeStats: emptyAiCodeStats, activityWindowDays: 14, }, }, diff --git a/packages/core/src/contracts/ipc.ts b/packages/core/src/contracts/ipc.ts index 4dbee19..6c256d6 100644 --- a/packages/core/src/contracts/ipc.ts +++ b/packages/core/src/contracts/ipc.ts @@ -186,6 +186,59 @@ const dashboardModelStatSchema = z.object({ sessionCount: z.number().int().nonnegative(), messageCount: z.number().int().nonnegative(), }); +const dashboardAiCodeSummarySchema = z.object({ + writeEventCount: z.number().int().nonnegative(), + measurableWriteEventCount: z.number().int().nonnegative(), + writeSessionCount: z.number().int().nonnegative(), + fileChangeCount: z.number().int().nonnegative(), + distinctFilesTouchedCount: z.number().int().nonnegative(), + linesAdded: z.number().int().nonnegative(), + linesDeleted: z.number().int().nonnegative(), + netLines: z.number().int(), + multiFileWriteCount: z.number().int().nonnegative(), + averageFilesPerWrite: z.number().nonnegative(), +}); +const dashboardAiCodeChangeTypeCountsSchema = z.object({ + add: z.number().int().nonnegative(), + update: z.number().int().nonnegative(), + delete: z.number().int().nonnegative(), +}); +const dashboardAiCodeProviderStatSchema = z.object({ + provider: providerSchema, + writeEventCount: z.number().int().nonnegative(), + fileChangeCount: z.number().int().nonnegative(), + linesAdded: z.number().int().nonnegative(), + linesDeleted: z.number().int().nonnegative(), + writeSessionCount: z.number().int().nonnegative(), +}); +const dashboardAiCodeActivityPointSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + writeEventCount: z.number().int().nonnegative(), + fileChangeCount: z.number().int().nonnegative(), + linesAdded: z.number().int().nonnegative(), + linesDeleted: z.number().int().nonnegative(), +}); +const dashboardAiCodeTopFileSchema = z.object({ + filePath: z.string().min(1), + writeEventCount: z.number().int().nonnegative(), + linesAdded: z.number().int().nonnegative(), + linesDeleted: z.number().int().nonnegative(), + lastTouchedAt: z.string().nullable(), +}); +const dashboardAiCodeTopFileTypeSchema = z.object({ + label: z.string().min(1), + fileChangeCount: z.number().int().nonnegative(), + linesAdded: z.number().int().nonnegative(), + linesDeleted: z.number().int().nonnegative(), +}); +const dashboardAiCodeStatsSchema = z.object({ + summary: dashboardAiCodeSummarySchema, + changeTypeCounts: dashboardAiCodeChangeTypeCountsSchema, + providerStats: z.array(dashboardAiCodeProviderStatSchema), + recentActivity: z.array(dashboardAiCodeActivityPointSchema), + topFiles: z.array(dashboardAiCodeTopFileSchema), + topFileTypes: z.array(dashboardAiCodeTopFileTypeSchema), +}); const monoFontSizeSchema = z.enum([ "10px", @@ -533,6 +586,7 @@ export const ipcContractSchemas = { recentActivity: z.array(dashboardActivityPointSchema), topProjects: z.array(dashboardProjectStatSchema), topModels: z.array(dashboardModelStatSchema), + aiCodeStats: dashboardAiCodeStatsSchema, activityWindowDays: z.number().int().positive(), }), }, From 5f670615d8e7a27a2d76306674a5fb4a10e0131b Mon Sep 17 00:00:00 2001 From: Orhan Biyiklioglu Date: Wed, 8 Apr 2026 22:24:21 +0300 Subject: [PATCH 3/4] Handle move edits in AI code activity stats Adjust the rebased AI code activity aggregation to support upstream's move change type in parsed tool edit payloads. Extend the dashboard IPC contract, default state, fixtures, and query-service rollups so move operations are counted explicitly instead of surfacing as invalid change-type totals. Update the dashboard change profile and focused tests to reflect the wider write classification model introduced on upstream/main while keeping the rebased dashboard and AI analytics behavior green. --- apps/desktop/src/main/data/queryService.test.ts | 4 +++- apps/desktop/src/main/data/queryService.ts | 1 + apps/desktop/src/main/ipc.test.ts | 1 + apps/desktop/src/renderer/features/DashboardView.test.tsx | 2 ++ apps/desktop/src/renderer/features/DashboardView.tsx | 6 ++++++ .../desktop/src/renderer/features/useDashboardController.ts | 1 + apps/desktop/src/renderer/test/appTestFixtures.ts | 1 + packages/core/src/contracts/ipc.test.ts | 1 + packages/core/src/contracts/ipc.ts | 1 + 9 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/data/queryService.test.ts b/apps/desktop/src/main/data/queryService.test.ts index 31da6a6..c42ed7b 100644 --- a/apps/desktop/src/main/data/queryService.test.ts +++ b/apps/desktop/src/main/data/queryService.test.ts @@ -479,6 +479,7 @@ describe("queryService in-memory", () => { add: 1, update: 0, delete: 0, + move: 0, }); expect(stats.aiCodeStats.providerStats.find((provider) => provider.provider === "codex")) .toMatchObject({ @@ -758,8 +759,9 @@ describe("queryService in-memory", () => { }); expect(stats.aiCodeStats.changeTypeCounts).toEqual({ add: 2, - update: 3, + update: 2, delete: 1, + move: 1, }); expect(stats.aiCodeStats.providerStats.find((provider) => provider.provider === "codex")) .toMatchObject({ diff --git a/apps/desktop/src/main/data/queryService.ts b/apps/desktop/src/main/data/queryService.ts index 73761b6..4a5403c 100644 --- a/apps/desktop/src/main/data/queryService.ts +++ b/apps/desktop/src/main/data/queryService.ts @@ -573,6 +573,7 @@ function collectDashboardAiCodeStats( add: 0, update: 0, delete: 0, + move: 0, }; const recentActivityByDate = new Map( recentDateKeys.map((date) => [ diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 082c67b..5c82ddb 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -57,6 +57,7 @@ const emptyAiCodeStats = { add: 0, update: 0, delete: 0, + move: 0, }, providerStats: [], recentActivity: [], diff --git a/apps/desktop/src/renderer/features/DashboardView.test.tsx b/apps/desktop/src/renderer/features/DashboardView.test.tsx index b5d7126..9e03223 100644 --- a/apps/desktop/src/renderer/features/DashboardView.test.tsx +++ b/apps/desktop/src/renderer/features/DashboardView.test.tsx @@ -159,6 +159,7 @@ const statsFixture: DashboardStatsResponse = { add: 2, update: 3, delete: 1, + move: 0, }, providerStats: [ { @@ -344,6 +345,7 @@ describe("DashboardView", () => { add: 0, update: 0, delete: 0, + move: 0, }, }, }} diff --git a/apps/desktop/src/renderer/features/DashboardView.tsx b/apps/desktop/src/renderer/features/DashboardView.tsx index 5436929..2366be2 100644 --- a/apps/desktop/src/renderer/features/DashboardView.tsx +++ b/apps/desktop/src/renderer/features/DashboardView.tsx @@ -186,6 +186,12 @@ export function DashboardView({ accent: "var(--accent-red)", percentage: (stats.aiCodeStats.changeTypeCounts.delete / total) * 100, }, + { + label: "Move", + count: stats.aiCodeStats.changeTypeCounts.move, + accent: "var(--accent-purple)", + percentage: (stats.aiCodeStats.changeTypeCounts.move / total) * 100, + }, ]; }, [stats.aiCodeStats.changeTypeCounts, stats.aiCodeStats.summary.fileChangeCount]); diff --git a/apps/desktop/src/renderer/features/useDashboardController.ts b/apps/desktop/src/renderer/features/useDashboardController.ts index 9db6f11..be13de1 100644 --- a/apps/desktop/src/renderer/features/useDashboardController.ts +++ b/apps/desktop/src/renderer/features/useDashboardController.ts @@ -44,6 +44,7 @@ const EMPTY_DASHBOARD_STATS: DashboardStatsResponse = { add: 0, update: 0, delete: 0, + move: 0, }, providerStats: [], recentActivity: [], diff --git a/apps/desktop/src/renderer/test/appTestFixtures.ts b/apps/desktop/src/renderer/test/appTestFixtures.ts index f00927e..a61cc10 100644 --- a/apps/desktop/src/renderer/test/appTestFixtures.ts +++ b/apps/desktop/src/renderer/test/appTestFixtures.ts @@ -251,6 +251,7 @@ function createRendererClient(handlers: Record) { add: 2, update: 2, delete: 1, + move: 0, }, providerStats: [ { diff --git a/packages/core/src/contracts/ipc.test.ts b/packages/core/src/contracts/ipc.test.ts index 19493e1..fa97db9 100644 --- a/packages/core/src/contracts/ipc.test.ts +++ b/packages/core/src/contracts/ipc.test.ts @@ -42,6 +42,7 @@ const emptyAiCodeStats = { add: 0, update: 0, delete: 0, + move: 0, }, providerStats: [], recentActivity: [], diff --git a/packages/core/src/contracts/ipc.ts b/packages/core/src/contracts/ipc.ts index 6c256d6..13c65dc 100644 --- a/packages/core/src/contracts/ipc.ts +++ b/packages/core/src/contracts/ipc.ts @@ -202,6 +202,7 @@ const dashboardAiCodeChangeTypeCountsSchema = z.object({ add: z.number().int().nonnegative(), update: z.number().int().nonnegative(), delete: z.number().int().nonnegative(), + move: z.number().int().nonnegative(), }); const dashboardAiCodeProviderStatSchema = z.object({ provider: providerSchema, From 20bdab81489093bc27f1ff233d876476da34b44e Mon Sep 17 00:00:00 2001 From: Orhan Biyiklioglu Date: Mon, 13 Apr 2026 08:29:53 +0300 Subject: [PATCH 4/4] Add OpenCode provider support across indexing and UI Introduce OpenCode as a first-class provider across discovery, parsing, indexing, search, settings, dashboard stats, and history rendering. Add a SQLite-backed OpenCode adapter that reads session data from , normalizes it through the existing provider pipeline, and preserves generic tool/edit payloads so diff rendering, tool-call indexing, and AI code activity stats work without provider-specific branches. Extend materialized-source handling so providers can use stable logical source keys while still tracking a real backing path for change detection. Add OpenCode change expansion, changed-db session cleanup, provider metadata/default roots, and provider-count/search plumbing so OpenCode participates in refresh, filtering, exports, and aggregate stats alongside the existing providers. Surface OpenCode in the desktop app through provider styling, settings labels, dashboard/provider displays, and raw edit rendering. Heal legacy saved indexing state so older default provider selections automatically enable OpenCode on startup without overriding intentional custom subsets, and cover the new behavior with discovery, parser, indexing, app-state, settings, dashboard, search, and app shell tests. --- README.md | 1 + apps/desktop/src/main/appStateStore.test.ts | 38 +- apps/desktop/src/main/appStateStore.ts | 28 +- apps/desktop/src/main/bootstrap.test.ts | 7 + apps/desktop/src/main/live/liveSnapshot.ts | 3 +- .../desktop/src/main/liveSessionStore.test.ts | 1 + apps/desktop/src/renderer/App.test.tsx | 8 + .../renderer/components/SettingsView.test.tsx | 5 + .../src/renderer/components/SettingsView.tsx | 1 + .../components/history/ProjectPane.test.tsx | 4 +- .../components/history/turnCombinedDiff.ts | 2 +- .../components/messages/toolParsing.test.ts | 30 ++ .../renderer/features/DashboardView.test.tsx | 22 +- .../renderer/features/useHistoryController.ts | 17 +- .../features/useLiveWatchController.test.ts | 2 +- .../features/useLiveWatchController.ts | 1 + .../renderer/hooks/usePaneStateSync.test.tsx | 5 + apps/desktop/src/renderer/styles.css | 57 +++ .../src/renderer/test/appTestFixtures.ts | 2 + apps/desktop/src/shared/toolParsing.ts | 6 + packages/core/src/contracts/canonical.ts | 9 +- packages/core/src/contracts/ipc.test.ts | 4 + .../core/src/contracts/providerMetadata.ts | 10 +- ...iscoverSessionFiles.pythonFixtures.test.ts | 1 + .../discovery/discoverSessionFiles.test.ts | 60 ++- .../src/discovery/discoverSessionFiles.ts | 32 ++ .../src/discovery/discoverSingleFile.test.ts | 36 ++ .../discovery/platformDiscoveryDefaults.ts | 12 + .../core/src/discovery/providers/claude.ts | 3 + .../core/src/discovery/providers/codex.ts | 1 + .../core/src/discovery/providers/copilot.ts | 1 + .../core/src/discovery/providers/cursor.ts | 1 + .../core/src/discovery/providers/gemini.ts | 1 + .../core/src/discovery/providers/opencode.ts | 402 ++++++++++++++++++ packages/core/src/discovery/types.ts | 2 + .../indexSessions.integration.test.ts | 217 +++++++++- packages/core/src/indexing/indexSessions.ts | 322 +++++++++++++- .../core/src/parsing/providerParsers.test.ts | 106 +++++ packages/core/src/parsing/providerParsers.ts | 252 +++++++++++ .../core/src/providers/adapters/opencode.ts | 48 +++ .../core/src/providers/adapters/shared.ts | 11 +- packages/core/src/providers/registry.ts | 2 + packages/core/src/providers/types.ts | 10 +- packages/core/src/search/searchMessages.ts | 5 +- packages/core/src/testing/inMemory.test.ts | 1 + .../core/src/testing/liveWatchFixtures.ts | 1 + packages/core/src/testing/opencodeFixture.ts | 161 +++++++ .../core/src/testing/settingsInfoFixture.ts | 1 + 48 files changed, 1919 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/discovery/providers/opencode.ts create mode 100644 packages/core/src/providers/adapters/opencode.ts create mode 100644 packages/core/src/testing/opencodeFixture.ts diff --git a/README.md b/README.md index 9e92e55..d1ad04a 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ Code Trail reads session files from the default provider directories: | Gemini CLI | `~/.gemini/tmp/` and `~/.gemini/history/` | | Cursor | `~/.cursor/projects/` | | VS Code Copilot | `.../Code/User/workspaceStorage/*/chatSessions/` | +| OpenCode | `~/.local/share/opencode/opencode.db` | Each session file is parsed into a canonical message format, indexed into a local SQLite database with FTS5 for full-text search, and made available through the UI. The database and settings are stored in the Electron `userData` directory, typically `~/Library/Application Support/Code Trail/` on macOS or `%APPDATA%\Code Trail\` on Windows. diff --git a/apps/desktop/src/main/appStateStore.test.ts b/apps/desktop/src/main/appStateStore.test.ts index 83f00b4..fdf7188 100644 --- a/apps/desktop/src/main/appStateStore.test.ts +++ b/apps/desktop/src/main/appStateStore.test.ts @@ -104,6 +104,7 @@ describe("AppStateStore", () => { gemini: [], cursor: [], copilot: [], + opencode: [], }, }); store.setIndexingState({ @@ -160,10 +161,11 @@ describe("AppStateStore", () => { gemini: [], cursor: [], copilot: [], + opencode: [], }, }); expect(reloaded.getIndexingState()).toEqual({ - enabledProviders: ["claude", "codex", "gemini", "cursor", "copilot"], + enabledProviders: ["claude", "codex", "gemini", "cursor", "copilot", "opencode"], removeMissingSessionsDuringIncrementalIndexing: true, }); expect(reloaded.getWindowState()).toEqual({ @@ -299,6 +301,40 @@ describe("AppStateStore", () => { expect(store.getWindowState()).toBeNull(); }); + it("heals the legacy default provider selection to include opencode", () => { + const filePath = "/tmp/codetrail-legacy-enabled-providers-ui-state.json"; + const fs = createMemoryFs({ + [filePath]: JSON.stringify({ + indexing: { + enabledProviders: ["claude", "codex", "gemini", "cursor", "copilot"], + }, + }), + }); + + const store = new AppStateStore(filePath, { fs }); + + expect(store.getIndexingState()).toEqual({ + enabledProviders: ["claude", "codex", "gemini", "cursor", "copilot", "opencode"], + }); + }); + + it("preserves intentional custom enabled-provider selections", () => { + const filePath = "/tmp/codetrail-custom-enabled-providers-ui-state.json"; + const fs = createMemoryFs({ + [filePath]: JSON.stringify({ + indexing: { + enabledProviders: ["claude", "cursor"], + }, + }), + }); + + const store = new AppStateStore(filePath, { fs }); + + expect(store.getIndexingState()).toEqual({ + enabledProviders: ["claude", "cursor"], + }); + }); + it("does not infer indexing config from pane data when indexing is absent", () => { const filePath = "/tmp/codetrail-pane-only-state.json"; const fs = createMemoryFs({ diff --git a/apps/desktop/src/main/appStateStore.ts b/apps/desktop/src/main/appStateStore.ts index c07e04b..8de80d0 100644 --- a/apps/desktop/src/main/appStateStore.ts +++ b/apps/desktop/src/main/appStateStore.ts @@ -116,6 +116,13 @@ const AUTO_REFRESH_STRATEGY_VALUES = [ "scan-5min", ] as const; const CURRENT_AUTO_REFRESH_STRATEGY_VALUES = ["off", ...AUTO_REFRESH_STRATEGY_VALUES] as const; +const LEGACY_PRE_OPENCODE_PROVIDER_VALUES = [ + "claude", + "codex", + "gemini", + "cursor", + "copilot", +] as const satisfies readonly Provider[]; const DEFAULT_FILE_SYSTEM: AppStateStoreFileSystem = { existsSync: (path) => existsSync(path), mkdirSync: (path, options) => mkdirSync(path, options), @@ -502,7 +509,9 @@ function sanitizeIndexingState(value: unknown): IndexingConfigState | null { } const record = value as Record; - const enabledProviders = sanitizeStringArray(record.enabledProviders, PROVIDER_VALUES); + const enabledProviders = healLegacyEnabledProviders( + sanitizeStringArray(record.enabledProviders, PROVIDER_VALUES), + ); const removeMissingSessionsDuringIncrementalIndexing = sanitizeOptionalBoolean( record.removeMissingSessionsDuringIncrementalIndexing, ); @@ -518,6 +527,23 @@ function sanitizeIndexingState(value: unknown): IndexingConfigState | null { }; } +function healLegacyEnabledProviders(providers: Provider[] | null): Provider[] | null { + if (providers === null) { + return null; + } + if (!matchesLegacyDefaultProviderSelection(providers)) { + return providers; + } + return addMissingProviders(providers); +} + +function matchesLegacyDefaultProviderSelection(providers: readonly Provider[]): boolean { + if (providers.length !== LEGACY_PRE_OPENCODE_PROVIDER_VALUES.length) { + return false; + } + return LEGACY_PRE_OPENCODE_PROVIDER_VALUES.every((provider) => providers.includes(provider)); +} + function sanitizeWindowState(value: unknown): WindowState | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; diff --git a/apps/desktop/src/main/bootstrap.test.ts b/apps/desktop/src/main/bootstrap.test.ts index 0d697af..f00d9a6 100644 --- a/apps/desktop/src/main/bootstrap.test.ts +++ b/apps/desktop/src/main/bootstrap.test.ts @@ -104,6 +104,9 @@ const { claude: ["^"], codex: ["^"], gemini: [], + cursor: [], + copilot: [], + opencode: [], } ); }), @@ -241,6 +244,7 @@ const { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [], claudeHookState: { @@ -291,6 +295,7 @@ vi.mock("@codetrail/core", async () => { geminiProjectsPath: null, cursorRoot: "/cursor/root", copilotRoot: "/copilot/root", + opencodeRoot: "/opencode/root", includeClaudeSubagents: false, }, initializeDatabase: mockInitializeDatabase, @@ -475,6 +480,7 @@ describe("bootstrapMainProcess", () => { gemini: [], cursor: [], copilot: [], + opencode: [], }, }; @@ -939,6 +945,7 @@ describe("bootstrapMainProcess", () => { gemini: [], cursor: [], copilot: [], + opencode: [], }, }); expect(getRequiredHandler(handlers, "indexer:getConfig")({})).toEqual({ diff --git a/apps/desktop/src/main/live/liveSnapshot.ts b/apps/desktop/src/main/live/liveSnapshot.ts index d9094cb..e97d02d 100644 --- a/apps/desktop/src/main/live/liveSnapshot.ts +++ b/apps/desktop/src/main/live/liveSnapshot.ts @@ -162,7 +162,8 @@ function providerCountsEqual( left.codex === right.codex && left.copilot === right.copilot && left.cursor === right.cursor && - left.gemini === right.gemini + left.gemini === right.gemini && + left.opencode === right.opencode ); } diff --git a/apps/desktop/src/main/liveSessionStore.test.ts b/apps/desktop/src/main/liveSessionStore.test.ts index 685092f..c501956 100644 --- a/apps/desktop/src/main/liveSessionStore.test.ts +++ b/apps/desktop/src/main/liveSessionStore.test.ts @@ -31,6 +31,7 @@ function makeConfig(dir: string): DiscoveryConfig { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, "copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, enabledProviders: ["claude", "codex"], }; diff --git a/apps/desktop/src/renderer/App.test.tsx b/apps/desktop/src/renderer/App.test.tsx index cdcb237..5cf347f 100644 --- a/apps/desktop/src/renderer/App.test.tsx +++ b/apps/desktop/src/renderer/App.test.tsx @@ -527,6 +527,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -601,6 +602,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -665,6 +667,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -1391,6 +1394,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -3095,6 +3099,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [], claudeHookState: createClaudeHookStateFixture(), @@ -3109,6 +3114,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -3210,6 +3216,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [], claudeHookState: createClaudeHookStateFixture(), @@ -3224,6 +3231,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { diff --git a/apps/desktop/src/renderer/components/SettingsView.test.tsx b/apps/desktop/src/renderer/components/SettingsView.test.tsx index ab6a2a5..1d678d4 100644 --- a/apps/desktop/src/renderer/components/SettingsView.test.tsx +++ b/apps/desktop/src/renderer/components/SettingsView.test.tsx @@ -135,6 +135,7 @@ function createBaseProps(): Omit< gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -308,6 +309,7 @@ function createBaseProps(): Omit< gemini: [], cursor: [], copilot: [], + opencode: [], }, onAddSystemMessageRegexRule: vi.fn(), onUpdateSystemMessageRegexRule: vi.fn(), @@ -353,6 +355,9 @@ describe("SettingsView", () => { expect(screen.getByText("Storage")).toBeInTheDocument(); expect(screen.getByText("Discovery Roots")).toBeInTheDocument(); expect(screen.getByText("System Message Rules")).toBeInTheDocument(); + expect(screen.getAllByText("OpenCode").length).toBeGreaterThan(0); + expect(screen.getByText("OpenCode data root")).toBeInTheDocument(); + expect(screen.getByTitle("/Users/test/.local/share/opencode")).toBeInTheDocument(); const selects = screen.getAllByRole("combobox"); expect(selects.length).toBeGreaterThanOrEqual(8); diff --git a/apps/desktop/src/renderer/components/SettingsView.tsx b/apps/desktop/src/renderer/components/SettingsView.tsx index f5a3ea2..066516d 100644 --- a/apps/desktop/src/renderer/components/SettingsView.tsx +++ b/apps/desktop/src/renderer/components/SettingsView.tsx @@ -172,6 +172,7 @@ const PROVIDER_ICONS: Record = { gemini: "G", cursor: "U", copilot: "P", + opencode: "O", }; export function SettingsView({ diff --git a/apps/desktop/src/renderer/components/history/ProjectPane.test.tsx b/apps/desktop/src/renderer/components/history/ProjectPane.test.tsx index 458309d..90b83ea 100644 --- a/apps/desktop/src/renderer/components/history/ProjectPane.test.tsx +++ b/apps/desktop/src/renderer/components/history/ProjectPane.test.tsx @@ -133,7 +133,7 @@ function createProjectPaneProps( projectQueryInput: "", projectProviders: ["claude", "codex", "gemini"], providers: ["claude", "codex", "gemini", "cursor"], - projectProviderCounts: { claude: 1, codex: 1, gemini: 1, cursor: 0, copilot: 0 }, + projectProviderCounts: { claude: 1, codex: 1, gemini: 1, cursor: 0, copilot: 0, opencode: 0 }, projectUpdates: { project_2: { messageDelta: 3, updatedAt: Date.now() } }, }; const sorting: ComponentProps["sorting"] = { @@ -1281,7 +1281,7 @@ describe("ProjectPane", () => { selectedProjectId: "", viewMode: "tree", updateSource: "auto", - projectProviderCounts: { claude: 1, codex: 1, gemini: 0, cursor: 0, copilot: 0 }, + projectProviderCounts: { claude: 1, codex: 1, gemini: 0, cursor: 0, copilot: 0, opencode: 0 }, projectUpdates: { project_1: { messageDelta: 5, updatedAt: Date.now() } }, }, actions: { diff --git a/apps/desktop/src/renderer/components/history/turnCombinedDiff.ts b/apps/desktop/src/renderer/components/history/turnCombinedDiff.ts index ca521a6..139dbbc 100644 --- a/apps/desktop/src/renderer/components/history/turnCombinedDiff.ts +++ b/apps/desktop/src/renderer/components/history/turnCombinedDiff.ts @@ -17,7 +17,7 @@ export function aggregateTurnCombinedFiles(messages: TurnCombinedMessage[]): Tur const grouped = groupEditsByFile( [ ...collectClaudeTurnEdits(messages), - ...collectRawTurnEdits(messages, { providers: ["codex", "gemini", "cursor"] }), + ...collectRawTurnEdits(messages, { providers: ["codex", "gemini", "cursor", "opencode"] }), ...collectRawTurnEdits(messages, { providers: ["copilot"], allowTouchedFileFallback: true, diff --git a/apps/desktop/src/renderer/components/messages/toolParsing.test.ts b/apps/desktop/src/renderer/components/messages/toolParsing.test.ts index c9db908..b6094e5 100644 --- a/apps/desktop/src/renderer/components/messages/toolParsing.test.ts +++ b/apps/desktop/src/renderer/components/messages/toolParsing.test.ts @@ -62,6 +62,36 @@ describe("toolParsing", () => { }); }); + it("parses OpenCode-native write and edit fields", () => { + const payload = parseToolEditPayload( + JSON.stringify({ + name: "write", + input: { + filePath: "src/opencode.ts", + oldString: "const before = 1;\n", + newString: "const after = 2;\n", + }, + }), + ); + + expect(payload).toEqual({ + filePath: "src/opencode.ts", + oldText: "const before = 1;\n", + newText: "const after = 2;\n", + diff: null, + files: [ + { + filePath: "src/opencode.ts", + previousFilePath: null, + changeType: "update", + oldText: "const before = 1;\n", + newText: "const after = 2;\n", + diff: null, + }, + ], + }); + }); + it("converts apply_patch payloads into unified diff and extracts file path", () => { const patch = [ "*** Begin Patch", diff --git a/apps/desktop/src/renderer/features/DashboardView.test.tsx b/apps/desktop/src/renderer/features/DashboardView.test.tsx index 9e03223..b649fd9 100644 --- a/apps/desktop/src/renderer/features/DashboardView.test.tsx +++ b/apps/desktop/src/renderer/features/DashboardView.test.tsx @@ -22,7 +22,7 @@ const statsFixture: DashboardStatsResponse = { totalDurationMs: 420_000, averageMessagesPerSession: 21.3, averageSessionDurationMs: 70_000, - activeProviderCount: 3, + activeProviderCount: 4, }, categoryCounts: { user: 20, @@ -37,6 +37,7 @@ const statsFixture: DashboardStatsResponse = { claude: 44, codex: 62, gemini: 22, + opencode: 14, cursor: 0, copilot: 0, }, @@ -71,6 +72,16 @@ const statsFixture: DashboardStatsResponse = { tokenOutputTotal: 136, lastActivity: "2026-03-14T10:05:03.000Z", }, + { + provider: "opencode", + projectCount: 1, + sessionCount: 1, + messageCount: 14, + toolCallCount: 3, + tokenInputTotal: 192, + tokenOutputTotal: 140, + lastActivity: "2026-03-16T08:05:03.000Z", + }, { provider: "cursor", projectCount: 0, @@ -186,6 +197,14 @@ const statsFixture: DashboardStatsResponse = { linesDeleted: 0, writeSessionCount: 0, }, + { + provider: "opencode", + writeEventCount: 1, + fileChangeCount: 1, + linesAdded: 6, + linesDeleted: 0, + writeSessionCount: 1, + }, { provider: "cursor", writeEventCount: 0, @@ -277,6 +296,7 @@ describe("DashboardView", () => { expect(screen.getByText("Where the action is")).toBeInTheDocument(); expect(screen.getByText("Most-used model signatures")).toBeInTheDocument(); expect(screen.getByText("Code Trail")).toBeInTheDocument(); + expect(screen.getAllByText("OpenCode").length).toBeGreaterThan(0); expect(screen.getByText("src/dashboard.tsx")).toBeInTheDocument(); expect(screen.getByText(".ts")).toBeInTheDocument(); expect(screen.getAllByText("codex-gpt-5")).toHaveLength(2); diff --git a/apps/desktop/src/renderer/features/useHistoryController.ts b/apps/desktop/src/renderer/features/useHistoryController.ts index 83ddff0..16cbc85 100644 --- a/apps/desktop/src/renderer/features/useHistoryController.ts +++ b/apps/desktop/src/renderer/features/useHistoryController.ts @@ -26,7 +26,10 @@ import type { SessionDetail, TreeAutoRevealSessionRequest, } from "../app/types"; -import { aggregateTurnCombinedFiles } from "../components/history/turnCombinedDiff"; +import { + aggregateTurnCombinedFiles, + type TurnCombinedMessage, +} from "../components/history/turnCombinedDiff"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { usePaneStateSync } from "../hooks/usePaneStateSync"; import { useCodetrailClient } from "../lib/codetrailClient"; @@ -57,17 +60,7 @@ const TURN_PRIMARY_HISTORY_CATEGORIES: readonly MessageCategory[] = [ "assistant", "tool_edit", ]; -function turnHasCombinedVisibleDiffs( - messages: Array<{ - category: MessageCategory; - content: string; - createdAt: string; - id: string; - sourceId: string; - provider: Provider; - toolEditFiles?: unknown; - }>, -): boolean { +function turnHasCombinedVisibleDiffs(messages: TurnCombinedMessage[]): boolean { return ( aggregateTurnCombinedFiles(messages.filter((message) => message.category !== "user")).length > 0 ); diff --git a/apps/desktop/src/renderer/features/useLiveWatchController.test.ts b/apps/desktop/src/renderer/features/useLiveWatchController.test.ts index ff8cbd3..095a2aa 100644 --- a/apps/desktop/src/renderer/features/useLiveWatchController.test.ts +++ b/apps/desktop/src/renderer/features/useLiveWatchController.test.ts @@ -16,7 +16,7 @@ function makeLiveStatusResponse( enabled: true, instrumentationEnabled: false, updatedAt: new Date().toISOString(), - providerCounts: { claude: 0, codex: 0, gemini: 0, cursor: 0, copilot: 0 }, + providerCounts: { claude: 0, codex: 0, gemini: 0, cursor: 0, copilot: 0, opencode: 0 }, sessions: [], revision: 1, claudeHookState: { diff --git a/apps/desktop/src/renderer/features/useLiveWatchController.ts b/apps/desktop/src/renderer/features/useLiveWatchController.ts index ac8f586..81bcc0e 100644 --- a/apps/desktop/src/renderer/features/useLiveWatchController.ts +++ b/apps/desktop/src/renderer/features/useLiveWatchController.ts @@ -25,6 +25,7 @@ const EMPTY_PROVIDER_COUNTS = { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, } as const; export function resolveLiveStatusPollMs(input: { diff --git a/apps/desktop/src/renderer/hooks/usePaneStateSync.test.tsx b/apps/desktop/src/renderer/hooks/usePaneStateSync.test.tsx index 6655a84..cee256b 100644 --- a/apps/desktop/src/renderer/hooks/usePaneStateSync.test.tsx +++ b/apps/desktop/src/renderer/hooks/usePaneStateSync.test.tsx @@ -169,6 +169,7 @@ function Harness({ logError }: { logError: (context: string, error: unknown) => gemini: [], cursor: [], copilot: [], + opencode: [], }); const sessionScrollTopRef = useRef(0); const pendingRestoredSessionScrollRef = useRef<{ @@ -385,6 +386,7 @@ describe("usePaneStateSync", () => { gemini: [], cursor: [], copilot: [], + opencode: [], }, }; } @@ -439,6 +441,8 @@ describe("usePaneStateSync", () => { codex: ["^"], gemini: [], cursor: [], + copilot: [], + opencode: [], }, }); const indexerSaveCalls = client.invoke.mock.calls.filter( @@ -569,6 +573,7 @@ describe("usePaneStateSync", () => { gemini: [], cursor: [], copilot: [], + opencode: [], }, }); } finally { diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index c52100e..872e2b0 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -1782,6 +1782,12 @@ body.platform-macos .titlebar-left { color: var(--accent-teal); } +.tag-opencode, +.provider-chip.provider-opencode { + background: var(--accent-red-dim); + color: var(--accent-red); +} + .tag-codex.active, .provider-chip.active.provider-codex { background: var(--accent-blue); @@ -1813,6 +1819,12 @@ body.platform-macos .titlebar-left { color: #ffffff; } +.tag-opencode.active, +.provider-chip.active.provider-opencode { + background: var(--accent-red); + color: #ffffff; +} + .list-scroll, .project-list, .session-list, @@ -2371,6 +2383,11 @@ body.platform-macos .titlebar-left { color: var(--accent-teal); } +.meta-tag.opencode { + background: var(--accent-red-dim); + color: var(--accent-red); +} + .sessions-count { font-size: 11px; font-weight: 600; @@ -3248,6 +3265,10 @@ button svg *, color: var(--accent-teal); } +.provider-label.provider-opencode { + color: var(--accent-red); +} + .message-header-actions { display: inline-flex; align-items: center; @@ -4826,6 +4847,12 @@ mark { background: color-mix(in srgb, var(--accent-teal-dim) 56%, var(--bg-elevated)); } +.search-filter-chip-provider-opencode { + color: color-mix(in srgb, var(--accent-red) 88%, var(--text-secondary)); + border-color: color-mix(in srgb, var(--accent-red) 20%, var(--border)); + background: color-mix(in srgb, var(--accent-red-dim) 56%, var(--bg-elevated)); +} + .search-filter-chip-provider.is-active { color: var(--text-primary); } @@ -4855,6 +4882,11 @@ mark { border-color: color-mix(in srgb, var(--accent-teal) 36%, transparent); } +.search-filter-chip-provider-opencode.is-active { + background: color-mix(in srgb, var(--accent-red) 18%, var(--bg-surface)); + border-color: color-mix(in srgb, var(--accent-red) 36%, transparent); +} + .search-filter-chip-category.is-active.search-filter-chip-category-user { background: var(--cat-user-bg); border-color: var(--cat-user-border); @@ -4985,6 +5017,10 @@ mark { background: var(--accent-teal); } +.search-result-card-opencode .search-result-accent { + background: var(--accent-red); +} + .search-result-content { flex: 1; min-width: 0; @@ -5036,6 +5072,11 @@ mark { color: var(--accent-teal); } +.search-result-provider-opencode { + background: color-mix(in srgb, var(--accent-red-dim) 82%, transparent); + color: var(--accent-red); +} + .search-result-category-user { background: var(--cat-user-bg); color: var(--cat-user-text); @@ -6007,6 +6048,11 @@ mark { --switch-accent-dim: var(--accent-teal-dim); } +.settings-switch-opencode { + --switch-accent: var(--accent-red); + --switch-accent-dim: var(--accent-red-dim); +} + .settings-switch input { position: absolute; inset: 0; @@ -6134,6 +6180,12 @@ mark { --provider-accent-dim: var(--accent-teal-dim); } +.settings-provider-dot-opencode, +.settings-provider-row-opencode { + --provider-accent: var(--accent-red); + --provider-accent-dim: var(--accent-red-dim); +} + .settings-provider-dot.is-active { background: var(--provider-accent); } @@ -6364,6 +6416,11 @@ mark { color: var(--accent-teal); } +.settings-provider-opencode { + background: var(--accent-red-dim); + color: var(--accent-red); +} + .settings-rule-count { font-family: var(--font-mono); font-size: 11px; diff --git a/apps/desktop/src/renderer/test/appTestFixtures.ts b/apps/desktop/src/renderer/test/appTestFixtures.ts index a61cc10..6338dba 100644 --- a/apps/desktop/src/renderer/test/appTestFixtures.ts +++ b/apps/desktop/src/renderer/test/appTestFixtures.ts @@ -24,6 +24,7 @@ const EMPTY_PROVIDER_COUNTS = { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, } as const; function cloneValue(value: T): T { @@ -131,6 +132,7 @@ function createRendererClient(handlers: Record) { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, providerStats: [ { diff --git a/apps/desktop/src/shared/toolParsing.ts b/apps/desktop/src/shared/toolParsing.ts index 031b247..8b914e0 100644 --- a/apps/desktop/src/shared/toolParsing.ts +++ b/apps/desktop/src/shared/toolParsing.ts @@ -91,19 +91,24 @@ export function parseToolEditPayload(text: string): ParsedToolEditPayload | null const args = asObject(parsed.args); const payload = input ?? args ?? parsed; const filePath = + asNonEmptyString(payload.filePath) ?? asNonEmptyString(payload.file_path) ?? asNonEmptyString(payload.path) ?? asNonEmptyString(payload.file) ?? + asNonEmptyString(parsed.filePath) ?? asNonEmptyString(parsed.file_path) ?? asNonEmptyString(parsed.path) ?? null; const oldText = + asString(payload.oldString) ?? asString(payload.old_string) ?? asString(payload.oldText) ?? asString(payload.before) ?? + asString(parsed.oldString) ?? asString(parsed.old_string) ?? null; const newText = + asString(payload.newString) ?? asString(payload.new_string) ?? asString(payload.newText) ?? asString(payload.after) ?? @@ -111,6 +116,7 @@ export function parseToolEditPayload(text: string): ParsedToolEditPayload | null asString(payload.text) ?? asString(payload.write_content) ?? asString(payload.new_content) ?? + asString(parsed.newString) ?? asString(parsed.new_string) ?? null; const diff = diff --git a/packages/core/src/contracts/canonical.ts b/packages/core/src/contracts/canonical.ts index 07ed864..dae801e 100644 --- a/packages/core/src/contracts/canonical.ts +++ b/packages/core/src/contracts/canonical.ts @@ -1,6 +1,13 @@ import { z } from "zod"; -export const providerSchema = z.enum(["claude", "codex", "gemini", "cursor", "copilot"]); +export const providerSchema = z.enum([ + "claude", + "codex", + "gemini", + "cursor", + "copilot", + "opencode", +]); export type Provider = z.infer; export const PROVIDER_VALUES = providerSchema.options; diff --git a/packages/core/src/contracts/ipc.test.ts b/packages/core/src/contracts/ipc.test.ts index fa97db9..dd8838e 100644 --- a/packages/core/src/contracts/ipc.test.ts +++ b/packages/core/src/contracts/ipc.test.ts @@ -117,6 +117,7 @@ const channelExamples: Record = { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, providerStats: [], recentActivity: [], @@ -347,6 +348,7 @@ const channelExamples: Record = { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, results: [], }, @@ -526,6 +528,7 @@ const channelExamples: Record = { gemini: [], cursor: [], copilot: [], + opencode: [], }, }, response: { ok: true }, @@ -596,6 +599,7 @@ const channelExamples: Record = { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { diff --git a/packages/core/src/contracts/providerMetadata.ts b/packages/core/src/contracts/providerMetadata.ts index cd4e637..526ee6f 100644 --- a/packages/core/src/contracts/providerMetadata.ts +++ b/packages/core/src/contracts/providerMetadata.ts @@ -9,7 +9,8 @@ export type ProviderDiscoveryPathKey = | "geminiHistoryRoot" | "geminiProjectsPath" | "cursorRoot" - | "copilotRoot"; + | "copilotRoot" + | "opencodeRoot"; export type ProviderDiscoveryPathDefinition = { key: ProviderDiscoveryPathKey; @@ -72,6 +73,13 @@ export const PROVIDER_METADATA: Record = { discoveryPaths: [{ key: "copilotRoot", label: "Copilot root", watch: true }], defaultSystemMessageRegexRules: [], }, + opencode: { + id: "opencode", + label: "OpenCode", + sourceFormat: "materialized_json", + discoveryPaths: [{ key: "opencodeRoot", label: "OpenCode data root", watch: true }], + defaultSystemMessageRegexRules: [], + }, }; export const PROVIDER_LIST: ProviderMetadata[] = PROVIDER_VALUES.map( diff --git a/packages/core/src/discovery/discoverSessionFiles.pythonFixtures.test.ts b/packages/core/src/discovery/discoverSessionFiles.pythonFixtures.test.ts index 26b0cbb..dea4d4f 100644 --- a/packages/core/src/discovery/discoverSessionFiles.pythonFixtures.test.ts +++ b/packages/core/src/discovery/discoverSessionFiles.pythonFixtures.test.ts @@ -15,6 +15,7 @@ describe("discoverSessionFiles python fixtures", () => { geminiProjectsPath: join(fixturesRoot, "gemini", "projects.json"), cursorRoot: join(fixturesRoot, "cursor", "projects"), copilotRoot: join(fixturesRoot, "copilot", "workspaceStorage"), + opencodeRoot: join(fixturesRoot, "opencode"), includeClaudeSubagents: false, }); diff --git a/packages/core/src/discovery/discoverSessionFiles.test.ts b/packages/core/src/discovery/discoverSessionFiles.test.ts index 760c88f..c3e72e0 100644 --- a/packages/core/src/discovery/discoverSessionFiles.test.ts +++ b/packages/core/src/discovery/discoverSessionFiles.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { discoverSessionFiles } from "./discoverSessionFiles"; +import { createOpenCodeFixtureDatabase } from "../testing/opencodeFixture"; +import { discoverChangedFiles, discoverSessionFiles } from "./discoverSessionFiles"; describe("discoverSessionFiles", () => { it("discovers provider session files with configured parity rules", () => { @@ -114,6 +115,7 @@ describe("discoverSessionFiles", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: true, }); @@ -144,6 +146,7 @@ describe("discoverSessionFiles", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }); @@ -156,6 +159,56 @@ describe("discoverSessionFiles", () => { rmSync(dir, { recursive: true, force: true }); }); + it("expands OpenCode database changes into session sources", () => { + const dir = mkdtempSync(join(tmpdir(), "codetrail-discovery-opencode-")); + const opencodeRoot = join(dir, ".local", "share", "opencode"); + const { dbPath } = createOpenCodeFixtureDatabase({ + rootDir: opencodeRoot, + sessions: [ + { + id: "ses-open-1", + directory: "/workspace/opencode-one", + title: "Session One", + version: "1.4.2", + timeCreated: 1_775_765_274_774, + timeUpdated: 1_775_766_558_747, + messages: [], + }, + { + id: "ses-open-2", + directory: "/workspace/opencode-two", + title: "Session Two", + version: "1.4.2", + timeCreated: 1_775_765_274_775, + timeUpdated: 1_775_766_558_748, + messages: [], + }, + ], + }); + + const discovered = discoverChangedFiles(dbPath, { + claudeRoot: join(dir, ".claude", "projects"), + codexRoot: join(dir, ".codex", "sessions"), + geminiRoot: join(dir, ".gemini", "tmp"), + geminiHistoryRoot: join(dir, ".gemini", "history"), + geminiProjectsPath: join(dir, ".gemini", "projects.json"), + cursorRoot: join(dir, ".cursor", "projects"), + copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot, + includeClaudeSubagents: false, + }); + + expect(discovered).toHaveLength(2); + expect(discovered.every((file) => file.provider === "opencode")).toBe(true); + expect(discovered.map((file) => file.backingFilePath)).toEqual([dbPath, dbPath]); + expect(discovered.map((file) => file.sourceSessionId).sort()).toEqual([ + "ses-open-1", + "ses-open-2", + ]); + + rmSync(dir, { recursive: true, force: true }); + }); + it("discovers cursor sessions using terminal cwd and marks unresolved project paths", () => { const dir = mkdtempSync(join(tmpdir(), "codetrail-discovery-cursor-")); const cursorRoot = join(dir, ".cursor", "projects"); @@ -210,6 +263,7 @@ describe("discoverSessionFiles", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot, copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }); @@ -284,6 +338,7 @@ describe("discoverSessionFiles", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot, + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }); @@ -315,6 +370,7 @@ describe("discoverSessionFiles", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, "nonexistent-copilot-root"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }); @@ -341,6 +397,7 @@ describe("discoverSessionFiles", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot, + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }); @@ -420,6 +477,7 @@ describe("discoverSessionFiles", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, "workspaceStorage"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }); diff --git a/packages/core/src/discovery/discoverSessionFiles.ts b/packages/core/src/discovery/discoverSessionFiles.ts index a4088fb..8ada275 100644 --- a/packages/core/src/discovery/discoverSessionFiles.ts +++ b/packages/core/src/discovery/discoverSessionFiles.ts @@ -138,6 +138,38 @@ export function discoverSingleFile( return null; } +export function discoverChangedFiles( + filePath: string, + config: DiscoveryConfig, + dependencies: DiscoveryDependencies = {}, +): DiscoveredSessionFile[] { + const resolvedDependencies = resolveDiscoveryDependencies(dependencies); + const resolvedConfig = resolveDiscoveryConfig(config); + const discovered: DiscoveredSessionFile[] = []; + + for (const provider of PROVIDER_ADAPTER_LIST) { + if (!resolvedConfig.enabledProviders.includes(provider.id)) { + continue; + } + + if (provider.discoverChanged) { + discovered.push(...provider.discoverChanged(filePath, resolvedConfig, resolvedDependencies)); + continue; + } + + const single = provider.discoverOne(filePath, resolvedConfig, resolvedDependencies); + if (single) { + discovered.push(single); + } + } + + const deduped = new Map(); + for (const item of discovered) { + deduped.set(item.filePath, item); + } + return [...deduped.values()]; +} + export function listDiscoverySettingsPaths( config: DiscoveryConfig = DEFAULT_DISCOVERY_CONFIG, ): DiscoverySettingsPath[] { diff --git a/packages/core/src/discovery/discoverSingleFile.test.ts b/packages/core/src/discovery/discoverSingleFile.test.ts index e1b9536..2974e72 100644 --- a/packages/core/src/discovery/discoverSingleFile.test.ts +++ b/packages/core/src/discovery/discoverSingleFile.test.ts @@ -17,6 +17,8 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { buildOpenCodeSessionSourceKey } from "./providers/opencode"; +import { createOpenCodeFixtureDatabase } from "../testing/opencodeFixture"; import { discoverSingleFile } from "./discoverSessionFiles"; import type { DiscoveryConfig } from "./types"; @@ -29,6 +31,7 @@ function makeConfig(dir: string): DiscoveryConfig { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, "copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }; } @@ -130,6 +133,39 @@ describe("discoverSingleFile", () => { rmSync(dir, { recursive: true, force: true }); }); + it("discovers an OpenCode session from its synthetic source key", () => { + const dir = mkdtempSync(join(tmpdir(), "codetrail-single-opencode-")); + const config = makeConfig(dir); + const { dbPath } = createOpenCodeFixtureDatabase({ + rootDir: config.opencodeRoot ?? join(dir, ".local", "share", "opencode"), + sessions: [ + { + id: "ses-opencode-1", + directory: "/workspace/opencode-app", + title: "Implement feature", + version: "1.4.2", + timeCreated: 1_775_765_274_774, + timeUpdated: 1_775_766_558_747, + messages: [], + }, + ], + }); + + const sourceKey = buildOpenCodeSessionSourceKey(dbPath, "ses-opencode-1"); + const result = discoverSingleFile(sourceKey, config); + + const discovered = expectDefined(result, "Expected OpenCode session result"); + expect(discovered.provider).toBe("opencode"); + expect(discovered.filePath).toBe(sourceKey); + expect(discovered.backingFilePath).toBe(dbPath); + expect(discovered.sourceSessionId).toBe("ses-opencode-1"); + expect(discovered.projectPath).toBe("/workspace/opencode-app"); + expect(discovered.metadata.providerClient).toBe("OpenCode"); + expect(discovered.metadata.providerClientVersion).toBe("1.4.2"); + + rmSync(dir, { recursive: true, force: true }); + }); + it("prefers Claude transcript cwd over decoded folder names when sessions-index is missing", () => { const dir = mkdtempSync(join(tmpdir(), "codetrail-single-claude-worktree-cwd-")); const config = makeConfig(dir); diff --git a/packages/core/src/discovery/platformDiscoveryDefaults.ts b/packages/core/src/discovery/platformDiscoveryDefaults.ts index 1cd2da1..627810c 100644 --- a/packages/core/src/discovery/platformDiscoveryDefaults.ts +++ b/packages/core/src/discovery/platformDiscoveryDefaults.ts @@ -39,6 +39,17 @@ export function getDefaultCopilotRoot( return join(homeDir, ".config", "Code", "User", "workspaceStorage"); } +export function getDefaultOpenCodeRoot( + platform: DiscoveryPlatform, + environment: DiscoveryPlatformEnvironment = {}, +): string { + const homeDir = environment.homeDir ?? homedir(); + if (platform === "win32") { + return join(environment.appDataDir ?? join(homeDir, "AppData", "Local"), "opencode"); + } + return join(homeDir, ".local", "share", "opencode"); +} + export function createDefaultDiscoveryConfig( platform: DiscoveryPlatform, environment: DiscoveryPlatformEnvironment = {}, @@ -52,6 +63,7 @@ export function createDefaultDiscoveryConfig( geminiProjectsPath: join(homeDir, ".gemini", "projects.json"), cursorRoot: join(homeDir, ".cursor", "projects"), copilotRoot: getDefaultCopilotRoot(platform, environment), + opencodeRoot: getDefaultOpenCodeRoot(platform, environment), includeClaudeSubagents: false, enabledProviders: [...PROVIDER_VALUES], }; diff --git a/packages/core/src/discovery/providers/claude.ts b/packages/core/src/discovery/providers/claude.ts index 002cac7..95b26d0 100644 --- a/packages/core/src/discovery/providers/claude.ts +++ b/packages/core/src/discovery/providers/claude.ts @@ -111,6 +111,7 @@ export function discoverClaudeFiles( sessionIdentity, sourceSessionId: sessionIdentity, filePath, + backingFilePath: filePath, fileSize: fileStat.size, fileMtimeMs: Math.trunc(fileStat.mtimeMs), metadata: { @@ -185,6 +186,7 @@ export function discoverClaudeFiles( sessionIdentity, sourceSessionId: parentSessionId, filePath, + backingFilePath: filePath, fileSize: fileStat.size, fileMtimeMs: Math.trunc(fileStat.mtimeMs), metadata: { @@ -269,6 +271,7 @@ export function discoverSingleClaudeFile( sessionIdentity, sourceSessionId: sessionIdentity, filePath, + backingFilePath: filePath, fileSize: fileStat.size, fileMtimeMs: Math.trunc(fileStat.mtimeMs), metadata: { diff --git a/packages/core/src/discovery/providers/codex.ts b/packages/core/src/discovery/providers/codex.ts index 7eda6f1..71d1c67 100644 --- a/packages/core/src/discovery/providers/codex.ts +++ b/packages/core/src/discovery/providers/codex.ts @@ -42,6 +42,7 @@ function toDiscoveredCodexFile( sessionIdentity, sourceSessionId, filePath, + backingFilePath: filePath, fileSize: fileStat.size, fileMtimeMs: Math.trunc(fileStat.mtimeMs), metadata: { diff --git a/packages/core/src/discovery/providers/copilot.ts b/packages/core/src/discovery/providers/copilot.ts index 67f47ad..a054dad 100644 --- a/packages/core/src/discovery/providers/copilot.ts +++ b/packages/core/src/discovery/providers/copilot.ts @@ -79,6 +79,7 @@ function toDiscoveredCopilotFile( sessionIdentity, sourceSessionId, filePath, + backingFilePath: filePath, fileSize: fileStat.size, fileMtimeMs: Math.trunc(fileStat.mtimeMs), metadata: { diff --git a/packages/core/src/discovery/providers/cursor.ts b/packages/core/src/discovery/providers/cursor.ts index 03b0405..1335134 100644 --- a/packages/core/src/discovery/providers/cursor.ts +++ b/packages/core/src/discovery/providers/cursor.ts @@ -118,6 +118,7 @@ function toDiscoveredCursorFile( sessionIdentity, sourceSessionId: uuid, filePath, + backingFilePath: filePath, fileSize: fileStat.size, fileMtimeMs: Math.trunc(fileStat.mtimeMs), metadata: { diff --git a/packages/core/src/discovery/providers/gemini.ts b/packages/core/src/discovery/providers/gemini.ts index b6729f1..61d5a5e 100644 --- a/packages/core/src/discovery/providers/gemini.ts +++ b/packages/core/src/discovery/providers/gemini.ts @@ -87,6 +87,7 @@ function toDiscoveredGeminiFile( sessionIdentity, sourceSessionId, filePath, + backingFilePath: filePath, fileSize: fileStat.size, fileMtimeMs: Math.trunc(fileStat.mtimeMs), metadata: { diff --git a/packages/core/src/discovery/providers/opencode.ts b/packages/core/src/discovery/providers/opencode.ts new file mode 100644 index 0000000..7f0e898 --- /dev/null +++ b/packages/core/src/discovery/providers/opencode.ts @@ -0,0 +1,402 @@ +import { basename, join } from "node:path"; + +import Database from "better-sqlite3"; + +import { compactMetadata } from "../../metadata"; +import { + type ProviderReadSourceResult, + type ProviderSource, + type ReadFileText, +} from "../../providers/types"; +import { readString } from "../../parsing/helpers"; +import { + type ResolvedDiscoveryDependencies, + getDiscoveryPath, + projectNameFromPath, + providerSessionIdentity, +} from "../shared"; +import type { DiscoveredSessionFile, ResolvedDiscoveryConfig } from "../types"; + +const OPENCODE_DB_FILENAME = "opencode.db"; +const OPENCODE_SOURCE_PREFIX = "opencode:"; + +type OpenCodeDiscoveryRow = { + session_id: string; + project_id: string; + parent_id: string | null; + directory: string; + title: string; + version: string; + time_created: number; + time_updated: number; + project_name: string | null; + worktree: string | null; + payload_bytes: number | null; +}; + +type OpenCodeSessionRow = { + session_id: string; + project_id: string; + parent_id: string | null; + slug: string; + directory: string; + title: string; + version: string; + time_created: number; + time_updated: number; + time_archived: number | null; + workspace_id: string | null; + project_name: string | null; + worktree: string | null; + project_time_created: number | null; + project_time_updated: number | null; +}; + +type OpenCodeMessageRow = { + id: string; + session_id: string; + time_created: number; + time_updated: number; + data: string; +}; + +type OpenCodePartRow = { + id: string; + message_id: string; + session_id: string; + time_created: number; + time_updated: number; + data: string; +}; + +export function buildOpenCodeDatabasePath(root: string): string { + return join(root, OPENCODE_DB_FILENAME); +} + +export function buildOpenCodeSessionSourceKey(dbPath: string, sessionId: string): string { + return `${OPENCODE_SOURCE_PREFIX}${dbPath}:${sessionId}`; +} + +export function buildOpenCodeSessionSourcePrefix(dbPath: string): string { + return `${OPENCODE_SOURCE_PREFIX}${dbPath}:`; +} + +export function parseOpenCodeSessionSourceKey(sourceKey: string): { + dbPath: string; + sessionId: string; +} | null { + if (!sourceKey.startsWith(OPENCODE_SOURCE_PREFIX)) { + return null; + } + + const remainder = sourceKey.slice(OPENCODE_SOURCE_PREFIX.length); + const separator = remainder.lastIndexOf(":"); + if (separator <= 0 || separator === remainder.length - 1) { + return null; + } + + return { + dbPath: remainder.slice(0, separator), + sessionId: remainder.slice(separator + 1), + }; +} + +export function normalizeOpenCodeDatabasePath( + changedPath: string, + opencodeRoot: string, +): string | null { + const dbPath = buildOpenCodeDatabasePath(opencodeRoot); + if ( + changedPath === dbPath || + changedPath === `${dbPath}-wal` || + changedPath === `${dbPath}-shm` + ) { + return dbPath; + } + return null; +} + +function openReadOnlyDatabase(dbPath: string): InstanceType { + return new Database(dbPath, { readonly: true, fileMustExist: true }); +} + +function parseJsonObject(text: string): Record | null { + try { + const parsed = JSON.parse(text) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function readOpenCodeDiscoveryRows( + dbPath: string, + dependencies: ResolvedDiscoveryDependencies, + sessionId?: string, +): OpenCodeDiscoveryRow[] { + let db: InstanceType | null = null; + try { + db = openReadOnlyDatabase(dbPath); + const query = db.prepare( + `SELECT + s.id AS session_id, + s.project_id AS project_id, + s.parent_id AS parent_id, + s.directory AS directory, + s.title AS title, + s.version AS version, + s.time_created AS time_created, + s.time_updated AS time_updated, + p.name AS project_name, + p.worktree AS worktree, + COALESCE((SELECT SUM(LENGTH(m.data)) FROM message m WHERE m.session_id = s.id), 0) + + COALESCE((SELECT SUM(LENGTH(prt.data)) FROM part prt WHERE prt.session_id = s.id), 0) AS payload_bytes + FROM session s + LEFT JOIN project p ON p.id = s.project_id + WHERE (? IS NULL OR s.id = ?) + ORDER BY s.time_updated DESC, s.id DESC`, + ); + return query.all(sessionId ?? null, sessionId ?? null) as OpenCodeDiscoveryRow[]; + } catch (error) { + dependencies.onDiscoveryIssue({ operation: "readFile", path: dbPath, error }); + return []; + } finally { + db?.close(); + } +} + +function toDiscoveredOpenCodeSession( + row: OpenCodeDiscoveryRow, + dbPath: string, +): DiscoveredSessionFile { + const projectPath = row.directory || row.worktree || ""; + const sourceKey = buildOpenCodeSessionSourceKey(dbPath, row.session_id); + const sessionIdentity = providerSessionIdentity("opencode", row.session_id, sourceKey); + const unresolvedProject = projectPath.length === 0; + const projectName = + !unresolvedProject && projectPath.length > 0 + ? projectNameFromPath(projectPath) + : row.project_name || basename(row.directory) || "Unknown"; + const resolutionSource = row.directory + ? "session_directory" + : row.worktree + ? "project_worktree" + : "unresolved"; + + return { + provider: "opencode", + projectPath, + canonicalProjectPath: projectPath, + projectName, + sessionIdentity, + sourceSessionId: row.session_id, + filePath: sourceKey, + backingFilePath: dbPath, + fileSize: Math.max(0, Number(row.payload_bytes ?? 0)), + fileMtimeMs: Math.max(0, Number(row.time_updated ?? row.time_created ?? 0)), + metadata: { + includeInHistory: true, + isSubagent: false, + unresolvedProject, + gitBranch: null, + cwd: row.directory || null, + worktreeLabel: null, + worktreeSource: null, + repositoryUrl: null, + forkedFromSessionId: row.parent_id, + parentSessionCwd: null, + providerProjectKey: row.project_id, + providerSessionId: row.session_id, + sessionKind: row.parent_id ? "forked" : "regular", + gitCommitHash: null, + providerClient: "OpenCode", + providerSource: null, + providerClientVersion: row.version || null, + lineageParentId: row.parent_id, + resolutionSource, + projectMetadata: null, + sessionMetadata: compactMetadata({ + title: row.title || undefined, + }), + }, + }; +} + +function discoverOpenCodeSessionsFromDb( + dbPath: string, + dependencies: ResolvedDiscoveryDependencies, + sessionId?: string, +): DiscoveredSessionFile[] { + if (!dependencies.fs.existsSync(dbPath)) { + return []; + } + return readOpenCodeDiscoveryRows(dbPath, dependencies, sessionId).map((row) => + toDiscoveredOpenCodeSession(row, dbPath), + ); +} + +export function discoverOpenCodeFiles( + config: ResolvedDiscoveryConfig, + dependencies: ResolvedDiscoveryDependencies, +): DiscoveredSessionFile[] { + const opencodeRoot = getDiscoveryPath(config, "opencode", "opencodeRoot"); + if (!opencodeRoot) { + return []; + } + return discoverOpenCodeSessionsFromDb(buildOpenCodeDatabasePath(opencodeRoot), dependencies); +} + +export function discoverSingleOpenCodeFile( + filePath: string, + config: ResolvedDiscoveryConfig, + dependencies: ResolvedDiscoveryDependencies, +): DiscoveredSessionFile | null { + const parsed = parseOpenCodeSessionSourceKey(filePath); + const opencodeRoot = getDiscoveryPath(config, "opencode", "opencodeRoot"); + const configuredDbPath = opencodeRoot ? buildOpenCodeDatabasePath(opencodeRoot) : null; + if (!parsed || !configuredDbPath || parsed.dbPath !== configuredDbPath) { + return null; + } + + return ( + discoverOpenCodeSessionsFromDb(parsed.dbPath, dependencies, parsed.sessionId)[0] ?? null + ); +} + +export function discoverChangedOpenCodeFiles( + filePath: string, + config: ResolvedDiscoveryConfig, + dependencies: ResolvedDiscoveryDependencies, +): DiscoveredSessionFile[] { + const parsed = parseOpenCodeSessionSourceKey(filePath); + if (parsed) { + return discoverOpenCodeSessionsFromDb(parsed.dbPath, dependencies, parsed.sessionId); + } + + const opencodeRoot = getDiscoveryPath(config, "opencode", "opencodeRoot"); + if (!opencodeRoot) { + return []; + } + + const dbPath = normalizeOpenCodeDatabasePath(filePath, opencodeRoot); + if (!dbPath) { + return []; + } + + return discoverOpenCodeSessionsFromDb(dbPath, dependencies); +} + +export function readOpenCodeSource( + discovered: DiscoveredSessionFile, + _readFileText: ReadFileText, +): ProviderReadSourceResult | null { + const parsed = parseOpenCodeSessionSourceKey(discovered.filePath); + const dbPath = parsed?.dbPath ?? discovered.backingFilePath; + const sessionId = parsed?.sessionId ?? discovered.sourceSessionId; + if (!dbPath) { + return null; + } + let db: InstanceType | null = null; + + try { + db = openReadOnlyDatabase(dbPath); + const sessionRow = db + .prepare( + `SELECT + s.id AS session_id, + s.project_id AS project_id, + s.parent_id AS parent_id, + s.slug AS slug, + s.directory AS directory, + s.title AS title, + s.version AS version, + s.time_created AS time_created, + s.time_updated AS time_updated, + s.time_archived AS time_archived, + s.workspace_id AS workspace_id, + p.name AS project_name, + p.worktree AS worktree, + p.time_created AS project_time_created, + p.time_updated AS project_time_updated + FROM session s + LEFT JOIN project p ON p.id = s.project_id + WHERE s.id = ?`, + ) + .get(sessionId) as OpenCodeSessionRow | undefined; + + if (!sessionRow) { + return null; + } + + const messageRows = db + .prepare( + `SELECT id, session_id, time_created, time_updated, data + FROM message + WHERE session_id = ? + ORDER BY time_created ASC, id ASC`, + ) + .all(sessionId) as OpenCodeMessageRow[]; + + const partRows = db + .prepare( + `SELECT id, message_id, session_id, time_created, time_updated, data + FROM part + WHERE session_id = ? + ORDER BY time_created ASC, id ASC`, + ) + .all(sessionId) as OpenCodePartRow[]; + + const partsByMessageId = new Map>>(); + for (const partRow of partRows) { + const list = partsByMessageId.get(partRow.message_id) ?? []; + list.push({ + id: partRow.id, + messageId: partRow.message_id, + sessionId: partRow.session_id, + timeCreated: partRow.time_created, + timeUpdated: partRow.time_updated, + data: parseJsonObject(partRow.data) ?? {}, + }); + partsByMessageId.set(partRow.message_id, list); + } + + return { + payload: { + session: { + id: sessionRow.session_id, + projectId: sessionRow.project_id, + parentId: sessionRow.parent_id, + slug: sessionRow.slug, + directory: sessionRow.directory, + title: sessionRow.title, + version: sessionRow.version, + timeCreated: sessionRow.time_created, + timeUpdated: sessionRow.time_updated, + timeArchived: sessionRow.time_archived, + workspaceId: sessionRow.workspace_id, + }, + project: { + id: sessionRow.project_id, + name: sessionRow.project_name, + worktree: sessionRow.worktree, + timeCreated: sessionRow.project_time_created, + timeUpdated: sessionRow.project_time_updated, + }, + messages: messageRows.map((messageRow) => ({ + id: messageRow.id, + sessionId: messageRow.session_id, + timeCreated: messageRow.time_created, + timeUpdated: messageRow.time_updated, + data: parseJsonObject(messageRow.data) ?? {}, + parts: partsByMessageId.get(messageRow.id) ?? [], + })), + } as ProviderSource, + }; + } catch { + return null; + } finally { + db?.close(); + } +} diff --git a/packages/core/src/discovery/types.ts b/packages/core/src/discovery/types.ts index 6d263b0..8cbf0e9 100644 --- a/packages/core/src/discovery/types.ts +++ b/packages/core/src/discovery/types.ts @@ -19,6 +19,7 @@ export type DiscoveredSessionFile = { sessionIdentity: string; sourceSessionId: string; filePath: string; + backingFilePath?: string | null; fileSize: number; fileMtimeMs: number; metadata: { @@ -54,6 +55,7 @@ export type DiscoveryConfig = { geminiProjectsPath?: string; cursorRoot: string; copilotRoot: string; + opencodeRoot: string; includeClaudeSubagents: boolean; enabledProviders?: Provider[]; }; diff --git a/packages/core/src/indexing/indexSessions.integration.test.ts b/packages/core/src/indexing/indexSessions.integration.test.ts index dee3c12..97f9b89 100644 --- a/packages/core/src/indexing/indexSessions.integration.test.ts +++ b/packages/core/src/indexing/indexSessions.integration.test.ts @@ -13,8 +13,9 @@ import { describe, expect, it, vi } from "vitest"; import { openDatabase } from "../db/bootstrap"; import type { DiscoveredSessionFile, DiscoveryConfig } from "../discovery"; +import { createOpenCodeFixtureDatabase } from "../testing/opencodeFixture"; import { makeSessionId } from "./ids"; -import { runIncrementalIndexing } from "./indexSessions"; +import { indexChangedFiles, runIncrementalIndexing } from "./indexSessions"; function createDiscoveryConfig(dir: string): DiscoveryConfig { return { @@ -25,6 +26,7 @@ function createDiscoveryConfig(dir: string): DiscoveryConfig { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }; } @@ -45,6 +47,7 @@ function makeDiscoveredSessionFile( sessionIdentity: overrides.sessionIdentity ?? `${overrides.provider}:session:test`, sourceSessionId: overrides.sourceSessionId ?? "session", filePath: overrides.filePath, + backingFilePath: overrides.backingFilePath ?? overrides.filePath, fileSize: overrides.fileSize ?? 1, fileMtimeMs: overrides.fileMtimeMs ?? Date.now(), metadata: { @@ -169,6 +172,206 @@ function tombstoneSession(dbPath: string, filePath: string): void { db.close(); } +describe("OpenCode indexing", () => { + it("indexes OpenCode sessions, tool calls, and editable file changes", () => { + const dir = mkdtempSync(join(tmpdir(), "codetrail-opencode-index-")); + const config = createDiscoveryConfig(dir); + const { dbPath: sourceDbPath } = createOpenCodeFixtureDatabase({ + rootDir: config.opencodeRoot ?? join(dir, ".local", "share", "opencode"), + sessions: [ + { + id: "ses-open-1", + directory: "/workspace/opencode-app", + title: "Implement OpenCode provider", + version: "1.4.2", + timeCreated: 1_775_765_274_774, + timeUpdated: 1_775_766_558_747, + messages: [ + { + id: "msg-user-1", + timeCreated: 1_775_765_274_798, + data: { + role: "user", + time: { created: 1_775_765_274_798 }, + model: { providerID: "ollama", modelID: "glm-5.1:cloud" }, + }, + parts: [{ type: "text", text: "Build the provider." }], + }, + { + id: "msg-assistant-1", + timeCreated: 1_775_765_274_808, + timeUpdated: 1_775_765_279_366, + data: { + role: "assistant", + providerID: "ollama", + modelID: "glm-5.1:cloud", + path: { cwd: "/workspace/opencode-app", root: "/" }, + tokens: { input: 21, output: 13 }, + time: { created: 1_775_765_274_808, completed: 1_775_765_279_366 }, + }, + parts: [ + { type: "reasoning", text: "I should inspect and then write the file." }, + { + type: "tool", + tool: "read", + callID: "call-read-1", + state: { + status: "completed", + input: { filePath: "/workspace/opencode-app/README.md" }, + output: "README", + time: { start: 1_775_765_274_900, end: 1_775_765_275_000 }, + }, + }, + { + type: "tool", + tool: "write", + callID: "call-write-1", + state: { + status: "completed", + input: { + filePath: "/workspace/opencode-app/src/index.ts", + content: "console.log('hello')\n", + }, + output: "Wrote file successfully.", + time: { start: 1_775_765_275_001, end: 1_775_765_275_050 }, + }, + }, + { type: "text", text: "Finished the change." }, + ], + }, + ], + }, + ], + }); + expect(sourceDbPath.endsWith("opencode.db")).toBe(true); + + const projectionDbPath = join(dir, "codetrail.sqlite"); + const result = runIncrementalIndexing({ + dbPath: projectionDbPath, + discoveryConfig: config, + enabledProviders: ["opencode"], + }); + + expect(result.indexedFiles).toBe(1); + const db = openDatabase(projectionDbPath); + const session = db + .prepare( + "SELECT provider, title, provider_client, provider_client_version, cwd FROM sessions LIMIT 1", + ) + .get() as + | { + provider: string; + title: string; + provider_client: string | null; + provider_client_version: string | null; + cwd: string | null; + } + | undefined; + const categories = db + .prepare("SELECT category, COUNT(*) as count FROM messages GROUP BY category ORDER BY category") + .all() as Array<{ category: string; count: number }>; + const toolCalls = db + .prepare("SELECT tool_name, args_json, result_json FROM tool_calls ORDER BY id") + .all() as Array<{ tool_name: string; args_json: string; result_json: string | null }>; + const editFiles = db + .prepare( + "SELECT file_path, change_type, added_line_count, exactness FROM message_tool_edit_files", + ) + .all() as Array<{ + file_path: string; + change_type: string; + added_line_count: number; + exactness: string; + }>; + db.close(); + + expect(session).toMatchObject({ + provider: "opencode", + provider_client: "OpenCode", + provider_client_version: "1.4.2", + cwd: "/workspace/opencode-app", + }); + expect(categories.map((row) => row.category)).toEqual([ + "assistant", + "thinking", + "tool_edit", + "tool_result", + "tool_use", + "user", + ]); + expect(toolCalls.some((row) => row.tool_name === "read")).toBe(true); + expect(toolCalls.some((row) => row.tool_name === "write")).toBe(true); + expect(editFiles).toEqual([ + expect.objectContaining({ + file_path: "/workspace/opencode-app/src/index.ts", + change_type: "add", + }), + ]); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("removes deleted OpenCode sessions during changed-db indexing", () => { + const dir = mkdtempSync(join(tmpdir(), "codetrail-opencode-remove-")); + const config = createDiscoveryConfig(dir); + const { dbPath: sourceDbPath } = createOpenCodeFixtureDatabase({ + rootDir: config.opencodeRoot ?? join(dir, ".local", "share", "opencode"), + sessions: [ + { + id: "ses-open-keep", + directory: "/workspace/keep", + title: "Keep", + timeCreated: 10, + timeUpdated: 20, + messages: [], + }, + { + id: "ses-open-delete", + directory: "/workspace/delete", + title: "Delete", + timeCreated: 30, + timeUpdated: 40, + messages: [], + }, + ], + }); + + const projectionDbPath = join(dir, "codetrail.sqlite"); + runIncrementalIndexing({ + dbPath: projectionDbPath, + discoveryConfig: config, + enabledProviders: ["opencode"], + }); + + const sourceDb = openDatabase(sourceDbPath); + sourceDb.prepare("DELETE FROM part WHERE session_id = ?").run("ses-open-delete"); + sourceDb.prepare("DELETE FROM message WHERE session_id = ?").run("ses-open-delete"); + sourceDb.prepare("DELETE FROM session WHERE id = ?").run("ses-open-delete"); + sourceDb.close(); + + const changed = indexChangedFiles( + { + dbPath: projectionDbPath, + discoveryConfig: config, + enabledProviders: ["opencode"], + removeMissingSessionsDuringIncrementalIndexing: true, + }, + [sourceDbPath], + ); + expect(changed.discoveredFiles).toBe(1); + + const db = openDatabase(projectionDbPath); + const remainingSessions = db + .prepare("SELECT provider_session_id FROM sessions ORDER BY provider_session_id") + .all() as Array<{ provider_session_id: string }>; + db.close(); + + expect(remainingSessions).toEqual([{ provider_session_id: "ses-open-keep" }]); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + describe("runIncrementalIndexing", () => { it("purges disabled providers during incremental indexing", () => { const dir = mkdtempSync(join(tmpdir(), "codetrail-enabled-providers-")); @@ -222,6 +425,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }; @@ -288,6 +492,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }; @@ -340,6 +545,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }; @@ -489,6 +695,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }; @@ -1809,6 +2016,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }, }, @@ -2254,6 +2462,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }, }); @@ -2328,6 +2537,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }; @@ -2353,6 +2563,9 @@ describe("runIncrementalIndexing", () => { claude: [], codex: [], gemini: [], + cursor: [], + copilot: [], + opencode: [], }, }); @@ -2443,6 +2656,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot, copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }, }); @@ -2545,6 +2759,7 @@ describe("runIncrementalIndexing", () => { geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot, copilotRoot: join(dir, ".copilot-workspace"), + opencodeRoot: join(dir, ".local", "share", "opencode"), includeClaudeSubagents: false, }, }); diff --git a/packages/core/src/indexing/indexSessions.ts b/packages/core/src/indexing/indexSessions.ts index ded492e..9442681 100644 --- a/packages/core/src/indexing/indexSessions.ts +++ b/packages/core/src/indexing/indexSessions.ts @@ -22,9 +22,14 @@ import { DEFAULT_DISCOVERY_CONFIG, type DiscoveryConfig, type WorktreeSource, + discoverChangedFiles, discoverSessionFiles, discoverSingleFile, } from "../discovery"; +import { + buildOpenCodeSessionSourcePrefix, + normalizeOpenCodeDatabasePath, +} from "../discovery/providers/opencode"; import { projectNameFromPath } from "../discovery/shared"; import { stringifyCompactMetadata } from "../metadata"; import { type ParserDiagnostic, parseSession, parseSessionEvent } from "../parsing"; @@ -73,6 +78,7 @@ export type IndexingResult = { }; export type IndexingDependencies = { + discoverChangedFiles?: typeof discoverChangedFiles; discoverSessionFiles?: typeof discoverSessionFiles; discoverSingleFile?: typeof discoverSingleFile; openDatabase?: typeof openDatabase; @@ -86,6 +92,7 @@ export type IndexingDependencies = { }; type ResolvedIndexingDependencies = { + discoverChangedFiles: typeof discoverChangedFiles; discoverSessionFiles: typeof discoverSessionFiles; discoverSingleFile: typeof discoverSingleFile; openDatabase: typeof openDatabase; @@ -658,7 +665,7 @@ export function indexChangedFiles( const discoveryConfig = resolveIndexingDiscoveryConfig(config); const initiallyDiscoveredFiles = changedFilePaths - .map((filePath) => resolvedDependencies.discoverSingleFile(filePath, discoveryConfig)) + .flatMap((filePath) => resolvedDependencies.discoverChangedFiles(filePath, discoveryConfig)) .filter((file): file is NonNullable => file !== null); const db = resolvedDependencies.openDatabase(config.dbPath); @@ -709,6 +716,17 @@ export function indexChangedFiles( FROM deleted_sessions WHERE file_path = ?`, ); + const listIndexedFilesByPrefix = db.prepare( + `SELECT + file_path, + provider, + project_path, + session_identity, + file_size, + file_mtime_ms + FROM indexed_files + WHERE provider = ? AND file_path LIKE ?`, + ); const deletedProjectRows = listDeletedProjects(db); const deletedProjectByKey = new Map( deletedProjectRows.map((row) => [deletedProjectKey(row.provider, row.project_path), row]), @@ -721,12 +739,28 @@ export function indexChangedFiles( const compiledSystemMessageRules = compileSystemMessageRules(config.systemMessageRegexRules); diagnostics.warnings += compiledSystemMessageRules.invalidCount; const statements = createIndexingStatements(db); + const enabledProviderSet = new Set(discoveryConfig.enabledProviders); + removeMissingOpenCodeSessionsForChangedPaths({ + changedFilePaths, + discoveredFiles, + discoveryConfig, + listIndexedFilesByPrefix: listIndexedFilesByPrefix as { + all: (provider: Provider, prefix: string) => IndexedFileRow[]; + }, + getSessionByFile: getSessionByFile as { + get: (filePath: string) => SessionFileRow | undefined; + }, + statements, + enabledProviderSet, + removeMissingSessionsDuringIncrementalIndexing: + config.removeMissingSessionsDuringIncrementalIndexing ?? false, + ...(config.projectScope ? { projectScope: config.projectScope } : {}), + }); // Handle deleted/renamed files — clean up indexed data for paths that can no longer be discovered // for disabled providers immediately, or for enabled providers only when the pruning toggle is // on. This mirrors the full incremental path, just scoped to the changed file set. const discoveredPathSet = new Set(discoveredFiles.map((f) => f.filePath)); - const enabledProviderSet = new Set(discoveryConfig.enabledProviders); for (const filePath of changedFilePaths) { if (discoveredPathSet.has(filePath)) continue; const existingSession = getSessionByFile.get(filePath) as SessionFileRow | undefined; @@ -791,6 +825,61 @@ export function indexChangedFiles( } } +function removeMissingOpenCodeSessionsForChangedPaths(args: { + changedFilePaths: string[]; + discoveredFiles: ReturnType; + discoveryConfig: DiscoveryConfig; + listIndexedFilesByPrefix: { all: (provider: Provider, prefix: string) => IndexedFileRow[] }; + getSessionByFile: { get: (filePath: string) => SessionFileRow | undefined }; + statements: IndexingStatements; + enabledProviderSet: Set; + removeMissingSessionsDuringIncrementalIndexing: boolean; + projectScope?: ProjectIndexingScope; +}): void { + if (!args.discoveryConfig.opencodeRoot) { + return; + } + + const discoveredPathSet = new Set(args.discoveredFiles.map((file) => file.filePath)); + for (const changedPath of args.changedFilePaths) { + const normalizedDbPath = normalizeOpenCodeDatabasePath( + changedPath, + args.discoveryConfig.opencodeRoot, + ); + if (!normalizedDbPath) { + continue; + } + + const prefix = `${buildOpenCodeSessionSourcePrefix(normalizedDbPath)}%`; + const indexedRows = args.listIndexedFilesByPrefix.all("opencode", prefix) as IndexedFileRow[]; + for (const indexedRow of indexedRows) { + if (discoveredPathSet.has(indexedRow.file_path)) { + continue; + } + if (!matchesProjectScope(indexedRow.provider, indexedRow.project_path, args.projectScope)) { + continue; + } + if ( + args.enabledProviderSet.has(indexedRow.provider) && + !args.removeMissingSessionsDuringIncrementalIndexing + ) { + continue; + } + + const existingSession = args.getSessionByFile.get(indexedRow.file_path) as + | SessionFileRow + | undefined; + if (!existingSession) { + continue; + } + + deleteSessionDataForFilePath(args.statements, indexedRow.file_path); + args.statements.deleteIndexedFileByFilePath.run(indexedRow.file_path); + args.statements.deleteCheckpointByFilePath.run(indexedRow.file_path); + } + } +} + function filterDiscoveredFilesByProjectScope( discoveredFiles: ReturnType, projectScope: ProjectIndexingScope | undefined, @@ -1347,6 +1436,7 @@ function resolveIndexingDependencies( dependencies: IndexingDependencies = {}, ): ResolvedIndexingDependencies { return { + discoverChangedFiles: dependencies.discoverChangedFiles ?? discoverChangedFiles, discoverSessionFiles: dependencies.discoverSessionFiles ?? discoverSessionFiles, discoverSingleFile: dependencies.discoverSingleFile ?? discoverSingleFile, openDatabase: dependencies.openDatabase ?? openDatabase, @@ -1479,7 +1569,7 @@ function indexMaterializedSessionFile(args: { let source: ProviderReadSourceResult; try { - const loaded = adapter.readSource(args.discovered.filePath, args.readFileText); + const loaded = adapter.readSource(args.discovered, args.readFileText); if (!loaded) { throw new Error("Unable to read provider source."); } @@ -1824,6 +1914,12 @@ function persistMaterializedMessages(args: { filePath: args.discovered.filePath, onNotice: args.onNotice, }); + registerGenericToolEditFiles({ + statements: args.statements, + discovered: args.discovered, + message, + persistedMessageId: makeMessageId(args.sessionDbId, message.id), + }); } args.statements.upsertIndexedFile.run( @@ -2492,6 +2588,12 @@ function persistStreamMessage(args: { message: messageToPersist, persistedMessageId, }); + registerGenericToolEditFiles({ + statements: args.statements, + discovered: args.discovered, + message: messageToPersist, + persistedMessageId, + }); } function createClaudeTurnNormalizationState( @@ -2592,6 +2694,203 @@ function registerClaudeToolEditCandidate(args: { }); } +function registerGenericToolEditFiles(args: { + statements: IndexingStatements; + discovered: ReturnType[number]; + message: IndexedMessage; + persistedMessageId: string; +}): void { + if (args.discovered.provider === "claude") { + return; + } + if (args.message.category !== "tool_edit") { + return; + } + + const parsedFiles = parseGenericToolEditFiles(args.message.content); + if (parsedFiles.length === 0) { + return; + } + + for (const [index, file] of parsedFiles.entries()) { + upsertMessageToolEditFile(args.statements, { + id: makeToolCallId(args.persistedMessageId, 1000 + index), + messageId: args.persistedMessageId, + fileOrdinal: index, + filePath: file.filePath, + previousFilePath: file.previousFilePath, + changeType: file.changeType, + unifiedDiff: file.unifiedDiff, + addedLineCount: file.addedLineCount, + removedLineCount: file.removedLineCount, + exactness: file.exactness, + beforeHash: file.beforeHash, + afterHash: file.afterHash, + }); + } +} + +function parseGenericToolEditFiles(content: string): Array<{ + filePath: string; + previousFilePath: string | null; + changeType: ToolEditChangeType; + unifiedDiff: string | null; + addedLineCount: number; + removedLineCount: number; + exactness: ToolEditExactness; + beforeHash: string | null; + afterHash: string | null; +}> { + const record = tryParseJsonRecord(content); + if (!record) { + return []; + } + + const payload = asRecord(record.input) ?? asRecord(record.args) ?? record; + const toolName = + readString(record.name) ?? + readString(record.tool_name) ?? + readString(record.tool) ?? + readString(record.operation) ?? + ""; + const filePath = + readString(payload?.filePath) ?? + readString(payload?.file_path) ?? + readString(payload?.path) ?? + readString(payload?.file) ?? + null; + if (!filePath) { + return []; + } + + const previousFilePath = + readString(payload?.previousFilePath) ?? + readString(payload?.previous_file_path) ?? + readString(payload?.oldPath) ?? + readString(payload?.old_path) ?? + null; + const oldText = + readString(payload?.oldString) ?? + readString(payload?.old_string) ?? + readString(payload?.oldText) ?? + readString(payload?.before) ?? + null; + const newText = + readString(payload?.newString) ?? + readString(payload?.new_string) ?? + readString(payload?.newText) ?? + readString(payload?.content) ?? + readString(payload?.text) ?? + readString(payload?.after) ?? + null; + const unifiedDiff = + readString(payload?.unifiedDiff) ?? + readString(payload?.unified_diff) ?? + readString(payload?.diff) ?? + readString(payload?.patch) ?? + null; + + const changeType = inferGenericToolEditChangeType({ + toolName, + filePath, + previousFilePath, + oldText, + newText, + }); + const lineCounts = summarizeGenericToolEditLineCounts({ + filePath, + changeType, + oldText, + newText, + unifiedDiff, + }); + + return [ + { + filePath, + previousFilePath, + changeType, + unifiedDiff, + addedLineCount: lineCounts.addedLineCount, + removedLineCount: lineCounts.removedLineCount, + exactness: "best_effort", + beforeHash: oldText === null ? null : hashText(oldText), + afterHash: newText === null ? null : hashText(newText), + }, + ]; +} + +function inferGenericToolEditChangeType(args: { + toolName: string; + filePath: string; + previousFilePath: string | null; + oldText: string | null; + newText: string | null; +}): ToolEditChangeType { + const normalizedToolName = args.toolName.trim().toLowerCase(); + + if (args.previousFilePath && args.previousFilePath !== args.filePath) { + return "move"; + } + if (normalizedToolName.includes("delete") || normalizedToolName.includes("remove")) { + return "delete"; + } + if (normalizedToolName.includes("move") || normalizedToolName.includes("rename")) { + return "move"; + } + if (args.oldText === null && args.newText !== null) { + return normalizedToolName.includes("edit") ? "update" : "add"; + } + if (args.oldText !== null && args.newText === null) { + return "delete"; + } + return "update"; +} + +function summarizeGenericToolEditLineCounts(args: { + filePath: string; + changeType: ToolEditChangeType; + oldText: string | null; + newText: string | null; + unifiedDiff: string | null; +}): { + addedLineCount: number; + removedLineCount: number; +} { + if (args.unifiedDiff) { + return countUnifiedDiffLines(args.unifiedDiff); + } + + if (args.oldText !== null && args.newText !== null) { + return countUnifiedDiffLines( + buildUnifiedDiffFromTextPair({ + oldText: args.oldText, + newText: args.newText, + filePath: args.filePath, + }), + ); + } + + if (args.changeType === "add" && args.newText !== null) { + return { + addedLineCount: countTextLines(args.newText), + removedLineCount: 0, + }; + } + + if (args.changeType === "delete" && args.oldText !== null) { + return { + addedLineCount: 0, + removedLineCount: countTextLines(args.oldText), + }; + } + + return { + addedLineCount: 0, + removedLineCount: 0, + }; +} + function processClaudeSnapshotEvent(args: { db: SqliteDatabase; discovered: ReturnType[number]; @@ -3084,6 +3383,23 @@ function hashText(value: string): string { return createHash("sha256").update(value).digest("hex"); } +function countTextLines(text: string): number { + if (text.length === 0) { + return 0; + } + const normalized = text.replace(/\r\n/g, "\n"); + let lineCount = 1; + for (const char of normalized) { + if (char === "\n") { + lineCount += 1; + } + } + if (normalized.endsWith("\n")) { + lineCount -= 1; + } + return Math.max(lineCount, 0); +} + function tryParseJsonRecord(value: string): Record | null { try { return asRecord(JSON.parse(value)); diff --git a/packages/core/src/parsing/providerParsers.test.ts b/packages/core/src/parsing/providerParsers.test.ts index 1da1c21..acedc8b 100644 --- a/packages/core/src/parsing/providerParsers.test.ts +++ b/packages/core/src/parsing/providerParsers.test.ts @@ -228,6 +228,112 @@ describe("parseProviderPayload (Copilot)", () => { }); }); +describe("parseProviderPayload (OpenCode)", () => { + it("maps text, reasoning, tool usage, edits, and results into canonical categories", () => { + const diagnostics: ParserDiagnostic[] = []; + + const messages = parseProviderPayload({ + provider: "opencode", + sessionId: "opencode-test", + diagnostics, + payload: { + session: { id: "ses-1", directory: "/workspace/opencode" }, + project: { id: "project-1", worktree: "/workspace/opencode" }, + messages: [ + { + id: "msg-user-1", + timeCreated: 1_775_765_274_798, + timeUpdated: 1_775_765_274_798, + data: { + role: "user", + time: { created: 1_775_765_274_798 }, + }, + parts: [{ id: "p1", data: { type: "text", text: "Build the feature" } }], + }, + { + id: "msg-assistant-1", + timeCreated: 1_775_765_274_808, + timeUpdated: 1_775_765_279_366, + data: { + role: "assistant", + modelID: "glm-5.1:cloud", + tokens: { input: 42, output: 11 }, + time: { created: 1_775_765_274_808, completed: 1_775_765_279_366 }, + }, + parts: [ + { + id: "p2", + data: { + type: "reasoning", + text: "I should inspect the files first.", + time: { start: 1_775_765_274_809, end: 1_775_765_274_900 }, + }, + }, + { + id: "p3", + data: { + type: "tool", + tool: "read", + callID: "call-read-1", + state: { + status: "completed", + input: { filePath: "/workspace/opencode/README.md" }, + output: "README contents", + time: { start: 1_775_765_274_901, end: 1_775_765_275_000 }, + }, + }, + }, + { + id: "p4", + data: { + type: "tool", + tool: "write", + callID: "call-write-1", + state: { + status: "completed", + input: { + filePath: "/workspace/opencode/src/app.ts", + content: "console.log('hello')", + }, + output: "Wrote file successfully.", + time: { start: 1_775_765_275_001, end: 1_775_765_275_100 }, + }, + }, + }, + { + id: "p5", + data: { + type: "text", + text: "Implemented the change.", + }, + }, + ], + }, + ], + }, + }); + + expect(messages.map((message) => message.category)).toEqual([ + "user", + "thinking", + "tool_use", + "tool_result", + "tool_edit", + "tool_result", + "assistant", + ]); + expect(messages[0]?.content).toBe("Build the feature"); + expect(messages[2]?.content).toContain("\"name\":\"read\""); + expect(messages[4]?.content).toContain("\"name\":\"write\""); + expect(messages[4]?.content).toContain("\"filePath\":\"/workspace/opencode/src/app.ts\""); + expect(messages[6]?.content).toBe("Implemented the change."); + expect(messages[1]?.operationDurationSource).toBe("native"); + expect(messages[6]?.tokenInput).toBeNull(); + expect(messages[1]?.tokenInput).toBe(42); + expect(messages[1]?.tokenOutput).toBe(11); + }); +}); + describe("parseProviderPayload (Codex tool classification)", () => { it("keeps write_stdin as tool_use", () => { const diagnostics: ParserDiagnostic[] = []; diff --git a/packages/core/src/parsing/providerParsers.ts b/packages/core/src/parsing/providerParsers.ts index 6801ab0..e38dfba 100644 --- a/packages/core/src/parsing/providerParsers.ts +++ b/packages/core/src/parsing/providerParsers.ts @@ -27,6 +27,7 @@ import { lowerString, readString, serializeUnknown, + toIsoTimestamp, } from "./helpers"; type EventSegment = { @@ -107,6 +108,7 @@ export const PROVIDER_EVENT_PARSERS: Record = { gemini: parseGeminiEvent, cursor: parseCursorEvent, copilot: parseCopilotEvent, + opencode: parseOpenCodeEvent, }; export const PROVIDER_PAYLOAD_PARSERS: Record = { @@ -115,6 +117,7 @@ export const PROVIDER_PAYLOAD_PARSERS: Record = gemini: (args) => parseEventStreamPayload(args, extractGeminiEvents), cursor: (args) => parseEventStreamPayload(args, extractEvents), copilot: parseCopilotPayload, + opencode: parseOpenCodePayload, }; // Each provider emits different event shapes, but all parsers normalize into the same stream of @@ -650,6 +653,145 @@ function parseCopilotEvent(args: ParseProviderEventArgs): ParseProviderEventResu return { messages: output, nextSequence }; } +function parseOpenCodePayload(args: ParseProviderPayloadArgs): ParsedProviderMessage[] { + const root = asRecord(args.payload); + const messages = asArray(root?.messages); + const output: ParsedProviderMessage[] = []; + let sequence = 0; + + for (const [eventIndex, event] of messages.entries()) { + const result = parseProviderEvent({ + provider: args.provider, + sessionId: args.sessionId, + eventIndex, + event, + diagnostics: args.diagnostics, + sequence, + }); + output.push(...result.messages); + sequence = result.nextSequence; + } + + return output; +} + +function parseOpenCodeEvent(args: ParseProviderEventArgs): ParseProviderEventResult { + const { provider, sessionId, eventIndex, event, diagnostics, sequence } = args; + const output: ParsedProviderMessage[] = []; + const eventRecord = asRecord(event); + if (!eventRecord) { + return { + messages: output, + nextSequence: pushNonObjectEvent({ + output, + provider, + sessionId, + eventIndex, + event, + diagnostics, + sequence, + }), + }; + } + + const messageData = asRecord(eventRecord.data); + if (!messageData) { + return { messages: output, nextSequence: sequence }; + } + + const role = lowerString(messageData.role); + const createdAt = extractOpenCodeCreatedAt(eventRecord, messageData); + const baseId = readString(eventRecord.id) ?? readString(messageData.id) ?? null; + const tokenUsage = extractTokenUsage(messageData); + const segments: EventSegment[] = []; + + for (const part of asArray(eventRecord.parts)) { + const partRecord = asRecord(part); + const partData = asRecord(partRecord?.data) ?? partRecord; + if (!partData) { + continue; + } + + const partType = lowerString(partData.type); + if (partType === "step-start" || partType === "step-finish") { + continue; + } + + if (partType === "text") { + const text = readString(partData.text); + if (text && text.length > 0) { + segments.push({ + category: role === "assistant" ? "assistant" : role === "user" ? "user" : "system", + content: text, + }); + } + continue; + } + + if (partType === "reasoning") { + const text = readString(partData.text); + if (text && text.length > 0) { + segments.push({ + category: "thinking", + content: text, + ...nativeDurationSegment(extractOpenCodePartDurationMs(partData)), + }); + } + continue; + } + + if (partType === "tool") { + const toolContent = buildOpenCodeToolUseContent(partData); + segments.push({ + category: inferToolUseCategory(toolContent), + content: toolContent, + ...nativeDurationSegment(extractOpenCodeToolDurationMs(partData)), + }); + + const resultContent = extractOpenCodeToolResultContent(partData); + if (resultContent) { + segments.push({ + category: "tool_result", + content: resultContent, + ...nativeDurationSegment(extractOpenCodeToolDurationMs(partData)), + }); + } + } + } + + if (segments.length === 0) { + const fallback = extractPrimaryText(messageData.summary); + if (fallback.length > 0) { + segments.push({ + category: role === "assistant" ? "assistant" : role === "user" ? "user" : "system", + content: fallback, + }); + } + } + + const messageDurationMs = extractOpenCodeMessageDurationMs(messageData); + if (messageDurationMs !== null) { + const segment = segments.find((candidate) => candidate.category !== "thinking"); + if (segment && segment.operationDurationMs === undefined) { + Object.assign(segment, nativeDurationSegment(messageDurationMs)); + } + } + + return { + messages: output, + nextSequence: pushSplitMessages({ + output, + sessionId, + sequence, + baseId, + createdAt, + tokenUsage, + segments, + fallbackRaw: event, + }), + }; +} + const CURSOR_USER_QUERY_OPEN_RE = /\s*/g; const CURSOR_USER_QUERY_CLOSE_RE = /\s*<\/user_query>/g; const CURSOR_WRAPPER_BLOCK_RES = [ @@ -672,6 +814,116 @@ function stripCursorWrapperTags(text: string): string { return result.trim(); } +function nativeDurationSegment(durationMs: number | null): Pick< + EventSegment, + "operationDurationMs" | "operationDurationSource" | "operationDurationConfidence" +> { + return durationMs === null + ? { + operationDurationMs: null, + operationDurationSource: null, + operationDurationConfidence: null, + } + : { + operationDurationMs: durationMs, + operationDurationSource: "native", + operationDurationConfidence: "high", + }; +} + +function extractOpenCodeCreatedAt( + eventRecord: Record, + messageData: Record, +): string { + const time = asRecord(messageData.time); + return ( + toIsoTimestamp(time?.created) ?? + toIsoTimestamp(eventRecord.timeCreated) ?? + extractEventTimestamp(messageData) ?? + EPOCH_ISO + ); +} + +function extractOpenCodeMessageDurationMs(messageData: Record): number | null { + const time = asRecord(messageData.time); + const createdAt = numericTimestampMs(time?.created); + const completedAt = numericTimestampMs(time?.completed); + if (createdAt === null || completedAt === null || completedAt <= createdAt) { + return null; + } + return completedAt - createdAt; +} + +function extractOpenCodePartDurationMs(partData: Record): number | null { + return extractOpenCodeDurationFromTime(asRecord(partData.time)); +} + +function extractOpenCodeToolDurationMs(partData: Record): number | null { + const state = asRecord(partData.state); + return extractOpenCodeDurationFromTime(asRecord(state?.time)); +} + +function extractOpenCodeDurationFromTime(time: Record | null): number | null { + const start = numericTimestampMs(time?.start); + const end = numericTimestampMs(time?.end); + if (start === null || end === null || end <= start) { + return null; + } + return end - start; +} + +function numericTimestampMs(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value > 1_000_000_000_000 ? Math.trunc(value) : Math.trunc(value * 1000); + } + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed > 1_000_000_000_000 ? Math.trunc(parsed) : Math.trunc(parsed * 1000); + } + } + return null; +} + +function buildOpenCodeToolUseContent(partData: Record): string { + const state = asRecord(partData.state); + const toolName = readString(partData.tool) ?? "tool"; + const operation = + toolName === "write" ? "write_file" : toolName === "edit" ? "edit" : toolName; + const result = state?.error ?? state?.output ?? null; + return serializeUnknown({ + type: "tool_use", + id: readString(partData.callID) ?? null, + name: toolName, + operation, + input: asRecord(state?.input) ?? {}, + result, + output: result, + }); +} + +function extractOpenCodeToolResultContent(partData: Record): string | null { + const state = asRecord(partData.state); + if (!state) { + return null; + } + + const error = readString(state.error); + if (error) { + return error; + } + + if (typeof state.output === "string" && state.output.trim().length > 0) { + return state.output; + } + + if (state.output !== undefined && state.output !== null) { + return serializeUnknown(state.output); + } + + return null; +} + function parseClaudeSegments( sourceType: string | null, event: Record, diff --git a/packages/core/src/providers/adapters/opencode.ts b/packages/core/src/providers/adapters/opencode.ts new file mode 100644 index 0000000..1f03c1f --- /dev/null +++ b/packages/core/src/providers/adapters/opencode.ts @@ -0,0 +1,48 @@ +import { PROVIDER_METADATA } from "../../contracts/providerMetadata"; +import { + discoverChangedOpenCodeFiles, + discoverOpenCodeFiles, + discoverSingleOpenCodeFile, + readOpenCodeSource, +} from "../../discovery/providers/opencode"; +import { asArray, asRecord, readString } from "../../parsing/helpers"; +import { PROVIDER_EVENT_PARSERS, PROVIDER_PAYLOAD_PARSERS } from "../../parsing/providerParsers"; + +import type { ProviderAdapter } from "../types"; +import { defaultTimestampNormalization, emptySourceMetadata, sortModels } from "./shared"; + +export const opencodeAdapter: ProviderAdapter = { + ...PROVIDER_METADATA.opencode, + sourceFormat: "materialized_json", + supportsIncrementalCheckpoints: false, + discoverAll: discoverOpenCodeFiles, + discoverOne: discoverSingleOpenCodeFile, + discoverChanged: discoverChangedOpenCodeFiles, + readSource: readOpenCodeSource, + parsePayload: PROVIDER_PAYLOAD_PARSERS.opencode, + parseEvent: PROVIDER_EVENT_PARSERS.opencode, + extractSourceMetadata: (payload) => { + const root = asRecord(payload); + const session = asRecord(root?.session); + const models = new Set(); + let cwd = readString(session?.directory); + + for (const entry of asArray(root?.messages)) { + const record = asRecord(entry); + const messageData = asRecord(record?.data); + const path = asRecord(messageData?.path); + const model = readString(messageData?.modelID) ?? readString(asRecord(messageData?.model)?.modelID); + if (model) { + models.add(model); + } + cwd ??= readString(path?.cwd); + } + + return { + ...emptySourceMetadata(), + models: sortModels(models), + cwd, + }; + }, + normalizeMessageTimestamp: defaultTimestampNormalization, +}; diff --git a/packages/core/src/providers/adapters/shared.ts b/packages/core/src/providers/adapters/shared.ts index 265facd..788b058 100644 --- a/packages/core/src/providers/adapters/shared.ts +++ b/packages/core/src/providers/adapters/shared.ts @@ -1,17 +1,20 @@ import type { - ProviderJsonObject, + ReadFileText, ProviderReadSourceResult, ProviderSourceMetadata, + ProviderJsonObject, ProviderTimestampNormalizationResult, - ReadFileText, } from "../types"; +import type { DiscoveredSessionFile } from "../../discovery/types"; export function readMaterializedJsonSource( - filePath: string, + discovered: DiscoveredSessionFile, readFileText: ReadFileText, ): ProviderReadSourceResult | null { try { - const parsed = JSON.parse(readFileText(filePath)) as ProviderJsonObject; + const parsed = JSON.parse( + readFileText(discovered.backingFilePath ?? discovered.filePath), + ) as ProviderJsonObject; return { payload: parsed, }; diff --git a/packages/core/src/providers/registry.ts b/packages/core/src/providers/registry.ts index e208973..c7cd8e8 100644 --- a/packages/core/src/providers/registry.ts +++ b/packages/core/src/providers/registry.ts @@ -5,6 +5,7 @@ import { codexAdapter } from "./adapters/codex"; import { copilotAdapter } from "./adapters/copilot"; import { cursorAdapter } from "./adapters/cursor"; import { geminiAdapter } from "./adapters/gemini"; +import { opencodeAdapter } from "./adapters/opencode"; import type { ProviderAdapter } from "./types"; export const PROVIDER_ADAPTERS: Record = { @@ -13,6 +14,7 @@ export const PROVIDER_ADAPTERS: Record = { gemini: geminiAdapter, cursor: cursorAdapter, copilot: copilotAdapter, + opencode: opencodeAdapter, }; export const PROVIDER_ADAPTER_LIST: ProviderAdapter[] = PROVIDER_VALUES.map( diff --git a/packages/core/src/providers/types.ts b/packages/core/src/providers/types.ts index d82d5bf..2018b7d 100644 --- a/packages/core/src/providers/types.ts +++ b/packages/core/src/providers/types.ts @@ -66,6 +66,11 @@ type CommonProviderAdapter = ProviderMetadata & { config: ResolvedDiscoveryConfig, dependencies: ResolvedDiscoveryDependencies, ) => DiscoveredSessionFile | null; + discoverChanged?: ( + filePath: string, + config: ResolvedDiscoveryConfig, + dependencies: ResolvedDiscoveryDependencies, + ) => DiscoveredSessionFile[]; sanitizeOversizedJsonlEvent?: ( event: unknown, context: ProviderOversizedJsonlEventContext, @@ -89,7 +94,10 @@ export type JsonlStreamProviderAdapter = CommonProviderAdapter & { export type MaterializedJsonProviderAdapter = CommonProviderAdapter & { sourceFormat: "materialized_json"; - readSource: (filePath: string, readFileText: ReadFileText) => ProviderReadSourceResult | null; + readSource: ( + discovered: DiscoveredSessionFile, + readFileText: ReadFileText, + ) => ProviderReadSourceResult | null; }; export type ProviderAdapter = JsonlStreamProviderAdapter | MaterializedJsonProviderAdapter; diff --git a/packages/core/src/search/searchMessages.ts b/packages/core/src/search/searchMessages.ts index f402486..4ff6d88 100644 --- a/packages/core/src/search/searchMessages.ts +++ b/packages/core/src/search/searchMessages.ts @@ -137,7 +137,8 @@ export function searchMessages( COALESCE(SUM(CASE WHEN provider = 'codex' THEN 1 ELSE 0 END), 0) as codex_count, COALESCE(SUM(CASE WHEN provider = 'gemini' THEN 1 ELSE 0 END), 0) as gemini_count, COALESCE(SUM(CASE WHEN provider = 'cursor' THEN 1 ELSE 0 END), 0) as cursor_count, - COALESCE(SUM(CASE WHEN provider = 'copilot' THEN 1 ELSE 0 END), 0) as copilot_count + COALESCE(SUM(CASE WHEN provider = 'copilot' THEN 1 ELSE 0 END), 0) as copilot_count, + COALESCE(SUM(CASE WHEN provider = 'opencode' THEN 1 ELSE 0 END), 0) as opencode_count FROM facet_matches`, ) .get(...facetWhereParams, ...categoryFilter.params) as @@ -155,6 +156,7 @@ export function searchMessages( gemini_count: number; cursor_count: number; copilot_count: number; + opencode_count: number; } | undefined; const totalCount = Number(summaryRow?.total_count ?? 0); @@ -170,6 +172,7 @@ export function searchMessages( providerCounts.gemini = Number(summaryRow?.gemini_count ?? 0); providerCounts.cursor = Number(summaryRow?.cursor_count ?? 0); providerCounts.copilot = Number(summaryRow?.copilot_count ?? 0); + providerCounts.opencode = Number(summaryRow?.opencode_count ?? 0); const limit = Math.max(1, input.limit ?? 50); const offset = Math.max(0, input.offset ?? 0); diff --git a/packages/core/src/testing/inMemory.test.ts b/packages/core/src/testing/inMemory.test.ts index aac9291..67391fb 100644 --- a/packages/core/src/testing/inMemory.test.ts +++ b/packages/core/src/testing/inMemory.test.ts @@ -65,6 +65,7 @@ describe("core testing helpers", () => { geminiProjectsPath: "/fixtures/.gemini/projects.json", cursorRoot: "/fixtures/.cursor/projects", copilotRoot: "/fixtures/.copilot/projects", + opencodeRoot: "/fixtures/.local/share/opencode", includeClaudeSubagents: false, }, { fs: fs.toDiscoveryFileSystem() }, diff --git a/packages/core/src/testing/liveWatchFixtures.ts b/packages/core/src/testing/liveWatchFixtures.ts index c9cd421..69ea038 100644 --- a/packages/core/src/testing/liveWatchFixtures.ts +++ b/packages/core/src/testing/liveWatchFixtures.ts @@ -36,6 +36,7 @@ export function createLiveStatusFixture( gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: input.sessions ?? [], claudeHookState: input.claudeHookState ?? createClaudeHookStateFixture(), diff --git a/packages/core/src/testing/opencodeFixture.ts b/packages/core/src/testing/opencodeFixture.ts new file mode 100644 index 0000000..3135300 --- /dev/null +++ b/packages/core/src/testing/opencodeFixture.ts @@ -0,0 +1,161 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import Database from "better-sqlite3"; + +export type OpenCodeFixtureMessage = { + id: string; + timeCreated: number; + timeUpdated?: number; + data: Record; + parts?: Array>; +}; + +export type OpenCodeFixtureSession = { + id: string; + projectId?: string; + parentId?: string | null; + slug?: string; + directory: string; + title: string; + version?: string; + timeCreated: number; + timeUpdated?: number; + messages: OpenCodeFixtureMessage[]; +}; + +export type OpenCodeFixtureInput = { + rootDir: string; + projectId?: string; + projectName?: string | null; + worktree?: string; + sessions: OpenCodeFixtureSession[]; +}; + +export function createOpenCodeFixtureDatabase(input: OpenCodeFixtureInput): { + dbPath: string; +} { + mkdirSync(input.rootDir, { recursive: true }); + const dbPath = join(input.rootDir, "opencode.db"); + const db = new Database(dbPath); + + db.exec(` + CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + vcs TEXT, + name TEXT, + icon_url TEXT, + icon_color TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_initialized INTEGER, + sandboxes TEXT NOT NULL, + commands TEXT + ); + + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + share_url TEXT, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, + revert TEXT, + permission TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + workspace_id TEXT + ); + + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + `); + + const projectId = input.projectId ?? "project-1"; + const projectTime = input.sessions[0]?.timeCreated ?? Date.now(); + db.prepare( + `INSERT INTO project ( + id, worktree, vcs, name, icon_url, icon_color, time_created, time_updated, time_initialized, sandboxes, commands + ) VALUES (?, ?, NULL, ?, NULL, NULL, ?, ?, NULL, '[]', NULL)`, + ).run( + projectId, + input.worktree ?? input.sessions[0]?.directory ?? "/workspace", + input.projectName ?? null, + projectTime, + input.sessions.at(-1)?.timeUpdated ?? input.sessions.at(-1)?.timeCreated ?? projectTime, + ); + + const insertSession = db.prepare( + `INSERT INTO session ( + id, project_id, parent_id, slug, directory, title, version, share_url, summary_additions, summary_deletions, + summary_files, summary_diffs, revert, permission, time_created, time_updated, time_compacting, time_archived, workspace_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, NULL, NULL, NULL)`, + ); + const insertMessage = db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`, + ); + const insertPart = db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`, + ); + + for (const session of input.sessions) { + insertSession.run( + session.id, + session.projectId ?? projectId, + session.parentId ?? null, + session.slug ?? session.id, + session.directory, + session.title, + session.version ?? "1.0.0", + session.timeCreated, + session.timeUpdated ?? session.timeCreated, + ); + + for (const message of session.messages) { + insertMessage.run( + message.id, + session.id, + message.timeCreated, + message.timeUpdated ?? message.timeCreated, + JSON.stringify(message.data), + ); + + for (const [partIndex, part] of (message.parts ?? []).entries()) { + insertPart.run( + `${message.id}:part:${partIndex}`, + message.id, + session.id, + message.timeCreated + partIndex, + message.timeUpdated ?? message.timeCreated + partIndex, + JSON.stringify(part), + ); + } + } + } + + db.close(); + return { dbPath }; +} diff --git a/packages/core/src/testing/settingsInfoFixture.ts b/packages/core/src/testing/settingsInfoFixture.ts index b441187..363edb0 100644 --- a/packages/core/src/testing/settingsInfoFixture.ts +++ b/packages/core/src/testing/settingsInfoFixture.ts @@ -21,6 +21,7 @@ function buildDefaultDiscoveryPaths(homeDir: string): Record