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
4 changes: 3 additions & 1 deletion db.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,12 @@ const stmts = {
subagentType = excluded.subagentType, description = excluded.description
`),
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 = ?'),
cacheGetByFolder: db.prepare('SELECT * 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 = ?'),
cacheDeleteFolder: db.prepare('DELETE FROM session_cache WHERE folder = ?'),
cacheTouchModified: db.prepare('UPDATE session_cache SET modified = ? WHERE sessionId = ?'),
// Cache meta statements
metaGet: db.prepare('SELECT * FROM cache_meta WHERE folder = ?'),
metaGetAll: db.prepare('SELECT * FROM cache_meta'),
Expand Down Expand Up @@ -404,6 +405,7 @@ function closeDb() {
module.exports = {
getMeta, getAllMeta, setName, toggleStar, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
touchCachedModified: (sessionId, modified) => stmts.cacheTouchModified.run(modified, sessionId),
deleteCachedSession, deleteCachedFolder,
getFolderMeta, getAllFolderMeta, setFolderMeta,
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
Expand Down
61 changes: 53 additions & 8 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ function createWindow() {
}

mainWindow.loadFile(path.join(__dirname, 'public', 'index.html'));
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
if (level >= 2) log.error(`[renderer:${level}] ${sourceId}:${line} ${message}`);
});

// Open external links in the system browser instead of a child BrowserWindow
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
Expand Down Expand Up @@ -496,6 +499,21 @@ ipcMain.handle('unwatch-file', (_event, filePath) => {
return { ok: true };
});

// Full re-scan triggered from the UI. Re-reads every jsonl file in the worker
// thread, which is the only path that rebuilds search_fts with the live tail
// of active sessions (refreshFolder uses a header-only read by design — see
// session-cache.js). Concurrent callers share the same in-flight worker via
// populateCacheViaWorker's internal Promise.
ipcMain.handle('rebuild-cache', async () => {
try {
await populateCacheViaWorker();
return { ok: true };
} catch (err) {
console.error('Error rebuilding cache:', err);
return { ok: false, error: err.message };
}
});

ipcMain.handle('get-projects', async (_event, showArchived) => {
try {
const needsPopulate = !isCachePopulated() || !isSearchIndexPopulated();
Expand Down Expand Up @@ -1477,20 +1495,31 @@ let projectsWatcher = null;
function startProjectsWatcher() {
if (!fs.existsSync(PROJECTS_DIR)) return;

const pendingFolders = new Set();
// pendingChanges: folder → Set<relativePath> | true.
// Set<string> — only the listed files changed (targeted refresh, fast path)
// true — folder-level event or unknown scope, do a full walk
// The watcher reports the specific filename, so for the common case of a
// subagent appending JSONL we can stat one file instead of thousands.
const pendingChanges = new Map();
let debounceTimer = null;

function flushChanges() {
debounceTimer = null;
const folders = new Set(pendingFolders);
pendingFolders.clear();
// Drain pendingChanges into a local copy so events arriving during the
// synchronous flush land in a fresh batch for the next tick.
const work = new Map(pendingChanges);
pendingChanges.clear();

let changed = false;
for (const folder of folders) {
for (const [folder, scope] of work) {
const folderPath = path.join(PROJECTS_DIR, folder);
if (fs.existsSync(folderPath)) {
detectSessionTransitions(folder);
refreshFolder(folder);
if (scope === true) {
refreshFolder(folder);
} else {
refreshFolder(folder, { files: scope });
}
} else {
deleteCachedFolder(folder);
}
Expand All @@ -1502,6 +1531,20 @@ function startProjectsWatcher() {
}
}

function recordChange(folder, relPath) {
const existing = pendingChanges.get(folder);
if (existing === true) return;
if (relPath === null) {
pendingChanges.set(folder, true);
return;
}
if (existing instanceof Set) {
existing.add(relPath);
} else {
pendingChanges.set(folder, new Set([relPath]));
}
}

try {
projectsWatcher = fs.watch(PROJECTS_DIR, { recursive: true }, (_eventType, filename) => {
if (!filename) return;
Expand All @@ -1511,12 +1554,14 @@ function startProjectsWatcher() {
const folder = parts[0];
if (!folder || folder === '.git') return;

// Only care about .jsonl changes or top-level folder add/remove
const basename = parts[parts.length - 1];
if (parts.length === 1) {
pendingFolders.add(folder);
// Top-level folder add/remove — must re-scan the whole folder
recordChange(folder, null);
} else if (basename.endsWith('.jsonl')) {
pendingFolders.add(folder);
// Specific .jsonl changed — targeted refresh on just this file
const rel = parts.slice(1).join(path.sep);
recordChange(folder, rel);
} else {
return;
}
Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld('api', {
readMemory: (filePath) => ipcRenderer.invoke('read-memory', filePath),
saveMemory: (filePath, content) => ipcRenderer.invoke('save-memory', filePath, content),
getProjects: (showArchived) => ipcRenderer.invoke('get-projects', showArchived),
rebuildCache: () => ipcRenderer.invoke('rebuild-cache'),
getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'),
getActiveTerminals: () => ipcRenderer.invoke('get-active-terminals'),
stopSession: (id) => ipcRenderer.invoke('stop-session', id),
Expand Down
127 changes: 83 additions & 44 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -461,56 +461,92 @@ searchClear.addEventListener('click', () => {
searchInput.focus();
});

// Extracted so the rebuild-cache button and Enter handler can call it too.
async function runSearchQuery() {
const query = searchInput.value.trim();
if (!query) {
clearSearch();
return;
}
try {
if (activeTab === 'sessions') {
const results = await window.api.search('session', query, searchTitlesOnly);
searchMatchIds = new Set(results.map(r => r.id));
searchMatchProjectPaths = null;
if (searchTitlesOnly) {
const lowerQ = query.toLowerCase();
for (const p of cachedAllProjects) {
const shortName = p.projectPath.split('/').filter(Boolean).slice(-2).join('/');
if (shortName.toLowerCase().includes(lowerQ)) {
if (!searchMatchProjectPaths) searchMatchProjectPaths = new Set();
searchMatchProjectPaths.add(p.projectPath);
}
}
}
refreshSidebar({ resort: true });
} else if (activeTab === 'plans') {
const results = await window.api.search('plan', query, searchTitlesOnly);
const matchIds = new Set(results.map(r => r.id));
renderPlans(cachedPlans.filter(p => matchIds.has(p.filename)));
} else if (activeTab === 'memory') {
const results = await window.api.search('memory', query, searchTitlesOnly);
const matchIds = new Set(results.map(r => r.id));
renderMemories(matchIds);
}
} catch {
if (activeTab === 'sessions') {
searchMatchIds = null;
searchMatchProjectPaths = null;
refreshSidebar({ resort: true });
}
}
}

// Debounced search-as-you-type. Bumped from 200ms to 350ms — gentler under
// heavy workloads (many active subagents) and gives the user time to finish
// a word before searching. Explicit triggers (Enter, refresh button) bypass
// the debounce.
searchInput.addEventListener('input', () => {
// Toggle clear button visibility
searchBar.classList.toggle('has-query', searchInput.value.length > 0);

if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(async () => {
searchDebounceTimer = setTimeout(() => {
searchDebounceTimer = null;
const query = searchInput.value.trim();

if (!query) {
clearSearch();
return;
}
runSearchQuery();
}, 350);
});

try {
if (activeTab === 'sessions') {
const results = await window.api.search('session', query, searchTitlesOnly);
searchMatchIds = new Set(results.map(r => r.id));
// When title-only, also match project names
searchMatchProjectPaths = null;
if (searchTitlesOnly) {
const lowerQ = query.toLowerCase();
for (const p of cachedAllProjects) {
const shortName = p.projectPath.split('/').filter(Boolean).slice(-2).join('/');
if (shortName.toLowerCase().includes(lowerQ)) {
if (!searchMatchProjectPaths) searchMatchProjectPaths = new Set();
searchMatchProjectPaths.add(p.projectPath);
}
}
}
refreshSidebar({ resort: true });
} else if (activeTab === 'plans') {
const results = await window.api.search('plan', query, searchTitlesOnly);
const matchIds = new Set(results.map(r => r.id));
renderPlans(cachedPlans.filter(p => matchIds.has(p.filename)));
} else if (activeTab === 'memory') {
const results = await window.api.search('memory', query, searchTitlesOnly);
const matchIds = new Set(results.map(r => r.id));
renderMemories(matchIds);
}
} catch {
if (activeTab === 'sessions') {
searchMatchIds = null;
searchMatchProjectPaths = null;
refreshSidebar({ resort: true });
}
}
}, 200);
// Enter in the search field = "I want fresh results": trigger a full worker
// reindex (which rewrites search_fts with the live content of active session
// JSONLs), then re-run the query. Pending debounce gets cancelled.
searchInput.addEventListener('keydown', async (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
if (searchDebounceTimer) { clearTimeout(searchDebounceTimer); searchDebounceTimer = null; }
await triggerRebuildAndSearch();
});

// Refresh button in the search bar — same behavior as pressing Enter.
const searchRefreshBtn = document.getElementById('search-refresh-btn');
if (searchRefreshBtn) {
searchRefreshBtn.addEventListener('click', () => triggerRebuildAndSearch());
}

let rebuildInFlight = false;
async function triggerRebuildAndSearch() {
if (rebuildInFlight) return;
rebuildInFlight = true;
if (searchRefreshBtn) searchRefreshBtn.classList.add('spinning');
try {
await window.api.rebuildCache();
} catch {}
finally {
rebuildInFlight = false;
if (searchRefreshBtn) searchRefreshBtn.classList.remove('spinning');
}
// After reindex, refire the current query so the user sees fresh hits.
await runSearchQuery();
}

// --- Stop session helper ---
async function confirmAndStopSession(sessionId) {
if (!confirm('Stop this session?')) return;
Expand Down Expand Up @@ -1047,10 +1083,13 @@ window.api.onProjectsChanged(() => {
projectsChangedWhileAway = true;
return;
}
// 300ms debounce was visibly flickering while live JSONLs trigger watcher
// flushes every ~500ms; with the main-side notify throttle (1.5s) too the
// sidebar redraws at most ~1×/sec.
projectsChangedTimer = setTimeout(() => {
projectsChangedTimer = null;
loadProjects();
}, 300);
}, 900);
});

// Status bar
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<input id="search-input" type="text" placeholder="Search sessions..." />
<button id="search-clear" type="button" aria-label="Clear search">&times;</button>
<button id="search-titles-toggle" type="button" title="Search titles only" aria-label="Search titles only">Tt</button>
<button id="search-refresh-btn" type="button" title="Reindex all sessions (full reread for search). Enter in the search field triggers the same."><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>
</div>
<div id="sidebar-content"></div>
<div id="plans-content" style="display:none;"></div>
Expand Down
24 changes: 18 additions & 6 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ function renderProjects(projects, resort) {
}

// Build the sessions list DOM (shared between projects and worktrees)
function buildSessionsList(fId, visible, older, subagentIndex) {
function buildSessionsList(fId, visible, older, subagentIndex, projectPath) {
const sessionsList = document.createElement('div');
sessionsList.className = 'project-sessions';
sessionsList.id = 'sessions-' + fId;
Expand Down Expand Up @@ -413,12 +413,24 @@ function renderProjects(projects, resort) {
}
}
if (orphans.length > 0) {
// Persist expand/collapse per project. Default = collapsed: this
// section is rarely the user's focus and can grow long on long-lived
// projects (this very session has 1300+ orphan subagents).
const orphanStateKey = 'orphanExpanded:' + projectPath;
const expanded = localStorage.getItem(orphanStateKey) === '1';

const orphanGroup = document.createElement('div');
orphanGroup.className = 'sidebar-orphan-subagents';
orphanGroup.className = 'sidebar-orphan-subagents' + (expanded ? '' : ' collapsed');

const orphanLabel = document.createElement('div');
orphanLabel.className = 'sidebar-orphan-label';
orphanLabel.textContent = 'Orphan subagents';
orphanLabel.innerHTML = `<span class="orphan-caret">&#9656;</span> Orphan subagents <span class="orphan-count">${orphans.length}</span>`;
orphanLabel.addEventListener('click', () => {
const isCollapsed = orphanGroup.classList.toggle('collapsed');
localStorage.setItem(orphanStateKey, isCollapsed ? '0' : '1');
});
orphanGroup.appendChild(orphanLabel);

for (const orphan of orphans) {
orphanGroup.appendChild(buildSubagentItem(orphan));
}
Expand All @@ -435,7 +447,7 @@ function renderProjects(projects, resort) {

const result = processProjectSessions(project, resort);
if (!result) continue;
const { filtered, visible, older, sortOrderEntry } = result;
const { filtered, visible, older, subagentIndex, sortOrderEntry } = result;
newSortedOrder.push(sortOrderEntry);
const fId = folderId(project.projectPath);

Expand Down Expand Up @@ -474,7 +486,7 @@ function renderProjects(projects, resort) {
newBtn.title = 'New session';
header.appendChild(newBtn);

const sessionsList = buildSessionsList(fId, visible, older, subagentIndex);
const sessionsList = buildSessionsList(fId, visible, older, subagentIndex, project.projectPath);

// Auto-collapse if most recent session is older than threshold, or project matched with no sessions
if (project._projectMatchedOnly) {
Expand Down Expand Up @@ -526,7 +538,7 @@ function renderProjects(projects, resort) {
wtNewBtn.title = 'New session in worktree';
wtHeader.appendChild(wtNewBtn);

const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older, wtResult.subagentIndex);
const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older, wtResult.subagentIndex, wt.projectPath);
wtSessionsList.className = 'worktree-sessions';

// Auto-collapse worktree if stale
Expand Down
Loading
Loading