Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 34 additions & 7 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
`);

Expand Down Expand Up @@ -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
// <folder>/<parentSessionId>/subagents/agent-<agentId>.jsonl alongside a
// .meta.json sidecar holding { agentType, description }. We surface them as
// first-class rows in session_cache, keyed by sessionId = "sub:<parent>:<agentId>".
// 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 = (() => {
Expand Down Expand Up @@ -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 = ?'),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion derive-project-path.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: <project>/.claude-worktrees/<name>, <project>/.worktrees/<name>, or <project>/.claude/worktrees/<name>
Expand Down Expand Up @@ -61,4 +66,4 @@ function deriveProjectPath(folderPath) {
return null;
}

module.exports = { deriveProjectPath };
module.exports = { deriveProjectPath, resolveWorktreePath };
41 changes: 37 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -907,6 +912,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);
Expand Down
2 changes: 2 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
86 changes: 83 additions & 3 deletions public/jsonl-viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<contextSessionId>|<desc>|<type>"
let agentMatchCounters = {};

function renderJsonlText(text) {
if (window.marked) {
// Escape XML/HTML-like tags so they render as visible text,
Expand Down Expand Up @@ -211,12 +217,82 @@ const toolRenderers = {
return toolBlock('#c090e0', 'Glob', '<code>' + escapeHtml(pattern) + '</code>', null);
},

Agent(input) {
Agent(input, block) {
const desc = input.description || '';
const type = input.subagent_type || '';
const summary = (type ? '<span class="jsonl-tool-detail">' + escapeHtml(type) + '</span> ' : '')
const caretSpan = '<span class="jsonl-agent-caret">&#9658;</span> ';
const summary = caretSpan
+ (type ? '<span class="jsonl-tool-detail">' + escapeHtml(type) + '</span> ' : '')
+ 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 = '&#9658;';
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 = '&#9660;';
});

return el;
},
};

Expand Down Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading