Skip to content
Merged
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
6 changes: 3 additions & 3 deletions derive-project-path.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -52,7 +52,7 @@ function deriveProjectPath(folderPath) {
}
if (jsonlPath) {
const cwd = extractCwdFromJsonl(jsonlPath);
if (cwd) return cwd;
if (cwd) return resolveWorktreePath(cwd);
}
}
} catch {}
Expand All @@ -61,4 +61,4 @@ function deriveProjectPath(folderPath) {
return null;
}

module.exports = { deriveProjectPath };
module.exports = { deriveProjectPath, resolveWorktreePath };
60 changes: 60 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ const MAX_BUFFER_SIZE = 256 * 1024;
const activeSessions = new Map();
let mainWindow = null;

// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId })
const subagentWatchers = new Map();
let subagentWatcherSeq = 0;

function createWindow() {
// Restore saved window bounds
const savedBounds = getSetting('global')?.windowBounds;
Expand Down Expand Up @@ -202,6 +206,11 @@ function createWindow() {
}
activeSessions.delete(id);
}
// Release all subagent file watchers
for (const [, entry] of subagentWatchers) {
try { fs.unwatchFile(entry.filePath); } catch {}
}
subagentWatchers.clear();
mainWindow = null;
});
}
Expand Down Expand Up @@ -940,6 +949,57 @@ ipcMain.handle('list-subagents', (_event, parentSessionId) => {
}));
});

// ── Subagent live-tail watchers ──────────────────────────────────────────────

ipcMain.handle('start-subagent-watch', (_event, parentSessionId, agentId) => {
const row = getCachedSession('sub:' + parentSessionId + ':' + agentId);
if (!row) return { error: 'Subagent not found in cache' };
const filePath = path.join(PROJECTS_DIR, row.folder, parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl');

const watchId = ++subagentWatcherSeq;
let offset = 0;
// Seek to EOF so we only deliver *new* lines
try { offset = fs.statSync(filePath).size; } catch {}

function readNewEntries() {
try {
const stat = fs.statSync(filePath);
if (stat.size <= offset) return;
const buf = Buffer.alloc(stat.size - offset);
const fd = fs.openSync(filePath, 'r');
const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset);
fs.closeSync(fd);
if (bytesRead <= 0) return;
offset += bytesRead;
const text = buf.toString('utf8', 0, bytesRead);
const entries = [];
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try { entries.push(JSON.parse(line)); } catch {}
}
if (entries.length > 0 && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('subagent-watch-event', { parentSessionId, agentId, entries });
}
} catch {}
}

// fs.watchFile gives reliable polling on Linux where inotify can be unreliable for JSONL appends
fs.watchFile(filePath, { interval: 1000, persistent: false }, readNewEntries);

subagentWatchers.set(watchId, { filePath, parentSessionId, agentId });
log.info(`[subagent-watch] start watchId=${watchId} parent=${parentSessionId} agentId=${agentId}`);
return { watchId };
});

ipcMain.handle('stop-subagent-watch', (_event, watchId) => {
const entry = subagentWatchers.get(watchId);
if (!entry) return { ok: false };
fs.unwatchFile(entry.filePath);
subagentWatchers.delete(watchId);
log.info(`[subagent-watch] stop watchId=${watchId}`);
return { ok: true };
});

ipcMain.handle('archive-session', (_event, sessionId, archived) => {
const val = archived ? 1 : 0;
setArchived(sessionId, val);
Expand Down
5 changes: 5 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ contextBridge.exposeInMainWorld('api', {
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),
startSubagentWatch: (parentSessionId, agentId) => ipcRenderer.invoke('start-subagent-watch', parentSessionId, agentId),
stopSubagentWatch: (watchId) => ipcRenderer.invoke('stop-subagent-watch', watchId),

// Settings
getSetting: (key) => ipcRenderer.invoke('get-setting', key),
Expand Down Expand Up @@ -63,6 +65,9 @@ contextBridge.exposeInMainWorld('api', {
onSessionForked: (callback) => {
ipcRenderer.on('session-forked', (_event, oldId, newId) => callback(oldId, newId));
},
onSubagentSpawned: (cb) => ipcRenderer.on('subagent-spawned', (_e, payload) => cb(payload)),
onSubagentCompleted: (cb) => ipcRenderer.on('subagent-completed', (_e, payload) => cb(payload)),
onSubagentWatchEvent: (cb) => ipcRenderer.on('subagent-watch-event', (_e, payload) => cb(payload)),
onProjectsChanged: (callback) => {
ipcRenderer.on('projects-changed', () => callback());
},
Expand Down
109 changes: 109 additions & 0 deletions public/grid-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,111 @@
let gridCards = new Map(); // sessionId → card wrapper element
let gridFocusedSessionId = null;

// Active subagents tracked via IPC events (subagent-spawned / subagent-completed).
// parentSessionId → Set of { agentId, subagentType, spawnedAt }
const activeSubagents = new Map();

// Subagent type → pill color (matches sidebar palette)
const GRID_SUBAGENT_TYPE_COLORS = {
explore: '#3ecf82',
plan: '#8088ff',
implement: '#ffaa40',
review: '#60bef0',
test: '#ff6464',
default: '#a0a0b4',
};

function gridSubagentColor(type) {
return GRID_SUBAGENT_TYPE_COLORS[(type || '').toLowerCase()] || GRID_SUBAGENT_TYPE_COLORS.default;
}

// Wire IPC listeners (guarded — bindings may not exist yet)
(function initSubagentListeners() {
if (typeof window.api === 'undefined') return;

if (typeof window.api.onSubagentSpawned === 'function') {
window.api.onSubagentSpawned((event, data) => {
const { parentSessionId, agentId, subagentType } = data || {};
if (!parentSessionId || !agentId) return;
if (!activeSubagents.has(parentSessionId)) activeSubagents.set(parentSessionId, new Map());
activeSubagents.get(parentSessionId).set(agentId, { agentId, subagentType, spawnedAt: Date.now() });
updateGridSubagentPills(parentSessionId);
});
}

if (typeof window.api.onSubagentCompleted === 'function') {
window.api.onSubagentCompleted((event, data) => {
const { parentSessionId, agentId } = data || {};
if (!parentSessionId || !agentId) return;
const map = activeSubagents.get(parentSessionId);
if (map) {
map.delete(agentId);
if (map.size === 0) activeSubagents.delete(parentSessionId);
}
updateGridSubagentPills(parentSessionId);
});
}
})();

// Prune subagents that have been running for more than 60 s without a completion event.
// Called on each grid render cycle.
function pruneStaleSubagents() {
const cutoff = Date.now() - 60000;
for (const [parentId, map] of activeSubagents) {
for (const [agentId, info] of map) {
if (info.spawnedAt < cutoff) map.delete(agentId);
}
if (map.size === 0) activeSubagents.delete(parentId);
}
}

// Re-render the pill row for a single card (if it exists in the grid).
function updateGridSubagentPills(parentSessionId) {
const card = gridCards.get(parentSessionId);
if (!card) return;

let pillRow = card.querySelector('.grid-subagent-pills');

const map = activeSubagents.get(parentSessionId);
if (!map || map.size === 0) {
if (pillRow) pillRow.remove();
return;
}

if (!pillRow) {
pillRow = document.createElement('div');
pillRow.className = 'grid-subagent-pills';
// Insert before the footer
const footer = card.querySelector('.grid-card-footer');
if (footer) {
card.insertBefore(pillRow, footer);
} else {
card.appendChild(pillRow);
}
}

pillRow.innerHTML = '';
const entries = [...map.values()];
const MAX_PILLS = 5;
const shown = entries.slice(0, MAX_PILLS);
const overflow = entries.length - shown.length;

for (const info of shown) {
const pill = document.createElement('span');
pill.className = 'grid-subagent-pill';
pill.title = info.subagentType || 'subagent';
pill.style.background = gridSubagentColor(info.subagentType);
pillRow.appendChild(pill);
}

if (overflow > 0) {
const more = document.createElement('span');
more.className = 'grid-subagent-pill-overflow';
more.textContent = `+${overflow} more`;
pillRow.appendChild(more);
}
}

function wrapInGridCard(sessionId) {
const entry = openSessions.get(sessionId);
const session = sessionMap.get(sessionId) || (entry && entry.session);
Expand Down Expand Up @@ -132,6 +237,10 @@ function wrapInGridCard(sessionId) {
gridCards.set(sessionId, card);
// Set initial status from the single source of truth
updateRunningIndicators();

// Render subagent pills for any already-tracked children
pruneStaleSubagents();
updateGridSubagentPills(sessionId);
}

function unwrapGridCards() {
Expand Down
91 changes: 90 additions & 1 deletion public/jsonl-viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ let currentViewerSessionId = null;
// Reset on each showJsonlViewer call. Key: "<contextSessionId>|<desc>|<type>"
let agentMatchCounters = {};

// --- Live subagent tracking ---
// Set of agentIds that are currently live (spawned but not yet completed).
// Keyed as "<parentSessionId>:<agentId>" so it's globally unique.
const liveSubagents = new Set();

// Register IPC listeners for subagent lifecycle events (called once at module load).
(function initSubagentListeners() {
if (!window.api) return; // guard for non-Electron contexts
window.api.onSubagentSpawned((payload) => {
const key = payload.parentSessionId + ':' + payload.agentId;
liveSubagents.add(key);
});
window.api.onSubagentCompleted((payload) => {
const key = payload.parentSessionId + ':' + payload.agentId;
liveSubagents.delete(key);
// Notify any active watch container so it can stop the watch and hide the indicator
document.querySelectorAll('[data-subagent-watch-key="' + key + '"]').forEach(el => {
el.dispatchEvent(new CustomEvent('subagent-completed-internal'));
});
});
window.api.onSubagentWatchEvent((payload) => {
const key = payload.parentSessionId + ':' + payload.agentId;
document.querySelectorAll('[data-subagent-watch-key="' + key + '"]').forEach(el => {
el.dispatchEvent(new CustomEvent('subagent-watch-data', { detail: payload }));
});
});
})()

function renderJsonlText(text) {
if (window.marked) {
// Escape XML/HTML-like tags so they render as visible text,
Expand Down Expand Up @@ -237,10 +265,24 @@ const toolRenderers = {

let expanded = false;
let nestedContainer = null;
let activeWatchId = null;
let liveIndicator = null;

function stopWatch() {
if (activeWatchId !== null) {
window.api.stopSubagentWatch(activeWatchId).catch(() => {});
activeWatchId = null;
}
if (liveIndicator) {
liveIndicator.remove();
liveIndicator = null;
}
}

el.addEventListener('click', async () => {
if (expanded && nestedContainer) {
// Collapse
// Collapse — stop live watch
stopWatch();
nestedContainer.remove();
nestedContainer = null;
expanded = false;
Expand Down Expand Up @@ -290,6 +332,53 @@ const toolRenderers = {
expanded = true;
const caret = el.querySelector('.jsonl-agent-caret');
if (caret) caret.innerHTML = '&#9660;';

// Start live watch if this subagent is still running
const watchKey = parentSessionId + ':' + match.agentId;
if (liveSubagents.has(watchKey)) {
// Attach the watch key to nestedContainer for event routing
nestedContainer.dataset.subagentWatchKey = watchKey;

const watchResult = await window.api.startSubagentWatch(parentSessionId, match.agentId);
if (watchResult && watchResult.watchId) {
activeWatchId = watchResult.watchId;

// Show "● live" indicator in the block header
liveIndicator = document.createElement('span');
liveIndicator.className = 'jsonl-agent-live';
liveIndicator.textContent = '● live';
const toolHeader = el.querySelector('.jsonl-tool-header');
if (toolHeader) toolHeader.appendChild(liveIndicator);

// Stream new entries into nestedContainer
nestedContainer.addEventListener('subagent-watch-data', (evt) => {
const { entries: newEntries } = evt.detail;
const merged = mergeLocalCommandEntries(newEntries);
const appendResultMap = new Map();
for (const entry of merged) {
const blocks2 = entry.message?.content || entry.content;
if (!Array.isArray(blocks2)) continue;
for (const b of blocks2) {
if (b.type === 'tool_result' && b.tool_use_id) {
appendResultMap.set(b.tool_use_id, b.content || b.output || '');
}
}
}
const savedId = currentViewerSessionId;
currentViewerSessionId = subSessionId;
for (const entry of merged) {
const entryEl = renderJsonlEntry(entry, appendResultMap);
if (entryEl) nestedContainer.appendChild(entryEl);
}
currentViewerSessionId = savedId;
});

// Stop watch when subagent completes
nestedContainer.addEventListener('subagent-completed-internal', () => {
stopWatch();
});
}
}
});

return el;
Expand Down
Loading
Loading