diff --git a/server/modules/database/migrations.ts b/server/modules/database/migrations.ts index 4d23469068..cfcc3e2c05 100644 --- a/server/modules/database/migrations.ts +++ b/server/modules/database/migrations.ts @@ -259,9 +259,12 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { if (!shouldRebuild) { addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0'); + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'is_subagent', 'BOOLEAN DEFAULT 0'); + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'parent_session_id', 'TEXT'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME'); db.exec('UPDATE sessions SET isArchived = COALESCE(isArchived, 0)'); + db.exec('UPDATE sessions SET is_subagent = COALESCE(is_subagent, 0)'); db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)'); db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)'); return; @@ -421,6 +424,48 @@ const ensureProjectsForSessionPaths = (db: Database): void => { `); }; +/** + * Ensures subagent columns exist on upgraded installs and backfills the classic + * Cursor `…/subagents/.jsonl` path layout. + */ +const ensureSessionSubagentColumns = (db: Database): void => { + if (!tableExists(db, 'sessions')) { + return; + } + + const columnNames = getTableInfo(db, 'sessions').map((column) => column.name); + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'is_subagent', 'BOOLEAN DEFAULT 0'); + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'parent_session_id', 'TEXT'); + db.exec('UPDATE sessions SET is_subagent = COALESCE(is_subagent, 0)'); + + // Classic Cursor layout: agent-transcripts//subagents/.jsonl + const subagentPathRows = db + .prepare( + `SELECT session_id, jsonl_path, parent_session_id + FROM sessions + WHERE jsonl_path LIKE '%/subagents/%'`, + ) + .all() as Array<{ session_id: string; jsonl_path: string; parent_session_id: string | null }>; + + const updateSubagent = db.prepare( + `UPDATE sessions + SET is_subagent = 1, parent_session_id = COALESCE(?, parent_session_id) + WHERE session_id = ?`, + ); + + for (const row of subagentPathRows) { + const normalized = row.jsonl_path.replace(/\\/g, '/'); + const marker = '/subagents/'; + const markerIndex = normalized.lastIndexOf(marker); + let parentProviderSessionId: string | null = row.parent_session_id; + if (markerIndex >= 0) { + const before = normalized.slice(0, markerIndex); + parentProviderSessionId = before.slice(before.lastIndexOf('/') + 1) || parentProviderSessionId; + } + updateSubagent.run(parentProviderSessionId, row.session_id); + } +}; + export const runMigrations = (db: Database) => { try { const usersTableInfo = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]; @@ -453,11 +498,14 @@ export const runMigrations = (db: Database) => { migrateLegacySessionNames(db); addProviderSessionIdMapping(db); ensureProjectsForSessionPaths(db); + ensureSessionSubagentColumns(db); db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_subagent ON sessions(is_subagent)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_parent_session_id ON sessions(parent_session_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)'); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 407e4f80c7..5df2c65bca 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -10,12 +10,23 @@ type SessionRow = { jsonl_path: string | null; custom_name: string | null; isArchived: number; + is_subagent: number; + parent_session_id: string | null; created_at: string; updated_at: string; }; +export type CreateSessionOptions = { + isSubagent?: boolean; + parentSessionId?: string | null; +}; + +export type ListSessionsOptions = { + includeSubagents?: boolean; +}; + const SESSION_ROW_COLUMNS = - 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; + 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, is_subagent, parent_session_id, created_at, updated_at'; const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; @@ -44,6 +55,8 @@ function normalizeSessionRow(row: T): T return { ...row, + is_subagent: Number(row.is_subagent ?? 0), + parent_session_id: row.parent_session_id ?? null, created_at: normalizeTimestamp(row.created_at) ?? row.created_at, updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at, }; @@ -58,6 +71,10 @@ function normalizeProjectPathForProvider(provider: string, projectPath: string): return normalizeProjectPath(projectPath); } +function subagentFilterSql(includeSubagents: boolean): string { + return includeSubagents ? '' : ' AND COALESCE(is_subagent, 0) = 0'; +} + export const sessionsDb = { /** * Upserts one session row discovered on disk by a provider synchronizer. @@ -74,12 +91,15 @@ export const sessionsDb = { customName?: string, createdAt?: string, updatedAt?: string, - jsonlPath?: string | null + jsonlPath?: string | null, + options: CreateSessionOptions = {}, ): string { const db = getConnection(); const createdAtValue = normalizeTimestamp(createdAt); const updatedAtValue = normalizeTimestamp(updatedAt); const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath); + const isSubagent = options.isSubagent ? 1 : 0; + const parentSessionId = options.parentSessionId ?? null; // First, ensure the project path is recorded in the projects table, // since it's a foreign key in the sessions table. @@ -89,7 +109,7 @@ export const sessionsDb = { .prepare( `SELECT session_id FROM sessions WHERE provider_session_id = ? AND provider = ? - LIMIT 1` + LIMIT 1`, ) .get(providerSessionId, provider) as { session_id: string } | undefined; @@ -101,15 +121,23 @@ export const sessionsDb = { project_path = ?, jsonl_path = ?, isArchived = 0, - custom_name = COALESCE(?, custom_name) - WHERE session_id = ?` + custom_name = COALESCE(?, custom_name), + is_subagent = CASE WHEN ? = 1 THEN 1 ELSE is_subagent END, + parent_session_id = CASE + WHEN ? IS NOT NULL THEN ? + ELSE parent_session_id + END + WHERE session_id = ?`, ).run( provider, updatedAtValue, normalizedProjectPath, jsonlPath ?? null, customName ?? null, - existing.session_id + isSubagent, + parentSessionId, + parentSessionId, + existing.session_id, ); return existing.session_id; @@ -119,8 +147,11 @@ export const sessionsDb = { // keyed by the provider-native id for both columns. The ON CONFLICT path // covers legacy rows that predate the provider_session_id mapping. db.prepare( - `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) + `INSERT INTO sessions ( + session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, + isArchived, is_subagent, parent_session_id, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, provider_session_id = excluded.provider_session_id, @@ -128,7 +159,9 @@ export const sessionsDb = { project_path = excluded.project_path, jsonl_path = excluded.jsonl_path, isArchived = 0, - custom_name = COALESCE(excluded.custom_name, sessions.custom_name)` + custom_name = COALESCE(excluded.custom_name, sessions.custom_name), + is_subagent = CASE WHEN excluded.is_subagent = 1 THEN 1 ELSE sessions.is_subagent END, + parent_session_id = COALESCE(excluded.parent_session_id, sessions.parent_session_id)`, ).run( providerSessionId, provider, @@ -136,13 +169,37 @@ export const sessionsDb = { customName ?? null, normalizedProjectPath, jsonlPath ?? null, + isSubagent, + parentSessionId, createdAtValue, - updatedAtValue + updatedAtValue, ); return providerSessionId; }, + /** + * Marks an existing session as a subagent of another session. + * Used after Cursor store.db Task results reveal an agentId link. + */ + markSessionAsSubagent( + providerOrSessionId: string, + parentSessionId: string, + ): boolean { + const db = getConnection(); + const result = db + .prepare( + `UPDATE sessions + SET + is_subagent = 1, + parent_session_id = COALESCE(?, parent_session_id) + WHERE session_id = ? OR provider_session_id = ?`, + ) + .run(parentSessionId, providerOrSessionId, providerOrSessionId); + + return result.changes > 0; + }, + /** * Inserts one app-allocated session row before any provider run happens. * @@ -158,8 +215,11 @@ export const sessionsDb = { projectsDb.createProjectPath(normalizedProjectPath); db.prepare( - `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) - VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` + `INSERT INTO sessions ( + session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, + isArchived, is_subagent, parent_session_id, created_at, updated_at + ) + VALUES (?, ?, NULL, NULL, ?, NULL, 0, 0, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, ).run(sessionId, provider, normalizedProjectPath); return sessionId; @@ -183,7 +243,7 @@ export const sessionsDb = { `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE (session_id = ? OR provider_session_id = ?) AND session_id <> ? - LIMIT 1` + LIMIT 1`, ) .get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined; @@ -194,9 +254,18 @@ export const sessionsDb = { provider_session_id = ?, jsonl_path = COALESCE(jsonl_path, ?), custom_name = COALESCE(custom_name, ?), + is_subagent = CASE WHEN ? = 1 THEN 1 ELSE is_subagent END, + parent_session_id = COALESCE(parent_session_id, ?), updated_at = CURRENT_TIMESTAMP - WHERE session_id = ?` - ).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId); + WHERE session_id = ?`, + ).run( + providerSessionId, + duplicate.jsonl_path, + duplicate.custom_name, + Number(duplicate.is_subagent ?? 0), + duplicate.parent_session_id, + sessionId, + ); return; } @@ -204,7 +273,7 @@ export const sessionsDb = { `UPDATE sessions SET provider_session_id = ?, updated_at = CURRENT_TIMESTAMP - WHERE session_id = ?` + WHERE session_id = ?`, ).run(providerSessionId, sessionId); }); @@ -216,7 +285,7 @@ export const sessionsDb = { db.prepare( `UPDATE sessions SET custom_name = ? - WHERE session_id = ?` + WHERE session_id = ?`, ).run(customName, sessionId); }, @@ -228,7 +297,7 @@ export const sessionsDb = { FROM sessions WHERE session_id = ? ORDER BY updated_at DESC - LIMIT 1` + LIMIT 1`, ) .get(sessionId) as SessionRow | undefined; @@ -250,7 +319,7 @@ export const sessionsDb = { FROM sessions WHERE provider_session_id = ? ORDER BY updated_at DESC - LIMIT 1` + LIMIT 1`, ) .get(providerSessionId) as SessionRow | undefined; @@ -286,21 +355,23 @@ export const sessionsDb = { AND project_path = ? AND provider_session_id IS NULL AND isArchived = 0 + AND COALESCE(is_subagent, 0) = 0 ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC - LIMIT 1` + LIMIT 1`, ) .get(provider, normalizedProjectPath) as SessionRow | undefined; return normalizeSessionRow(row) ?? null; }, - getAllSessions(): SessionRow[] { + getAllSessions(options: ListSessionsOptions = {}): SessionRow[] { const db = getConnection(); + const includeSubagents = Boolean(options.includeSubagents); const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions - WHERE isArchived = 0` + WHERE isArchived = 0${subagentFilterSql(includeSubagents)}`, ) .all() as SessionRow[]; @@ -318,22 +389,23 @@ export const sessionsDb = { `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 1 - ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` + ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`, ) .all() as SessionRow[]; return normalizeSessionRows(rows); }, - getSessionsByProjectPath(projectPath: string): SessionRow[] { + getSessionsByProjectPath(projectPath: string, options: ListSessionsOptions = {}): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); + const includeSubagents = Boolean(options.includeSubagents); const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ? - AND isArchived = 0` + AND isArchived = 0${subagentFilterSql(includeSubagents)}`, ) .all(normalizedProjectPath) as SessionRow[]; @@ -351,39 +423,46 @@ export const sessionsDb = { .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions - WHERE project_path = ?` + WHERE project_path = ?`, ) .all(normalizedProjectPath) as SessionRow[]; return normalizeSessionRows(rows); }, - getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] { + getSessionsByProjectPathPage( + projectPath: string, + limit: number, + offset: number, + options: ListSessionsOptions = {}, + ): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); + const includeSubagents = Boolean(options.includeSubagents); const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ? - AND isArchived = 0 + AND isArchived = 0${subagentFilterSql(includeSubagents)} ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC - LIMIT ? OFFSET ?` + LIMIT ? OFFSET ?`, ) .all(normalizedProjectPath, limit, offset) as SessionRow[]; return normalizeSessionRows(rows); }, - countSessionsByProjectPath(projectPath: string): number { + countSessionsByProjectPath(projectPath: string, options: ListSessionsOptions = {}): number { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); + const includeSubagents = Boolean(options.includeSubagents); const row = db .prepare( `SELECT COUNT(*) AS count FROM sessions WHERE project_path = ? - AND isArchived = 0` + AND isArchived = 0${subagentFilterSql(includeSubagents)}`, ) .get(normalizedProjectPath) as { count: number } | undefined; @@ -402,7 +481,7 @@ export const sessionsDb = { .prepare( `SELECT custom_name FROM sessions - WHERE session_id = ? AND provider = ?` + WHERE session_id = ? AND provider = ?`, ) .get(sessionId, provider) as { custom_name: string | null } | undefined; @@ -418,7 +497,7 @@ export const sessionsDb = { db.prepare( `UPDATE sessions SET isArchived = ? - WHERE session_id = ?` + WHERE session_id = ?`, ).run(isArchived ? 1 : 0, sessionId); }, diff --git a/server/modules/database/schema.ts b/server/modules/database/schema.ts index a02cfebaed..3cd94f57d3 100644 --- a/server/modules/database/schema.ts +++ b/server/modules/database/schema.ts @@ -110,6 +110,10 @@ CREATE TABLE IF NOT EXISTS sessions ( project_path TEXT, jsonl_path TEXT, isArchived BOOLEAN DEFAULT 0, + -- Cursor (and similar) Task/subagent transcripts. Hidden from the default + -- sidebar list; still addressable via /session/:id and parent-chat links. + is_subagent BOOLEAN DEFAULT 0, + parent_session_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (session_id), diff --git a/server/modules/projects/projects.routes.ts b/server/modules/projects/projects.routes.ts index e08c23d7c1..29cea6be2a 100644 --- a/server/modules/projects/projects.routes.ts +++ b/server/modules/projects/projects.routes.ts @@ -73,10 +73,14 @@ router.get( readQueryStringValue(req.query.skipSync).trim() === '1'; const sessionsLimit = readOptionalNumericQueryValue(req.query.sessionsLimit) ?? undefined; const sessionsOffset = readOptionalNumericQueryValue(req.query.sessionsOffset) ?? undefined; + const includeSubagents = + readQueryStringValue(req.query.includeSubagents).trim() === '1' || + readQueryStringValue(req.query.includeSubagents).trim() === 'true'; const projects = await getProjectsWithSessions({ skipSynchronization, sessionsLimit, sessionsOffset, + includeSubagents, }); res.json(projects); }), @@ -96,7 +100,10 @@ router.get( const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : ''; const limit = parseNonNegativeIntQuery(req.query.limit, 'limit', 20); const offset = parseNonNegativeIntQuery(req.query.offset, 'offset', 0); - const sessionsPage = await getProjectSessionsPage(projectId, { limit, offset }); + const includeSubagents = + readQueryStringValue(req.query.includeSubagents).trim() === '1' || + readQueryStringValue(req.query.includeSubagents).trim() === 'true'; + const sessionsPage = await getProjectSessionsPage(projectId, { limit, offset, includeSubagents }); res.json(sessionsPage); }), ); diff --git a/server/modules/projects/services/projects-with-sessions-fetch.service.ts b/server/modules/projects/services/projects-with-sessions-fetch.service.ts index 296ec59408..9c10e40a31 100644 --- a/server/modules/projects/services/projects-with-sessions-fetch.service.ts +++ b/server/modules/projects/services/projects-with-sessions-fetch.service.ts @@ -13,6 +13,8 @@ type SessionSummary = { summary: string; messageCount: number; lastActivity: string; + isSubagent?: boolean; + parentSessionId?: string | null; }; type SessionRepositoryRow = { @@ -21,6 +23,8 @@ type SessionRepositoryRow = { custom_name?: string | null; updated_at?: string | null; created_at?: string | null; + is_subagent?: number | null; + parent_session_id?: string | null; }; export type ProjectListItem = { @@ -51,11 +55,13 @@ type GetProjectsWithSessionsOptions = { skipSynchronization?: boolean; sessionsLimit?: number; sessionsOffset?: number; + includeSubagents?: boolean; }; type SessionPaginationOptions = { limit?: number; offset?: number; + includeSubagents?: boolean; }; type ProjectSessionsPageResult = { @@ -118,12 +124,19 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l } function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary { + const isSubagent = Boolean(row.is_subagent); return { id: row.session_id, provider: row.provider, summary: row.custom_name || '', messageCount: 0, lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), + ...(isSubagent + ? { + isSubagent: true, + parentSessionId: row.parent_session_id ?? null, + } + : {}), }; } @@ -145,12 +158,14 @@ function readProjectSessionsPageByPath( options: SessionPaginationOptions = {}, ): ProjectSessionsPageResult { const pagination = normalizeSessionPagination(options); + const listOptions = { includeSubagents: Boolean(options.includeSubagents) }; const rows = sessionsDb.getSessionsByProjectPathPage( projectPath, pagination.limit, pagination.offset, + listOptions, ) as SessionRepositoryRow[]; - const total = sessionsDb.countSessionsByProjectPath(projectPath); + const total = sessionsDb.countSessionsByProjectPath(projectPath, listOptions); return { sessions: rows.map(mapSessionRowToSummary), @@ -215,6 +230,7 @@ export async function getProjectsWithSessions( const sessionsPage = readProjectSessionsPageByPath(projectPath, { limit: options.sessionsLimit, offset: options.sessionsOffset, + includeSubagents: options.includeSubagents, }); projects.push({ @@ -302,7 +318,11 @@ export async function getProjectSessionsPage( }); } - const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options); + const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, { + limit: options.limit, + offset: options.offset, + includeSubagents: options.includeSubagents, + }); return { projectId: projectRow.project_id, sessions: sessionsPage.sessions, diff --git a/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts b/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts index 2b2fc5feae..db63560d89 100644 --- a/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +++ b/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts @@ -14,17 +14,24 @@ import { } from '@/shared/utils.js'; import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; +import { + extractCursorAgentIds, + parseCursorSubagentTranscriptPath, +} from './utils/cursor-subagent.js'; + type ParsedSession = { sessionId: string; projectPath: string; sessionName?: string; + isSubagent?: boolean; + parentProviderSessionId?: string | null; }; /** * Returns directory entries or an empty list when the folder is missing. */ async function listDirectoryEntriesSafe( - directoryPath: string + directoryPath: string, ): Promise { try { return await fsp.readdir(directoryPath, { withFileTypes: true }); @@ -64,11 +71,19 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { parsed.sessionName, timestamps.createdAt, timestamps.updatedAt, - filePath + filePath, + { + isSubagent: Boolean(parsed.isSubagent), + parentSessionId: parsed.parentProviderSessionId ?? null, + }, ); processed += 1; } + // Cursor Task subagents often land as peer agent-transcript folders (not + // under …/subagents/). Link those via parent store.db agentId references. + processed += await this.linkSubagentsFromCursorStores(); + return processed; } @@ -86,15 +101,26 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { } const timestamps = await readFileTimestamps(filePath); - return sessionsDb.createSession( + const sessionId = sessionsDb.createSession( parsed.sessionId, this.provider, parsed.projectPath, parsed.sessionName, timestamps.createdAt, timestamps.updatedAt, - filePath + filePath, + { + isSubagent: Boolean(parsed.isSubagent), + parentSessionId: parsed.parentProviderSessionId ?? null, + }, ); + + // A parent transcript update may reveal new Task agentIds. + if (!parsed.isSubagent) { + await this.linkSubagentsFromParentStore(parsed.sessionId, parsed.projectPath); + } + + return sessionId; } /** @@ -126,8 +152,14 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { */ private async processSessionFile(filePath: string): Promise { const sessionId = path.basename(filePath, '.jsonl'); - const grandparentDir = path.dirname(path.dirname(path.dirname(filePath))); - const workerLogPath = path.join(grandparentDir, 'worker.log'); + const subagentPath = parseCursorSubagentTranscriptPath(filePath); + + // worker.log lives on the project slug directory. For classic subagent + // paths that is four levels up from the file; for root transcripts, three. + const projectSlugDir = subagentPath + ? path.dirname(path.dirname(path.dirname(path.dirname(filePath)))) + : path.dirname(path.dirname(path.dirname(filePath))); + const workerLogPath = path.join(projectSlugDir, 'worker.log'); const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath); if (!projectPath) { @@ -153,7 +185,107 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { sessionId, projectPath, sessionName: normalizeSessionName(firstLine, 'Untitled Cursor Session'), + isSubagent: Boolean(subagentPath), + parentProviderSessionId: subagentPath?.parentProviderSessionId ?? null, }; }); } + + /** + * Scans Cursor chat store.db files for Task tool agentId references and marks + * those sessions as hidden subagents of the parent conversation. + */ + private async linkSubagentsFromCursorStores(): Promise { + const chatsRoot = path.join(this.cursorHome, 'chats'); + const cwdEntries = await listDirectoryEntriesSafe(chatsRoot); + let linked = 0; + + for (const cwdEntry of cwdEntries) { + if (!cwdEntry.isDirectory()) { + continue; + } + + const cwdDir = path.join(chatsRoot, cwdEntry.name); + const sessionEntries = await listDirectoryEntriesSafe(cwdDir); + for (const sessionEntry of sessionEntries) { + if (!sessionEntry.isDirectory()) { + continue; + } + + const parentProviderSessionId = sessionEntry.name; + const storeDbPath = path.join(cwdDir, parentProviderSessionId, 'store.db'); + try { + await fsp.access(storeDbPath); + } catch { + continue; + } + + linked += await this.linkSubagentsFromStoreDb(parentProviderSessionId, storeDbPath); + } + } + + return linked; + } + + private async linkSubagentsFromParentStore( + parentProviderSessionId: string, + projectPath: string, + ): Promise { + const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); + const storeDbPath = path.join(this.cursorHome, 'chats', cwdId, parentProviderSessionId, 'store.db'); + try { + await fsp.access(storeDbPath); + } catch { + return 0; + } + + return this.linkSubagentsFromStoreDb(parentProviderSessionId, storeDbPath); + } + + private async linkSubagentsFromStoreDb( + parentProviderSessionId: string, + storeDbPath: string, + ): Promise { + const parentRow = sessionsDb.getSessionByProviderSessionId(parentProviderSessionId) + ?? sessionsDb.getSessionById(parentProviderSessionId); + const parentSessionId = parentRow?.session_id ?? parentProviderSessionId; + + let agentIds: string[] = []; + try { + const { default: Database } = await import('better-sqlite3'); + const db = new Database(storeDbPath, { readonly: true, fileMustExist: true }); + try { + const blobs = db.prepare('SELECT data FROM blobs').all() as Array<{ data?: Buffer }>; + const found = new Set(); + for (const blob of blobs) { + if (!blob.data || blob.data[0] !== 0x7b) { + continue; + } + const text = blob.data.toString('utf8'); + if (!text.includes('agentId') && !text.includes('Agent ID:')) { + continue; + } + for (const agentId of extractCursorAgentIds(text)) { + if (agentId !== parentProviderSessionId && agentId !== parentSessionId) { + found.add(agentId); + } + } + } + agentIds = [...found]; + } finally { + db.close(); + } + } catch { + return 0; + } + + let linked = 0; + for (const agentId of agentIds) { + if (sessionsDb.markSessionAsSubagent(agentId, parentSessionId)) { + linked += 1; + } + } + + return linked; + } } diff --git a/server/modules/providers/list/cursor/cursor-sessions.provider.ts b/server/modules/providers/list/cursor/cursor-sessions.provider.ts index 5c61c64a52..e47f68f14d 100644 --- a/server/modules/providers/list/cursor/cursor-sessions.provider.ts +++ b/server/modules/providers/list/cursor/cursor-sessions.provider.ts @@ -1,7 +1,11 @@ import crypto from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import readline from 'node:readline'; +import { sessionsDb } from '@/modules/database/index.js'; import { parseImagesInputTag } from '@/shared/image-attachments.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; @@ -13,6 +17,8 @@ import { sliceTailPage, } from '@/shared/utils.js'; +import { extractAgentIdFromToolResult } from './utils/cursor-subagent.js'; + const PROVIDER = 'cursor'; type CursorDbBlob = { @@ -404,8 +410,19 @@ export class CursorSessionsProvider implements IProviderSessions { const providerSessionId = options.providerSessionId ?? sessionId; try { - const blobs = await this.loadCursorBlobs(providerSessionId, projectPath); - const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); + let allNormalized: NormalizedMessage[]; + try { + const blobs = await this.loadCursorBlobs(providerSessionId, projectPath); + allNormalized = this.normalizeCursorBlobs(blobs, sessionId); + } catch (storeError) { + // Subagent sessions usually only have agent-transcripts JSONL, no store.db. + allNormalized = await this.loadCursorJsonlHistory(sessionId, providerSessionId); + if (allNormalized.length === 0) { + throw storeError; + } + } + + await this.enrichTaskSubagentLinks(allNormalized, sessionId); const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result'); const total = renderableMessages.length; const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset); @@ -424,6 +441,189 @@ export class CursorSessionsProvider implements IProviderSessions { } } + /** + * Attaches Cursor Task → subagent session links (and optional tool history) + * so the parent chat can open the subagent transcript. + */ + private async enrichTaskSubagentLinks( + messages: NormalizedMessage[], + parentSessionId: string | null, + ): Promise { + for (const message of messages) { + if (message.kind !== 'tool_use' || message.toolName !== 'Task') { + continue; + } + + const agentId = extractAgentIdFromToolResult(message.toolResult) + ?? extractAgentIdFromToolResult(message.toolResult?.toolUseResult); + if (!agentId) { + continue; + } + + message.subagentSessionId = agentId; + if (parentSessionId) { + sessionsDb.markSessionAsSubagent(agentId, parentSessionId); + } + + const tools = await this.loadSubagentToolsFromJsonl(agentId); + if (tools.length > 0) { + message.subagentTools = tools; + } + } + } + + /** + * Loads tool_use entries from a Cursor agent-transcripts JSONL file. + */ + private async loadSubagentToolsFromJsonl(agentId: string): Promise { + const row = sessionsDb.getSessionById(agentId) + ?? sessionsDb.getSessionByProviderSessionId(agentId); + const jsonlPath = row?.jsonl_path; + if (!jsonlPath) { + return []; + } + + const tools: AnyRecord[] = []; + try { + const fileStream = fs.createReadStream(jsonlPath, { encoding: 'utf8' }); + const lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + for await (const line of lineReader) { + if (!line.trim()) { + continue; + } + try { + const entry = JSON.parse(line) as AnyRecord; + const content = entry.message?.content; + if (!Array.isArray(content)) { + continue; + } + for (const part of content) { + if (part?.type === 'tool_use' && part?.name) { + tools.push({ + toolId: part.id || `${agentId}_${tools.length}`, + toolName: part.name, + toolInput: part.input, + timestamp: entry.timestamp, + }); + } + } + } catch { + // Skip malformed JSONL lines. + } + } + } catch { + return []; + } + + return tools; + } + + /** + * Fallback history reader for Cursor sessions that only have agent-transcripts + * JSONL (typical for Task/subagent runs without a chats///store.db). + */ + private async loadCursorJsonlHistory( + sessionId: string, + providerSessionId: string, + ): Promise { + const row = sessionsDb.getSessionById(sessionId) + ?? sessionsDb.getSessionByProviderSessionId(providerSessionId); + const jsonlPath = row?.jsonl_path; + if (!jsonlPath) { + return []; + } + + try { + await fsp.access(jsonlPath); + } catch { + return []; + } + + const messages: NormalizedMessage[] = []; + const baseTime = Date.now(); + let sequence = 0; + + try { + const fileStream = fs.createReadStream(jsonlPath, { encoding: 'utf8' }); + const lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + for await (const line of lineReader) { + if (!line.trim()) { + continue; + } + try { + const entry = JSON.parse(line) as AnyRecord; + const roleRaw = entry.role || entry.message?.role; + if (roleRaw !== 'user' && roleRaw !== 'assistant') { + continue; + } + const role = roleRaw === 'user' ? 'user' : 'assistant'; + const content = entry.message?.content ?? entry.content; + const ts = typeof entry.timestamp === 'string' + ? entry.timestamp + : new Date(baseTime + sequence * 100).toISOString(); + + if (Array.isArray(content)) { + for (let partIdx = 0; partIdx < content.length; partIdx++) { + const part = content[partIdx] as AnyRecord; + if (part?.type === 'text' && typeof part.text === 'string') { + const { text } = extractUserTextAndImages(part.text, role); + if (!text.trim()) { + continue; + } + sequence += 1; + messages.push(createNormalizedMessage({ + id: `${providerSessionId}_${sequence}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role, + content: text, + sequence, + })); + } else if (part?.type === 'tool_use' && part?.name) { + sequence += 1; + messages.push(createNormalizedMessage({ + id: `${providerSessionId}_${sequence}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: part.name === 'ApplyPatch' ? 'Edit' : part.name, + toolInput: normalizeCursorToolInput(part.name, part.input), + toolId: typeof part.id === 'string' ? part.id : `tool_${sequence}`, + sequence, + })); + } + } + } else if (typeof content === 'string' && content.trim()) { + const { text } = extractUserTextAndImages(content, role); + if (!text.trim()) { + continue; + } + sequence += 1; + messages.push(createNormalizedMessage({ + id: `${providerSessionId}_${sequence}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role, + content: text, + sequence, + })); + } + } catch { + // Skip malformed lines. + } + } + } catch { + return []; + } + + return messages; + } + /** * Converts Cursor SQLite message blobs into normalized messages and attaches * matching tool results to their tool_use entries. diff --git a/server/modules/providers/list/cursor/utils/cursor-subagent.test.ts b/server/modules/providers/list/cursor/utils/cursor-subagent.test.ts new file mode 100644 index 0000000000..4e42831e95 --- /dev/null +++ b/server/modules/providers/list/cursor/utils/cursor-subagent.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + extractAgentIdFromToolResult, + extractCursorAgentIds, + parseCursorSubagentTranscriptPath, +} from './cursor-subagent.js'; + +test('parseCursorSubagentTranscriptPath detects classic layout', () => { + const info = parseCursorSubagentTranscriptPath( + '/root/.cursor/projects/demo/agent-transcripts/parent-uuid/subagents/child-uuid.jsonl', + ); + assert.deepEqual(info, { + sessionId: 'child-uuid', + parentProviderSessionId: 'parent-uuid', + }); +}); + +test('parseCursorSubagentTranscriptPath ignores peer transcripts', () => { + assert.equal( + parseCursorSubagentTranscriptPath( + '/root/.cursor/projects/demo/agent-transcripts/session-uuid/session-uuid.jsonl', + ), + null, + ); +}); + +test('extractCursorAgentIds finds json and text forms', () => { + const ids = extractCursorAgentIds( + 'Agent ID: 0940f589-93cd-4f0b-93c0-eca349f5c261 and {"agentId":"4fddc5e1-8208-42c6-be14-dd8f4bcbc9f3"}', + ); + assert.deepEqual(ids.sort(), [ + '0940f589-93cd-4f0b-93c0-eca349f5c261', + '4fddc5e1-8208-42c6-be14-dd8f4bcbc9f3', + ].sort()); +}); + +test('extractAgentIdFromToolResult reads nested toolUseResult', () => { + assert.equal( + extractAgentIdFromToolResult({ + content: 'done', + toolUseResult: { agentId: '8903abc9-08d5-4127-a219-b68fb6cef14e' }, + }), + '8903abc9-08d5-4127-a219-b68fb6cef14e', + ); +}); diff --git a/server/modules/providers/list/cursor/utils/cursor-subagent.ts b/server/modules/providers/list/cursor/utils/cursor-subagent.ts new file mode 100644 index 0000000000..87a15dc755 --- /dev/null +++ b/server/modules/providers/list/cursor/utils/cursor-subagent.ts @@ -0,0 +1,92 @@ +import path from 'node:path'; + +const UUID_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; +const AGENT_ID_JSON_RE = new RegExp(`"agentId"\\s*:\\s*"(${UUID_RE})"`, 'gi'); +const AGENT_ID_TEXT_RE = new RegExp(`Agent ID:\\s*(${UUID_RE})`, 'gi'); + +export type CursorSubagentPathInfo = { + sessionId: string; + parentProviderSessionId: string; +}; + +/** + * Detects the classic Cursor layout: + * `…/agent-transcripts//subagents/.jsonl` + */ +export function parseCursorSubagentTranscriptPath(filePath: string): CursorSubagentPathInfo | null { + const normalized = path.normalize(filePath); + const parts = normalized.split(path.sep); + const subagentsIndex = parts.lastIndexOf('subagents'); + if (subagentsIndex <= 0) { + return null; + } + + const parentProviderSessionId = parts[subagentsIndex - 1]; + const fileName = parts[parts.length - 1] || ''; + if (!fileName.endsWith('.jsonl') || !parentProviderSessionId) { + return null; + } + + const sessionId = path.basename(fileName, '.jsonl'); + if (!sessionId || sessionId === parentProviderSessionId) { + return null; + } + + return { sessionId, parentProviderSessionId }; +} + +/** + * Extracts Cursor Task subagent ids from a store.db JSON blob / tool result. + */ +export function extractCursorAgentIds(payload: string): string[] { + const found = new Set(); + + for (const regex of [AGENT_ID_JSON_RE, AGENT_ID_TEXT_RE]) { + regex.lastIndex = 0; + let match = regex.exec(payload); + while (match) { + if (match[1]) { + found.add(match[1]); + } + match = regex.exec(payload); + } + } + + return [...found]; +} + +/** + * Pulls a single agentId from a Task tool result envelope when present. + */ +export function extractAgentIdFromToolResult(toolResult: unknown): string | null { + if (!toolResult || typeof toolResult !== 'object') { + return null; + } + + const record = toolResult as Record; + const direct = record.agentId ?? record.agent_id; + if (typeof direct === 'string' && direct.trim()) { + return direct.trim(); + } + + const nested = record.toolUseResult; + if (nested && typeof nested === 'object') { + const nestedRecord = nested as Record; + const nestedId = nestedRecord.agentId ?? nestedRecord.agent_id; + if (typeof nestedId === 'string' && nestedId.trim()) { + return nestedId.trim(); + } + } + + const content = record.content; + if (typeof content === 'string') { + const ids = extractCursorAgentIds(content); + return ids[0] ?? null; + } + + try { + return extractCursorAgentIds(JSON.stringify(toolResult))[0] ?? null; + } catch { + return null; + } +} diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 96f13e2ab4..cedb30d3c3 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -144,6 +144,7 @@ async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Prom ? project.custom_project_name : await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath); + const isSubagent = Boolean((row as { is_subagent?: number }).is_subagent); return JSON.stringify({ kind: 'session_upserted', sessionId: row.session_id, @@ -153,6 +154,12 @@ async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Prom summary: row.custom_name || '', messageCount: 0, lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), + ...(isSubagent + ? { + isSubagent: true, + parentSessionId: (row as { parent_session_id?: string | null }).parent_session_id ?? null, + } + : {}), }, project: project ? { diff --git a/server/shared/types.ts b/server/shared/types.ts index 45092ecf73..23b9c92c1b 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -264,6 +264,8 @@ export type NormalizedMessage = { summary?: string; tokenBudget?: unknown; subagentTools?: unknown; + /** Cursor Task child session id (provider-native / app-facing). */ + subagentSessionId?: string; toolUseResult?: unknown; sequence?: number; rowid?: number; diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 90157d8b03..f4ac951165 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -177,6 +177,9 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes toolId: msg.toolId, toolResult, isSubagentContainer, + subagentSessionId: typeof (msg as { subagentSessionId?: unknown }).subagentSessionId === 'string' + ? (msg as { subagentSessionId: string }).subagentSessionId + : undefined, subagentState: isSubagentContainer ? { childTools, diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index f9ebfbe5ee..8555bc03b4 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -27,6 +27,7 @@ interface ToolRendererProps { showRawParameters?: boolean; rawToolInput?: string; isSubagentContainer?: boolean; + subagentSessionId?: string; subagentState?: { childTools: SubagentChildTool[]; currentToolIndex: number; @@ -82,6 +83,7 @@ export const ToolRenderer: React.FC = memo(({ showRawParameters = false, rawToolInput, isSubagentContainer, + subagentSessionId, subagentState }) => { const config = getToolConfig(toolName); @@ -117,6 +119,7 @@ export const ToolRenderer: React.FC = memo(({ toolInput={toolInput} toolResult={toolResult} subagentState={subagentState} + subagentSessionId={subagentSessionId} /> ); } diff --git a/src/components/chat/tools/components/SubagentContainer.tsx b/src/components/chat/tools/components/SubagentContainer.tsx index 83d342084d..a00dd89bee 100644 --- a/src/components/chat/tools/components/SubagentContainer.tsx +++ b/src/components/chat/tools/components/SubagentContainer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import type { SubagentChildTool } from '../../types/types'; import { CollapsibleSection } from './CollapsibleSection'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui'; @@ -6,6 +7,7 @@ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../.. interface SubagentContainerProps { toolInput: unknown; toolResult?: { content?: unknown; isError?: boolean } | null; + subagentSessionId?: string; subagentState: { childTools: SubagentChildTool[]; currentToolIndex: number; @@ -43,6 +45,7 @@ const getCompactToolDisplay = (toolName: string, toolInput: unknown): string => export const SubagentContainer: React.FC = ({ toolInput, toolResult, + subagentSessionId, subagentState, }) => { const parsedInput = typeof toolInput === 'string' ? (() => { @@ -64,6 +67,17 @@ export const SubagentContainer: React.FC = ({ toolName="Task" open={false} > + {subagentSessionId && ( +
+ + Open transcript + +
+ )} + {/* Prompt/request to the subagent */} {prompt && (
diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 4ec7ffe37f..b15af081c8 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -57,6 +57,8 @@ export interface ChatMessage { isLocalCommandStdout?: boolean; isCompactSummary?: boolean; isSubagentContainer?: boolean; + /** Cursor/Claude Task child session id — opens `/session/:id`. */ + subagentSessionId?: string; subagentState?: { childTools: SubagentChildTool[]; currentToolIndex: number; diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 61cf8ee37b..ca1f27f9a1 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -187,6 +187,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined} isSubagentContainer={message.isSubagentContainer} subagentState={message.subagentState} + subagentSessionId={message.subagentSessionId} /> )} diff --git a/src/components/command-palette/sources/useSessionsSource.ts b/src/components/command-palette/sources/useSessionsSource.ts index ec045b268b..6e95695492 100644 --- a/src/components/command-palette/sources/useSessionsSource.ts +++ b/src/components/command-palette/sources/useSessionsSource.ts @@ -19,6 +19,15 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea deps: [projectId], fetcher: (signal) => { const params = new URLSearchParams({ limit: '50', offset: '0' }); + try { + const raw = localStorage.getItem('uiPreferences'); + const parsed = raw ? JSON.parse(raw) as { showSubagentSessions?: boolean } : null; + if (parsed?.showSubagentSessions) { + params.set('includeSubagents', '1'); + } + } catch { + // Ignore malformed preference storage. + } return authenticatedFetch( `/api/projects/${encodeURIComponent(projectId!)}/sessions?${params.toString()}`, { signal }, diff --git a/src/components/quick-settings-panel/constants.ts b/src/components/quick-settings-panel/constants.ts index 91cfbafc43..8b18f80fc2 100644 --- a/src/components/quick-settings-panel/constants.ts +++ b/src/components/quick-settings-panel/constants.ts @@ -1,6 +1,7 @@ import { Brain, Eye, + GitBranch, Languages, Mic, } from 'lucide-react'; @@ -33,6 +34,11 @@ export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [ labelKey: 'quickSettings.showThinking', icon: Brain, }, + { + key: 'showSubagentSessions', + labelKey: 'quickSettings.showSubagentSessions', + icon: GitBranch, + }, ]; export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [ diff --git a/src/components/quick-settings-panel/types.ts b/src/components/quick-settings-panel/types.ts index 8bb760d5bd..3872cf2c8f 100644 --- a/src/components/quick-settings-panel/types.ts +++ b/src/components/quick-settings-panel/types.ts @@ -4,6 +4,7 @@ import type { LucideIcon } from 'lucide-react'; export type PreferenceToggleKey = | 'showRawParameters' | 'showThinking' + | 'showSubagentSessions' | 'sendByCtrlEnter' | 'voiceEnabled'; diff --git a/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx b/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx index 07535d258e..c43f84147c 100644 --- a/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx +++ b/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx @@ -26,11 +26,13 @@ export default function QuickSettingsPanelView() { const quickSettingsPreferences = useMemo(() => ({ showRawParameters: preferences.showRawParameters, showThinking: preferences.showThinking, + showSubagentSessions: preferences.showSubagentSessions, sendByCtrlEnter: preferences.sendByCtrlEnter, voiceEnabled: preferences.voiceEnabled, }), [ preferences.sendByCtrlEnter, preferences.showRawParameters, + preferences.showSubagentSessions, preferences.showThinking, preferences.voiceEnabled, ]); diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index 4d907a8b52..0b6fe85eb4 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -3,6 +3,7 @@ import type { TFunction } from 'i18next'; import { api } from '../../../utils/api'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; +import { useUiPreferences } from '../../../hooks/useUiPreferences'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionActivityMap } from '../../../hooks/useSessionProtection'; import type { @@ -117,6 +118,8 @@ export function useSidebarController({ sidebarVisible, }: UseSidebarControllerArgs) { const paletteOps = usePaletteOps(); + const { preferences } = useUiPreferences(); + const includeSubagents = preferences.showSubagentSessions; const [expandedProjects, setExpandedProjects] = useState>(new Set()); const [editingProject, setEditingProject] = useState(null); const [showNewProject, setShowNewProject] = useState(false); @@ -522,7 +525,10 @@ export function useSidebarController({ [resolveProjectStarState], ); - const getProjectSessions = useCallback((project: Project) => getAllSessions(project), []); + const getProjectSessions = useCallback( + (project: Project) => getAllSessions(project, { includeSubagents }), + [includeSubagents], + ); const loadMoreSessionsForProject = useCallback(async (projectId: string) => { if (!onLoadMoreSessions) { diff --git a/src/components/sidebar/utils/utils.ts b/src/components/sidebar/utils/utils.ts index 23010915e9..d5c247b60a 100644 --- a/src/components/sidebar/utils/utils.ts +++ b/src/components/sidebar/utils/utils.ts @@ -96,13 +96,20 @@ export const createSessionViewModel = ( }; }; -export const getAllSessions = (project: Project): SessionWithProvider[] => { - return (project.sessions || []).map((session) => ({ - ...session, - __provider: getSessionProvider(session), - })).sort( - (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), - ); +export const getAllSessions = ( + project: Project, + options: { includeSubagents?: boolean } = {}, +): SessionWithProvider[] => { + const includeSubagents = Boolean(options.includeSubagents); + return (project.sessions || []) + .filter((session) => includeSubagents || !session.isSubagent) + .map((session) => ({ + ...session, + __provider: getSessionProvider(session), + })) + .sort( + (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), + ); }; export const getProjectLastActivity = (project: Project): Date => { diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 91ea437cab..405194bf7e 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -170,7 +170,14 @@ export default function SidebarSessionItem({
-
{sessionView.sessionName}
+
+ {session.isSubagent && ( + + sub + + )} + {sessionView.sessionName} +
{isProcessing ? ( @@ -239,7 +246,14 @@ export default function SidebarSessionItem({
-
{sessionView.sessionName}
+
+ {session.isSubagent && ( + + sub + + )} + {sessionView.sessionName} +
{isProcessing ? ( ([]); const [selectedProject, setSelectedProject] = useState(null); const [selectedSession, setSelectedSession] = useState(null); @@ -455,7 +461,7 @@ export function useProjectsState({ if (showLoadingState) { setIsLoadingProjects(true); } - const response = await api.projects(); + const response = await api.projects({ includeSubagents }); const projectData = (await response.json()) as Project[]; setProjects((prevProjects) => { @@ -477,7 +483,7 @@ export function useProjectsState({ setIsLoadingProjects(false); } } - }, []); + }, [includeSubagents]); const refreshProjectsSilently = useCallback(async () => { // Keep chat view stable while still syncing sidebar/session metadata in background. @@ -685,6 +691,16 @@ export function useProjectsState({ markSessionAttention(upsert.sessionId); } + // Hide Cursor Task/subagent sessions from the sidebar unless the user + // opted into "Show subagent sessions". Direct /session/:id still works. + if ( + upsert.session.isSubagent + && !includeSubagentsRef.current + && currentSelectedSession?.id !== upsert.sessionId + ) { + return; + } + setProjects((previousProjects) => { const targetProjectId = upsert.project?.projectId; const existingProject = previousProjects.find((project) => @@ -967,6 +983,7 @@ export function useProjectsState({ const response = await api.projectSessions(projectId, { limit: 20, offset: loadedCount, + includeSubagents, }); if (!response.ok) { @@ -999,7 +1016,7 @@ export function useProjectsState({ if (selectedProject?.projectId === projectId && mergedProjectForSelection) { setSelectedProject(mergedProjectForSelection); } - }, [projects, selectedProject?.projectId]); + }, [includeSubagents, projects, selectedProject?.projectId]); // `projectId` is the DB identifier passed from the sidebar's delete flow // after the migration away from folder-derived project names. diff --git a/src/hooks/useUiPreferences.ts b/src/hooks/useUiPreferences.ts index 35e03aeb70..e7d0e0ce5c 100644 --- a/src/hooks/useUiPreferences.ts +++ b/src/hooks/useUiPreferences.ts @@ -3,6 +3,7 @@ import { useEffect, useReducer, useRef } from 'react'; type UiPreferences = { showRawParameters: boolean; showThinking: boolean; + showSubagentSessions: boolean; sendByCtrlEnter: boolean; sidebarVisible: boolean; voiceEnabled: boolean; @@ -34,6 +35,7 @@ type UiPreferencesAction = const DEFAULTS: UiPreferences = { showRawParameters: false, showThinking: true, + showSubagentSessions: false, sendByCtrlEnter: false, sidebarVisible: true, voiceEnabled: false, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 079dfe46eb..10f2dc0d06 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -75,6 +75,7 @@ "darkMode": "Dark Mode", "showRawParameters": "Show raw parameters", "showThinking": "Show thinking", + "showSubagentSessions": "Show subagent sessions", "sendByCtrlEnter": "Send by Ctrl+Enter", "voiceEnabled": "Voice (mic + read aloud)", "sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.", diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 6117cbb7b3..ed45f13026 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -80,6 +80,7 @@ export interface NormalizedMessage { actualSessionId?: string; parentToolUseId?: string; subagentTools?: unknown[]; + subagentSessionId?: string; isFinal?: boolean; // Cursor-specific ordering sequence?: number; diff --git a/src/types/app.ts b/src/types/app.ts index 8fa4a68430..46f0b5fa8e 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -41,6 +41,9 @@ export interface ProjectSession { // Tags the session with the owning project's DB `projectId` so UI handlers // (session switching, sidebar focus, etc.) can match against selectedProject. __projectId?: string; + /** Cursor Task/subagent transcript — hidden from sidebar by default. */ + isSubagent?: boolean; + parentSessionId?: string | null; [key: string]: unknown; } diff --git a/src/utils/api.js b/src/utils/api.js index e5eb3fbcd4..6bfd391a66 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -64,12 +64,22 @@ export const api = { // config endpoint removed - no longer needed (frontend uses window.location) // After the projectName → projectId migration the path/query identifier is // the DB-assigned `projectId`; parameter names reflect that for clarity. - projects: () => authenticatedFetch('/api/projects'), + projects: ({ includeSubagents = false } = {}) => { + const params = new URLSearchParams(); + if (includeSubagents) { + params.set('includeSubagents', '1'); + } + const query = params.toString(); + return authenticatedFetch(`/api/projects${query ? `?${query}` : ''}`); + }, archivedProjects: () => authenticatedFetch('/api/projects/archived'), - projectSessions: (projectId, { limit = 20, offset = 0 } = {}) => { + projectSessions: (projectId, { limit = 20, offset = 0, includeSubagents = false } = {}) => { const params = new URLSearchParams(); params.set('limit', String(limit)); params.set('offset', String(offset)); + if (includeSubagents) { + params.set('includeSubagents', '1'); + } return authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/sessions?${params.toString()}`); }, projectTaskmaster: (projectId) =>