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 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..7b8ae37 --- /dev/null +++ b/.husky/pre-commit @@ -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 diff --git a/README.md b/README.md index 3d23a6d..f07b504 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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) diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..1a68274 --- /dev/null +++ b/Taskfile.yaml @@ -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}}" diff --git a/db.js b/db.js index 4fabae4..ef9d5de 100644 --- a/db.js +++ b/db.js @@ -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 }); @@ -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); @@ -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 ) `); @@ -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 + // //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,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'), @@ -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); } @@ -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, }; 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 }; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..81ede52 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,305 @@ +// ESLint flat config for Switchboard (ESLint 9.x). +// +// Goals: +// 1. Catch dumb "undefined variable" mistakes in renderer code +// (no-undef). Two recent regressions in public/sidebar.js +// (subagentIndex undefined; project.projectPath out of scope) +// would have been caught instantly by this rule. +// 2. Warn about unused vars without blocking the build. +// 3. Keep the existing 24+ node:test suite green. +// +// The renderer (public/*.js) loads as a set of classic