From 1b46d0221a06902db8aa1b0ceb1742f9e81035e9 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:27 +0200 Subject: [PATCH 01/10] feat(db): add subagent columns and migration v4 Subagent transcripts written by Claude CLI live at //subagents/agent-.jsonl alongside a .meta.json sidecar holding { agentType, description }. Surface them as first-class rows in session_cache, keyed by sessionId 'sub::'. - adds parentSessionId, agentId, subagentType, description columns - migration v4 clears the cache so a re-index repopulates everything - adds idx_session_cache_parent for hierarchy lookups - new query getCachedByParent + widened cacheGetByFolder --- db.js | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/db.js b/db.js index 4fabae4..eaef03e 100644 --- a/db.js +++ b/db.js @@ -49,7 +49,11 @@ db.exec(` modified TEXT, messageCount INTEGER DEFAULT 0, slug TEXT, - aiTitle TEXT + aiTitle TEXT, + parentSessionId TEXT, + agentId TEXT, + subagentType TEXT, + description TEXT ) `); @@ -99,6 +103,20 @@ const migrations = [ try { db.exec('DELETE FROM session_cache'); } catch {} try { db.exec('DELETE FROM cache_meta'); } catch {} }, + // v4: Add subagent columns. Subagent transcripts live under + // //subagents/agent-.jsonl alongside a + // .meta.json sidecar holding { agentType, description }. We surface them as + // first-class rows in session_cache, keyed by sessionId = "sub::". + // Clear cache so subagent rows get picked up on first re-index. + (db) => { + try { db.exec('ALTER TABLE session_cache ADD COLUMN parentSessionId TEXT'); } catch {} + try { db.exec('ALTER TABLE session_cache ADD COLUMN agentId TEXT'); } catch {} + try { db.exec('ALTER TABLE session_cache ADD COLUMN subagentType TEXT'); } catch {} + try { db.exec('ALTER TABLE session_cache ADD COLUMN description TEXT'); } catch {} + try { db.exec('CREATE INDEX IF NOT EXISTS idx_session_cache_parent ON session_cache(parentSessionId)'); } catch {} + try { db.exec('DELETE FROM session_cache'); } catch {} + try { db.exec('DELETE FROM cache_meta'); } catch {} + }, ]; const currentDbVersion = (() => { @@ -152,16 +170,19 @@ const stmts = { cacheCount: db.prepare('SELECT COUNT(*) as cnt FROM session_cache'), cacheGetAll: db.prepare('SELECT * FROM session_cache'), cacheUpsert: db.prepare(` - INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sessionId) DO UPDATE SET folder = excluded.folder, projectPath = excluded.projectPath, summary = excluded.summary, firstPrompt = excluded.firstPrompt, created = excluded.created, modified = excluded.modified, messageCount = excluded.messageCount, slug = excluded.slug, - aiTitle = excluded.aiTitle + aiTitle = excluded.aiTitle, + parentSessionId = excluded.parentSessionId, agentId = excluded.agentId, + subagentType = excluded.subagentType, description = excluded.description `), - cacheGetByFolder: db.prepare('SELECT sessionId, modified FROM session_cache WHERE folder = ?'), + cacheGetByParent: db.prepare('SELECT * FROM session_cache WHERE parentSessionId = ? ORDER BY created ASC'), + cacheGetByFolder: db.prepare('SELECT sessionId, modified, parentSessionId, agentId FROM session_cache WHERE folder = ?'), cacheGetFolder: db.prepare('SELECT folder FROM session_cache WHERE sessionId = ?'), cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'), cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'), @@ -246,11 +267,17 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => { stmts.cacheUpsert.run( s.sessionId, s.folder, s.projectPath, s.summary, s.firstPrompt, s.created, s.modified, s.messageCount || 0, - s.slug || null, s.aiTitle || null + s.slug || null, s.aiTitle || null, + s.parentSessionId || null, s.agentId || null, + s.subagentType || null, s.description || null ); } }); +function getCachedByParent(parentSessionId) { + return stmts.cacheGetByParent.all(parentSessionId); +} + function upsertCachedSessions(sessions) { upsertCachedSessionsBatch(sessions); } @@ -376,7 +403,7 @@ function closeDb() { module.exports = { getMeta, getAllMeta, setName, toggleStar, setArchived, - isCachePopulated, getAllCached, getCachedByFolder, getCachedFolder, getCachedSession, upsertCachedSessions, + isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, deleteCachedSession, deleteCachedFolder, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, From 271381e0c77b58ea21c5043c3eed909d8d0ed398 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:34 +0200 Subject: [PATCH 02/10] feat(read-session-file): support subagent layout Adds helpers for the on-disk subagent layout: - enumerateSessionFiles(folderPath) walks top-level + /subagents/*.jsonl + legacy /*.jsonl fallback - subagentSessionId(parent, agentId) gives the synthetic 'sub:

:' id - resolveJsonlPath(projectsDir, row) reconstructs the absolute path - readSubagentMeta reads the .meta.json sidecar readSessionFile now accepts opts.parentSessionId. When set, it requires isSidechain on at least one entry (defensive guard), reads the sidecar for agentType/description, falls back to filename for agentId if absent in entries. Adds 10 unit tests covering both branches, the sidecar guard, the legacy fallback path, and the synthetic-id format. --- read-session-file.js | 133 +++++++++++++++++++- test/read-session-file.test.js | 219 +++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 test/read-session-file.test.js diff --git a/read-session-file.js b/read-session-file.js index bb42318..f4a533d 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -1,9 +1,41 @@ const path = require('path'); const fs = require('fs'); -/** Parse a single .jsonl file into a session object (or null if invalid) */ -function readSessionFile(filePath, folder, projectPath) { - const sessionId = path.basename(filePath, '.jsonl'); +/** Subagent transcripts land under //subagents/agent-.jsonl. + * We surface them as first-class rows with a synthetic sessionId so they're addressable + * exactly like top-level sessions (search, archive, rename, etc). + */ +function subagentSessionId(parentSessionId, agentId) { + return `sub:${parentSessionId}:${agentId}`; +} + +/** Resolve the absolute jsonl path for a row from session_cache. + * Works for both top-level sessions and subagents. */ +function resolveJsonlPath(projectsDir, row) { + if (!row || !row.folder) return null; + if (row.parentSessionId && row.agentId) { + return path.join(projectsDir, row.folder, row.parentSessionId, 'subagents', `agent-${row.agentId}.jsonl`); + } + return path.join(projectsDir, row.folder, row.sessionId + '.jsonl'); +} + +/** Read sidecar { agentType, description } if present. */ +function readSubagentMeta(jsonlPath) { + const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json'); + try { + return JSON.parse(fs.readFileSync(metaPath, 'utf8')); + } catch { + return null; + } +} + +/** Parse a single .jsonl file into a session object (or null if invalid). + * opts.parentSessionId — if set, treat as a subagent transcript and stamp the + * parent reference into the returned row. + */ +function readSessionFile(filePath, folder, projectPath, opts = {}) { + const fileBase = path.basename(filePath, '.jsonl'); + const isSubagent = Boolean(opts.parentSessionId); try { const stat = fs.statSync(filePath); const content = fs.readFileSync(filePath, 'utf8'); @@ -14,9 +46,13 @@ function readSessionFile(filePath, folder, projectPath) { let slug = null; let customTitle = null; let aiTitle = null; + let agentId = null; + let sidechainSeen = false; for (const line of lines) { const entry = JSON.parse(line); if (entry.slug && !slug) slug = entry.slug; + if (entry.agentId && !agentId) agentId = entry.agentId; + if (entry.isSidechain) sidechainSeen = true; if (entry.type === 'custom-title' && entry.customTitle) { customTitle = entry.customTitle; } @@ -44,8 +80,37 @@ function readSessionFile(filePath, folder, projectPath) { } } if (!summary || messageCount < 1) return null; + + if (isSubagent) { + // Sidechain marker must be present — otherwise the file lives under a + // subagents/ directory but isn't actually a subagent transcript. Bail. + if (!sidechainSeen) return null; + if (!agentId) { + // Fall back to filename: agent-.jsonl + const m = fileBase.match(/^agent-(.+)$/); + if (m) agentId = m[1]; + } + if (!agentId) return null; + const meta = readSubagentMeta(filePath) || {}; + const subagentType = meta.agentType || null; + const description = meta.description || null; + return { + sessionId: subagentSessionId(opts.parentSessionId, agentId), + folder, projectPath, + summary: description || summary, + firstPrompt: summary, + created: stat.birthtime.toISOString(), + modified: stat.mtime.toISOString(), + messageCount, textContent, slug, customTitle, aiTitle, + parentSessionId: opts.parentSessionId, + agentId, + subagentType, + description, + }; + } + return { - sessionId, folder, projectPath, + sessionId: fileBase, folder, projectPath, summary, firstPrompt: summary, created: stat.birthtime.toISOString(), modified: stat.mtime.toISOString(), @@ -56,4 +121,62 @@ function readSessionFile(filePath, folder, projectPath) { } } -module.exports = { readSessionFile }; +/** Enumerate every jsonl in a project folder: top-level sessions plus any + * subagent transcripts under //subagents/*.jsonl + * (or directly under //*.jsonl for legacy layouts). + * Returns [{ filePath, sessionId, parentSessionId|null }]. */ +function enumerateSessionFiles(folderPath) { + const out = []; + let topEntries; + try { + topEntries = fs.readdirSync(folderPath, { withFileTypes: true }); + } catch { return out; } + + // Top-level .jsonl files = ordinary sessions + for (const e of topEntries) { + if (e.isFile() && e.name.endsWith('.jsonl')) { + out.push({ + filePath: path.join(folderPath, e.name), + sessionId: path.basename(e.name, '.jsonl'), + parentSessionId: null, + }); + } + } + + // UUID subdirs may hold subagent transcripts + for (const e of topEntries) { + if (!e.isDirectory()) continue; + const parentSessionId = e.name; + const subDir = path.join(folderPath, parentSessionId); + // Preferred layout: subagents/ subfolder + const subagentsDir = path.join(subDir, 'subagents'); + try { + if (fs.statSync(subagentsDir).isDirectory()) { + for (const f of fs.readdirSync(subagentsDir)) { + if (!f.endsWith('.jsonl')) continue; + out.push({ + filePath: path.join(subagentsDir, f), + sessionId: path.basename(f, '.jsonl'), + parentSessionId, + }); + } + continue; + } + } catch {} + // Fallback: jsonl directly in the UUID dir (older CLI versions) + try { + for (const f of fs.readdirSync(subDir)) { + if (!f.endsWith('.jsonl')) continue; + out.push({ + filePath: path.join(subDir, f), + sessionId: path.basename(f, '.jsonl'), + parentSessionId, + }); + } + } catch {} + } + + return out; +} + +module.exports = { readSessionFile, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles }; diff --git a/test/read-session-file.test.js b/test/read-session-file.test.js new file mode 100644 index 0000000..17dbc03 --- /dev/null +++ b/test/read-session-file.test.js @@ -0,0 +1,219 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + readSessionFile, + subagentSessionId, + resolveJsonlPath, + readSubagentMeta, + enumerateSessionFiles, +} = require('../read-session-file'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-rsf-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +test('subagentSessionId formats parent and agent ids into the expected colon-delimited string', () => { + assert.equal(subagentSessionId('parent', 'agent'), 'sub:parent:agent'); + const parent = '11111111-2222-3333-4444-555555555555'; + const agent = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + assert.equal(subagentSessionId(parent, agent), `sub:${parent}:${agent}`); + // Round-trip the prefix structure. + const id = subagentSessionId(parent, agent); + const parts = id.split(':'); + assert.equal(parts[0], 'sub'); + assert.equal(parts[1], parent); + assert.equal(parts[2], agent); +}); + +test('resolveJsonlPath returns top-level path when row has no parent/agent', () => { + const projectsDir = '/projects'; + const row = { folder: 'foo', sessionId: 'session-1' }; + assert.equal(resolveJsonlPath(projectsDir, row), path.join('/projects', 'foo', 'session-1.jsonl')); +}); + +test('resolveJsonlPath returns subagent path when parentSessionId and agentId are set', () => { + const projectsDir = '/projects'; + const row = { + folder: 'foo', + sessionId: 'sub:parent-uuid:agent-1', + parentSessionId: 'parent-uuid', + agentId: 'agent-1', + }; + assert.equal( + resolveJsonlPath(projectsDir, row), + path.join('/projects', 'foo', 'parent-uuid', 'subagents', 'agent-agent-1.jsonl') + ); +}); + +test('readSubagentMeta reads sibling .meta.json when present and returns null when missing', () => { + const tmp = mkTmp(); + try { + const jsonlPath = path.join(tmp, 'agent-x.jsonl'); + const metaPath = path.join(tmp, 'agent-x.meta.json'); + fs.writeFileSync(metaPath, JSON.stringify({ agentType: 'Explore', description: 'find things' }), 'utf8'); + const meta = readSubagentMeta(jsonlPath); + assert.deepEqual(meta, { agentType: 'Explore', description: 'find things' }); + + const missing = readSubagentMeta(path.join(tmp, 'does-not-exist.jsonl')); + assert.equal(missing, null); + } finally { + cleanup(tmp); + } +}); + +test('enumerateSessionFiles discovers top-level sessions plus subagents and ignores junk', () => { + const tmp = mkTmp(); + try { + // Top-level session + fs.writeFileSync(path.join(tmp, 'aaa.jsonl'), '', 'utf8'); + // Subagents under preferred layout + const bbbSubagents = path.join(tmp, 'bbb', 'subagents'); + fs.mkdirSync(bbbSubagents, { recursive: true }); + fs.writeFileSync(path.join(bbbSubagents, 'agent-1.jsonl'), '', 'utf8'); + fs.writeFileSync(path.join(bbbSubagents, 'agent-2.jsonl'), '', 'utf8'); + // Fallback layout: jsonl directly in uuid dir + const cccDir = path.join(tmp, 'ccc'); + fs.mkdirSync(cccDir); + fs.writeFileSync(path.join(cccDir, 'legacy.jsonl'), '', 'utf8'); + // Junk that must be ignored + fs.writeFileSync(path.join(tmp, 'not-a-jsonl.txt'), 'noise', 'utf8'); + fs.writeFileSync(path.join(tmp, 'bbb', 'random-file'), 'noise', 'utf8'); + + const entries = enumerateSessionFiles(tmp); + assert.equal(entries.length, 4, `expected 4 entries, got ${entries.length}: ${JSON.stringify(entries)}`); + + const byId = new Map(entries.map(e => [e.sessionId, e])); + + const top = byId.get('aaa'); + assert.ok(top, 'expected aaa top-level entry'); + assert.equal(top.parentSessionId, null); + assert.equal(top.filePath, path.join(tmp, 'aaa.jsonl')); + + const a1 = byId.get('agent-1'); + assert.ok(a1, 'expected agent-1 subagent entry'); + assert.equal(a1.parentSessionId, 'bbb'); + assert.equal(a1.filePath, path.join(bbbSubagents, 'agent-1.jsonl')); + + const a2 = byId.get('agent-2'); + assert.ok(a2, 'expected agent-2 subagent entry'); + assert.equal(a2.parentSessionId, 'bbb'); + + const legacy = byId.get('legacy'); + assert.ok(legacy, 'expected legacy fallback entry'); + assert.equal(legacy.parentSessionId, 'ccc'); + assert.equal(legacy.filePath, path.join(cccDir, 'legacy.jsonl')); + } finally { + cleanup(tmp); + } +}); + +test('readSessionFile returns a top-level row when called without opts', () => { + const tmp = mkTmp(); + try { + const sessionId = 'plain-session'; + const filePath = path.join(tmp, `${sessionId}.jsonl`); + fs.writeFileSync( + filePath, + JSON.stringify({ type: 'user', message: 'hello world' }) + '\n', + 'utf8' + ); + + const row = readSessionFile(filePath, 'folder-x', '/some/project'); + assert.ok(row, 'expected a row'); + assert.equal(row.sessionId, sessionId); + assert.equal(row.folder, 'folder-x'); + assert.equal(row.projectPath, '/some/project'); + assert.equal(row.summary, 'hello world'); + assert.equal(row.messageCount, 1); + assert.equal(row.parentSessionId, undefined); + assert.equal(row.agentId, undefined); + } finally { + cleanup(tmp); + } +}); + +test('readSessionFile (subagent) returns synthetic id plus parent/agent metadata when sidechain present', () => { + const tmp = mkTmp(); + try { + const parentSessionId = 'parent-uuid-abc'; + const agentId = 'abc123'; + const subagentsDir = path.join(tmp, parentSessionId, 'subagents'); + fs.mkdirSync(subagentsDir, { recursive: true }); + const filePath = path.join(subagentsDir, `agent-${agentId}.jsonl`); + const metaPath = path.join(subagentsDir, `agent-${agentId}.meta.json`); + + fs.writeFileSync( + filePath, + JSON.stringify({ + type: 'user', + message: 'first prompt to subagent', + isSidechain: true, + agentId, + }) + '\n', + 'utf8' + ); + fs.writeFileSync( + metaPath, + JSON.stringify({ agentType: 'Explore', description: 'do a thing' }), + 'utf8' + ); + + const row = readSessionFile(filePath, 'folder-x', '/some/project', { parentSessionId }); + assert.ok(row, 'expected a subagent row'); + assert.equal(row.sessionId, `sub:${parentSessionId}:${agentId}`); + assert.equal(row.parentSessionId, parentSessionId); + assert.equal(row.agentId, agentId); + assert.equal(row.subagentType, 'Explore'); + assert.equal(row.description, 'do a thing'); + // summary prefers description when meta provides one + assert.equal(row.summary, 'do a thing'); + // firstPrompt holds the actual first user text + assert.equal(row.firstPrompt, 'first prompt to subagent'); + } finally { + cleanup(tmp); + } +}); + +test('readSessionFile (subagent) returns null when the file is not actually a sidechain', () => { + const tmp = mkTmp(); + try { + const filePath = path.join(tmp, 'agent-foo.jsonl'); + fs.writeFileSync( + filePath, + JSON.stringify({ type: 'user', message: 'looks normal', agentId: 'foo' }) + '\n', + 'utf8' + ); + + const row = readSessionFile(filePath, 'folder-x', '/p', { parentSessionId: 'parent' }); + assert.equal(row, null); + } finally { + cleanup(tmp); + } +}); + +test('readSessionFile (subagent) falls back to filename when agentId is absent in jsonl entries', () => { + const tmp = mkTmp(); + try { + const filePath = path.join(tmp, 'agent-fallback.jsonl'); + fs.writeFileSync( + filePath, + JSON.stringify({ type: 'user', message: 'prompt', isSidechain: true }) + '\n', + 'utf8' + ); + + const row = readSessionFile(filePath, 'folder-x', '/p', { parentSessionId: 'parent-1' }); + assert.ok(row, 'expected a row even without inline agentId'); + assert.equal(row.agentId, 'fallback'); + assert.equal(row.sessionId, 'sub:parent-1:fallback'); + } finally { + cleanup(tmp); + } +}); From 9e36ba23614adbdcceda6ee18fdd9860868d29c2 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:40 +0200 Subject: [PATCH 03/10] feat(scan): index subagent transcripts in session cache Both scanners (synchronous refreshFolder + worker scan-projects) now use enumerateSessionFiles instead of a flat top-level readdir, so subagent transcripts get cached as first-class rows with parentSessionId set. FTS indexing inherits this for free: subagent summaries land in search_fts the same way top-level sessions do, with type='session' and folder=. Full-text search now finds anything an Agent call discussed. --- session-cache.js | 59 +++++++++++++++++++++++----------------- workers/scan-projects.js | 13 ++++----- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/session-cache.js b/session-cache.js index f066004..bd46019 100644 --- a/session-cache.js +++ b/session-cache.js @@ -3,7 +3,7 @@ const fs = require('fs'); const { Worker } = require('worker_threads'); const { getFolderIndexMtimeMs } = require('./folder-index-state'); const { deriveProjectPath } = require('./derive-project-path'); -const { readSessionFile } = require('./read-session-file'); +const { readSessionFile, enumerateSessionFiles, resolveJsonlPath } = require('./read-session-file'); const { encodeProjectPath } = require('./encode-project-path'); /** @@ -46,13 +46,10 @@ function readFolderFromFilesystem(folder) { if (!projectPath) return { projectPath: null, sessions: [] }; const sessions = []; - try { - const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - for (const file of jsonlFiles) { - const s = readSessionFile(path.join(folderPath, file), folder, projectPath); - if (s) sessions.push(s); - } - } catch {} + for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); + if (s) sessions.push(s); + } return { projectPath, sessions }; } @@ -71,19 +68,18 @@ function refreshFolder(folder) { return; } - // Get what's currently cached for this folder + // Get what's currently cached for this folder. + // cachedMap: DB sessionId → { modified, filePath } so we can do mtime comparison + // even for subagents whose DB sessionId differs from the on-disk filename. const cachedSessions = getCachedByFolder(folder); - const cachedMap = new Map(); // sessionId → modified ISO string + const cachedMap = new Map(); // DB sessionId → { modified, filePath } for (const row of cachedSessions) { - cachedMap.set(row.sessionId, row.modified); + cachedMap.set(row.sessionId, { + modified: row.modified, + filePath: resolveJsonlPath(PROJECTS_DIR, row), + }); } - // Scan current .jsonl files - let jsonlFiles; - try { - jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - } catch { return; } - const currentIds = new Set(); let changed = false; @@ -93,22 +89,35 @@ function refreshFolder(folder) { const namesToSet = []; const sessionsToDelete = []; - for (const file of jsonlFiles) { - const filePath = path.join(folderPath, file); - const sessionId = path.basename(file, '.jsonl'); - currentIds.add(sessionId); - - // Check if file mtime changed + for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + // Check if file mtime changed. + // We need the DB sessionId to look up the cache, but we don't know it until after + // readSessionFile — for subagents it's sub::. Use the file path + // to find a matching cached entry instead. let fileMtime; try { fileMtime = fs.statSync(filePath).mtime.toISOString(); } catch { continue; } - if (cachedMap.has(sessionId) && cachedMap.get(sessionId) === fileMtime) { + // Find cached entry by file path (handles both top-level and subagent IDs) + let cachedEntry = null; + let cachedDbId = null; + for (const [dbId, entry] of cachedMap) { + if (entry.filePath === filePath) { + cachedEntry = entry; + cachedDbId = dbId; + break; + } + } + + if (cachedDbId !== null) currentIds.add(cachedDbId); + + if (cachedEntry && cachedEntry.modified === fileMtime) { continue; // unchanged, skip } // File is new or modified — re-read it - const s = readSessionFile(filePath, folder, projectPath); + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); if (s) { + currentIds.add(s.sessionId); // ensure we don't delete a newly-read subagent row sessionsToUpsert.push(s); // Title precedence: user rename (session_meta.name) > JSONL custom-title > JSONL ai-title. // Only customTitle (Claude /title) promotes to session_meta.name — AI titles must NEVER diff --git a/workers/scan-projects.js b/workers/scan-projects.js index 62bd73a..be3d530 100644 --- a/workers/scan-projects.js +++ b/workers/scan-projects.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); const { getFolderIndexMtimeMs } = require('../folder-index-state'); const { deriveProjectPath } = require('../derive-project-path'); -const { readSessionFile } = require('../read-session-file'); +const { readSessionFile, enumerateSessionFiles } = require('../read-session-file'); const PROJECTS_DIR = workerData.projectsDir; @@ -14,13 +14,10 @@ function readFolderFromFilesystem(folder) { const sessions = []; const indexMtimeMs = getFolderIndexMtimeMs(folderPath); - try { - const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - for (const file of jsonlFiles) { - const s = readSessionFile(path.join(folderPath, file), folder, projectPath); - if (s) sessions.push(s); - } - } catch {} + for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); + if (s) sessions.push(s); + } return { folder, projectPath, sessions, indexMtimeMs }; } From 2fee16fe8213a24848fd3a776b1e0292c7858607 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:47 +0200 Subject: [PATCH 04/10] feat(viewer): expand subagent transcripts inline via Agent block Clicking an Agent tool_use block in the JSONL viewer now loads the matching subagent transcript and renders it nested below the block. Re-click collapses without refetching. - main.js: new IPC read-subagent-jsonl(parentSessionId, agentId) and list-subagents(parentSessionId) - preload.js: window.api.readSubagentJsonl / listSubagents - jsonl-viewer.js: clickable Agent block, caret indicator, recursive nested render. Identical (description, subagentType) pairs are disambiguated by occurrence ordinal so parallel fanout calls each open their own transcript. - style.css: subtle hover + left-border indent for nested transcripts --- main.js | 30 ++++++++++++++- preload.js | 2 + public/jsonl-viewer.js | 86 ++++++++++++++++++++++++++++++++++++++++-- public/style.css | 23 +++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/main.js b/main.js index 2c587b7..8a89337 100644 --- a/main.js +++ b/main.js @@ -61,7 +61,7 @@ if (app.isPackaged || process.env.FORCE_UPDATER) { } const { getMeta, getAllMeta, toggleStar, setName, setArchived, - isCachePopulated, getAllCached, getCachedByFolder, getCachedFolder, getCachedSession, upsertCachedSessions, + isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, deleteCachedSession, deleteCachedFolder, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, @@ -907,6 +907,34 @@ ipcMain.handle('read-session-jsonl', (_event, sessionId) => { } }); +ipcMain.handle('read-subagent-jsonl', (_event, parentSessionId, agentId) => { + const row = getCachedSession('sub:' + parentSessionId + ':' + agentId); + if (!row) return { error: 'Subagent session not found in cache' }; + const jsonlPath = path.join(PROJECTS_DIR, row.folder, parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl'); + try { + const content = fs.readFileSync(jsonlPath, 'utf-8'); + const entries = []; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { entries.push(JSON.parse(line)); } catch {} + } + return { entries }; + } catch (err) { + return { error: err.message }; + } +}); + +ipcMain.handle('list-subagents', (_event, parentSessionId) => { + return getCachedByParent(parentSessionId).map(r => ({ + sessionId: r.sessionId, + agentId: r.agentId, + subagentType: r.subagentType, + description: r.description, + modified: r.modified, + messageCount: r.messageCount, + })); +}); + ipcMain.handle('archive-session', (_event, sessionId, archived) => { const val = archived ? 1 : 0; setArchived(sessionId, val); diff --git a/preload.js b/preload.js index 91d8b5e..252d286 100644 --- a/preload.js +++ b/preload.js @@ -21,6 +21,8 @@ contextBridge.exposeInMainWorld('api', { openTerminal: (id, projectPath, isNew, sessionOptions) => ipcRenderer.invoke('open-terminal', id, projectPath, isNew, sessionOptions), search: (type, query, titleOnly) => ipcRenderer.invoke('search', type, query, titleOnly), readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId), + readSubagentJsonl: (parentSessionId, agentId) => ipcRenderer.invoke('read-subagent-jsonl', parentSessionId, agentId), + listSubagents: (parentSessionId) => ipcRenderer.invoke('list-subagents', parentSessionId), // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), diff --git a/public/jsonl-viewer.js b/public/jsonl-viewer.js index 9c28bab..ffc3593 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -2,6 +2,12 @@ // Depends on globals: escapeHtml (utils.js), hideAllViewers, placeholder, // terminalArea, jsonlViewer, jsonlViewerTitle, jsonlViewerSessionId, jsonlViewerBody (app.js) +// Current viewer session — set once per showJsonlViewer call, read by Agent renderer +let currentViewerSessionId = null; +// Counter for matching identical (description, subagentType) blocks in fanout scenarios +// Reset on each showJsonlViewer call. Key: "||" +let agentMatchCounters = {}; + function renderJsonlText(text) { if (window.marked) { // Escape XML/HTML-like tags so they render as visible text, @@ -211,12 +217,82 @@ const toolRenderers = { return toolBlock('#c090e0', 'Glob', '' + escapeHtml(pattern) + '', null); }, - Agent(input) { + Agent(input, block) { const desc = input.description || ''; const type = input.subagent_type || ''; - const summary = (type ? '' + escapeHtml(type) + ' ' : '') + const caretSpan = ' '; + const summary = caretSpan + + (type ? '' + escapeHtml(type) + ' ' : '') + escapeHtml(desc); - return toolBlock('#f0a050', 'Agent', summary, null); + const el = toolBlock('#f0a050', 'Agent', summary, null); + el.classList.add('jsonl-agent-expandable'); + // Capture context at render time + const parentSessionId = currentViewerSessionId; + if (!parentSessionId) return el; + + // Determine which Nth match this block is for fanout deduplication + const counterKey = parentSessionId + '|' + desc + '|' + type; + if (agentMatchCounters[counterKey] === undefined) agentMatchCounters[counterKey] = 0; + const matchIndex = agentMatchCounters[counterKey]++; + + let expanded = false; + let nestedContainer = null; + + el.addEventListener('click', async () => { + if (expanded && nestedContainer) { + // Collapse + nestedContainer.remove(); + nestedContainer = null; + expanded = false; + const caret = el.querySelector('.jsonl-agent-caret'); + if (caret) caret.innerHTML = '►'; + return; + } + // Fetch subagent list for this parent + const subagents = await window.api.listSubagents(parentSessionId); + const matches = subagents.filter(s => + (s.description || '') === desc && (s.subagentType || '') === type + ); + const match = matches[matchIndex] || matches[0]; + if (!match) return; + + const result = await window.api.readSubagentJsonl(parentSessionId, match.agentId); + if (result.error || !result.entries) return; + + nestedContainer = document.createElement('div'); + nestedContainer.className = 'jsonl-subagent-nested'; + + const subSessionId = match.sessionId; + const rawNested = result.entries; + const nestedEntries = mergeLocalCommandEntries(rawNested); + + // Build tool result map for nested entries + const nestedResultMap = new Map(); + for (const entry of nestedEntries) { + const blocks = entry.message?.content || entry.content; + if (!Array.isArray(blocks)) continue; + for (const b of blocks) { + if (b.type === 'tool_result' && b.tool_use_id) { + nestedResultMap.set(b.tool_use_id, b.content || b.output || ''); + } + } + } + + const prevSessionId = currentViewerSessionId; + currentViewerSessionId = subSessionId; + for (const entry of nestedEntries) { + const entryEl = renderJsonlEntry(entry, nestedResultMap); + if (entryEl) nestedContainer.appendChild(entryEl); + } + currentViewerSessionId = prevSessionId; + + el.after(nestedContainer); + expanded = true; + const caret = el.querySelector('.jsonl-agent-caret'); + if (caret) caret.innerHTML = '▼'; + }); + + return el; }, }; @@ -559,6 +635,10 @@ async function showJsonlViewer(session) { terminalArea.style.display = 'none'; jsonlViewer.style.display = 'flex'; + // Set viewer context for Agent block expansion + currentViewerSessionId = session.sessionId; + agentMatchCounters = {}; + const displayName = session.name || session.aiTitle || session.summary || session.sessionId; jsonlViewerTitle.textContent = displayName; jsonlViewerSessionId.textContent = session.sessionId; diff --git a/public/style.css b/public/style.css index f6070c0..a50b1e0 100644 --- a/public/style.css +++ b/public/style.css @@ -2338,6 +2338,29 @@ body { display: flex; flex-direction: column; } .jsonl-tool-diff::-webkit-scrollbar-thumb, .jsonl-tool-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 2px; } +.jsonl-agent-expandable { + cursor: pointer; + transition: background 0.1s; +} +.jsonl-agent-expandable:hover { + background: rgba(240, 160, 80, 0.07); + border-radius: 4px; +} +.jsonl-agent-caret { + display: inline-block; + font-family: monospace; + font-size: 0.8em; + margin-right: 3px; + user-select: none; +} +.jsonl-subagent-nested { + margin-left: 1.5em; + padding-left: 0.8em; + border-left: 2px solid rgba(240, 160, 80, 0.4); + margin-top: 2px; + margin-bottom: 2px; +} + .jsonl-toggle { font-size: 11px; font-weight: 500; From 249749ef5fd168484e8dc44aa6f840f32e8db4f9 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:08:33 +0200 Subject: [PATCH 05/10] ci: add github actions workflow for npm test Runs `npm ci && npm test` on ubuntu-latest against Node 20 and 22. Triggers on pull requests targeting main and on direct pushes to main. --- .github/workflows/test.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5e29675 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test From 078e8cf0fa734413a1f65226b12e2cacdf398ba4 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:26:02 +0200 Subject: [PATCH 06/10] fix(get-projects): await scan when cache is empty post-migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrations that clear session_cache (v2, v3, v4) leave the cache empty on first launch. The previous get-projects handler returned [] immediately and fire-and-forgot the worker scan, relying on a later 'projects-changed' IPC to trigger a reload — but app.js only reloads on that event when the user happens to be on the Sessions tab. If they were on another tab (or hadn't navigated to Sessions yet), the list stayed empty until they manually switched tabs. Make populateCacheViaWorker return a Promise that resolves when the scan finishes. Concurrent callers share the same Promise. get-projects awaits that Promise when the cache is empty, so the response carries the freshly populated cache and the renderer paints immediately. --- main.js | 11 ++++++++--- session-cache.js | 37 +++++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/main.js b/main.js index 8a89337..2040f40 100644 --- a/main.js +++ b/main.js @@ -407,13 +407,18 @@ ipcMain.handle('unwatch-file', (_event, filePath) => { return { ok: true }; }); -ipcMain.handle('get-projects', (_event, showArchived) => { +ipcMain.handle('get-projects', async (_event, showArchived) => { try { const needsPopulate = !isCachePopulated() || !isSearchIndexPopulated(); if (needsPopulate) { - populateCacheViaWorker(); - return []; + // First call after a migration that clears session_cache (e.g. v4) finds + // an empty cache. Returning [] immediately makes the renderer paint an + // empty list and rely on `notifyRendererProjectsChanged` firing later — + // which only triggers a reload if the user is on the Sessions tab. To + // avoid that race, await the scan here so the response carries the + // freshly-populated cache. Concurrent callers share the same Promise. + await populateCacheViaWorker(); } return buildProjectsFromCache(showArchived); diff --git a/session-cache.js b/session-cache.js index bd46019..dd90faa 100644 --- a/session-cache.js +++ b/session-cache.js @@ -306,14 +306,25 @@ function sendStatus(text, type) { } } -// --- Worker-based cache population (non-blocking) --- -let populatingCache = false; +// --- Worker-based cache population --- +// Returns a Promise that resolves when the in-flight scan finishes. Concurrent +// callers share the same Promise so the first get-projects after a migration +// can await it instead of seeing an empty list. +let populatePromise = null; function populateCacheViaWorker() { - if (populatingCache) return; - populatingCache = true; + if (populatePromise) return populatePromise; sendStatus('Scanning projects\u2026', 'active'); + populatePromise = new Promise((resolve) => { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + populatePromise = null; + resolve(); + }; + const worker = new Worker(path.join(__dirname, 'workers', 'scan-projects.js'), { workerData: { projectsDir: PROJECTS_DIR }, }); @@ -328,7 +339,7 @@ function populateCacheViaWorker() { if (!msg.ok) { console.error('Worker scan error:', msg.error); sendStatus('Scan failed: ' + msg.error, 'error'); - populatingCache = false; + settle(); return; } @@ -360,30 +371,28 @@ function populateCacheViaWorker() { setFolderMeta(folder, projectPath, indexMtimeMs); } - populatingCache = false; sendStatus(`Indexed ${sessionCount} sessions across ${msg.results.length} projects`, 'done'); // Clear status after a few seconds setTimeout(() => sendStatus(''), 5000); notifyRendererProjectsChanged(); + settle(); }); worker.on('error', (err) => { console.error('Worker error:', err); sendStatus('Worker error: ' + err.message, 'error'); - populatingCache = false; + settle(); }); // If the worker exits abnormally (SIGSEGV, OOM, uncaught exception) without // sending a message, neither the 'message' nor 'error' handler will fire. - // Reset the flag here to prevent a permanent lockout where the session list - // stays empty because populateCacheViaWorker() returns immediately. + // Resolve here so awaiters aren't stuck forever and the next call can retry. worker.on('exit', (code) => { - if (populatingCache) { - populatingCache = false; - if (code !== 0) { - sendStatus('Scan worker exited unexpectedly', 'error'); - } + if (!settled && code !== 0) { + sendStatus('Scan worker exited unexpectedly', 'error'); } + settle(); + }); }); } From a9357e556a0350d11b0d40e0816b70219ebecda0 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:31:53 +0200 Subject: [PATCH 07/10] fix(projects-payload): expose subagent fields to the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildProjectsFromCache was projecting only the pre-PR#1 columns onto the session object sent to the renderer. The renderer's hierarchical sidebar (introduced in PR#2) reads parentSessionId/agentId/subagentType/description from each session and silently flattens to top-level when they're missing — which is what happened: all subagents appeared as siblings of their parent instead of nested under it. Add the four subagent columns to the projection. No DB or wire change. --- session-cache.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/session-cache.js b/session-cache.js index dd90faa..7d55ed4 100644 --- a/session-cache.js +++ b/session-cache.js @@ -206,6 +206,10 @@ function buildProjectsFromCache(showArchived) { projectPath: row.projectPath, slug: row.slug || null, aiTitle: row.aiTitle || null, + parentSessionId: row.parentSessionId || null, + agentId: row.agentId || null, + subagentType: row.subagentType || null, + description: row.description || null, name: meta?.name || null, starred: meta?.starred || 0, archived: meta?.archived || 0, From 2fd730fb3f468037d37599eb9e581d4f7dae9127 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:58:14 +0200 Subject: [PATCH 08/10] fix(read-session-file): tolerate concurrent writes to JSONL files readSessionFile JSON.parse'd every line of a session file in a single outer try/catch. If a Claude CLI session was actively writing the file while the worker scan read it, one mid-write line could throw and invalidate the ENTIRE file's session row. With many parallel live sessions this manifested as 'most projects show no sessions after a fresh index'. - Per-line try/catch inside readSessionFile: skip malformed lines, keep parsing the rest. - Per-file try/catch in workers/scan-projects.js: defensive belt and braces so one unparseable file can't abort an entire folder scan. --- read-session-file.js | 7 ++++++- workers/scan-projects.js | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/read-session-file.js b/read-session-file.js index f4a533d..d304d16 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -49,7 +49,12 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let agentId = null; let sidechainSeen = false; for (const line of lines) { - const entry = JSON.parse(line); + // Per-line try/catch: a JSONL file being written concurrently by a live + // Claude CLI session can have its tail captured mid-write — one truncated + // line should not invalidate the whole file. Skip the malformed line and + // keep parsing. + let entry; + try { entry = JSON.parse(line); } catch { continue; } if (entry.slug && !slug) slug = entry.slug; if (entry.agentId && !agentId) agentId = entry.agentId; if (entry.isSidechain) sidechainSeen = true; diff --git a/workers/scan-projects.js b/workers/scan-projects.js index be3d530..2e37666 100644 --- a/workers/scan-projects.js +++ b/workers/scan-projects.js @@ -15,8 +15,10 @@ function readFolderFromFilesystem(folder) { const indexMtimeMs = getFolderIndexMtimeMs(folderPath); for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { - const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); - if (s) sessions.push(s); + try { + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); + if (s) sessions.push(s); + } catch {} } return { folder, projectPath, sessions, indexMtimeMs }; From 46d596c83a107f01bfcacf1c9129aa9370e9b5e4 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 09:28:17 +0200 Subject: [PATCH 09/10] fix(derive-project-path): collapse worktree cwd to parent project resolveWorktreePath was defined and exported in the file but never actually called from deriveProjectPath. As a result every worktree under /.claude/worktrees// appeared as a separate project group in the sidebar instead of being grouped under its parent project. Wire the call in both branches of deriveProjectPath (direct .jsonl path and subdirectory path) and export resolveWorktreePath for other callers. --- derive-project-path.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/derive-project-path.js b/derive-project-path.js index f563e35..829da5e 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -33,7 +33,7 @@ function deriveProjectPath(folderPath) { for (const e of entries) { if (e.isFile() && e.name.endsWith('.jsonl')) { const cwd = extractCwdFromJsonl(path.join(folderPath, e.name)); - if (cwd) return cwd; + if (cwd) return resolveWorktreePath(cwd); } } // Check session subdirectories (UUID folders with subagent .jsonl files) @@ -52,7 +52,7 @@ function deriveProjectPath(folderPath) { } if (jsonlPath) { const cwd = extractCwdFromJsonl(jsonlPath); - if (cwd) return cwd; + if (cwd) return resolveWorktreePath(cwd); } } } catch {} @@ -61,4 +61,4 @@ function deriveProjectPath(folderPath) { return null; } -module.exports = { deriveProjectPath }; +module.exports = { deriveProjectPath, resolveWorktreePath }; From 1b20928ff8a91df21219c14baec8c897e3c371c0 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Wed, 10 Jun 2026 19:42:33 -0700 Subject: [PATCH 10/10] fix(sidebar): nest all worktree layouts under parent instead of collapsing at index time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 46d596c wired resolveWorktreePath into deriveProjectPath, collapsing every worktree cwd to its parent repo at index time. That fixed sidebar fragmentation for the .worktrees/ and .claude-worktrees/ layouts, but it erased the worktree projectPath the sidebar matches to build nested worktree groups — killing the existing nesting/hide/new-session UI for .claude/worktrees/ and leaving no anchor for worktree-scoped actions. Fix the fragmentation where it lives instead: - deriveProjectPath returns the raw cwd again (index-time collapse reverted); resolveWorktreePath stays exported for callers that need the parent <-> worktree relationship - sidebar worktreePattern now matches all three layouts, so .worktrees/ and .claude-worktrees/ nest under their parent just like .claude/worktrees/ already did - worktrees whose parent project is absent from the payload stay top-level instead of vanishing (nested groups only render inside their parent's group) Co-Authored-By: Claude Fable 5 --- derive-project-path.js | 9 +++++++-- public/sidebar.js | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/derive-project-path.js b/derive-project-path.js index 829da5e..c95d0f2 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -15,6 +15,11 @@ function extractCwdFromJsonl(filePath) { return null; } +// Maps a worktree cwd to its parent repo path. Exported for callers that need +// the parent ↔ worktree relationship. Deliberately NOT applied in +// deriveProjectPath: the sidebar nests worktree projects under their parent by +// matching the full worktree projectPath, so collapsing at index time would +// erase the worktree grouping (and break worktree-scoped actions like delete). function resolveWorktreePath(cwd) { if (!cwd) return cwd; // Detect worktree paths: /.claude-worktrees/, /.worktrees/, or /.claude/worktrees/ @@ -33,7 +38,7 @@ function deriveProjectPath(folderPath) { for (const e of entries) { if (e.isFile() && e.name.endsWith('.jsonl')) { const cwd = extractCwdFromJsonl(path.join(folderPath, e.name)); - if (cwd) return resolveWorktreePath(cwd); + if (cwd) return cwd; } } // Check session subdirectories (UUID folders with subagent .jsonl files) @@ -52,7 +57,7 @@ function deriveProjectPath(folderPath) { } if (jsonlPath) { const cwd = extractCwdFromJsonl(jsonlPath); - if (cwd) return resolveWorktreePath(cwd); + if (cwd) return cwd; } } } catch {} diff --git a/public/sidebar.js b/public/sidebar.js index 45985dd..3827fe1 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -131,12 +131,15 @@ function renderProjects(projects, resort) { // projects are now in the correct order (data order for resort, preserved order otherwise) // Detect worktree projects and group them under their parent - const worktreePattern = /^(.+?)\/\.claude\/worktrees\/([^/]+)\/?$/; + const worktreePattern = /^(.+?)\/\.(?:claude\/worktrees|claude-worktrees|worktrees)\/([^/]+)\/?$/; + const allProjectPaths = new Set(projects.map(p => p.projectPath)); const worktreeMap = new Map(); // parentPath → [worktreeProject, ...] const worktreeSet = new Set(); for (const project of projects) { const match = project.projectPath.match(worktreePattern); - if (match) { + // Only nest when the parent project is present — worktree groups render + // inside their parent's group, so nesting an orphan would hide it entirely + if (match && allProjectPaths.has(match[1])) { const parentPath = match[1]; if (!worktreeMap.has(parentPath)) worktreeMap.set(parentPath, []); worktreeMap.get(parentPath).push(project);