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/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..c42ed7b 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,607 @@ 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", + ); + 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), + 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(2); + 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: 2, + }); + expect(stats.topProjects[0]).toMatchObject({ + projectId: "project_2", + bookmarkCount: 2, + }); + expect(stats.topModels[0]).toMatchObject({ + 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, + move: 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: 2, + delete: 1, + move: 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 c3d6e75..4a5403c 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; @@ -157,6 +158,64 @@ 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 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; @@ -193,6 +252,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 +323,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 +351,453 @@ 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 aiCodeStats = collectDashboardAiCodeStats(db, recentActivity.map((point) => point.date)); + + 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), + })), + 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, + move: 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 a10877e..5c82ddb 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -40,6 +40,31 @@ 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, + move: 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>(); @@ -57,6 +82,39 @@ 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: [], + aiCodeStats: emptyAiCodeStats, + activityWindowDays: 14, + }), "indexer:refresh": (payload) => ({ jobId: payload.force ? "force-1" : "normal-1" }), "indexer:getStatus": () => ({ running: false, @@ -293,6 +351,39 @@ 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: [], + aiCodeStats: emptyAiCodeStats, + activityWindowDays: 14, + }), "indexer:refresh": () => ({ jobId: "refresh-1" }), "indexer:getStatus": () => ({ running: false, 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/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..5cf347f 100644 --- a/apps/desktop/src/renderer/App.test.tsx +++ b/apps/desktop/src/renderer/App.test.tsx @@ -69,6 +69,54 @@ 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("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(); @@ -479,6 +527,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -553,6 +602,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -617,6 +667,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -1343,6 +1394,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -3047,6 +3099,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [], claudeHookState: createClaudeHookStateFixture(), @@ -3061,6 +3114,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { @@ -3162,6 +3216,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [], claudeHookState: createClaudeHookStateFixture(), @@ -3176,6 +3231,7 @@ describe("App shell", () => { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { 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/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/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)} +
+
+ + + + + + +
+ +
+
+
+

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} +
+ ))} +
+
+
+ + )} +
+ +
+
+
+
+

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..be13de1 --- /dev/null +++ b/apps/desktop/src/renderer/features/useDashboardController.ts @@ -0,0 +1,111 @@ +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: [], + 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, + move: 0, + }, + providerStats: [], + recentActivity: [], + topFiles: [], + topFileTypes: [], + }, + 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") { + return; + } + void reloadStats(); + }, [mainView, reloadStats]); + + return { + stats, + loading, + loaded, + error, + reloadStats, + }; +} 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/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..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; @@ -7185,6 +7242,683 @@ 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, +.dashboard-ai-main-grid, +.dashboard-ai-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-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 { + 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-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; +} + +.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; + } + + .dashboard-ai-metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1100px) { + .dashboard-main-grid, + .dashboard-secondary-grid, + .dashboard-ai-main-grid, + .dashboard-ai-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, + .dashboard-ai-velocity { + gap: 6px; + } + + .dashboard-section-heading { + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 560px) { + .dashboard-summary-grid { + grid-template-columns: minmax(0, 1fr); + } + + .dashboard-ai-metric-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..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 { @@ -99,6 +100,253 @@ 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, + opencode: 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, + }, + ], + 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, + move: 0, + }, + 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, + }; + } if (channel === "watcher:start") { return { ok: true, watchedRoots: [], backend: "default" }; } 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/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/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 bae24c6..dd8838e 100644 --- a/packages/core/src/contracts/ipc.test.ts +++ b/packages/core/src/contracts/ipc.test.ts @@ -25,6 +25,31 @@ 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, + move: 0, + }, + providerStats: [], + recentActivity: [], + topFiles: [], + topFileTypes: [], +}; + function createClaudeHookStateExample(input: { installed: boolean }) { return createClaudeHookStateFixture({ settingsPath: "/home/user/.claude/settings.json", @@ -59,6 +84,49 @@ 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, + opencode: 0, + }, + providerStats: [], + recentActivity: [], + topProjects: [], + topModels: [], + aiCodeStats: emptyAiCodeStats, + activityWindowDays: 14, + }, + }, "indexer:refresh": { request: { force: true, projectId: "project_1" }, response: { jobId: "refresh-1" }, @@ -280,6 +348,7 @@ const channelExamples: Record = { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, results: [], }, @@ -459,6 +528,7 @@ const channelExamples: Record = { gemini: [], cursor: [], copilot: [], + opencode: [], }, }, response: { ok: true }, @@ -529,6 +599,7 @@ const channelExamples: Record = { gemini: 0, cursor: 0, copilot: 0, + opencode: 0, }, sessions: [ { diff --git a/packages/core/src/contracts/ipc.ts b/packages/core/src/contracts/ipc.ts index 795e184..13c65dc 100644 --- a/packages/core/src/contracts/ipc.ts +++ b/packages/core/src/contracts/ipc.ts @@ -141,6 +141,105 @@ 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 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(), + move: 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", @@ -478,6 +577,20 @@ 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), + aiCodeStats: dashboardAiCodeStatsSchema, + activityWindowDays: z.number().int().positive(), + }), + }, "indexer:refresh": { request: z.object({ force: z.boolean().default(false), 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