Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1b46d02
feat(db): add subagent columns and migration v4
JeanBaptisteRenard May 21, 2026
271381e
feat(read-session-file): support subagent layout
JeanBaptisteRenard May 21, 2026
9e36ba2
feat(scan): index subagent transcripts in session cache
JeanBaptisteRenard May 21, 2026
2fee16f
feat(viewer): expand subagent transcripts inline via Agent block
JeanBaptisteRenard May 21, 2026
249749e
ci: add github actions workflow for npm test
JeanBaptisteRenard May 21, 2026
078e8cf
fix(get-projects): await scan when cache is empty post-migration
JeanBaptisteRenard May 21, 2026
a9357e5
fix(projects-payload): expose subagent fields to the renderer
JeanBaptisteRenard May 21, 2026
6db1951
Merge pull request #1 from JeanBaptisteRenard/feat/subagent-support
JeanBaptisteRenard May 21, 2026
2fd730f
fix(read-session-file): tolerate concurrent writes to JSONL files
JeanBaptisteRenard May 21, 2026
46d596c
fix(derive-project-path): collapse worktree cwd to parent project
JeanBaptisteRenard May 22, 2026
3d821b0
feat(transitions): detect subagent spawn/completion + live-tail IPC
JeanBaptisteRenard May 21, 2026
1fea1aa
feat(ui): hierarchical sidebar, grid badges, live indicator
JeanBaptisteRenard May 21, 2026
a6210a6
fix(transitions): silent cold-start to avoid IPC flood on attach
JeanBaptisteRenard May 22, 2026
bd5aff2
Merge pull request #2 from JeanBaptisteRenard/feat/subagent-observabi…
JeanBaptisteRenard May 22, 2026
1c235d1
feat(worktree): add 'Delete worktree' button that actually removes fr…
JeanBaptisteRenard May 21, 2026
fd82261
chore(ui): suffix app name with '(dev)' when not packaged
JeanBaptisteRenard May 22, 2026
f8124cf
test: cover resolveWorktreePath and detectSubagentTransitions cold-start
JeanBaptisteRenard May 22, 2026
0514607
Merge pull request #3 from JeanBaptisteRenard/feat/worktree-cleanup-ui
JeanBaptisteRenard May 22, 2026
92d70c6
Merge pull request #4 from JeanBaptisteRenard/tests/coverage-transiti…
JeanBaptisteRenard May 22, 2026
d58be56
perf(refresh): O(1) cached lookup in refreshFolder
JeanBaptisteRenard May 22, 2026
d1f901c
perf(refresh): targeted refresh — only stat files the watcher flagged
JeanBaptisteRenard May 22, 2026
d32dcae
perf(refresh): bump-only update for huge cached files
JeanBaptisteRenard May 22, 2026
ba1d55a
perf(refresh): header-only read for cached sessions, full read for ne…
JeanBaptisteRenard May 22, 2026
65c30da
feat(search): explicit reindex via Enter or refresh button
JeanBaptisteRenard May 22, 2026
206f393
fix(sidebar): destructure subagentIndex (empty-sidebar regression)
JeanBaptisteRenard May 22, 2026
50f8743
feat(sidebar): collapsible 'Orphan subagents' section, collapsed by d…
JeanBaptisteRenard May 22, 2026
69476e9
fix(sidebar): scope projectPath into buildSessionsList for orphan toggle
JeanBaptisteRenard May 22, 2026
e76a3df
perf(ui): throttle projects-changed notifies + renderer debounce
JeanBaptisteRenard May 22, 2026
b4d1c48
Merge pull request #5 from JeanBaptisteRenard/fix/refresh-folder-O1-l…
JeanBaptisteRenard May 22, 2026
1a6256f
chore: add Taskfile.yaml as standard task entrypoint
JeanBaptisteRenard May 22, 2026
8314f69
test(lint): add ESLint flat config + jsdom renderer tests for sidebar
JeanBaptisteRenard May 22, 2026
3a4cd04
chore: pre-commit hook runs task check (lint + tests)
JeanBaptisteRenard May 22, 2026
bf18dc1
feat(db): SWITCHBOARD_DATA_DIR env var to isolate dev DB from AppImage
JeanBaptisteRenard May 22, 2026
0001c83
Merge pull request #6 from JeanBaptisteRenard/chore/taskfile-tooling
JeanBaptisteRenard May 22, 2026
76aaaf3
fix(stats): source heatmap from session_cache instead of stats-cache.…
JeanBaptisteRenard May 22, 2026
4dbae28
test(session-transitions): inject `now` for full determinism
JeanBaptisteRenard May 23, 2026
0d58630
test(session-transitions): cover the 5-min GC branch
JeanBaptisteRenard May 23, 2026
85c36bf
test(session-transitions): assert spawn IPC carries agentType/descrip…
JeanBaptisteRenard May 23, 2026
ff9960e
test(session-transitions): document real-vs-unused deps in setupModule
JeanBaptisteRenard May 23, 2026
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
13 changes: 13 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Lint + tests before allowing commit — same gate as `task check`.
# Set SKIP_TESTS=1 to skip the heavier test suite (lint still runs).

set -e

if command -v task >/dev/null 2>&1; then
task check
else
npm run lint
if [ -z "$SKIP_TESTS" ]; then
npm test
fi
fi
37 changes: 30 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,38 @@ Grab the latest release for your platform:
- **Linux**: `build-essential`, `python3` (`sudo apt install build-essential python3`)
- **Windows**: Visual Studio Build Tools or `npm install -g windows-build-tools`

## Tooling

[task](https://taskfile.dev) is the preferred entrypoint for all dev operations. Install it once (`brew install go-task` / `snap install task --classic` / see taskfile.dev for other platforms), then:

```bash
task install # npm install
task dev # launch Electron (--no-sandbox, required on Linux)
task test # node --test (24 tests)
task lint # eslint .
task check # test + lint — pre-commit / pre-push gate
task ci # same as check but sequential, verbose
task build # npm run build:linux
task clean # wipe dist/, codemirror bundle, local DB (asks for confirmation)
task db:reset # wipe ~/.switchboard/switchboard.db only
```

Run `task` (no args) to list all tasks with descriptions.

The npm scripts are still present and work as before; `task` just wraps them as a consistent entrypoint.

## Development Setup

```bash
# Install dependencies (runs postinstall automatically)
npm install
task install # install dependencies (runs postinstall automatically)
task dev # launch Electron
```

# Start the app
npm start
Or with npm directly:

```bash
npm install
npm start # bundles CodeMirror then launches Electron
```

`npm start` bundles CodeMirror and launches Electron. For faster iteration after the first run:
Expand All @@ -99,10 +123,9 @@ npm run electron
All build commands bundle CodeMirror first, then invoke electron-builder.

```bash
# Current platform
npm run build
task build # AppImage + deb (Linux)

# Platform-specific
# npm equivalents:
npm run build:mac # DMG + zip (arm64 + x64)
npm run build:win # NSIS installer (x64 + arm64)
npm run build:linux # AppImage + deb (x64 + arm64)
Expand Down
100 changes: 100 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# https://taskfile.dev

version: "3"

set: [pipefail]

vars:
SWITCHBOARD_DB: "{{.HOME}}/.switchboard/switchboard.db"
SWITCHBOARD_DEV_DATA_DIR: "{{.HOME}}/.switchboard-dev"

tasks:
default:
desc: List all available tasks
silent: true
cmds:
- task --list

install:
desc: Install npm dependencies
cmds:
- npm install

dev:
desc: Launch Switchboard in development mode (Electron, no-sandbox, isolated DB)
silent: true
env:
# Force a separate SQLite DB so a running AppImage isn't disturbed by the
# dev electron sharing session_cache. Override per-run with
# `SWITCHBOARD_DATA_DIR=/some/other/dir task dev`.
SWITCHBOARD_DATA_DIR: "{{.SWITCHBOARD_DEV_DATA_DIR}}"
cmds:
- npx electron . --no-sandbox

"db:reset:dev":
desc: Wipe just the dev SQLite database
cmds:
- rm -f "{{.SWITCHBOARD_DEV_DATA_DIR}}/switchboard.db" "{{.SWITCHBOARD_DEV_DATA_DIR}}/switchboard.db-wal" "{{.SWITCHBOARD_DEV_DATA_DIR}}/switchboard.db-shm"
- echo "Removed {{.SWITCHBOARD_DEV_DATA_DIR}}/switchboard.db"

build:
desc: Build Linux distribution packages (AppImage + deb)
silent: true
cmds:
- npm run build:linux

test:
desc: Run the node:test suite (24 tests)
cmds:
- npm test

lint:
desc: Run ESLint across the codebase
cmds:
- |
if [ ! -f eslint.config.js ] && [ ! -f .eslintrc.js ] && [ ! -f .eslintrc.json ] && [ ! -f .eslintrc.yaml ] && [ ! -f .eslintrc.yml ]; then
echo "ESLint not yet configured; run \`task install:lint\` first"
exit 1
fi
npx eslint .

"install:lint":
desc: Install ESLint + jsdom dev deps (idempotent)
cmds:
- |
MISSING=""
node -e "require('eslint')" 2>/dev/null || MISSING="$MISSING eslint"
node -e "require('jsdom')" 2>/dev/null || MISSING="$MISSING jsdom"
if [ -n "$MISSING" ]; then
npm install --save-dev $MISSING
else
echo "ESLint and jsdom already installed — nothing to do."
fi

check:
desc: Run tests + lint (pre-commit / pre-push gate)
deps: [test, lint]

ci:
desc: CI gate — runs test then lint, verbose, exits on first failure
cmds:
- npm test
- npx eslint .

clean:
desc: Remove dist/, codemirror bundle, and local DB (asks for confirmation)
interactive: true
cmds:
- |
read -p "This will delete dist/, public/codemirror-bundle.js, and {{.SWITCHBOARD_DB}}. Type YES to confirm: " ans
[ "$ans" = "YES" ]
- rm -rf dist/
- rm -f public/codemirror-bundle.js
- rm -f "{{.SWITCHBOARD_DB}}"
- echo "Clean complete."

"db:reset":
desc: Wipe the local Switchboard SQLite database (no confirmation)
cmds:
- rm -f "{{.SWITCHBOARD_DB}}"
- echo "Removed {{.SWITCHBOARD_DB}}"
78 changes: 69 additions & 9 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ const Database = require('better-sqlite3');
const path = require('path');
const os = require('os');

const DATA_DIR = path.join(os.homedir(), '.switchboard');
// SWITCHBOARD_DATA_DIR lets dev/agent runs use a separate DB from the
// installed AppImage so they don't race on session_cache. Default stays
// ~/.switchboard so existing installs keep working. Resolve env var at
// require-time (any later mutation would be ignored).
const DATA_DIR = process.env.SWITCHBOARD_DATA_DIR
? path.resolve(process.env.SWITCHBOARD_DATA_DIR.replace(/^~(?=$|\/)/, os.homedir()))
: path.join(os.homedir(), '.switchboard');
const fs = require('fs');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });

Expand All @@ -14,7 +20,11 @@ const OLD_LOCATIONS = [
path.join(os.homedir(), '.claude', 'browser', 'session-browser.db'),
path.join(os.homedir(), '.claude', 'session-browser.db'),
];
if (!fs.existsSync(DB_PATH)) {
// Skip the legacy ~/.claude/browser/ migration when running with a custom
// DATA_DIR (typical dev/agent setup) — otherwise a fresh dev DB would steal
// the AppImage's old data on first launch.
const IS_DEFAULT_DATA_DIR = !process.env.SWITCHBOARD_DATA_DIR;
if (IS_DEFAULT_DATA_DIR && !fs.existsSync(DB_PATH)) {
for (const oldPath of OLD_LOCATIONS) {
if (fs.existsSync(oldPath)) {
fs.renameSync(oldPath, DB_PATH);
Expand Down Expand Up @@ -49,7 +59,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 +113,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,20 +180,24 @@ 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 * 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 @@ -246,11 +278,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 @@ -370,17 +408,39 @@ function deleteSetting(key) {
stmts.settingsDelete.run(key);
}

// --- Daily activity aggregate (for stats heatmap) ---

// Returns [{date: 'YYYY-MM-DD', messageCount, sessionCount}, ...] sorted ASC.
// Aggregates ALL rows in session_cache (parent sessions + subagents) so the
// heatmap reflects real usage regardless of whether Claude rotated the parent
// JSONL files.
function getDailyActivity() {
return db.prepare(`
SELECT
substr(modified, 1, 10) AS date,
SUM(messageCount) AS messageCount,
COUNT(*) AS sessionCount
FROM session_cache
WHERE modified IS NOT NULL
AND length(modified) >= 10
GROUP BY date
ORDER BY date ASC
`).all();
}

function closeDb() {
try { db.close(); } catch {}
}

module.exports = {
getMeta, getAllMeta, setName, toggleStar, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedFolder, getCachedSession, upsertCachedSessions,
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,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
getDailyActivity,
closeDb,
};
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 };
Loading