From 68fdd5491ca55fc2445841e5d72c0c826463b448 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Thu, 3 Sep 2026 20:33:49 -0700 Subject: [PATCH 01/24] Add project workspace and Git overview --- README.md | 102 +- db.js | 265 ++ git.js | 290 +++ harnesses/claude.js | 6 + harnesses/codex.js | 15 +- main.js | 164 +- preload.js | 32 +- projects.js | 1291 ++++++++++ public/app.js | 244 +- public/dialogs.js | 20 +- public/index.html | 20 +- public/plan-parser.js | 123 + public/plans-memory-view.js | 44 +- public/project-git-view.js | 262 ++ public/projects-view.js | 3052 ++++++++++++++++++++++++ public/settings-panel.js | 51 +- public/sidebar.js | 398 +-- public/style.css | 2178 ++++++++++++++++- public/task-runner.js | 81 +- public/terminal-manager.js | 13 +- session-cache.js | 14 +- session-transitions.js | 13 +- task-config.js | 10 +- task-manager.js | 10 +- templates/customer/CLAUDE.md | 11 + templates/customer/contacts.md | 4 + templates/customer/proposals/README.md | 3 + templates/customer/template.json | 5 + templates/feature/CLAUDE.md | 12 + templates/feature/template.json | 5 + templates/research/CLAUDE.md | 12 + templates/research/notes/README.md | 3 + templates/research/template.json | 5 + test/db-schema-reconcile.test.js | 70 +- test/git.test.js | 142 ++ test/harness-claude.test.js | 11 + test/harness-codex.test.js | 37 +- test/plan-parser.test.js | 73 + test/projects.test.js | 869 +++++++ test/task-config.test.js | 36 +- 40 files changed, 9701 insertions(+), 295 deletions(-) create mode 100644 git.js create mode 100644 projects.js create mode 100644 public/plan-parser.js create mode 100644 public/project-git-view.js create mode 100644 public/projects-view.js create mode 100644 templates/customer/CLAUDE.md create mode 100644 templates/customer/contacts.md create mode 100644 templates/customer/proposals/README.md create mode 100644 templates/customer/template.json create mode 100644 templates/feature/CLAUDE.md create mode 100644 templates/feature/template.json create mode 100644 templates/research/CLAUDE.md create mode 100644 templates/research/notes/README.md create mode 100644 templates/research/template.json create mode 100644 test/git.test.js create mode 100644 test/plan-parser.test.js create mode 100644 test/projects.test.js diff --git a/README.md b/README.md index e7eef64b..f9c735b7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ straight away. At least one CLI always stays on. ### Key Features -- **Session Browser** — All your sessions from every supported CLI, organized by project, searchable by content +- **Session Browser** — All your sessions from every supported CLI, organized by folder, searchable by content +- **Projects** — Group the work, not the folders: a project has a folder on disk with a brief the agent reads, a plan, a todo list, and the sessions filed under it - **Built-in Terminal** — Connect to running sessions or launch new ones without leaving the app - **Project Tasks & Server Logs** — Run `.vscode/tasks.json` commands from a compact project or worktree menu and view their live logs in the built-in terminal - **Status Notifications** — In-app alerts when a session is waiting for permission approval or user input @@ -51,9 +52,11 @@ keep beside your coding sessions. - **Compact task launcher** — A play button appears in each project header. In worktree headers it appears between the hide and new-session buttons when you hover, and stays visible while a task is running. -- **Live, retained logs** — Selecting a task opens its terminal in the main - pane. Output is retained when you switch to another session or task, and the - selected task view is restored after a renderer reload. +- **Live, retained logs** — Running a task keeps the menu open and shows its + state on the row. Clicking a task that has run opens its terminal in the + main pane; inside a project it opens beside the session list. Output is + retained when you switch to another session or task, and the selected task + view is restored after a renderer reload. - **Independent lifecycle** — Tasks run separately from Claude and Codex sessions. Stop or restart them from the terminal header without altering an agent transcript. @@ -102,6 +105,97 @@ variables are expanded. Debug adapters, breakpoints, VS Code command/input variables, and problem-matcher diagnostics are outside the current scope; the feature is intentionally focused on running commands and seeing their logs. +## Projects + +The Sessions tab groups sessions by the folder they run in. The Projects tab +groups them by the work they belong to. + +| | What it is | +|---|---| +| **Project** | A piece of work with a folder on disk. Everything else about it is optional. | +| **Track** | A line of effort inside a project, with its own sessions. Optional. | +| **Session** | Exactly what it is today, plus the project it belongs to. | +| **Folder** | What the Sessions tab shows: a path on disk. A project can attach any number. | + +Every project gets a real folder under `~/Switchboard/` (change it under +**Projects Folder** in Global Settings). Switchboard writes a starter brief into +`CLAUDE.md` and `AGENTS.md` there. The brief tells the agent where the +project's files go, and the agent creates them when it first needs them: a +plan the user asks for goes to `plan.md` in whatever form fits, +`plan-tracker.md` is kept beside it as phases with checkboxes so Switchboard +can show progress, follow-ups go to `todos.md`, and anything worth remembering +across sessions goes to `memory.md`. None of them is read at the start of a +session, only when the plan or the todos come up. The brief also lists the +attached folders and tells the agent to read each folder's own instructions +before changing files there. Attaching or detaching a folder updates that +list; the rest of the brief is yours to edit. + +Sessions start in the project folder by default. A project or a track can +choose an attached folder instead. Wherever a project session starts, +Switchboard passes the project folder and every attached folder to the CLI as +extra directories, so the agent can read and edit all of them. Claude also +loads the `CLAUDE.md` from each of those directories. Codex only reads +`AGENTS.md` from the folder it starts in, so a Codex session that starts in +an attached folder does not see the project brief. Codex also refuses to +start with extra directories unless its sandbox is `workspace-write` or +`danger-full-access`, so Switchboard's global default for Codex is +`workspace-write`. Choose "Default" in Settings to hand the choice back to +codex's own config; under read-only the extra directories are left off, since +read-only can read every path anyway. + +The Projects tab lists projects only. Selecting one opens its **Overview** +page: the brief, the plan's progress, open todos, attached folders, and one +card per track with its latest sessions. Opening a session from there switches +to working mode: a slim project strip on top, a session list beside the +terminal, and the plan and todo counts at the foot of the list. The list can +be grouped by **Time** (today, yesterday, this week...), **Track**, or +**State** (needs input, running, idle). Each row shows the title, the track, +the CLI, its age and message count. Right-click a project, track, or session +for its actions. Projects and sessions are ordered by their last event: a +session started, finished a turn, asked for something, or was opened. A +session that is still working does not move while its transcript grows, so +two working sessions hold their places instead of leapfrogging. Archived +sessions sit in a closed "Archived" line at the foot of the list and under a +track card's foot; one click opens them, dimmed, in place. The +**Settings** tab is a list of rows: the name, the start +folder, folders, the worktree branch, and the tracks. Changes save as you +make them. Folder rows show the branch each folder is on, read from git when +the page opens, and "modified" when it has uncommitted changes. + +- **New project** — Name it, pick a template, and attach the folders it works + in. The dialog shows what Create will make: the project folder and its + files, each worktree on its branch, and the tracks the template adds. Start + sessions from the overview; they are filed under the project and still show + under their folder in the Sessions tab. +- **Tracks** — Optional lines of work inside a project, each with its own + sessions, start folder and CLI. A track card's "Resume latest" reopens its + most recent session; "New" starts one there. +- **Plan tab** — The phases in `plan-tracker.md` with their items, the todos, + and which sessions started or finished each one. Tick items in place, or + start a session on a phase or a todo: it opens with that item as its first + prompt, filed under the project. A plan written in Claude Code's plan mode + can be adopted into a project from the Plans list. +- **Templates** — New project offers Feature, Research and Customer. A + template is a folder under the app's data directory with a + `template.json` (name, description, tracks) and the files a new project + starts with; its `CLAUDE.md` becomes the top of the brief. Edit them or add + your own from Global Settings. +- **Worktrees** — Attaching a plain folder uses it where it is. Attaching a + git repository asks how the project should work in it: as it is, on + whatever branch is checked out, or with its own checkout under the project + folder (`repos/`), on one branch shared + by every worktree in the project, or one you name per repository. The + worktree inherits the repository's `.vscode/tasks.json`. For Codex, + Switchboard writes an `AGENTS.override.md` into the worktree carrying the + project brief (and the repository's own `AGENTS.md`), excluded from git. + Marking a project done offers to remove its worktrees; branches stay. +- **Move to project** — Any session row has a move action. Nothing is filed + until you launch it from a project or move it there. +- **Play button** — A project's task menu combines the `.vscode/tasks.json` + tasks from every attached folder. +- **Mark as done** — Done projects drop to the bottom, collapsed. Removing a + project only forgets it; the folder and the sessions stay on disk. + ## Session Grid Overview Toggle the grid overview from the sidebar for a bird's-eye view of all your open sessions at once, grouped by project. diff --git a/db.js b/db.js index 751b08a0..1b5d20c4 100644 --- a/db.js +++ b/db.js @@ -171,6 +171,80 @@ if (migrations.length > currentDbVersion) { if (!cols.has('sessionFile')) db.exec('ALTER TABLE session_cache ADD COLUMN sessionFile TEXT'); } +// --- Projects --- +// A project is a piece of work with a folder on disk (`root`). It attaches +// zero or more folders (the cwds sessions run in) and, later, tracks. Sessions +// are assigned through session_meta.projectId / trackId. These live in their +// own tables, never under the `project:` settings key, because hiding a +// folder deletes that key (see remove-project in main.js). +// +// Same rule as above: tables and columns are ensured by inspecting the schema, +// not by db_version, so a DB touched by a parallel branch still gets them. +db.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + root TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + sharedBranch INTEGER NOT NULL DEFAULT 1, + branchName TEXT, + created TEXT NOT NULL, + modified TEXT NOT NULL + ) +`); +db.exec(` + CREATE TABLE IF NOT EXISTS project_folders ( + projectId TEXT NOT NULL, + path TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'in-place', + sourcePath TEXT, + branch TEXT, + sortOrder INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (projectId, path) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_project_folders_path ON project_folders(path)'); +db.exec(` + CREATE TABLE IF NOT EXISTS tracks ( + id TEXT PRIMARY KEY, + projectId TEXT NOT NULL, + name TEXT NOT NULL, + cwd TEXT, + cli TEXT, + status TEXT NOT NULL DEFAULT 'active', + sortOrder INTEGER NOT NULL DEFAULT 0, + created TEXT NOT NULL + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_tracks_project ON tracks(projectId)'); +{ + const cols = new Set(db.prepare('PRAGMA table_info(session_meta)').all().map(c => c.name)); + if (!cols.has('projectId')) db.exec('ALTER TABLE session_meta ADD COLUMN projectId TEXT'); + if (!cols.has('trackId')) db.exec('ALTER TABLE session_meta ADD COLUMN trackId TEXT'); +} +{ + // Where a project's sessions start by default (null = the project folder). + // Tracks inherit it unless they set their own cwd. + const cols = new Set(db.prepare('PRAGMA table_info(projects)').all().map(c => c.name)); + if (!cols.has('defaultCwd')) db.exec('ALTER TABLE projects ADD COLUMN defaultCwd TEXT'); +} +// Which session started or finished a plan phase or a todo. Items are matched +// by their text, not their line, so editing the file above an item does not +// orphan its history. +db.exec(` + CREATE TABLE IF NOT EXISTS plan_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + projectId TEXT NOT NULL, + file TEXT NOT NULL, + itemText TEXT NOT NULL, + sessionId TEXT NOT NULL, + kind TEXT NOT NULL, + at TEXT NOT NULL + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_plan_links_project ON plan_links(projectId)'); + // --- FTS5 full-text search --- db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( @@ -261,6 +335,48 @@ const stmts = { ORDER BY rank LIMIT ? `), + // Project statements + projectList: db.prepare('SELECT * FROM projects ORDER BY created'), + projectGet: db.prepare('SELECT * FROM projects WHERE id = ?'), + projectGetBySlug: db.prepare('SELECT * FROM projects WHERE slug = ?'), + projectInsert: db.prepare(` + INSERT INTO projects (id, name, slug, root, status, sharedBranch, branchName, created, modified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + projectDelete: db.prepare('DELETE FROM projects WHERE id = ?'), + projectFoldersList: db.prepare('SELECT * FROM project_folders WHERE projectId = ? ORDER BY sortOrder, path'), + projectFoldersListAll: db.prepare('SELECT * FROM project_folders ORDER BY projectId, sortOrder, path'), + projectFolderUpsert: db.prepare(` + INSERT INTO project_folders (projectId, path, mode, sourcePath, branch, sortOrder) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(projectId, path) DO UPDATE SET + mode = excluded.mode, sourcePath = excluded.sourcePath, + branch = excluded.branch, sortOrder = excluded.sortOrder + `), + projectFolderDelete: db.prepare('DELETE FROM project_folders WHERE projectId = ? AND path = ?'), + projectFoldersDeleteByProject: db.prepare('DELETE FROM project_folders WHERE projectId = ?'), + tracksList: db.prepare('SELECT * FROM tracks WHERE projectId = ? ORDER BY sortOrder, created'), + tracksListAll: db.prepare('SELECT * FROM tracks ORDER BY projectId, sortOrder, created'), + trackGet: db.prepare('SELECT * FROM tracks WHERE id = ?'), + trackInsert: db.prepare(` + INSERT INTO tracks (id, projectId, name, cwd, cli, status, sortOrder, created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), + trackDelete: db.prepare('DELETE FROM tracks WHERE id = ?'), + tracksDeleteByProject: db.prepare('DELETE FROM tracks WHERE projectId = ?'), + // Session ↔ project assignment lives on session_meta so it survives cache + // rebuilds. name/starred/archived are left untouched by these statements. + assignmentSet: db.prepare(` + INSERT INTO session_meta (sessionId, projectId, trackId) VALUES (?, ?, ?) + ON CONFLICT(sessionId) DO UPDATE SET projectId = excluded.projectId, trackId = excluded.trackId + `), + assignmentClearProject: db.prepare('UPDATE session_meta SET projectId = NULL, trackId = NULL WHERE projectId = ?'), + assignmentClearTrack: db.prepare('UPDATE session_meta SET trackId = NULL WHERE trackId = ?'), + // Plan links + planLinkInsert: db.prepare('INSERT INTO plan_links (projectId, file, itemText, sessionId, kind, at) VALUES (?, ?, ?, ?, ?, ?)'), + planLinksByProject: db.prepare('SELECT * FROM plan_links WHERE projectId = ? ORDER BY at'), + planLinksDeleteByProject: db.prepare('DELETE FROM plan_links WHERE projectId = ?'), + planLinksRekey: db.prepare('UPDATE plan_links SET sessionId = ? WHERE sessionId = ?'), }; function getMeta(sessionId) { @@ -427,6 +543,150 @@ function deleteSetting(key) { stmts.settingsDelete.run(key); } +// --- Project functions --- + +const PROJECT_PATCH_KEYS = ['name', 'status', 'sharedBranch', 'branchName', 'defaultCwd', 'modified']; +const TRACK_PATCH_KEYS = ['name', 'cwd', 'cli', 'status', 'sortOrder']; + +function listProjects() { + return stmts.projectList.all(); +} + +function getProject(id) { + return stmts.projectGet.get(id) || null; +} + +function getProjectBySlug(slug) { + return stmts.projectGetBySlug.get(slug) || null; +} + +function insertProject(row) { + stmts.projectInsert.run( + row.id, row.name, row.slug, row.root, + row.status || 'active', row.sharedBranch === false ? 0 : 1, row.branchName || null, + row.created, row.modified + ); +} + +// Only whitelisted columns can change; the patch is applied with one UPDATE so +// callers cannot rename `id`, `slug` or `root` by accident. +function updatePatch(table, allowed, id, patch) { + const sets = []; + const values = []; + for (const key of allowed) { + if (!(key in patch)) continue; + sets.push(`${key} = ?`); + let value = patch[key]; + if (key === 'sharedBranch') value = value ? 1 : 0; + values.push(value === undefined ? null : value); + } + if (!sets.length) return 0; + values.push(id); + return db.prepare(`UPDATE ${table} SET ${sets.join(', ')} WHERE id = ?`).run(...values).changes; +} + +function updateProject(id, patch) { + return updatePatch('projects', PROJECT_PATCH_KEYS, id, patch); +} + +const deleteProjectTx = db.transaction((id) => { + stmts.assignmentClearProject.run(id); + stmts.tracksDeleteByProject.run(id); + stmts.projectFoldersDeleteByProject.run(id); + stmts.planLinksDeleteByProject.run(id); + stmts.projectDelete.run(id); +}); + +// --- Plan links --- + +function insertPlanLink(row) { + stmts.planLinkInsert.run(row.projectId, row.file, row.itemText, row.sessionId, row.kind, row.at || new Date().toISOString()); +} + +function listPlanLinks(projectId) { + return stmts.planLinksByProject.all(projectId); +} + +/** A session that started under a temporary id keeps its links once the real id is known. */ +function rekeyPlanLinks(fromId, toId) { + return stmts.planLinksRekey.run(toId, fromId).changes; +} + +function deleteProject(id) { + deleteProjectTx(id); +} + +function listProjectFolders(projectId) { + return stmts.projectFoldersList.all(projectId); +} + +function listAllProjectFolders() { + return stmts.projectFoldersListAll.all(); +} + +function upsertProjectFolder(row) { + stmts.projectFolderUpsert.run( + row.projectId, row.path, row.mode || 'in-place', + row.sourcePath || null, row.branch || null, row.sortOrder || 0 + ); +} + +function deleteProjectFolder(projectId, folderPath) { + stmts.projectFolderDelete.run(projectId, folderPath); +} + +function listTracks(projectId) { + return stmts.tracksList.all(projectId); +} + +function listAllTracks() { + return stmts.tracksListAll.all(); +} + +function getTrack(id) { + return stmts.trackGet.get(id) || null; +} + +function insertTrack(row) { + stmts.trackInsert.run( + row.id, row.projectId, row.name, row.cwd || null, row.cli || null, + row.status || 'active', row.sortOrder || 0, row.created + ); +} + +function updateTrack(id, patch) { + return updatePatch('tracks', TRACK_PATCH_KEYS, id, patch); +} + +const deleteTrackTx = db.transaction((id) => { + stmts.assignmentClearTrack.run(id); + stmts.trackDelete.run(id); +}); + +function deleteTrack(id) { + deleteTrackTx(id); +} + +function setSessionAssignment(sessionId, projectId, trackId) { + stmts.assignmentSet.run(sessionId, projectId || null, trackId || null); +} + +// A fork inherits its parent's project and track. +function copySessionAssignment(fromId, toId) { + const row = stmts.get.get(fromId); + if (!row || (!row.projectId && !row.trackId)) return false; + stmts.assignmentSet.run(toId, row.projectId || null, row.trackId || null); + return true; +} + +// A Codex session runs under a temporary id until its transcript appears; the +// assignment recorded at launch has to follow it to the real id. +function moveSessionAssignment(fromId, toId) { + const copied = copySessionAssignment(fromId, toId); + if (copied) stmts.assignmentSet.run(fromId, null, null); + return copied; +} + function closeDb() { try { db.close(); } catch {} } @@ -440,5 +700,10 @@ module.exports = { upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, + listProjects, getProject, getProjectBySlug, insertProject, updateProject, deleteProject, + listProjectFolders, listAllProjectFolders, upsertProjectFolder, deleteProjectFolder, + listTracks, listAllTracks, getTrack, insertTrack, updateTrack, deleteTrack, + setSessionAssignment, copySessionAssignment, moveSessionAssignment, + insertPlanLink, listPlanLinks, rekeyPlanLinks, closeDb, }; diff --git a/git.js b/git.js new file mode 100644 index 00000000..be7b57a3 --- /dev/null +++ b/git.js @@ -0,0 +1,290 @@ +// git.js — the few git commands Switchboard runs itself, for project worktrees. +// +// Every call goes through execFile with an argv array and no shell, so a path +// or branch name can never be read as shell syntax. Errors carry git's stderr +// as their message. Nothing here is used unless a project attaches a folder +// "on a branch". + +const { execFile } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const MAX_BUFFER = 4 * 1024 * 1024; +const MAX_DIFF_LENGTH = 256 * 1024; +const MAX_UNTRACKED_STAT_BYTES = 2 * 1024 * 1024; + +function run(args, cwd) { + return new Promise((resolve, reject) => { + execFile('git', args, { + cwd, + maxBuffer: MAX_BUFFER, + // Never hang on a credential prompt; a worktree add has no reason to ask. + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }, (err, stdout, stderr) => { + if (err) { + const error = new Error(String(stderr || err.message).trim() || `git ${args[0]} failed`); + error.code = err.code; + error.stderr = String(stderr || ''); + reject(error); + return; + } + resolve(String(stdout).trim()); + }); + }); +} + +/** Run git without trimming its output. Some machine-readable formats use NULs. */ +function runRaw(args, cwd, { allowExitCodes = [] } = {}) { + return new Promise((resolve, reject) => { + execFile('git', args, { + cwd, + maxBuffer: MAX_BUFFER, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }, (err, stdout, stderr) => { + if (!err || allowExitCodes.includes(err.code)) { + resolve(String(stdout)); + return; + } + const error = new Error(String(stderr || err.message).trim() || `git ${args[0]} failed`); + error.code = err.code; + error.stderr = String(stderr || ''); + error.stdout = String(stdout || ''); + reject(error); + }); + }); +} + +async function runOr(args, cwd, fallback = '') { + try { return await runRaw(args, cwd); } catch { return fallback; } +} + +async function version() { + try { return await run(['--version']); } catch { return null; } +} + +async function isGitRepo(dir) { + try { return (await run(['rev-parse', '--is-inside-work-tree'], dir)) === 'true'; } catch { return false; } +} + +/** Top-level directory of the checkout that contains dir. Rejects outside a repo. */ +async function repoRoot(dir) { + return run(['rev-parse', '--show-toplevel'], dir); +} + +async function branchExists(repo, name) { + try { + await run(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], repo); + return true; + } catch { + return false; + } +} + +/** Check the branch out at targetPath, creating the branch from HEAD if needed. */ +async function worktreeAdd(repo, targetPath, branch) { + if (await branchExists(repo, branch)) { + await run(['worktree', 'add', targetPath, branch], repo); + } else { + await run(['worktree', 'add', '-b', branch, targetPath], repo); + } +} + +/** Remove a worktree. Refuses a dirty one unless force; the branch always stays. */ +async function worktreeRemove(repo, targetPath, { force = false } = {}) { + const args = ['worktree', 'remove']; + if (force) args.push('--force'); + args.push(targetPath); + await run(args, repo); + try { await run(['worktree', 'prune'], repo); } catch {} +} + +/** True when git refused to remove a worktree because it has local changes. */ +function isDirtyWorktreeError(err) { + return /modified or untracked|use --force|uncommitted|contains modified/i.test(String(err?.message || '')); +} + +/** The repository's shared .git directory, absolute, from any of its worktrees. */ +async function gitCommonDir(dir) { + const out = await run(['rev-parse', '--git-common-dir'], dir); + return path.resolve(dir, out); +} + +/** Branch, whether anything is modified or untracked, and ahead/behind upstream. */ +async function status(dir) { + const branch = await run(['rev-parse', '--abbrev-ref', 'HEAD'], dir); + const porcelain = await run(['status', '--porcelain'], dir); + let ahead = null; + let behind = null; + try { + const counts = await run(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], dir); + const [b, a] = counts.split(/\s+/).map(Number); + if (Number.isFinite(a) && Number.isFinite(b)) { ahead = a; behind = b; } + } catch {} + return { branch, dirty: porcelain.length > 0, ahead, behind }; +} + +const CONFLICT_CODES = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']); + +/** Parse `git status --porcelain=v1 -z` without losing unusual filenames. */ +function parsePorcelain(output) { + const records = String(output || '').split('\0'); + const changes = []; + for (let i = 0; i < records.length; i += 1) { + const record = records[i]; + if (!record || record.length < 3) continue; + const code = record.slice(0, 2); + const indexStatus = code[0]; + const worktreeStatus = code[1]; + const filePath = record.slice(3); + let oldPath = null; + // With -z, a rename/copy is the destination followed by a second, + // NUL-terminated source pathname. + if (/[RC]/.test(code) && i + 1 < records.length) oldPath = records[++i] || null; + + let statusName = 'modified'; + if (CONFLICT_CODES.has(code)) statusName = 'conflicted'; + else if (code === '??') statusName = 'untracked'; + else if (/[RC]/.test(code)) statusName = 'renamed'; + else if (code.includes('D')) statusName = 'deleted'; + else if (code.includes('A')) statusName = 'added'; + + changes.push({ + path: filePath, + oldPath, + code, + status: statusName, + indexStatus, + worktreeStatus, + staged: indexStatus !== ' ' && indexStatus !== '?', + }); + } + return changes; +} + +function parseShortStat(output) { + const text = String(output || ''); + const insertions = Number(text.match(/(\d+) insertion/)?.[1] || 0); + const deletions = Number(text.match(/(\d+) deletion/)?.[1] || 0); + return { insertions, deletions }; +} + +function countUntrackedInsertions(dir, changes) { + let insertions = 0; + for (const change of changes) { + if (change.status !== 'untracked') continue; + const absolute = path.resolve(dir, change.path); + const relative = path.relative(dir, absolute); + if (relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) continue; + try { + const stat = fs.statSync(absolute); + if (!stat.isFile() || stat.size > MAX_UNTRACKED_STAT_BYTES) continue; + const content = fs.readFileSync(absolute); + if (content.includes(0)) continue; + if (content.length) insertions += content.toString('utf8').split('\n').length - (content.at(-1) === 10 ? 1 : 0); + } catch {} + } + return insertions; +} + +function parseCommits(output) { + return String(output || '') + .split('\x1e') + .map(record => record.replace(/^\n+|\n+$/g, '')) + .filter(Boolean) + .map((record) => { + const [hash, shortHash, author, date, subject] = record.split('\0'); + return { hash, shortHash, author, date, subject }; + }); +} + +function gitOperation(gitDir) { + const exists = (name) => fs.existsSync(path.join(gitDir, name)); + if (exists('rebase-merge') || exists('rebase-apply')) return 'rebase'; + if (exists('MERGE_HEAD')) return 'merge'; + if (exists('CHERRY_PICK_HEAD')) return 'cherry-pick'; + if (exists('REVERT_HEAD')) return 'revert'; + if (exists('BISECT_LOG')) return 'bisect'; + return null; +} + +/** Read-only state used by the project Git tab. */ +async function snapshot(dir, { commitLimit = 20 } = {}) { + if (!await isGitRepo(dir)) return { git: false }; + + const [branchName, head, porcelain, counts, logOutput, statOutput, gitDirOutput] = await Promise.all([ + runOr(['rev-parse', '--abbrev-ref', 'HEAD'], dir), + runOr(['rev-parse', '--short', 'HEAD'], dir), + runRaw(['status', '--porcelain=v1', '-z', '--untracked-files=all'], dir), + runOr(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], dir, ''), + runOr(['log', `-${Math.max(1, Math.min(100, Number(commitLimit) || 20))}`, '--date=iso-strict', '--pretty=format:%H%x00%h%x00%an%x00%aI%x00%s%x1e'], dir, ''), + runOr(['diff', '--shortstat', 'HEAD', '--'], dir, ''), + runOr(['rev-parse', '--git-dir'], dir, ''), + ]); + + const changes = parsePorcelain(porcelain); + const [behindRaw, aheadRaw] = counts.trim().split(/\s+/); + const ahead = counts ? Number(aheadRaw) : null; + const behind = counts ? Number(behindRaw) : null; + const stats = parseShortStat(statOutput); + stats.insertions += countUntrackedInsertions(dir, changes); + const gitDir = gitDirOutput ? path.resolve(dir, gitDirOutput.trim()) : null; + const detached = branchName.trim() === 'HEAD'; + + return { + git: true, + branch: detached ? '' : branchName.trim(), + detached, + head: head.trim(), + dirty: changes.length > 0, + ahead: Number.isFinite(ahead) ? ahead : null, + behind: Number.isFinite(behind) ? behind : null, + operation: gitDir ? gitOperation(gitDir) : null, + changes, + stats, + commits: parseCommits(logOutput), + }; +} + +function safeRelativePath(dir, filePath) { + if (typeof filePath !== 'string' || !filePath) throw new Error('File path is required'); + const absolute = path.resolve(dir, filePath); + const relative = path.relative(dir, absolute); + if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) { + throw new Error('File is outside the repository'); + } + return { absolute, relative }; +} + +/** Return a read-only unified diff for one currently changed file. */ +async function fileDiff(dir, filePath) { + const { absolute, relative } = safeRelativePath(dir, filePath); + const changes = parsePorcelain(await runRaw(['status', '--porcelain=v1', '-z', '--untracked-files=all'], dir)); + const change = changes.find(item => item.path === relative || item.path === filePath); + if (!change) throw new Error('File is not currently changed'); + + let diff = ''; + if (change.status === 'untracked') { + const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; + diff = await runRaw(['diff', '--no-index', '--no-ext-diff', '--unified=3', '--', nullPath, absolute], dir, { allowExitCodes: [1] }); + } else { + try { + diff = await runRaw(['diff', '--no-ext-diff', '--unified=3', 'HEAD', '--', relative], dir); + } catch { + const [staged, unstaged] = await Promise.all([ + runOr(['diff', '--cached', '--no-ext-diff', '--unified=3', '--', relative], dir, ''), + runOr(['diff', '--no-ext-diff', '--unified=3', '--', relative], dir, ''), + ]); + diff = [staged, unstaged].filter(Boolean).join('\n'); + } + } + + const truncated = diff.length > MAX_DIFF_LENGTH; + if (truncated) diff = diff.slice(0, MAX_DIFF_LENGTH) + '\n\n… diff truncated by Switchboard …\n'; + return { path: relative, diff, truncated }; +} + +module.exports = { + run, version, isGitRepo, repoRoot, branchExists, + worktreeAdd, worktreeRemove, isDirtyWorktreeError, gitCommonDir, status, + parsePorcelain, snapshot, fileDiff, +}; diff --git a/harnesses/claude.js b/harnesses/claude.js index 1c8c1928..c053b854 100644 --- a/harnesses/claude.js +++ b/harnesses/claude.js @@ -391,6 +391,12 @@ function buildLaunchArgs({ sessionId, isNew, options }) { args.push('--append-system-prompt', String(options.appendSystemPrompt)); } + // A first prompt, as the positional argument. Only for a brand-new session: + // a resume continues where it was, and a fork carries its parent's prompt. + if (isNew && !options?.forkFrom && options?.initialPrompt) { + args.push(String(options.initialPrompt)); + } + return args; } diff --git a/harnesses/codex.js b/harnesses/codex.js index a536bf1a..f48c3ad5 100644 --- a/harnesses/codex.js +++ b/harnesses/codex.js @@ -585,7 +585,14 @@ function buildLaunchArgs({ sessionId, isNew, options }) { if (options.codexModel) { args.push('--model', String(options.codexModel)); } - if (options.addDirs) { + // --add-dir names extra writable roots, and codex refuses to start when + // the sandbox cannot grant them ("effective permissions do not allow + // additional writable roots"). Read-only, the default, can already read + // every path, and the bypass flag can already write everywhere, so the + // dirs only go on the command line for the two modes that take them. + const canAddDirs = !options.dangerouslySkipPermissions && + (options.codexSandbox === 'workspace-write' || options.codexSandbox === 'danger-full-access'); + if (options.addDirs && canAddDirs) { const dirs = String(options.addDirs).split(',').map(d => d.trim()).filter(Boolean); for (const dir of dirs) { args.push('--add-dir', dir); @@ -593,6 +600,12 @@ function buildLaunchArgs({ sessionId, isNew, options }) { } } + // A first prompt, as codex's positional argument. Fresh sessions only; a + // resume or fork already has a conversation. + if (isNew && !options?.forkFrom && options?.initialPrompt) { + args.push(String(options.initialPrompt)); + } + return args; } diff --git a/main.js b/main.js index 2fd68841..c6cea268 100644 --- a/main.js +++ b/main.js @@ -74,8 +74,10 @@ const { upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, + copySessionAssignment, moveSessionAssignment, rekeyPlanLinks, closeDb, } = require('./db'); +const dbModule = require('./db'); const { getHarness, DEFAULT_HARNESS, transcriptPath, availableHarnesses, allHarnesses, progressBusyState, harnessForFolder: getHarnessForFolder } = require('./harnesses'); @@ -281,6 +283,25 @@ const { refreshFolder, reconcileCacheFromFilesystem, buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, refreshHarnessTitles, initializeHiddenProjectTimestamps } = sessionCache; +// --- Projects (a piece of work with a folder on disk) --- +const projects = require('./projects'); +projects.init({ + db: dbModule, + log, + buildProjectsFromCache, + notifyRendererProjectsChanged, + isHarnessId: (id) => allHarnesses().some(h => h.id === id), + plansDir: PLANS_DIR, +}); +// Watch every project's plan-tracker.md and todos.md so a tick made by a +// session is credited to it and the page refreshes. +projects.initPlanWatch({ + activeSessions, + send: (channel, ...args) => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args); + }, +}); + // --- IPC: browse-folder --- ipcMain.handle('browse-folder', async () => { const result = await dialog.showOpenDialog(mainWindow, { @@ -361,12 +382,73 @@ ipcMain.handle('remove-project', (_event, projectPath) => { } }); +// --- IPC: projects --- +// Every mutating handler notifies the renderer itself (projects.js), so both +// the Sessions and the Projects tab refresh from one event. +function guarded(fn) { + return async (_event, ...args) => { + try { return await fn(...args); } catch (err) { + log.error('[projects]', err); + return { error: err.message }; + } + }; +} +ipcMain.handle('get-project-tree', guarded((showArchived) => { + // Mirrors get-projects: until the cache is populated there is nothing to + // file, and the renderer is told via projects-changed once there is. + if (!isCachePopulated() || !isSearchIndexPopulated()) return { projects: [] }; + return projects.buildProjectTree(!!showArchived); +})); +ipcMain.handle('create-project', guarded((spec) => projects.createProject(spec || {}))); +ipcMain.handle('update-project', guarded((id, patch) => projects.updateProject(id, patch || {}))); +ipcMain.handle('delete-project', guarded((id) => projects.deleteProject(id))); +ipcMain.handle('attach-project-folder', guarded((id, spec) => projects.attachFolder(id, spec || {}))); +ipcMain.handle('detach-project-folder', guarded((id, folderPath, opts) => projects.detachFolder(id, folderPath, opts || {}))); +ipcMain.handle('set-session-assignment', guarded((sessionId, projectId, trackId) => + projects.assignSession(sessionId, projectId || null, trackId || null))); +ipcMain.handle('create-track', guarded((projectId, spec) => projects.createTrack(projectId, spec || {}))); +ipcMain.handle('update-track', guarded((id, patch) => projects.updateTrack(id, patch || {}))); +ipcMain.handle('delete-track', guarded((id) => projects.deleteTrack(id))); +ipcMain.handle('get-projects-root', guarded(() => projects.projectsRoot())); +ipcMain.handle('get-project-git-status', guarded((id, opts) => projects.folderGitStatus(id, opts || {}))); +ipcMain.handle('get-project-git-info', guarded((id) => projects.projectGitInfo(id))); +ipcMain.handle('get-project-git-diff', guarded((id, folderPath, filePath) => projects.projectGitDiff(id, folderPath, filePath))); +ipcMain.handle('get-folder-git-status', guarded((folderPath) => projects.folderGitInfo(String(folderPath || '')))); +// The .env files a folder has, and the ones the dialog ticks by default, +// so a new worktree can be offered its repository's local environment. +ipcMain.handle('list-env-files', guarded((folderPath) => ({ + ok: true, + files: projects.listEnvFiles(String(folderPath || '')), + defaults: projects.defaultEnvSelection(String(folderPath || '')), +}))); +ipcMain.handle('save-project-brief', guarded((id, content) => projects.saveBrief(id, content))); +ipcMain.handle('create-project-file', guarded((id, name, content) => projects.createProjectFile(id, name, content))); +ipcMain.handle('get-project-plan', guarded((id) => projects.readProjectPlan(id))); +ipcMain.handle('set-plan-item', guarded((id, kind, line, done) => projects.setPlanItem(id, kind, line, done))); +ipcMain.handle('append-plan-item', guarded((id, kind, text) => projects.appendPlanItem(id, kind, text))); +ipcMain.handle('edit-plan-item', guarded((id, kind, line, text) => projects.editPlanItem(id, kind, line, text))); +ipcMain.handle('adopt-plan', guarded((id, filename, opts) => projects.adoptPlan(id, filename, opts || {}))); +ipcMain.handle('list-templates', guarded(() => projects.listTemplates())); + // --- IPC: get-projects --- ipcMain.handle('open-external', (_event, url) => { log.info('[open-external IPC]', url); if (/^https?:\/\//i.test(url)) return shell.openExternal(url); }); +// Reveal a folder in the OS file manager. Only existing directories, so a +// renderer value can never launch a file. +ipcMain.handle('open-path', async (_event, target) => { + try { + const resolved = path.resolve(String(target || '')); + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) return { error: 'Not a folder' }; + const err = await shell.openPath(resolved); + return err ? { error: err } : { ok: true }; + } catch (err) { + return { error: err.message }; + } +}); + // --- IPC: clipboard write --- // The renderer's navigator.clipboard.writeText is gated on focus/user-activation and // is flaky-to-dead on Linux/Wayland (Ozone). The main-process clipboard has no such @@ -691,7 +773,7 @@ ipcMain.handle('get-codex-usage', async () => { // --- IPC: get-memories --- function folderToShortPath(folder) { - // Convert "-Users-home-dev-MyClaude" → "dev/MyClaude" + // Convert "-Users-me-dev-my-app" → "dev/my-app" const parts = folder.replace(/^-/, '').split('-'); const meaningful = parts.filter(Boolean); return meaningful.slice(-2).join('/'); @@ -737,7 +819,7 @@ ipcMain.handle('get-memories', () => { const projectPath = deriveProjectPath(folderPath, folder); if (projectPath && hiddenProjects.has(projectPath)) continue; - // Use same 2-deep short path as Sessions tab (e.g. "dev/MyClaude") + // Use same 2-deep short path as Sessions tab (e.g. "dev/my-app") // Splits on both separators — `cwd` is backslash-separated on Windows, // where splitting on '/' alone left the whole path as one segment. const shortName = projectPath @@ -938,7 +1020,9 @@ const SETTING_DEFAULTS = { // Codex equivalents of the permission settings above. Kept separate because // the vocabularies do not map: codex has no permission modes, and Claude has // no sandbox policy. An empty value means "leave it to codex's own config". - codexSandbox: '', + // The app starts on workspace-write: a project's attached folders only reach + // codex under that sandbox, and read-only sessions cannot do the work. + codexSandbox: 'workspace-write', codexApproval: '', codexModel: '', }; @@ -993,6 +1077,8 @@ ipcMain.handle('get-effective-settings', (_event, projectPath) => effectiveSetti const taskManager = createTaskManager({ baseEnv: cleanPtyEnv, getShellProfile: (projectPath) => resolveShell(effectiveSettings(projectPath).shellProfile), + // A project worktree inherits its source repo's tasks.json. + resolveWorktreeParent: (projectPath) => projects.worktreeParentFor(projectPath), log, send: (channel, ...args) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args); @@ -1135,6 +1221,39 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se log.info(`[open-terminal] ${sessionId} has no transcript; starting a new session instead of resuming`); } + // A session that belongs to a project gets the project folder and every + // attached folder as extra directories, so the brief loads wherever it + // starts and the agent can edit the repos. New sessions say which project + // in their options; resumed ones are looked up by their assignment. + let projectEnv = {}; + // A fork of a project session belongs to the same project and track, even + // when it is started from the Sessions tab where no project is in play. + if (!isPlainTerminal && sessionOptions?.forkFrom && !sessionOptions.projectId) { + const source = getMeta(sessionOptions.forkFrom); + if (source?.projectId) sessionOptions = { ...sessionOptions, projectId: source.projectId, trackId: source.trackId || null }; + } + if (!isPlainTerminal) { + const launchProjectId = sessionOptions?.projectId || getMeta(sessionId)?.projectId || null; + if (launchProjectId) { + try { + const ctx = projects.launchContext(launchProjectId, projectPath); + if (ctx && ctx.addDirs.length) { + sessionOptions = { ...(sessionOptions || {}), addDirs: projects.mergeAddDirs(sessionOptions?.addDirs, ctx.addDirs) }; + projectEnv = ctx.env; + } + // A project worktree is already the isolated checkout; asking Claude + // for another one on top of it would nest worktrees. + if (ctx?.worktree && sessionOptions?.worktree) { + sessionOptions = { ...sessionOptions }; + delete sessionOptions.worktree; + delete sessionOptions.worktreeName; + } + } catch (err) { + log.error('[projects] launch context failed', err); + } + } + } + // Which CLI drives this session. The cached row is authoritative for anything // that already exists on disk; the caller only gets to say for a brand-new // session, which has no row yet. @@ -1268,6 +1387,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se ...cleanPtyEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', + ...projectEnv, }; // A harness that cannot be told its session id up front gets to stamp the // environment instead, so its transcript can be recognised afterwards. @@ -1312,6 +1432,29 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se }; activeSessions.set(sessionId, session); + // A session launched from a project is filed there. Recorded under whatever + // id the session has right now; resolvePendingLaunches moves it to the real + // id for a harness that only learns its id from the transcript. + if (startFresh && !isPlainTerminal && sessionOptions?.projectId) { + try { + const assignment = projects.recordLaunchAssignment(sessionId, sessionOptions); + if (assignment) { + session.projectId = assignment.projectId; + session.trackId = assignment.trackId; + // Started from a phase or a todo on the project page. + const item = sessionOptions.planItem; + if (item?.itemText) projects.recordPlanLink(assignment.projectId, item.file, item.itemText, sessionId, 'started'); + } + } catch (err) { + log.error('[projects] could not record launch assignment', err); + } + } + // A resumed project session is a project session too, for the plan watcher. + if (!isPlainTerminal && !session.projectId) { + const meta = getMeta(session.realSessionId || sessionId); + if (meta?.projectId) { session.projectId = meta.projectId; session.trackId = meta.trackId || null; } + } + ptyProcess.onData(data => { const currentId = session.realSessionId || sessionId; @@ -1526,7 +1669,7 @@ ipcMain.on('close-terminal', (_event, sessionId) => { // Session transitions → session-transitions.js const sessionTransitions = require('./session-transitions'); -sessionTransitions.init({ PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer }); +sessionTransitions.init({ PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer, copySessionAssignment }); const { detectSessionTransitions } = sessionTransitions; // --- fs.watch on projects directory --- @@ -1569,7 +1712,8 @@ function startProjectsWatcher() { } if (changed) { - notifyRendererProjectsChanged(); + // A transcript folder moved: sessions only, not the projects themselves. + notifyRendererProjectsChanged('sessions'); } } @@ -1630,6 +1774,12 @@ function resolvePendingLaunches(candidatePaths) { session.pendingLaunch = null; activeSessions.delete(tempId); activeSessions.set(realId, session); + if (session.projectId) { + try { + moveSessionAssignment(tempId, realId); + rekeyPlanLinks(tempId, realId); + } catch (err) { log.error('[projects] assignment move failed', err); } + } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('session-detected', tempId, realId); } @@ -1726,7 +1876,7 @@ function startHarnessWatchers() { try { refreshFolder(folder); } catch (err) { log.error('[harness-watch]', folder, err.message); } } const titlesChanged = refreshHarnessTitles(h); - if (folders.size || titlesChanged) notifyRendererProjectsChanged(); + if (folders.size || titlesChanged) notifyRendererProjectsChanged('sessions'); } try { @@ -1762,7 +1912,7 @@ function startHarnessWatchers() { if (titleTimer) clearTimeout(titleTimer); titleTimer = setTimeout(() => { titleTimer = null; - if (refreshHarnessTitles(h)) notifyRendererProjectsChanged(); + if (refreshHarnessTitles(h)) notifyRendererProjectsChanged('sessions'); }, 300); }); titleWatcher.on('error', (err) => log.error(`[harness-title-watch] ${h.id}:`, err.message)); diff --git a/preload.js b/preload.js index 483154e6..300d4433 100644 --- a/preload.js +++ b/preload.js @@ -46,7 +46,37 @@ contextBridge.exposeInMainWorld('api', { browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), + + // Projects (a piece of work with a folder on disk; see projects.js) + getProjectTree: (showArchived) => ipcRenderer.invoke('get-project-tree', showArchived), + createProject: (spec) => ipcRenderer.invoke('create-project', spec), + updateProject: (id, patch) => ipcRenderer.invoke('update-project', id, patch), + deleteProject: (id) => ipcRenderer.invoke('delete-project', id), + attachProjectFolder: (id, spec) => ipcRenderer.invoke('attach-project-folder', id, spec), + detachProjectFolder: (id, folderPath, opts) => ipcRenderer.invoke('detach-project-folder', id, folderPath, opts), + setSessionAssignment: (sessionId, projectId, trackId) => ipcRenderer.invoke('set-session-assignment', sessionId, projectId, trackId), + getProjectsRoot: () => ipcRenderer.invoke('get-projects-root'), + getProjectGitStatus: (id, opts) => ipcRenderer.invoke('get-project-git-status', id, opts), + getProjectGitInfo: (id) => ipcRenderer.invoke('get-project-git-info', id), + getProjectGitDiff: (id, folderPath, filePath) => ipcRenderer.invoke('get-project-git-diff', id, folderPath, filePath), + getFolderGitStatus: (folderPath) => ipcRenderer.invoke('get-folder-git-status', folderPath), + listEnvFiles: (folderPath) => ipcRenderer.invoke('list-env-files', folderPath), + saveProjectBrief: (id, content) => ipcRenderer.invoke('save-project-brief', id, content), + createProjectFile: (id, name, content) => ipcRenderer.invoke('create-project-file', id, name, content), + getProjectPlan: (id) => ipcRenderer.invoke('get-project-plan', id), + setPlanItem: (id, kind, line, done) => ipcRenderer.invoke('set-plan-item', id, kind, line, done), + appendPlanItem: (id, kind, text) => ipcRenderer.invoke('append-plan-item', id, kind, text), + editPlanItem: (id, kind, line, text) => ipcRenderer.invoke('edit-plan-item', id, kind, line, text), + adoptPlan: (id, filename, opts) => ipcRenderer.invoke('adopt-plan', id, filename, opts), + listTemplates: () => ipcRenderer.invoke('list-templates'), + onProjectPlanChanged: (callback) => { + ipcRenderer.on('project-plan-changed', (_event, projectId) => callback(projectId)); + }, + createTrack: (projectId, spec) => ipcRenderer.invoke('create-track', projectId, spec), + updateTrack: (id, patch) => ipcRenderer.invoke('update-track', id, patch), + deleteTrack: (id) => ipcRenderer.invoke('delete-track', id), openExternal: (url) => ipcRenderer.invoke('open-external', url), + openPath: (target) => ipcRenderer.invoke('open-path', target), writeClipboard: (text) => ipcRenderer.invoke('clipboard-write-text', text), // Send (fire-and-forget) @@ -80,7 +110,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('session-forked', (_event, oldId, newId) => callback(oldId, newId)); }, onProjectsChanged: (callback) => { - ipcRenderer.on('projects-changed', () => callback()); + ipcRenderer.on('projects-changed', (_e, reason) => callback(reason || 'project')); }, onStatusUpdate: (callback) => { ipcRenderer.on('status-update', (_event, text, type) => callback(text, type)); diff --git a/projects.js b/projects.js new file mode 100644 index 00000000..38b6d1f7 --- /dev/null +++ b/projects.js @@ -0,0 +1,1291 @@ +// projects.js — Projects: a piece of work with a folder on disk. +// +// A project owns a root folder under the projects root (default ~/Switchboard), +// attaches zero or more folders (the cwds sessions run in), and files sessions +// under itself or under one of its tracks. The folder on disk carries the +// context: CLAUDE.md / AGENTS.md is the brief every session below it reads, +// plan.md holds the plan in whatever form the agent writes it, plan-tracker.md +// is the fixed-format checklist Switchboard reads for progress, and todos.md +// the follow-ups. +// +// Rows live in their own tables (db.js: projects, project_folders, tracks) and +// the session ↔ project link is session_meta.projectId / trackId. Nothing here +// is keyed on the `project:` settings blob, which is deleted when a folder +// is hidden. +// +// Like session-cache.js this is a singleton initialised with init(ctx) so tests +// can hand it a fake db and a fake session list. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const git = require('./git'); +const planParser = require('./public/plan-parser'); + +const DEFAULT_ROOT_NAME = 'Switchboard'; +const REPOS_DIR = 'repos'; +// Codex reads AGENTS.override.md instead of AGENTS.md at the same level, and +// only from the git root down, so a worktree gets a copy of the project brief +// under that name (excluded from git). Claude needs nothing: it walks up. +const CODEX_BRIDGE_FILE = 'AGENTS.override.md'; +const BRANCH_NAME_RE = /^(?!-)[A-Za-z0-9._\/-]+$/; +const SLUG_MAX = 60; +const FOLDER_MODES = new Set(['in-place', 'worktree']); +const PROJECT_STATUSES = new Set(['active', 'done']); +// A .env is a few lines; anything larger under that name is something else. +const ENV_FILE_MAX_BYTES = 1024 * 1024; + +let db, log, buildProjectsFromCache, notifyRendererProjectsChanged, isHarnessId, plansDir; + +function init(ctx) { + db = ctx.db; + log = ctx.log || console; + buildProjectsFromCache = ctx.buildProjectsFromCache; + notifyRendererProjectsChanged = ctx.notifyRendererProjectsChanged || (() => {}); + // Which CLI ids a track may name. Main passes the harness registry; tests + // and callers that do not care accept anything. + isHarnessId = ctx.isHarnessId || (() => true); + // Where Claude Code keeps plan-mode plans (~/.claude/plans), for adopting one. + plansDir = ctx.plansDir || path.join(os.homedir(), '.claude', 'plans'); + // Project templates; tests point this at a temp folder or the bundled one. + templatesDir = ctx.templatesDir || null; +} + +// --- Root and slugs --- + +function expandHome(p) { + if (p === '~') return os.homedir(); + if (p.startsWith('~' + path.sep) || p.startsWith('~/')) return path.join(os.homedir(), p.slice(2)); + return p; +} + +/** Where new projects are created. A global-only setting, like hiddenProjects. */ +function projectsRoot() { + const global = (db.getSetting && db.getSetting('global')) || {}; + const configured = typeof global.projectsRoot === 'string' ? global.projectsRoot.trim() : ''; + return configured ? path.resolve(expandHome(configured)) : path.join(os.homedir(), DEFAULT_ROOT_NAME); +} + +/** Lowercase, runs of non [a-z0-9] become "-", trimmed, at most SLUG_MAX chars. */ +function slugify(name) { + let slug = String(name || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (slug.length > SLUG_MAX) slug = slug.slice(0, SLUG_MAX).replace(/-+$/g, ''); + return slug; +} + +/** slugify(name), with -2, -3, … appended while a project already uses it. */ +function uniqueSlug(name) { + const base = slugify(name) || 'project'; + if (!db.getProjectBySlug(base)) return base; + for (let n = 2; ; n++) { + const candidate = `${base}-${n}`; + if (!db.getProjectBySlug(candidate)) return candidate; + } +} + +// --- Files written into a new project folder --- +// +// The brief uses absolute paths throughout, because a session may start in an +// attached folder rather than the project folder and "./plan.md" would then +// point at the wrong place. The attached-folder list sits between markers so +// it can be rewritten on attach/detach without touching the user's own text. + +const FOLDERS_START = ''; +const FOLDERS_END = ''; +const BRIEF_FILES = ['CLAUDE.md', 'AGENTS.md']; + +function foldersBlock(folderPaths) { + const lines = [FOLDERS_START, '## Attached folders']; + if (!folderPaths.length) { + lines.push('No folders are attached yet. Attach the repos this project works in from the project settings in Switchboard.'); + } else { + lines.push('The project works in these folders. Before changing files in one of them, read its own instructions (CLAUDE.md or AGENTS.md at its root) and follow them there.'); + for (const p of folderPaths) lines.push(`- ${p}`); + } + lines.push(FOLDERS_END); + return lines.join('\n') + '\n'; +} + +// The plan itself is the agent's to shape (plan.md). What Switchboard reads is +// the tracker (plan-tracker.md), whose format is fixed so progress can be +// parsed. Neither is read at the start of a session; the rules say to open +// them only when the user brings the plan or the todos up. None of these +// files is created up front: the agent makes them when it first needs them. +const PROJECT_FILES = ['plan.md', 'plan-tracker.md', 'todos.md', 'memory.md']; + +function briefHeader(name, root) { + return `# ${name} + + + +Project folder: ${root} +`; +} + +function defaultBrief(name, root, folderPaths = [], header = null) { + return `${header || briefHeader(name, root)} +${briefRules(root)} +${foldersBlock(folderPaths)}`; +} + +function briefRules(root) { + const plan = path.join(root, 'plan.md'); + const tracker = path.join(root, 'plan-tracker.md'); + const todos = path.join(root, 'todos.md'); + const memory = path.join(root, 'memory.md'); + return `## Working rules +- This folder is the project. Keep plans, notes and drafts here, not in the attached folders. +- When the user asks for a plan, write it to ${plan} in whatever form fits the work. +- Whenever you write or change a plan, also keep ${tracker} up to date: one + "## Phase N: title" heading per phase, each with "- [ ]" checkbox items under it. + Tick an item when it is finished and the heading's own checkbox when the whole + phase is done. Switchboard reads this file to show progress. +- Keep follow-ups and ideas in ${todos} as "- [ ]" checkbox lines. When asked to + add a todo, append it there. Tick a todo when it is done. Work that is already + a phase or an item in the tracker does not belong in the todos as well. +- Anything you want to remember across sessions goes in ${memory}. +- Read ${plan}, ${tracker} or ${todos} only when the user refers to the plan or + the todos, not at the start of every session. +- These files may not exist yet. Create them when you first need them. +`; +} + +function writeIfMissing(filePath, content) { + if (fs.existsSync(filePath)) return false; + fs.writeFileSync(filePath, content, 'utf8'); + return true; +} + +/** Only the brief is written up front; plan, tracker, todos and memory are the agent's to create. */ +function writeProjectFiles(root, name, folderPaths, header = null) { + const brief = defaultBrief(name, root, folderPaths, header); + for (const file of BRIEF_FILES) writeIfMissing(path.join(root, file), brief); +} + +// --- Templates --- +// +// A template is a folder under /templates// with a +// template.json ({ name, description, tracks }) and the files a new project +// starts with. Three are seeded from the app's own templates/ folder the +// first time the folder is missing; after that they are the user's to edit, +// and a new folder there is a new template with no code change. A template's +// CLAUDE.md is the top of the brief; the working rules and the folder block +// are still appended, so every project keeps the same contract. + +const BUNDLED_TEMPLATES_DIR = path.join(__dirname, 'templates'); +const TEMPLATE_META = 'template.json'; +const TEMPLATE_KIND_RE = /^[a-z0-9][a-z0-9-]*$/; +let templatesDir = null; + +function dataDir() { + return process.env.SWITCHBOARD_DATA_DIR ? path.resolve(process.env.SWITCHBOARD_DATA_DIR) : path.join(os.homedir(), '.switchboard'); +} + +function templatesRoot() { + return templatesDir || path.join(dataDir(), 'templates'); +} + +/** Copy the bundled templates in when the folder does not exist yet. Never overwrites. */ +function seedTemplates() { + const dir = templatesRoot(); + if (fs.existsSync(dir)) return false; + if (!fs.existsSync(BUNDLED_TEMPLATES_DIR)) return false; + try { + fs.cpSync(BUNDLED_TEMPLATES_DIR, dir, { recursive: true }); + return true; + } catch (err) { + log.error?.(`[projects] could not seed templates into ${dir}`, err); + return false; + } +} + +function readTemplate(kind) { + if (typeof kind !== 'string' || !TEMPLATE_KIND_RE.test(kind)) return null; + const dir = path.join(templatesRoot(), kind); + const metaPath = path.join(dir, TEMPLATE_META); + if (!fs.existsSync(metaPath)) return null; + let meta; + try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return null; } + const tracks = (Array.isArray(meta.tracks) ? meta.tracks : []) + .map(t => (typeof t === 'string' ? { name: t } : t)) + .filter(t => t && typeof t.name === 'string' && t.name.trim()) + .map(t => ({ name: t.name.trim(), cli: typeof t.cli === 'string' && t.cli ? t.cli : null })); + return { kind, dir, name: typeof meta.name === 'string' && meta.name.trim() ? meta.name.trim() : kind, description: typeof meta.description === 'string' ? meta.description : '', tracks }; +} + +function listTemplates() { + seedTemplates(); + const dir = templatesRoot(); + const templates = []; + if (fs.existsSync(dir)) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const template = readTemplate(entry.name); + if (template) templates.push({ kind: template.kind, name: template.name, description: template.description, tracks: template.tracks }); + } + } + templates.sort((a, b) => a.name.localeCompare(b.name)); + return { dir, templates }; +} + +function renderTemplateText(text, vars) { + return String(text).replace(/\{\{\s*(name|slug|root)\s*\}\}/g, (_m, key) => vars[key] ?? ''); +} + +/** + * Copy a template's files into a new project folder. Markdown files have + * {{name}}, {{slug}} and {{root}} filled in; template.json and the brief + * files are skipped (the brief is composed separately). Returns the brief + * header from the template's CLAUDE.md, or null. + */ +function applyTemplate(template, root, vars) { + let header = null; + const walk = (from, rel) => { + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const childRel = rel ? path.join(rel, entry.name) : entry.name; + const source = path.join(from, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(path.join(root, childRel), { recursive: true }); + walk(source, childRel); + continue; + } + if (!entry.isFile()) continue; + if (!rel && entry.name === TEMPLATE_META) continue; + if (!rel && BRIEF_FILES.includes(entry.name)) { + if (entry.name === 'CLAUDE.md' || (entry.name === 'AGENTS.md' && !header)) { + header = renderTemplateText(fs.readFileSync(source, 'utf8'), vars).trimEnd() + '\n'; + } + continue; + } + const dest = path.join(root, childRel); + if (fs.existsSync(dest)) continue; + if (/\.md$/i.test(entry.name)) fs.writeFileSync(dest, renderTemplateText(fs.readFileSync(source, 'utf8'), vars), 'utf8'); + else fs.copyFileSync(source, dest); + } + }; + walk(template.dir, ''); + return header; +} + +/** + * Create one of the agent-owned files when the user opens it from the page + * before any session has. Only the four known names, only when missing. + */ +function createProjectFile(projectId, name, content) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + if (!PROJECT_FILES.includes(name)) return { error: `Not a project file: ${name}` }; + const filePath = path.join(project.root, name); + if (fs.existsSync(filePath)) return { ok: true, filePath, created: false }; + const label = name.replace(/\.md$/, '').replace(/-/g, ' '); + const text = typeof content === 'string' ? content : `# ${project.name} ${label}\n\n`; + try { fs.writeFileSync(filePath, text, 'utf8'); } catch (err) { return { error: err.message }; } + return { ok: true, filePath, created: true }; +} + +/** Replace the managed block in one brief file, or append it when missing. */ +function syncBriefFile(filePath, block) { + if (!fs.existsSync(filePath)) return false; + const text = fs.readFileSync(filePath, 'utf8'); + const start = text.indexOf(FOLDERS_START); + const end = text.indexOf(FOLDERS_END); + let next; + if (start !== -1 && end !== -1 && end > start) { + next = text.slice(0, start) + block.trimEnd() + text.slice(end + FOLDERS_END.length); + } else { + next = text.trimEnd() + '\n\n' + block; + } + if (next !== text) fs.writeFileSync(filePath, next, 'utf8'); + return true; +} + +/** + * Keep the attached-folder list in CLAUDE.md and AGENTS.md current, and + * refresh the Codex bridge file in every worktree folder, since it carries a + * copy of AGENTS.md. + */ +async function syncProjectBrief(projectId) { + const project = db.getProject(projectId); + if (!project) return; + const folders = db.listProjectFolders(projectId); + const block = foldersBlock(folders.map(f => f.path)); + for (const file of BRIEF_FILES) { + try { syncBriefFile(path.join(project.root, file), block); } catch (err) { + log.error?.(`[projects] could not update ${file} in ${project.root}`, err); + } + } + for (const folder of folders) { + if (folder.mode !== 'worktree') continue; + try { await writeCodexBridge(project, folder.path); } catch (err) { + log.error?.(`[projects] could not write ${CODEX_BRIDGE_FILE} in ${folder.path}`, err); + } + } +} + +// --- Codex bridge file --- + +/** Add a pattern to the repository's shared info/exclude, once. */ +async function excludeFromGit(worktree, pattern) { + const common = await git.gitCommonDir(worktree); + const excludePath = path.join(common, 'info', 'exclude'); + fs.mkdirSync(path.dirname(excludePath), { recursive: true }); + const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf8') : ''; + if (existing.split(/\r?\n/).some(line => line.trim() === pattern)) return false; + const prefix = existing.trim() ? existing.trimEnd() + '\n' : ''; + fs.writeFileSync(excludePath, prefix + pattern + '\n', 'utf8'); + return true; +} + +/** + * Write /AGENTS.override.md: the project's AGENTS.md followed by the + * repo's own AGENTS.md when it has one, so the override loses nothing. The + * file is excluded from git through the repo's info/exclude, never a commit. + */ +async function writeCodexBridge(project, worktree) { + const briefPath = path.join(project.root, 'AGENTS.md'); + if (!fs.existsSync(briefPath) || !isDirectory(worktree)) return false; + let content = fs.readFileSync(briefPath, 'utf8').trimEnd() + '\n'; + const repoAgents = path.join(worktree, 'AGENTS.md'); + if (fs.existsSync(repoAgents)) { + content += `\n---\n\n\n\n` + fs.readFileSync(repoAgents, 'utf8').trimEnd() + '\n'; + } + const header = `\n\n`; + const filePath = path.join(worktree, CODEX_BRIDGE_FILE); + const next = header + content; + const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null; + if (current !== next) fs.writeFileSync(filePath, next, 'utf8'); + await excludeFromGit(worktree, CODEX_BRIDGE_FILE); + return true; +} + +function removeCodexBridge(worktree) { + try { fs.rmSync(path.join(worktree, CODEX_BRIDGE_FILE), { force: true }); } catch {} +} + +/** + * Save the brief from the project page. CLAUDE.md and AGENTS.md always carry + * the same text, and every worktree's bridge file is a copy of AGENTS.md, so + * one save writes all of them. The managed folder block is re-synced, which + * puts it back if the user deleted it. Returns the text as written. + */ +async function saveBrief(projectId, content) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + if (typeof content !== 'string') return { error: 'Brief must be text' }; + const text = content.endsWith('\n') || content === '' ? content : content + '\n'; + try { + for (const file of BRIEF_FILES) fs.writeFileSync(path.join(project.root, file), text, 'utf8'); + } catch (err) { + return { error: `Could not write the brief: ${err.message}` }; + } + await syncProjectBrief(projectId); + const saved = fs.readFileSync(path.join(project.root, 'CLAUDE.md'), 'utf8'); + return { ok: true, content: saved }; +} + +// --- Validation helpers --- + +function isDirectory(p) { + try { return fs.statSync(p).isDirectory(); } catch { return false; } +} + +function normalizeFolderSpec(spec) { + if (!spec || typeof spec !== 'object') return { error: 'Folder must be an object' }; + const folderPath = typeof spec.path === 'string' ? spec.path.trim() : ''; + if (!folderPath || !path.isAbsolute(folderPath)) return { error: 'Folder path must be absolute' }; + if (!isDirectory(folderPath)) return { error: `Not a directory: ${folderPath}` }; + const mode = spec.mode || 'in-place'; + if (!FOLDER_MODES.has(mode)) return { error: `Unknown folder mode: ${mode}` }; + // Only a worktree needs .env copied; an in-place folder already has its own. + const copyEnv = mode === 'worktree' && Array.isArray(spec.copyEnv) + ? spec.copyEnv.filter(name => typeof name === 'string') + : []; + return { path: path.resolve(folderPath), mode, branch: typeof spec.branch === 'string' ? spec.branch.trim() : '', copyEnv }; +} + +// realpath of the longest existing ancestor, with the rest joined back on. +// A session cwd may be a directory that no longer exists (a removed worktree), +// and on macOS /var is a symlink to /private/var, so comparing a resolved +// path against a realpath'd root would miss. +function realOrResolved(p) { + const resolved = path.resolve(p); + let current = resolved; + const tail = []; + for (;;) { + try { return path.join(fs.realpathSync(current), ...tail); } catch {} + const parent = path.dirname(current); + if (parent === current) return resolved; + tail.unshift(path.basename(current)); + current = parent; + } +} + +function isInside(child, parent) { + if (child === parent) return true; + return child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep); +} + +// --- Tree node shape handed to the renderer --- + +function trackNode(row) { + return { + id: row.id, projectId: row.projectId, name: row.name, + cwd: row.cwd || null, cli: row.cli || null, status: row.status || 'active', + sortOrder: row.sortOrder || 0, created: row.created, + sessions: [], + }; +} + +function projectNode(row, folderRows = [], trackRows = []) { + return { + id: row.id, name: row.name, slug: row.slug, root: row.root, + status: row.status || 'active', + sharedBranch: row.sharedBranch === undefined ? true : !!row.sharedBranch, + branchName: row.branchName || null, + defaultCwd: row.defaultCwd || null, + created: row.created, modified: row.modified, + lastActivity: null, sessionCount: 0, + folders: folderRows.map(f => ({ + path: f.path, mode: f.mode || 'in-place', sourcePath: f.sourcePath || null, + branch: f.branch || null, sortOrder: f.sortOrder || 0, + })), + tracks: trackRows.map(trackNode), + sessions: [], + }; +} + +function loadProjectNode(id) { + const row = db.getProject(id); + if (!row) return null; + return projectNode(row, db.listProjectFolders(id), db.listTracks(id)); +} + +// --- Create / update / delete --- + +async function createProject(spec) { + const name = typeof spec?.name === 'string' ? spec.name.trim() : ''; + if (!name) return { error: 'Project name is required' }; + + const folders = []; + for (const raw of (Array.isArray(spec.folders) ? spec.folders : [])) { + const normalized = normalizeFolderSpec(raw); + if (normalized.error) return { error: normalized.error }; + if (folders.some(f => f.path === normalized.path)) continue; + folders.push(normalized); + } + const branchName = typeof spec.branchName === 'string' && spec.branchName.trim() ? spec.branchName.trim() : null; + if (branchName && !BRANCH_NAME_RE.test(branchName)) return { error: `Invalid branch name: ${branchName}` }; + let template = null; + if (spec.template) { + template = readTemplate(spec.template); + if (!template) return { error: `Unknown template: ${spec.template}` }; + } + + const slug = uniqueSlug(name); + const rootDir = projectsRoot(); + const root = path.join(rootDir, slug); + if (fs.existsSync(root)) { + return { error: `A folder named "${slug}" already exists in ${rootDir}` }; + } + + // In-place folders are listed in the brief right away; worktree folders + // are added after the row exists, since their path is only known once the + // checkout is made. + const inPlace = folders.filter(f => f.mode === 'in-place'); + try { + fs.mkdirSync(root, { recursive: true }); + const header = template ? applyTemplate(template, root, { name, slug, root }) : null; + writeProjectFiles(root, name, inPlace.map(f => f.path), header); + } catch (err) { + return { error: `Could not create ${root}: ${err.message}` }; + } + + const now = new Date().toISOString(); + const row = { + id: crypto.randomUUID(), name, slug, root, + status: 'active', + sharedBranch: spec.sharedBranch === undefined ? true : !!spec.sharedBranch, + branchName, + created: now, modified: now, + }; + db.insertProject(row); + inPlace.forEach((f, i) => db.upsertProjectFolder({ projectId: row.id, path: f.path, mode: 'in-place', sortOrder: i })); + // A template's tracks start in the project folder and inherit the default CLI + // unless the template names one. + if (template) { + template.tracks.forEach((t, i) => db.insertTrack({ + id: crypto.randomUUID(), projectId: row.id, name: t.name, cwd: null, + cli: t.cli && isHarnessId(t.cli) ? t.cli : null, status: 'active', sortOrder: i, created: now, + })); + } + + const errors = []; + for (const f of folders.filter(f => f.mode === 'worktree')) { + const result = await attachWorktree(db.getProject(row.id), { path: f.path, branch: f.branch, copyEnv: f.copyEnv }, { notify: false }); + if (result.error) errors.push(`${f.path}: ${result.error}`); + } + + log.info?.(`[projects] created "${name}" at ${root}`); + refreshPlanWatchers(); + notifyRendererProjectsChanged(); + return { ok: true, project: loadProjectNode(row.id), errors }; +} + +function updateProject(id, patch) { + const row = db.getProject(id); + if (!row) return { error: 'Project not found' }; + const clean = {}; + if (typeof patch?.name === 'string') { + const name = patch.name.trim(); + if (!name) return { error: 'Project name is required' }; + clean.name = name; + } + if (patch?.status !== undefined) { + if (!PROJECT_STATUSES.has(patch.status)) return { error: `Unknown status: ${patch.status}` }; + clean.status = patch.status; + } + if (patch?.sharedBranch !== undefined) clean.sharedBranch = !!patch.sharedBranch; + if (patch?.branchName !== undefined) { + clean.branchName = typeof patch.branchName === 'string' && patch.branchName.trim() ? patch.branchName.trim() : null; + } + if (patch?.defaultCwd !== undefined) { + const cwd = normalizeCwdWithinProject(row, patch.defaultCwd); + if (cwd.error) return { error: cwd.error }; + clean.defaultCwd = cwd.cwd; + } + if (!Object.keys(clean).length) return { ok: true, project: loadProjectNode(id) }; + clean.modified = new Date().toISOString(); + db.updateProject(id, clean); + notifyRendererProjectsChanged(); + const result = { ok: true, project: loadProjectNode(id) }; + // Finishing a project is the moment to offer removing its worktrees; the + // renderer asks, then detaches each one with removeWorktree. + if (clean.status === 'done') { + result.worktrees = db.listProjectFolders(id).filter(f => f.mode === 'worktree').map(f => f.path); + } + return result; +} + +/** Rows only. The folder on disk and every session stay. */ +function deleteProject(id) { + const row = db.getProject(id); + if (!row) return { error: 'Project not found' }; + db.deleteProject(id); + log.info?.(`[projects] removed "${row.name}" (folder kept at ${row.root})`); + refreshPlanWatchers(); + notifyRendererProjectsChanged(); + return { ok: true, root: row.root }; +} + +async function attachFolder(id, spec) { + const row = db.getProject(id); + if (!row) return { error: 'Project not found' }; + const normalized = normalizeFolderSpec(spec); + if (normalized.error) return { error: normalized.error }; + if (normalized.mode === 'worktree') return attachWorktree(row, normalized); + const existing = db.listProjectFolders(id); + if (existing.some(f => f.path === normalized.path)) return { ok: true, project: loadProjectNode(id) }; + const sortOrder = existing.reduce((max, f) => Math.max(max, f.sortOrder || 0), -1) + 1; + db.upsertProjectFolder({ projectId: id, path: normalized.path, mode: 'in-place', sortOrder }); + db.updateProject(id, { modified: new Date().toISOString() }); + await syncProjectBrief(id); + notifyRendererProjectsChanged(); + return { ok: true, project: loadProjectNode(id) }; +} + +// --- .env files --- +// +// git worktree add checks out tracked files only, so a repository's .env stays +// behind in the source checkout and the new worktree cannot run the app. The +// new project dialog lists what it found and the user picks; nothing is copied +// unless a name was asked for. + +/** A sample file is listed but never picked by default: it is usually tracked already. */ +function isEnvSample(name) { + return /^\.env\.(example|sample|template|dist)$/i.test(name); +} + +/** + * The .env files at the top of a folder: `.env` and `.env.*`, sorted, files + * only. Big files are left out — a .env is a few lines, and anything large + * under that name is something else. + */ +function listEnvFiles(dir) { + const root = typeof dir === 'string' ? path.resolve(dir.trim()) : ''; + if (!root || !isDirectory(root)) return []; + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; } + const names = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name !== '.env' && !entry.name.startsWith('.env.')) continue; + try { if (fs.statSync(path.join(root, entry.name)).size > ENV_FILE_MAX_BYTES) continue; } catch { continue; } + names.push(entry.name); + } + names.sort(); + return names; +} + +/** The names the dialog ticks for the user: everything but the samples. */ +function defaultEnvSelection(dir) { + return listEnvFiles(dir).filter(name => !isEnvSample(name)); +} + +/** + * Copy the named .env files from one folder to another. A name the source + * does not actually have is ignored, so the renderer can never name a path of + * its own, and an existing destination is never overwritten. + */ +function copyEnvFiles(source, target, names) { + const wanted = Array.isArray(names) ? names : []; + if (!wanted.length) return { copied: [], skipped: [] }; + const available = new Set(listEnvFiles(source)); + const copied = []; + const skipped = []; + for (const name of wanted) { + if (!available.has(name)) { skipped.push(name); continue; } + const dest = path.join(target, name); + if (fs.existsSync(dest)) { skipped.push(name); continue; } + try { + fs.copyFileSync(path.join(source, name), dest); + fs.chmodSync(dest, fs.statSync(path.join(source, name)).mode & 0o777); + copied.push(name); + } catch (err) { + log.error?.(`[projects] could not copy ${name} into ${target}`, err); + skipped.push(name); + } + } + return { copied, skipped }; +} + +/** The branch a project's worktrees use: one shared name, or per folder. */ +function worktreeBranchFor(project, requested) { + if (project.sharedBranch === undefined || project.sharedBranch) return project.branchName || project.slug; + return (typeof requested === 'string' && requested.trim()) ? requested.trim() : project.slug; +} + +/** + * Check a repository out under /repos/ on the project's branch + * and attach that checkout. The source repo itself is left alone apart from + * the new branch and one line in its info/exclude. + */ +async function attachWorktree(project, spec, { notify = true } = {}) { + const source = typeof spec?.path === 'string' ? path.resolve(spec.path.trim()) : ''; + if (!source || !isDirectory(source)) return { error: `Not a directory: ${source || spec?.path}` }; + let top; + try { top = await git.repoRoot(source); } catch { return { error: `${source} is not inside a git repository` }; } + if (realOrResolved(top) !== realOrResolved(source)) { + return { error: `${source} is not the root of a git repository (${top} is)` }; + } + const branch = worktreeBranchFor(project, spec.branch); + if (!BRANCH_NAME_RE.test(branch)) return { error: `Invalid branch name: ${branch}` }; + + const existing = db.listProjectFolders(project.id); + if (existing.some(f => f.sourcePath === source)) return { error: 'That repository is already attached on a branch' }; + const target = path.join(project.root, REPOS_DIR, path.basename(source)); + if (fs.existsSync(target)) return { error: `${target} already exists` }; + if (existing.some(f => f.path === target)) return { error: 'That folder is already attached' }; + + try { + fs.mkdirSync(path.join(project.root, REPOS_DIR), { recursive: true }); + await git.worktreeAdd(source, target, branch); + } catch (err) { + return { error: `git worktree add failed: ${err.message}` }; + } + + // The checkout has tracked files only. Bring the .env files the user picked + // across so a session in the worktree can run the app. Never fatal: the + // worktree is attached either way. + const env = copyEnvFiles(source, target, spec.copyEnv); + if (env.copied.length) log.info?.(`[projects] copied ${env.copied.join(', ')} into ${target}`); + + const sortOrder = existing.reduce((max, f) => Math.max(max, f.sortOrder || 0), -1) + 1; + db.upsertProjectFolder({ projectId: project.id, path: target, mode: 'worktree', sourcePath: source, branch, sortOrder }); + db.updateProject(project.id, { modified: new Date().toISOString() }); + await syncProjectBrief(project.id); + log.info?.(`[projects] worktree ${target} on ${branch} from ${source}`); + if (notify) notifyRendererProjectsChanged(); + return { ok: true, project: loadProjectNode(project.id), folder: { path: target, mode: 'worktree', sourcePath: source, branch }, copiedEnv: env.copied }; +} + +/** + * Detach a folder. For a worktree folder, removeWorktree also deletes the + * checkout (never the branch); a dirty checkout is refused with dirty: true + * unless force is set, so the caller can ask before losing changes. + */ +async function detachFolder(id, folderPath, opts = {}) { + const row = db.getProject(id); + if (!row) return { error: 'Project not found' }; + const folder = db.listProjectFolders(id).find(f => f.path === folderPath) || null; + let worktreeRemoved = false; + if (folder?.mode === 'worktree') { + removeCodexBridge(folder.path); + if (opts.removeWorktree && fs.existsSync(folder.path)) { + try { + await git.worktreeRemove(folder.sourcePath, folder.path, { force: !!opts.force }); + worktreeRemoved = true; + } catch (err) { + return { error: `Could not remove the worktree: ${err.message}`, dirty: git.isDirtyWorktreeError(err) }; + } + } + } + db.deleteProjectFolder(id, folderPath); + // A default or track cwd that pointed into the detached folder falls back + // to the project folder rather than a place the project no longer works in. + if (row.defaultCwd && isInside(realOrResolved(row.defaultCwd), realOrResolved(folderPath))) { + db.updateProject(id, { defaultCwd: null }); + } + for (const track of db.listTracks(id)) { + if (track.cwd && isInside(realOrResolved(track.cwd), realOrResolved(folderPath))) db.updateTrack(track.id, { cwd: null }); + } + db.updateProject(id, { modified: new Date().toISOString() }); + await syncProjectBrief(id); + notifyRendererProjectsChanged(); + return { ok: true, project: loadProjectNode(id), worktreeRemoved }; +} + +/** Source repository of a project worktree folder, for task inheritance. */ +function worktreeParentFor(folderPath) { + if (!folderPath) return null; + const folder = db.listAllProjectFolders().find(f => f.mode === 'worktree' && f.path === folderPath); + return folder?.sourcePath || null; +} + +// --- Launch context --- + +/** + * Extra directories for a session that belongs to a project: the project + * folder and every attached folder, except the cwd itself and anything + * already under it. A parent of the cwd (the project folder above a worktree, + * a repo above a track that starts in one of its subfolders) is added too: + * the CLI only edits under the cwd unless told otherwise, and the plan and + * todos live in the project folder. Claude loads CLAUDE.md from added + * directories when CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD is set, so a + * session started in a repo still gets the brief and a session started in the + * project folder sees each repo's instructions. + */ +function launchContext(projectId, cwd) { + const project = db.getProject(projectId); + if (!project) return null; + const real = realOrResolved(cwd || project.root); + const folders = db.listProjectFolders(projectId); + const candidates = [project.root, ...folders.map(f => f.path)]; + const addDirs = []; + for (const p of candidates) { + const realP = realOrResolved(p); + if (realP === real || isInside(realP, real)) continue; + if (!addDirs.includes(p)) addDirs.push(p); + } + // Starting inside a project worktree: the caller must not ask the CLI for + // another worktree on top of it. + const worktree = folders.some(f => f.mode === 'worktree' && isInside(real, realOrResolved(f.path))); + return { + addDirs, + env: addDirs.length ? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1' } : {}, + worktree, + }; +} + +/** addDirs travels as a comma-separated string (see harnesses buildLaunchArgs). */ +function mergeAddDirs(existing, extra) { + const list = String(existing || '').split(',').map(s => s.trim()).filter(Boolean); + for (const dir of extra || []) if (!list.includes(dir)) list.push(dir); + return list.join(','); +} + +// --- Tracks --- + +/** + * Where sessions start, for a project's default or a track. It has to be the + * project folder, an attached folder, or somewhere inside one of them; null + * means "not set" (the project folder, or for a track the project's default). + * Returns { cwd } or { error }. + */ +function normalizeCwdWithinProject(project, cwd) { + if (cwd === undefined || cwd === null || cwd === '') return { cwd: null }; + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return { error: 'Folder must be an absolute path' }; + const resolved = path.resolve(cwd); + if (!isDirectory(resolved)) return { error: `Not a directory: ${resolved}` }; + const real = realOrResolved(resolved); + const allowed = [project.root, ...db.listProjectFolders(project.id).map(f => f.path)]; + if (!allowed.some(p => isInside(real, realOrResolved(p)))) { + return { error: 'Folder must be the project folder, an attached folder, or inside one of them' }; + } + return { cwd: resolved }; +} +const normalizeTrackCwd = normalizeCwdWithinProject; + +function normalizeTrackCli(cli) { + if (cli === undefined || cli === null || cli === '') return { cli: null }; + if (typeof cli !== 'string' || !isHarnessId(cli)) return { error: `Unknown CLI: ${cli}` }; + return { cli }; +} + +function createTrack(projectId, spec) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const name = typeof spec?.name === 'string' ? spec.name.trim() : ''; + if (!name) return { error: 'Track name is required' }; + const cwd = normalizeTrackCwd(project, spec.cwd); + if (cwd.error) return { error: cwd.error }; + const cli = normalizeTrackCli(spec.cli); + if (cli.error) return { error: cli.error }; + const existing = db.listTracks(projectId); + const sortOrder = existing.reduce((max, t) => Math.max(max, t.sortOrder || 0), -1) + 1; + const row = { + id: crypto.randomUUID(), projectId, name, cwd: cwd.cwd, cli: cli.cli, + status: 'active', sortOrder, created: new Date().toISOString(), + }; + db.insertTrack(row); + db.updateProject(projectId, { modified: row.created }); + notifyRendererProjectsChanged(); + return { ok: true, track: trackNode(row), project: loadProjectNode(projectId) }; +} + +function updateTrack(id, patch) { + const track = db.getTrack(id); + if (!track) return { error: 'Track not found' }; + const project = db.getProject(track.projectId); + if (!project) return { error: 'Project not found' }; + const clean = {}; + if (typeof patch?.name === 'string') { + const name = patch.name.trim(); + if (!name) return { error: 'Track name is required' }; + clean.name = name; + } + if (patch?.cwd !== undefined) { + const cwd = normalizeTrackCwd(project, patch.cwd); + if (cwd.error) return { error: cwd.error }; + clean.cwd = cwd.cwd; + } + if (patch?.cli !== undefined) { + const cli = normalizeTrackCli(patch.cli); + if (cli.error) return { error: cli.error }; + clean.cli = cli.cli; + } + if (patch?.status !== undefined) { + if (!PROJECT_STATUSES.has(patch.status)) return { error: `Unknown status: ${patch.status}` }; + clean.status = patch.status; + } + if (patch?.sortOrder !== undefined) clean.sortOrder = Number(patch.sortOrder) || 0; + if (!Object.keys(clean).length) return { ok: true, track: trackNode(track) }; + db.updateTrack(id, clean); + db.updateProject(track.projectId, { modified: new Date().toISOString() }); + notifyRendererProjectsChanged(); + return { ok: true, track: trackNode(db.getTrack(id)) }; +} + +/** Rows only. Its sessions keep their project and lose the track. */ +function deleteTrack(id) { + const track = db.getTrack(id); + if (!track) return { error: 'Track not found' }; + db.deleteTrack(id); + db.updateProject(track.projectId, { modified: new Date().toISOString() }); + notifyRendererProjectsChanged(); + return { ok: true, projectId: track.projectId }; +} + +// --- Session assignment --- + +/** Explicitly file a session. projectId null clears both ids. */ +function assignSession(sessionId, projectId, trackId) { + if (!sessionId) return { error: 'Session id is required' }; + if (!projectId) { + db.setSessionAssignment(sessionId, null, null); + notifyRendererProjectsChanged(); + return { ok: true }; + } + if (!db.getProject(projectId)) return { error: 'Project not found' }; + let cleanTrack = null; + if (trackId) { + const track = db.getTrack(trackId); + if (!track || track.projectId !== projectId) return { error: 'Track not found in that project' }; + cleanTrack = trackId; + } + db.setSessionAssignment(sessionId, projectId, cleanTrack); + notifyRendererProjectsChanged(); + return { ok: true }; +} + +/** + * Called from open-terminal for a brand-new session whose launch options carry + * a projectId. Returns the assignment that was recorded, or null. + */ +function recordLaunchAssignment(sessionId, options) { + const projectId = options?.projectId; + if (!projectId || !db.getProject(projectId)) return null; + let trackId = options?.trackId || null; + if (trackId) { + const track = db.getTrack(trackId); + if (!track || track.projectId !== projectId) trackId = null; + } + db.setSessionAssignment(sessionId, projectId, trackId); + return { projectId, trackId }; +} + +// --- The tree --- + +/** The project whose root contains cwd, or null. Explicit filing beats this. */ +function projectForCwd(cwd, projectRows) { + if (!cwd) return null; + const real = realOrResolved(cwd); + const rows = projectRows || db.listProjects(); + for (const row of rows) { + if (isInside(real, realOrResolved(row.root))) return row; + } + return null; +} + +/** + * Every project with its folders, tracks and sessions. Sessions come from the + * same buildProjectsFromCache() the Sessions tab uses, so a row here is the + * exact object the sidebar already knows how to render. + */ +function buildProjectTree(showArchived) { + const projectRows = db.listProjects(); + if (!projectRows.length) return { projects: [] }; + + const folderRows = db.listAllProjectFolders(); + const trackRows = db.listAllTracks(); + const byId = new Map(); + for (const row of projectRows) { + byId.set(row.id, projectNode( + row, + folderRows.filter(f => f.projectId === row.id), + trackRows.filter(t => t.projectId === row.id) + )); + } + const rootsById = new Map(projectRows.map(r => [r.id, realOrResolved(r.root)])); + const cwdCache = new Map(); + const projectIdForCwd = (cwd) => { + if (!cwd) return null; + if (cwdCache.has(cwd)) return cwdCache.get(cwd); + const real = realOrResolved(cwd); + let found = null; + for (const [id, root] of rootsById) { + if (isInside(real, root)) { found = id; break; } + } + cwdCache.set(cwd, found); + return found; + }; + + const folders = buildProjectsFromCache(showArchived) || []; + for (const folder of folders) { + for (const session of folder.sessions || []) { + let pid = session.projectId && byId.has(session.projectId) ? session.projectId : null; + if (!pid) pid = projectIdForCwd(session.projectPath); + if (!pid) continue; + const node = byId.get(pid); + const track = session.trackId ? node.tracks.find(t => t.id === session.trackId) : null; + (track ? track.sessions : node.sessions).push(session); + } + } + + const byModified = (a, b) => new Date(b.modified || 0) - new Date(a.modified || 0); + const projects = []; + for (const node of byId.values()) { + node.sessions.sort(byModified); + let latest = null; + let count = node.sessions.length; + for (const s of node.sessions) if (!latest || s.modified > latest) latest = s.modified; + for (const track of node.tracks) { + track.sessions.sort(byModified); + count += track.sessions.length; + for (const s of track.sessions) if (!latest || s.modified > latest) latest = s.modified; + } + node.lastActivity = latest; + node.sessionCount = count; + projects.push(node); + } + + projects.sort((a, b) => { + if (a.status !== b.status) return a.status === 'active' ? -1 : 1; + const aKey = a.status === 'active' ? (a.lastActivity || a.modified) : a.modified; + const bKey = b.status === 'active' ? (b.lastActivity || b.modified) : b.modified; + return new Date(bKey || 0) - new Date(aKey || 0); + }); + + return { projects }; +} + +// --- Plan tracker and todos --- +// +// plan-tracker.md and todos.md are parsed with public/plan-parser.js, the same +// code the page uses. Ticks are written line by line so the rest of the file +// stays as the agent wrote it. A watcher on each project folder notices ticks +// made by a session and records which session did them (plan_links), which is +// how a phase shows the sessions that worked on it. + +const TRACKER_FILE = 'plan-tracker.md'; +const TODOS_FILE = 'todos.md'; +const PLAN_FILE_BY_KIND = { plan: TRACKER_FILE, todos: TODOS_FILE }; + +function readTextOrEmpty(filePath) { + try { return fs.readFileSync(filePath, 'utf8'); } catch { return ''; } +} + +function hasBody(content) { + return String(content || '').replace(//g, '').split(/\r?\n/) + .some(line => line.trim() && !/^#\s/.test(line.trim())); +} + +/** Everything the Plan tab shows: parsed tracker and todos, which files exist, and the session links. */ +function readProjectPlan(projectId) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const planPath = path.join(project.root, 'plan.md'); + const trackerPath = path.join(project.root, TRACKER_FILE); + const todosPath = path.join(project.root, TODOS_FILE); + const trackerText = readTextOrEmpty(trackerPath); + const todosText = readTextOrEmpty(todosPath); + return { + ok: true, + plan: planParser.parsePlan(trackerText), + todos: planParser.parseTodos(todosText), + links: db.listPlanLinks(projectId), + planPath, trackerPath, todosPath, + hasPlan: hasBody(readTextOrEmpty(planPath)), + hasTracker: fs.existsSync(trackerPath), + hasTodos: fs.existsSync(todosPath), + }; +} + +/** Tick or untick one line of the tracker ('plan') or the todos ('todos'). */ +function setPlanItem(projectId, kind, lineNo, done) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const name = PLAN_FILE_BY_KIND[kind]; + if (!name) return { error: `Unknown file: ${kind}` }; + const filePath = path.join(project.root, name); + if (!fs.existsSync(filePath)) return { error: `${name} does not exist yet` }; + const result = planParser.toggleLine(fs.readFileSync(filePath, 'utf8'), Number(lineNo), !!done); + if (!result.ok) return { error: 'That line is not a checkbox' }; + fs.writeFileSync(filePath, result.content, 'utf8'); + // The page did this, not a session: the watcher must not link it. + planWatchers.get(projectId)?.snapshots.set(name, result.content); + return { ok: true, text: result.text }; +} + +/** Rewrite the text of one checkbox line of the tracker ('plan') or the todos ('todos'). */ +function editPlanItem(projectId, kind, lineNo, text) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const name = PLAN_FILE_BY_KIND[kind]; + if (!name) return { error: `Unknown file: ${kind}` }; + const filePath = path.join(project.root, name); + if (!fs.existsSync(filePath)) return { error: `${name} does not exist yet` }; + if (!String(text || '').trim()) return { error: 'Nothing to save' }; + const result = planParser.setLineText(fs.readFileSync(filePath, 'utf8'), Number(lineNo), text); + if (!result.ok) return { error: 'That line is not a checkbox' }; + fs.writeFileSync(filePath, result.content, 'utf8'); + planWatchers.get(projectId)?.snapshots.set(name, result.content); + return { ok: true, text: result.text, previous: result.previous }; +} + +/** Append "- [ ] text" to the todos (or the tracker), creating the file when missing. */ +function appendPlanItem(projectId, kind, text) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const name = PLAN_FILE_BY_KIND[kind]; + if (!name) return { error: `Unknown file: ${kind}` }; + const clean = String(text || '').trim(); + if (!clean) return { error: 'Nothing to add' }; + const filePath = path.join(project.root, name); + const heading = `${project.name} ${kind === 'plan' ? 'plan tracker' : 'todos'}`; + const content = planParser.appendItem(readTextOrEmpty(filePath), clean, heading); + fs.writeFileSync(filePath, content, 'utf8'); + planWatchers.get(projectId)?.snapshots.set(name, content); + return { ok: true }; +} + +/** Remember which session started or finished an item. */ +function recordPlanLink(projectId, kind, itemText, sessionId, linkKind) { + if (!db.getProject(projectId) || !itemText || !sessionId) return false; + db.insertPlanLink({ projectId, file: kind === 'todos' ? 'todos' : 'plan', itemText: String(itemText), sessionId, kind: linkKind, at: new Date().toISOString() }); + return true; +} + +/** + * Copy a plan-mode plan from ~/.claude/plans into the project as plan.md. + * The tracker is left for a session to derive; the page says so. + */ +function adoptPlan(projectId, filename, { replace = false } = {}) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const source = path.join(plansDir, path.basename(String(filename || ''))); + if (!fs.existsSync(source)) return { error: 'That plan file no longer exists' }; + const target = path.join(project.root, 'plan.md'); + if (!replace && hasBody(readTextOrEmpty(target))) { + return { error: 'plan.md already has content', exists: true }; + } + try { fs.copyFileSync(source, target); } catch (err) { return { error: err.message }; } + notifyRendererProjectsChanged(); + return { ok: true, planPath: target }; +} + +// The watcher: one fs.watch per project folder, looking only at the two +// checklist files. When a tick appears that the page did not make, the most +// recently started running session of that project gets the credit. +const planWatchers = new Map(); // projectId → { watcher, snapshots: Map } +let planCtx = null; // { activeSessions, send } + +function initPlanWatch(ctx) { + planCtx = ctx; + refreshPlanWatchers(); +} + +function refreshPlanWatchers() { + if (!planCtx) return; + const rows = db.listProjects(); + const live = new Set(rows.map(r => r.id)); + for (const [id, entry] of planWatchers) { + if (live.has(id)) continue; + try { entry.watcher.close(); } catch {} + planWatchers.delete(id); + } + for (const row of rows) { + if (planWatchers.has(row.id) || !isDirectory(row.root)) continue; + startPlanWatcher(row); + } +} + +function startPlanWatcher(row) { + const snapshots = new Map(); + for (const name of [TRACKER_FILE, TODOS_FILE]) snapshots.set(name, readTextOrEmpty(path.join(row.root, name))); + const pending = new Set(); + let timer = null; + let watcher; + try { + watcher = fs.watch(row.root, (_event, filename) => { + const name = String(filename || ''); + if (name !== TRACKER_FILE && name !== TODOS_FILE) return; + pending.add(name); + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + const names = [...pending]; + pending.clear(); + for (const n of names) { + try { onPlanFileChanged(row.id, n); } catch (err) { log.error?.('[projects] plan watch failed', err); } + } + }, 300); + }); + } catch (err) { + log.error?.(`[projects] could not watch ${row.root}`, err); + return; + } + watcher.on('error', (err) => log.error?.(`[projects] watcher error for ${row.root}`, err)); + planWatchers.set(row.id, { watcher, snapshots }); +} + +/** The running session of a project that most recently started, or null. */ +function runningProjectSession(projectId) { + let best = null; + for (const [id, session] of planCtx.activeSessions || []) { + if (session.exited || session.isPlainTerminal || session.projectId !== projectId) continue; + const at = session._openedAt || 0; + if (!best || at > best.at) best = { id: session.realSessionId || id, at }; + } + return best ? best.id : null; +} + +function onPlanFileChanged(projectId, name) { + const project = db.getProject(projectId); + const entry = planWatchers.get(projectId); + if (!project || !entry) return; + const next = readTextOrEmpty(path.join(project.root, name)); + const prev = entry.snapshots.get(name) || ''; + entry.snapshots.set(name, next); + if (next === prev) return; + const before = planParser.tickedTexts(prev); + const newly = [...planParser.tickedTexts(next)].filter(text => !before.has(text)); + if (newly.length) { + const sessionId = runningProjectSession(projectId); + if (sessionId) { + for (const text of newly) recordPlanLink(projectId, name === TRACKER_FILE ? 'plan' : 'todos', text, sessionId, 'ticked'); + } + } + planCtx.send?.('project-plan-changed', projectId); +} + +function stopPlanWatchers() { + for (const entry of planWatchers.values()) { try { entry.watcher.close(); } catch {} } + planWatchers.clear(); + // Until initPlanWatch is called again, creating a project starts no watcher + // (a stray fs.watch keeps a process alive). + planCtx = null; +} + +// --- Live git state of attached folders --- +// A worktree's branch is stored when it is attached, but an in-place folder is +// on whatever its owner has checked out right now, so the renderer asks for +// this and shows it beside the folder. Cached per path so a page that +// re-renders often does not shell out to git each time. +const GIT_STATUS_TTL_MS = 30 * 1000; +const gitStatusCache = new Map(); // folder path → { at, status } + +/** { git: false } | { git: true, branch, dirty, ahead, behind } for one folder, cached briefly. */ +async function folderGitInfo(folderPath, { force = false } = {}) { + const cached = gitStatusCache.get(folderPath); + if (!force && cached && Date.now() - cached.at < GIT_STATUS_TTL_MS) return cached.status; + let status = { git: false }; + try { + if (await git.isGitRepo(folderPath)) status = { git: true, ...(await git.status(folderPath)) }; + } catch (err) { + log.info('[projects] git status failed for', folderPath, err.message); + } + gitStatusCache.set(folderPath, { at: Date.now(), status }); + return status; +} + +/** { ok, byPath: { [folderPath]: folderGitInfo(folderPath) } } for every folder of a project. */ +async function folderGitStatus(projectId, { force = false } = {}) { + if (!db.getProject(projectId)) throw new Error('Project not found'); + const byPath = {}; + await Promise.all(db.listProjectFolders(projectId).map(async (folder) => { + byPath[folder.path] = await folderGitInfo(folder.path, { force }); + })); + return { ok: true, byPath }; +} + +/** Detailed, read-only Git information for the repositories attached to a project. */ +async function projectGitInfo(projectId) { + if (!db.getProject(projectId)) throw new Error('Project not found'); + const folders = db.listProjectFolders(projectId); + const repositories = await Promise.all(folders.map(async (folder) => ({ + path: folder.path, + mode: folder.mode || 'in-place', + sourcePath: folder.sourcePath || null, + ...(await git.snapshot(folder.path)), + }))); + return { ok: true, repositories }; +} + +/** Diff one changed file, restricted to a folder already attached to the project. */ +async function projectGitDiff(projectId, folderPath, filePath) { + if (!db.getProject(projectId)) throw new Error('Project not found'); + const folder = db.listProjectFolders(projectId).find(item => item.path === folderPath); + if (!folder) throw new Error('Repository is not attached to this project'); + return { ok: true, ...(await git.fileDiff(folder.path, filePath)) }; +} + +module.exports = { + init, + projectsRoot, slugify, uniqueSlug, defaultBrief, + createProject, updateProject, deleteProject, attachFolder, detachFolder, + folderGitStatus, folderGitInfo, projectGitInfo, projectGitDiff, + syncProjectBrief, saveBrief, createProjectFile, launchContext, mergeAddDirs, worktreeParentFor, + CODEX_BRIDGE_FILE, PROJECT_FILES, + readProjectPlan, setPlanItem, appendPlanItem, recordPlanLink, adoptPlan, + initPlanWatch, refreshPlanWatchers, stopPlanWatchers, + listTemplates, templatesRoot, renderTemplateText, editPlanItem, + listEnvFiles, defaultEnvSelection, copyEnvFiles, + createTrack, updateTrack, deleteTrack, + assignSession, recordLaunchAssignment, + projectForCwd, buildProjectTree, +}; diff --git a/public/app.js b/public/app.js index 913fb962..e539edf6 100644 --- a/public/app.js +++ b/public/app.js @@ -40,6 +40,7 @@ const terminalArea = document.getElementById('terminal-area'); const settingsViewer = document.getElementById('settings-viewer'); const globalSettingsBtn = document.getElementById('global-settings-btn'); const addProjectBtn = document.getElementById('add-project-btn'); +const projectsContent = document.getElementById('projects-content'); const resortBtn = document.getElementById('resort-btn'); const jsonlViewer = document.getElementById('jsonl-viewer'); const jsonlViewerTitle = document.getElementById('jsonl-viewer-title'); @@ -75,8 +76,22 @@ let showRunningOnly = false; let showTodayOnly = false; let cachedProjects = []; let cachedAllProjects = []; +// Projects tab (projects-view.js): project → tracks → sessions, from +// getProjectTree. Same session objects as the two caches above (dedupTree). +let cachedProjectTree = { projects: [] }; // archived excluded +let cachedProjectTreeAll = { projects: [] }; // everything let activePtyIds = new Set(); let sortedOrder = []; // [{ projectPath, itemIds: [itemId, ...] }, ...] — single source of truth for sidebar order +// Only Sessions and Projects are remembered: the others (Plans, Agent Files, +// Stats) are places you visit, not places you work from. +const REMEMBERED_TABS = ['sessions', 'projects']; +const LAST_TAB_KEY = 'lastTab'; +function rememberedTab() { + try { + const saved = localStorage.getItem(LAST_TAB_KEY); + return REMEMBERED_TABS.includes(saved) ? saved : 'sessions'; + } catch { return 'sessions'; } +} let activeTab = 'sessions'; let cachedPlans = []; let visibleSessionCount = 10; @@ -113,6 +128,47 @@ const attentionSessions = new Set(); // sessions needing user action (OSC 9) const responseReadySessions = new Set(); // CLI finished, user hasn't looked (terminal state) const sessionBusyState = new Map(); // sessionId → boolean (currently active) +// Unread and needs-you outlive the app. A session that finished, or asked for +// something, while you were away still says so after a restart; only opening +// it (or Mark as read) clears it. Busy is not saved: nothing is running yet. +const SESSION_NOTICES_KEY = 'sessionNotices'; + +// When something last happened to a session that is worth moving it for: it +// started, finished a turn, asked for something, or was opened. A working +// session rewrites its transcript constantly, so the Projects tab sorts on +// this clock instead of the file's modified time and holds still mid-turn. +const sessionEventTimes = new Map(); // sessionId → ms since epoch + +function bumpSessionEvent(sessionId) { + if (!sessionId) return; + sessionEventTimes.set(sessionId, Date.now()); + saveSessionNotices(); + if (typeof refreshProjectViews === 'function') refreshProjectViews({ reason: 'sessions' }); +} + +/** The time to sort a session by: its last event, else the transcript's modified time. */ +function sessionEventTime(session) { + const known = sessionEventTimes.get(session.sessionId); + if (known) return known; + const t = new Date(session.modified).getTime(); + return Number.isFinite(t) ? t : 0; +} + +function saveSessionNotices() { + try { + // Ids of sessions that no longer exist cost nothing but should not pile up. + const cap = (set) => [...set].slice(-200); + const events = Object.fromEntries([...sessionEventTimes].sort((a, b) => b[1] - a[1]).slice(0, 500)); + localStorage.setItem(SESSION_NOTICES_KEY, JSON.stringify({ ready: cap(responseReadySessions), attention: cap(attentionSessions), events })); + } catch {} +} +try { + const saved = JSON.parse(localStorage.getItem(SESSION_NOTICES_KEY) || 'null'); + for (const id of saved?.ready || []) responseReadySessions.add(id); + for (const id of saved?.attention || []) attentionSessions.add(id); + for (const [id, t] of Object.entries(saved?.events || {})) if (Number.isFinite(t)) sessionEventTimes.set(id, t); +} catch {} + // Some CLIs (notably Codex) start under a temporary ID and are re-keyed once // their transcript appears. Activity often begins before that detection, so it // must move with the rest of the session or the eventual idle event will have @@ -127,6 +183,14 @@ function rekeySessionActivity(oldId, newId) { sessionBusyState.delete(oldId); } if (activePtyIds.delete(oldId)) activePtyIds.add(newId); + if (sessionEventTimes.has(oldId)) { sessionEventTimes.set(newId, sessionEventTimes.get(oldId)); sessionEventTimes.delete(oldId); } + saveSessionNotices(); +} + +// A session row can be on screen twice: under its folder in the Sessions tab +// and in a project's pane or track card. State classes go to every copy. +function forEachSessionItem(sessionId, fn) { + document.querySelectorAll(`.session-item[data-session-id="${sessionId}"], .pane-session[data-session-id="${sessionId}"]`).forEach(fn); } // Central activity dispatcher @@ -137,8 +201,7 @@ function setActivity(sessionId, active) { // progress start must be able to put the session straight back into running. if (active && responseReadySessions.has(sessionId)) { responseReadySessions.delete(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) item.classList.remove('response-ready'); + forEachSessionItem(sessionId, item => item.classList.remove('response-ready')); } if (responseReadySessions.has(sessionId)) { @@ -149,30 +212,31 @@ function setActivity(sessionId, active) { sessionBusyState.set(sessionId, active); if (wasActive && !active) { + bumpSessionEvent(sessionId); // Activity ended → response-ready if user isn't looking at this session if (sessionId !== activeSessionId) { responseReadySessions.add(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) { + forEachSessionItem(sessionId, item => { item.classList.remove('cli-busy'); item.classList.add('response-ready'); - } + }); } } // Sync cli-busy class (only if not response-ready) if (!responseReadySessions.has(sessionId)) { - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) item.classList.toggle('cli-busy', active); + forEachSessionItem(sessionId, item => item.classList.toggle('cli-busy', active)); } + // The Projects tab rolls working / finished / needs-you up onto its rows. + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); + saveSessionNotices(); } function clearUnread(sessionId) { responseReadySessions.delete(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) { - item.classList.remove('response-ready'); - } + forEachSessionItem(sessionId, item => item.classList.remove('response-ready')); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); + saveSessionNotices(); } // User-initiated: put a session back into the response-ready state, as if @@ -182,18 +246,21 @@ function markUnread(sessionId) { if (responseReadySessions.has(sessionId)) return; responseReadySessions.add(sessionId); sessionBusyState.set(sessionId, false); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) { + forEachSessionItem(sessionId, item => { item.classList.remove('cli-busy'); item.classList.add('response-ready'); - } + }); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); + saveSessionNotices(); } function clearNotifications(sessionId) { + bumpSessionEvent(sessionId); clearUnread(sessionId); attentionSessions.delete(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) item.classList.remove('needs-attention'); + forEachSessionItem(sessionId, item => item.classList.remove('needs-attention')); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); + saveSessionNotices(); } // Terminal themes, utils (cleanDisplayName, formatDate, escapeHtml, shellEscape) // are defined in terminal-themes.js and utils.js (loaded before app.js). @@ -362,6 +429,7 @@ window.api.onProcessExited((sessionId, exitCode, signal, userStopped) => { proj.sessions = proj.sessions.filter(s => s.sessionId !== sessionId); } } + removeSessionFromTrees(sessionId); sessionMap.delete(sessionId); refreshSidebar(); // The pending marker can outlive the .jsonl by a beat (reconciliation only @@ -380,8 +448,10 @@ window.api.onTerminalNotification((sessionId, message, kind) => { // "Approval requested: ". if (kind === 'attention' && sessionId !== activeSessionId) { attentionSessions.add(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) item.classList.add('needs-attention'); + bumpSessionEvent(sessionId); + // The same session can be on screen in both tabs. + document.querySelectorAll(`.session-item[data-session-id="${sessionId}"]`).forEach(item => item.classList.add('needs-attention')); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); } else if (kind === 'idle') { // A completion notification is authoritative even if a quick turn never // produced a busy frame, or its busy state arrived under a temporary ID. @@ -405,7 +475,9 @@ window.api.onCliBusyState((sessionId, busy) => { // --- Single entry point for all sidebar renders --- // resort=true: re-sort items by priority+time (use for user-initiated actions) // resort=false (default): preserve existing DOM order, new items go to top -function refreshSidebar({ resort = false } = {}) { +// `reason` is passed straight to the Projects tab: 'sessions' means only the +// session list moved, so the project page patches itself instead of rebuilding. +function refreshSidebar({ resort = false, reason = 'project' } = {}) { // When searching, always use all projects (search ignores archive filter) let projects = (searchMatchIds !== null) ? cachedAllProjects @@ -425,6 +497,7 @@ function refreshSidebar({ resort = false } = {}) { } renderProjects(projects, resort); + if (typeof refreshProjectViews === 'function') refreshProjectViews({ reason }); } // --- Archive toggle --- @@ -469,7 +542,36 @@ globalSettingsBtn.addEventListener('click', () => { openSettingsViewer('global'); }); -// --- Add project button --- +// --- "More" button: Plans, Agent Files, Stats and Global settings share one +// menu so the tab strip stays short. The tab buttons stay in the DOM, hidden, +// so everything that clicks them (shortcuts, the quota gauge) keeps working. +const sidebarMoreBtn = document.getElementById('sidebar-more-btn'); +const MORE_TABS = ['plans', 'memory', 'stats']; +const moreIdleIcon = sidebarMoreBtn.innerHTML; +const tabButton = (name) => document.querySelector(`.sidebar-tab[data-tab="${name}"]`); +const menuIcon = (svg) => svg.replace(/width="18" height="18"/, 'width="14" height="14"'); + +/** Show the active hidden tab's icon on the more button, or the dots when none is active. */ +function updateMoreButton() { + const tab = MORE_TABS.includes(activeTab) ? tabButton(activeTab) : null; + sidebarMoreBtn.innerHTML = tab ? tab.innerHTML : moreIdleIcon; + sidebarMoreBtn.title = tab ? tab.title : 'More'; + sidebarMoreBtn.classList.toggle('active', !!tab); +} + +sidebarMoreBtn.addEventListener('click', (e) => { + const tabItem = (name) => { + const tab = tabButton(name); + return { label: tab.title, icon: menuIcon(tab.innerHTML), muted: activeTab === name, onClick: () => tab.click() }; + }; + showContextMenu([ + ...MORE_TABS.map(tabItem), + { sep: true }, + { label: 'Global settings', icon: ICONS.gear(14), onClick: () => globalSettingsBtn.click() }, + ], { anchor: e.currentTarget }); +}); + +// --- Add folder / new project buttons --- addProjectBtn.addEventListener('click', () => { showAddProjectDialog(); }); @@ -504,7 +606,7 @@ function clearSearch() { searchInput.value = ''; searchBar.classList.remove('has-query'); if (searchDebounceTimer) { clearTimeout(searchDebounceTimer); searchDebounceTimer = null; } - if (activeTab === 'sessions') { + if (activeTab === 'sessions' || activeTab === 'projects') { searchMatchIds = null; searchMatchProjectPaths = null; refreshSidebar({ resort: true }); @@ -535,7 +637,7 @@ searchInput.addEventListener('input', () => { } try { - if (activeTab === 'sessions') { + if (activeTab === 'sessions' || activeTab === 'projects') { const results = await window.api.search('session', query, searchTitlesOnly); searchMatchIds = new Set(results.map(r => r.id)); // When title-only, also match project names @@ -561,7 +663,7 @@ searchInput.addEventListener('input', () => { renderMemories(matchIds); } } catch { - if (activeTab === 'sessions') { + if (activeTab === 'sessions' || activeTab === 'projects') { searchMatchIds = null; searchMatchProjectPaths = null; refreshSidebar({ resort: true }); @@ -591,6 +693,7 @@ function dismissSession(sessionId) { proj.sessions = proj.sessions.filter(s => s.sessionId !== sessionId); } } + if (typeof removeSessionFromTrees === 'function') removeSessionFromTrees(sessionId); if (openSessions.has(sessionId)) destroySession(sessionId); if (activeSessionId === sessionId) { setActiveSession(null); @@ -641,6 +744,8 @@ function scheduleActiveSessionsPoll() { async function pollActiveSessions() { try { const ids = await window.api.getActiveSessions(); + // A session that just came alive, whoever started it, is news. + for (const id of ids) if (!activePtyIds.has(id)) { sessionEventTimes.set(id, Date.now()); } activePtyIds = new Set(ids); updateRunningIndicators(); updateTerminalHeader(); @@ -654,9 +759,10 @@ function updateRunningIndicators() { const running = activePtyIds.has(id); item.classList.toggle('has-running-pty', running); if (!running) { - item.classList.remove('needs-attention', 'response-ready', 'cli-busy'); - attentionSessions.delete(id); - responseReadySessions.delete(id); + // Unread and needs-you stay until the user looks; only busy needs a PTY. + item.classList.remove('cli-busy'); + item.classList.toggle('needs-attention', attentionSessions.has(id)); + item.classList.toggle('response-ready', responseReadySessions.has(id)); sessionBusyState.delete(id); } const dot = item.querySelector('.session-status-dot'); @@ -668,6 +774,7 @@ function updateRunningIndicators() { const dot = group.querySelector('.slug-group-dot'); if (dot) dot.classList.toggle('running', hasRunning); }); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); // Update grid card dots and status text for (const [sid, card] of gridCards) { const running = activePtyIds.has(sid); @@ -732,23 +839,29 @@ function dedup(projects) { } } -async function loadProjects({ resort = false } = {}) { +async function loadProjects({ resort = false, reason = 'project' } = {}) { const wasEmpty = cachedProjects.length === 0; if (wasEmpty) { loadingStatus.textContent = 'Loading\u2026'; loadingStatus.className = 'active'; loadingStatus.style.display = ''; } - const [defaultProjects, allProjects] = await Promise.all([ + const [defaultProjects, allProjects, tree, treeAll] = await Promise.all([ window.api.getProjects(false), window.api.getProjects(true), + window.api.getProjectTree(false).catch(() => ({ projects: [] })), + window.api.getProjectTree(true).catch(() => ({ projects: [] })), ]); cachedProjects = defaultProjects; cachedAllProjects = allProjects; + cachedProjectTree = tree || { projects: [] }; + cachedProjectTreeAll = treeAll || { projects: [] }; loadingStatus.style.display = 'none'; loadingStatus.className = ''; dedup(cachedProjects); dedup(cachedAllProjects); + dedupTree(cachedProjectTree); + dedupTree(cachedProjectTreeAll); // Reconcile pending sessions: remove ones that now have real data let hasReinjected = false; @@ -770,6 +883,7 @@ async function loadProjects({ resort = false } = {}) { proj.sessions.unshift(pending.session); } } + injectPendingIntoTree(pending.session); } } @@ -791,9 +905,11 @@ async function loadProjects({ resort = false } = {}) { } } catch {} - await hydrateProjectTasks([cachedProjects, cachedAllProjects]); + // Project roots and attached folders get their tasks too, even with no + // sessions of their own, so a project's task menu is complete. + await hydrateProjectTasks([cachedProjects, cachedAllProjects], treeTaskPaths(cachedProjectTreeAll)); await pollActiveSessions(); - refreshSidebar({ resort }); + refreshSidebar({ resort, reason }); renderDefaultStatus(); } @@ -822,6 +938,16 @@ async function launchNewSession(project, sessionOptions) { created: new Date().toISOString(), }; + // Launched from a project (or one of its tracks): main files the session + // there when it spawns, and the Projects tab shows it right away. + const options = { ...(sessionOptions || {}) }; + if (project.projectId) { + options.projectId = project.projectId; + if (project.trackId) options.trackId = project.trackId; + session.projectId = project.projectId; + session.trackId = project.trackId || null; + } + // Track as pending (no .jsonl yet) const folder = encodeProjectPath(projectPath); pendingSessions.set(sessionId, { session, projectPath, folder }); @@ -836,12 +962,13 @@ async function launchNewSession(project, sessionOptions) { } proj.sessions.unshift(session); } + injectPendingIntoTree(session); refreshSidebar(); const entry = createTerminalEntry(session); // Open terminal in main process with session options - const result = await window.api.openTerminal(sessionId, projectPath, true, sessionOptions || null); + const result = await window.api.openTerminal(sessionId, projectPath, true, Object.keys(options).length ? options : null); if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; @@ -944,9 +1071,14 @@ window.addEventListener('resize', () => { document.querySelectorAll('.sidebar-tab').forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.dataset.tab; - if (tabName === activeTab) return; + // The more button shares the tab styling but opens a menu instead. + if (!tabName || tabName === activeTab) return; + // Leaving the Projects tab takes its page, strip and pane with it. + if (activeTab === 'projects' && typeof leaveProjectViews === 'function') leaveProjectViews(); activeTab = tabName; + if (REMEMBERED_TABS.includes(tabName)) { try { localStorage.setItem(LAST_TAB_KEY, tabName); } catch {} } document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabName)); + updateMoreButton(); // Clear search on tab switch searchInput.value = ''; @@ -956,18 +1088,16 @@ document.querySelectorAll('.sidebar-tab').forEach(tab => { // Hide all sidebar content areas sidebarContent.style.display = 'none'; + projectsContent.style.display = 'none'; plansContent.style.display = 'none'; statsContent.style.display = 'none'; memoryContent.style.display = 'none'; sessionFilters.style.display = 'none'; searchBar.style.display = 'none'; - if (tabName === 'sessions') { - sessionFilters.style.display = ''; - searchBar.style.display = ''; - searchInput.placeholder = 'Search sessions...'; - sidebarContent.style.display = ''; - // Restore terminal area + // Sessions and Projects share the main area: the grid, the active + // terminal, or the placeholder. + function restoreTerminalArea() { hideAllViewers(); if (gridViewActive) { // Grid is still set up — just re-show it and refit @@ -982,11 +1112,30 @@ document.querySelectorAll('.sidebar-tab').forEach(tab => { } else { placeholder.style.display = ''; } + } + + if (tabName === 'sessions') { + sessionFilters.style.display = ''; + searchBar.style.display = ''; + searchInput.placeholder = 'Search sessions...'; + sidebarContent.style.display = ''; + restoreTerminalArea(); // Catch up on changes that happened while on another tab if (projectsChangedWhileAway) { projectsChangedWhileAway = false; loadProjects(); } + } else if (tabName === 'projects') { + searchBar.style.display = ''; + searchInput.placeholder = 'Search projects...'; + projectsContent.style.display = ''; + if (projectsChangedWhileAway) { + projectsChangedWhileAway = false; + loadProjects().then(() => showProjectHome()); + } else { + renderProjectList(); + showProjectHome(); + } } else if (tabName === 'plans') { searchBar.style.display = ''; searchInput.placeholder = 'Search plans...'; @@ -1147,6 +1296,9 @@ setTimeout(() => { })(); loadProjects().then(async () => { + // Open the tab the user was last working in. + const lastTab = rememberedTab(); + if (lastTab !== activeTab) document.querySelector(`.sidebar-tab[data-tab="${lastTab}"]`)?.click(); await restoreActiveTaskView(); // Restore grid view preference before opening sessions so they enter grid mode if (!activeTaskView && localStorage.getItem('gridViewActive') === '1') { @@ -1161,17 +1313,23 @@ loadProjects().then(async () => { // Live-reload sidebar when filesystem changes are detected let projectsChangedTimer = null; +// The strongest reason seen while the debounce window is open. +let projectsChangedReason = 'sessions'; let projectsChangedWhileAway = false; -window.api.onProjectsChanged(() => { +window.api.onProjectsChanged((reason) => { // Debounce to avoid rapid re-renders during bulk changes if (projectsChangedTimer) clearTimeout(projectsChangedTimer); - if (activeTab !== 'sessions') { + if (activeTab !== 'sessions' && activeTab !== 'projects') { projectsChangedWhileAway = true; return; } + // A batch that mixes both is a project change: the wider refresh covers both. + if (reason !== 'sessions') projectsChangedReason = 'project'; projectsChangedTimer = setTimeout(() => { projectsChangedTimer = null; - loadProjects(); + const only = projectsChangedReason; + projectsChangedReason = 'sessions'; + loadProjects({ reason: only }); }, 300); }); @@ -1185,7 +1343,9 @@ function renderDefaultStatus() { const parts = []; if (running > 0) parts.push(`${running} running`); parts.push(`${totalSessions} sessions`); - parts.push(`${totalProjects} projects`); + parts.push(`${totalProjects} folders`); + const projectCount = (cachedProjectTreeAll?.projects || []).filter(p => p.status === 'active').length; + if (projectCount > 0) parts.push(`${projectCount} project${projectCount === 1 ? '' : 's'}`); statusBarInfo.textContent = parts.join(' \u00b7 '); } diff --git a/public/dialogs.js b/public/dialogs.js index 1e393297..e6b66dec 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -1,7 +1,9 @@ // --- Dialogs & session launch helpers --- // Depends on globals: launchNewSession, cachedProjects, cachedAllProjects, sessionMap, // pendingSessions, openSessions, activePtyIds, refreshSidebar, pollActiveSessions (app.js) -// Depends on: ICONS (icons.js) +// Depends on: ICONS (icons.js)// +// Dialog rule: a click on the backdrop does nothing. These hold typed work, and +// a stray click used to throw it away. Escape and Cancel are the ways out. // --- New session dialog --- async function resolveDefaultSessionOptions(project) { @@ -180,6 +182,12 @@ async function launchTerminalSession(project) { created: new Date().toISOString(), type: 'terminal', }; + // Started from a project or one of its tracks: the terminal belongs there, + // so it shows in the project's list and not only in its own window. + if (project.projectId) { + session.projectId = project.projectId; + session.trackId = project.trackId || null; + } // Track as pending const folder = encodeProjectPath(projectPath); @@ -195,6 +203,7 @@ async function launchTerminalSession(project) { } proj.sessions.unshift(session); } + if (typeof injectPendingIntoTree === 'function') injectPendingIntoTree(session); refreshSidebar(); const entry = createTerminalEntry(session); @@ -375,7 +384,6 @@ async function showNewSessionDialog(project, runtime = 'claude') { dialog.querySelector('.new-session-cancel-btn').onclick = close; dialog.querySelector('.new-session-start-btn').onclick = start; - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); // Keyboard support function onKey(e) { @@ -492,7 +500,6 @@ async function showResumeSessionDialog(session) { dialog.querySelector('.new-session-cancel-btn').onclick = close; dialog.querySelector('.new-session-start-btn').onclick = resume; - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); function onKey(e) { if (e.key === 'Escape') close(); @@ -512,10 +519,10 @@ function showAddProjectDialog() { dialog.className = 'add-project-dialog'; dialog.innerHTML = ` -

Add Project

-
Select a folder to create a new project. To start a session in an existing project, use the + on its project header.
+

Add Folder

+
Select a folder. Its sessions appear in the Sessions tab. To start a session in a folder that is already listed, use the + on its header.
- +
@@ -563,7 +570,6 @@ function showAddProjectDialog() { dialog.querySelector('.add-project-cancel-btn').onclick = close; dialog.querySelector('.add-project-add-btn').onclick = addProject; - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); function onKey(e) { if (e.key === 'Escape') close(); diff --git a/public/index.html b/public/index.html index 29d76612..1dbdee46 100644 --- a/public/index.html +++ b/public/index.html @@ -13,11 +13,13 @@ + + + +
+ +
+
+ Projects Folder +
Where new projects are created. Existing projects stay where they are.
+
+
+ +
+
+ +
+
+ Project Templates +
One folder per template, each with a template.json and the files a new project starts with. Add a folder to add a template.
+
+
+ +
+
+
IDE Emulation @@ -320,7 +350,7 @@
- ${isProject ? '' : ''} + ${isProject ? '' : ''}
`; @@ -369,6 +399,16 @@ }); }); + // Project templates folder (global only) + const openTemplates = settingsViewerBody.querySelector('#sv-open-templates'); + if (openTemplates) { + window.api.listTemplates().then(result => { + const dirEl = settingsViewerBody.querySelector('#sv-templates-dir'); + if (dirEl && result?.dir) dirEl.textContent = result.dir; + openTemplates.onclick = () => { if (result?.dir) window.api.openPath(result.dir); }; + }).catch(() => {}); + } + // Save button settingsViewerBody.querySelector('#sv-save-btn').addEventListener('click', async () => { let settings = {}; @@ -415,6 +455,7 @@ settings.terminalTheme = settingsViewerBody.querySelector('#sv-terminal-theme').value || 'switchboard'; settings.mcpEmulation = settingsViewerBody.querySelector('#sv-mcp-emulation').checked; settings.shellProfile = settingsViewerBody.querySelector('#sv-shell-profile').value || 'auto'; + settings.projectsRoot = settingsViewerBody.querySelector('#sv-projects-root').value.trim(); } // Merge form values into existing settings to preserve keys not managed by the form diff --git a/public/sidebar.js b/public/sidebar.js index 1fef8b1f..acbb4b9e 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -120,6 +120,184 @@ function buildSlugGroup(slug, sessions) { return group; } +// Process a list of sessions: filter, sort, slug-group, order, and truncate. +// `project` needs `.sessions`, a `.projectPath` used as the order key, and an +// optional `._projectMatchedOnly`. The Projects tab passes a synthetic key +// ("project:") and its own previous order list. +// Returns { filtered, visible, older, sortOrderEntry } or null if the group +// should be skipped. +function processProjectSessions(project, resort, previousOrder = sortedOrder) { + let filtered = project.sessions; + if (showStarredOnly) filtered = filtered.filter(s => s.starred); + if (showRunningOnly) filtered = filtered.filter(s => activePtyIds.has(s.sessionId)); + if (showTodayOnly) { + const now = new Date(); + const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + filtered = filtered.filter(s => { + if (!s.modified) return false; + const d = new Date(s.modified); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` === todayStr; + }); + } + const anyFilterActive = showStarredOnly || showRunningOnly || showTodayOnly || searchMatchIds !== null; + if (filtered.length === 0 && !project._projectMatchedOnly && (project.sessions.length > 0 || anyFilterActive)) return null; + + // Sort + filtered = [...filtered].sort((a, b) => { + const aRunning = isSessionRunning(a.sessionId); + const bRunning = isSessionRunning(b.sessionId); + const aPri = (a.starred && aRunning ? 3 : aRunning ? 2 : a.starred ? 1 : 0); + const bPri = (b.starred && bRunning ? 3 : bRunning ? 2 : b.starred ? 1 : 0); + if (aPri !== bPri) return bPri - aPri; + return new Date(b.modified) - new Date(a.modified); + }); + + // Slug grouping + const slugMap = new Map(); + const ungrouped = []; + for (const session of filtered) { + if (session.slug) { + if (!slugMap.has(session.slug)) slugMap.set(session.slug, []); + slugMap.get(session.slug).push(session); + } else { + ungrouped.push(session); + } + } + const allItems = []; + for (const session of ungrouped) { + const isRunning = isSessionRunning(session.sessionId); + allItems.push({ sortTime: new Date(session.modified).getTime(), pinned: !!session.starred, running: isRunning, element: buildSessionItem(session) }); + } + for (const [slug, sessions] of slugMap) { + const mostRecentTime = Math.max(...sessions.map(s => new Date(s.modified).getTime())); + const hasRunning = sessions.some(s => isSessionRunning(s.sessionId)); + const hasPinned = sessions.some(s => s.starred); + const element = sessions.length === 1 ? buildSessionItem(sessions[0]) : buildSlugGroup(slug, sessions); + allItems.push({ sortTime: mostRecentTime, pinned: hasPinned, running: hasRunning, element }); + } + + // Sort render items + const prevEntry = previousOrder.find(e => e.projectPath === project.projectPath); + if (resort || !prevEntry) { + allItems.sort((a, b) => { + const aPri = (a.pinned && a.running ? 3 : a.running ? 2 : a.pinned ? 1 : 0); + const bPri = (b.pinned && b.running ? 3 : b.running ? 2 : b.pinned ? 1 : 0); + if (aPri !== bPri) return bPri - aPri; + return b.sortTime - a.sortTime; + }); + } else { + const orderIndex = new Map(prevEntry.itemIds.map((id, i) => [id, i])); + allItems.sort((a, b) => { + const aPos = orderIndex.get(a.element.id); + const bPos = orderIndex.get(b.element.id); + if (aPos !== undefined && bPos !== undefined) return aPos - bPos; + if (aPos === undefined && bPos !== undefined) return -1; + if (aPos !== undefined && bPos === undefined) return 1; + return b.sortTime - a.sortTime; + }); + } + + // Truncate + let visible = []; + let older = []; + if (searchMatchIds !== null || showStarredOnly || showRunningOnly || showTodayOnly) { + visible = allItems; + } else { + let count = 0; + const ageCutoff = Date.now() - sessionMaxAgeDays * 86400000; + for (const item of allItems) { + if (item.running || item.pinned || (count < visibleSessionCount && item.sortTime >= ageCutoff)) { + visible.push(item); + count++; + } else { + older.push(item); + } + } + if (visible.length === 0 && older.length > 0) { visible = older; older = []; } + } + + return { + filtered, visible, older, + sortOrderEntry: { projectPath: project.projectPath, itemIds: allItems.map(item => item.element.id) }, + }; +} + +// Build the sessions list DOM (shared between folders, worktrees and projects) +function buildSessionsList(fId, visible, older) { + const sessionsList = document.createElement('div'); + sessionsList.className = 'project-sessions'; + sessionsList.id = 'sessions-' + fId; + for (const item of visible) sessionsList.appendChild(item.element); + if (older.length > 0) { + const moreBtn = document.createElement('div'); + moreBtn.className = 'sessions-more-toggle'; + moreBtn.id = 'older-' + fId; + + const moreLabel = document.createElement('span'); + moreLabel.className = 'sessions-more-label'; + + const archiveOlderBtn = document.createElement('button'); + archiveOlderBtn.className = 'sessions-older-archive-btn'; + archiveOlderBtn.title = 'Archive all older sessions'; + archiveOlderBtn.setAttribute('aria-label', 'Archive all older sessions'); + archiveOlderBtn.innerHTML = ICONS.archive(14); + + moreBtn.appendChild(moreLabel); + moreBtn.appendChild(archiveOlderBtn); + const olderList = document.createElement('div'); + olderList.className = 'sessions-older'; + olderList.id = 'older-list-' + fId; + olderList.style.display = 'none'; + for (const item of older) olderList.appendChild(item.element); + // An older render item can be a slug group containing several sessions. + // Count the actual unarchived session rows so this label matches the + // archive confirmation and the work the archive button will perform. + const olderSessionCount = olderList.querySelectorAll('.session-item:not(.archived-item)').length; + moreLabel.textContent = `+ ${olderSessionCount} older`; + sessionsList.appendChild(moreBtn); + sessionsList.appendChild(olderList); + } + return sessionsList; +} + +// morphdom callback: carry DOM-only state (collapse, expansion, an open rename) +// from the element on screen to its replacement. Shared with the Projects tab. +function preserveSidebarDomState(fromEl, toEl) { + // Skip updating session items that have an active rename input + if (fromEl.classList.contains('session-item') && fromEl.querySelector('.session-rename-input')) { + return false; + } + if (fromEl.classList.contains('project-header')) { + if (fromEl.classList.contains('collapsed')) { + toEl.classList.add('collapsed'); + } else { + toEl.classList.remove('collapsed'); + } + } + if (fromEl.classList.contains('slug-group') || fromEl.classList.contains('worktree-header')) { + if (fromEl.classList.contains('collapsed')) { + toEl.classList.add('collapsed'); + } else { + toEl.classList.remove('collapsed'); + } + } + if (fromEl.classList.contains('sessions-older') && fromEl.style.display !== 'none') { + toEl.style.display = ''; + } + if (fromEl.classList.contains('sessions-more-toggle') && fromEl.classList.contains('expanded')) { + toEl.classList.add('expanded'); + const label = toEl.querySelector('.sessions-more-label'); + if (label) label.textContent = '- hide older'; + } + if (fromEl.classList.contains('slug-group-older') && fromEl.style.display !== 'none') { + toEl.style.display = ''; + } + if (fromEl.classList.contains('slug-group-more') && fromEl.classList.contains('expanded')) { + toEl.classList.add('expanded'); + } + return true; +} + function renderProjects(projects, resort) { const newSidebar = document.createElement('div'); @@ -153,142 +331,6 @@ function renderProjects(projects, resort) { const newSortedOrder = []; - // Process a project's sessions: filter, sort, slug-group, order, and truncate. - // Returns { filtered, visible, older, sortOrderEntry } or null if project should be skipped. - function processProjectSessions(project, resort) { - let filtered = project.sessions; - if (showStarredOnly) filtered = filtered.filter(s => s.starred); - if (showRunningOnly) filtered = filtered.filter(s => activePtyIds.has(s.sessionId)); - if (showTodayOnly) { - const now = new Date(); - const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - filtered = filtered.filter(s => { - if (!s.modified) return false; - const d = new Date(s.modified); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` === todayStr; - }); - } - const anyFilterActive = showStarredOnly || showRunningOnly || showTodayOnly || searchMatchIds !== null; - if (filtered.length === 0 && !project._projectMatchedOnly && (project.sessions.length > 0 || anyFilterActive)) return null; - - // Sort - filtered = [...filtered].sort((a, b) => { - const aRunning = isSessionRunning(a.sessionId); - const bRunning = isSessionRunning(b.sessionId); - const aPri = (a.starred && aRunning ? 3 : aRunning ? 2 : a.starred ? 1 : 0); - const bPri = (b.starred && bRunning ? 3 : bRunning ? 2 : b.starred ? 1 : 0); - if (aPri !== bPri) return bPri - aPri; - return new Date(b.modified) - new Date(a.modified); - }); - - // Slug grouping - const slugMap = new Map(); - const ungrouped = []; - for (const session of filtered) { - if (session.slug) { - if (!slugMap.has(session.slug)) slugMap.set(session.slug, []); - slugMap.get(session.slug).push(session); - } else { - ungrouped.push(session); - } - } - const allItems = []; - for (const session of ungrouped) { - const isRunning = isSessionRunning(session.sessionId); - allItems.push({ sortTime: new Date(session.modified).getTime(), pinned: !!session.starred, running: isRunning, element: buildSessionItem(session) }); - } - for (const [slug, sessions] of slugMap) { - const mostRecentTime = Math.max(...sessions.map(s => new Date(s.modified).getTime())); - const hasRunning = sessions.some(s => isSessionRunning(s.sessionId)); - const hasPinned = sessions.some(s => s.starred); - const element = sessions.length === 1 ? buildSessionItem(sessions[0]) : buildSlugGroup(slug, sessions); - allItems.push({ sortTime: mostRecentTime, pinned: hasPinned, running: hasRunning, element }); - } - - // Sort render items - const prevEntry = sortedOrder.find(e => e.projectPath === project.projectPath); - if (resort || !prevEntry) { - allItems.sort((a, b) => { - const aPri = (a.pinned && a.running ? 3 : a.running ? 2 : a.pinned ? 1 : 0); - const bPri = (b.pinned && b.running ? 3 : b.running ? 2 : b.pinned ? 1 : 0); - if (aPri !== bPri) return bPri - aPri; - return b.sortTime - a.sortTime; - }); - } else { - const orderIndex = new Map(prevEntry.itemIds.map((id, i) => [id, i])); - allItems.sort((a, b) => { - const aPos = orderIndex.get(a.element.id); - const bPos = orderIndex.get(b.element.id); - if (aPos !== undefined && bPos !== undefined) return aPos - bPos; - if (aPos === undefined && bPos !== undefined) return -1; - if (aPos !== undefined && bPos === undefined) return 1; - return b.sortTime - a.sortTime; - }); - } - - // Truncate - let visible = []; - let older = []; - if (searchMatchIds !== null || showStarredOnly || showRunningOnly || showTodayOnly) { - visible = allItems; - } else { - let count = 0; - const ageCutoff = Date.now() - sessionMaxAgeDays * 86400000; - for (const item of allItems) { - if (item.running || item.pinned || (count < visibleSessionCount && item.sortTime >= ageCutoff)) { - visible.push(item); - count++; - } else { - older.push(item); - } - } - if (visible.length === 0 && older.length > 0) { visible = older; older = []; } - } - - return { - filtered, visible, older, - sortOrderEntry: { projectPath: project.projectPath, itemIds: allItems.map(item => item.element.id) }, - }; - } - - // Build the sessions list DOM (shared between projects and worktrees) - function buildSessionsList(fId, visible, older) { - const sessionsList = document.createElement('div'); - sessionsList.className = 'project-sessions'; - sessionsList.id = 'sessions-' + fId; - for (const item of visible) sessionsList.appendChild(item.element); - if (older.length > 0) { - const moreBtn = document.createElement('div'); - moreBtn.className = 'sessions-more-toggle'; - moreBtn.id = 'older-' + fId; - - const moreLabel = document.createElement('span'); - moreLabel.className = 'sessions-more-label'; - - const archiveOlderBtn = document.createElement('button'); - archiveOlderBtn.className = 'sessions-older-archive-btn'; - archiveOlderBtn.title = 'Archive all older sessions'; - archiveOlderBtn.setAttribute('aria-label', 'Archive all older sessions'); - archiveOlderBtn.innerHTML = ICONS.archive(14); - - moreBtn.appendChild(moreLabel); - moreBtn.appendChild(archiveOlderBtn); - const olderList = document.createElement('div'); - olderList.className = 'sessions-older'; - olderList.id = 'older-list-' + fId; - olderList.style.display = 'none'; - for (const item of older) olderList.appendChild(item.element); - // An older render item can be a slug group containing several sessions. - // Count the actual unarchived session rows so this label matches the - // archive confirmation and the work the archive button will perform. - const olderSessionCount = olderList.querySelectorAll('.session-item:not(.archived-item)').length; - moreLabel.textContent = `+ ${olderSessionCount} older`; - sessionsList.appendChild(moreBtn); - sessionsList.appendChild(olderList); - } - return sessionsList; - } - for (const project of projects) { // Skip worktree projects — they'll be rendered nested under their parent if (worktreeSet.has(project.projectPath)) continue; @@ -307,8 +349,19 @@ function renderProjects(projects, resort) { const header = document.createElement('div'); header.className = 'project-header'; header.id = 'ph-' + fId; - const shortName = shortProjectPath(project.projectPath); - header.innerHTML = ` ${shortName}`; + // A folder that sits inside a project's folder is labelled by the project + // (" · repos/website") instead of by its last two path segments. + const rootLabel = typeof projectRootLabel === 'function' ? projectRootLabel(project.projectPath) : null; + const shortName = rootLabel || shortProjectPath(project.projectPath); + header.title = project.projectPath; + if (rootLabel) header.classList.add('project-header--in-project'); + // A project worktree (checked out under /repos/) gets the same + // branch mark a Claude worktree has. + const folderMode = typeof projectFolderMode === 'function' ? projectFolderMode(project.projectPath) : null; + const branchMark = folderMode === 'worktree' + ? ' ' + : ''; + header.innerHTML = ` ${branchMark}${escapeHtml(shortName)}`; const taskBtn = createProjectTaskButton(project); if (taskBtn) header.appendChild(taskBtn); @@ -413,41 +466,7 @@ function renderProjects(projects, resort) { morphdom(sidebarContent, newSidebar, { childrenOnly: true, - onBeforeElUpdated(fromEl, toEl) { - // Skip updating session items that have an active rename input - if (fromEl.classList.contains('session-item') && fromEl.querySelector('.session-rename-input')) { - return false; - } - if (fromEl.classList.contains('project-header')) { - if (fromEl.classList.contains('collapsed')) { - toEl.classList.add('collapsed'); - } else { - toEl.classList.remove('collapsed'); - } - } - if (fromEl.classList.contains('slug-group') || fromEl.classList.contains('worktree-header')) { - if (fromEl.classList.contains('collapsed')) { - toEl.classList.add('collapsed'); - } else { - toEl.classList.remove('collapsed'); - } - } - if (fromEl.classList.contains('sessions-older') && fromEl.style.display !== 'none') { - toEl.style.display = ''; - } - if (fromEl.classList.contains('sessions-more-toggle') && fromEl.classList.contains('expanded')) { - toEl.classList.add('expanded'); - const label = toEl.querySelector('.sessions-more-label'); - if (label) label.textContent = '- hide older'; - } - if (fromEl.classList.contains('slug-group-older') && fromEl.style.display !== 'none') { - toEl.style.display = ''; - } - if (fromEl.classList.contains('slug-group-more') && fromEl.classList.contains('expanded')) { - toEl.classList.add('expanded'); - } - return true; - }, + onBeforeElUpdated: preserveSidebarDomState, getNodeKey(node) { return node.id || undefined; } @@ -543,7 +562,14 @@ function rebindSidebarEvents(projects) { }; }); - sidebarContent.querySelectorAll('.slug-group-header').forEach(header => { + bindSessionListEvents(sidebarContent); +} + +// Per-row handlers for every session list: slug groups, "+N older" toggles and +// the session items themselves. Shared by the Sessions tab (sidebarContent) +// and the Projects tab (projectsContent). +function bindSessionListEvents(container) { + container.querySelectorAll('.slug-group-header').forEach(header => { const archiveBtn = header.querySelector('.slug-group-archive-btn'); if (archiveBtn) { archiveBtn.onclick = async (e) => { @@ -569,7 +595,7 @@ function rebindSidebarEvents(projects) { }; }); - sidebarContent.querySelectorAll('.slug-group-more').forEach(moreBtn => { + container.querySelectorAll('.slug-group-more').forEach(moreBtn => { moreBtn.onclick = () => { const group = moreBtn.closest('.slug-group'); if (group) { @@ -579,7 +605,7 @@ function rebindSidebarEvents(projects) { }; }); - sidebarContent.querySelectorAll('.sessions-more-toggle').forEach(moreBtn => { + container.querySelectorAll('.sessions-more-toggle').forEach(moreBtn => { const olderList = moreBtn.nextElementSibling; if (!olderList || !olderList.classList.contains('sessions-older')) return; const count = olderList.querySelectorAll('.session-item:not(.archived-item)').length; @@ -614,13 +640,21 @@ function rebindSidebarEvents(projects) { } }); - sidebarContent.querySelectorAll('.session-item').forEach(item => { + container.querySelectorAll('.session-item').forEach(item => { const sessionId = item.dataset.sessionId; const session = sessionMap.get(sessionId); if (!session) return; item.onclick = () => openSession(session); + const moveBtn = item.querySelector('.session-move-btn'); + if (moveBtn) { + moveBtn.onclick = (e) => { + e.stopPropagation(); + if (typeof showMovePopover === 'function') showMovePopover(session, moveBtn); + }; + } + const pin = item.querySelector('.session-pin'); if (pin) { pin.onclick = async (e) => { @@ -708,7 +742,7 @@ function rebindSidebarEvents(projects) { // Auto-expand slug group if it contains the active session if (activeSessionId) { - const activeItem = sidebarContent.querySelector(`[data-session-id="${activeSessionId}"]`); + const activeItem = container.querySelector(`[data-session-id="${activeSessionId}"]`); const collapsedGroup = activeItem?.closest('.slug-group.collapsed'); if (collapsedGroup) { collapsedGroup.classList.remove('collapsed'); @@ -831,12 +865,20 @@ function buildSessionItem(session) { unreadBtn.title = isUnread ? 'Mark as read' : 'Mark as unread'; unreadBtn.innerHTML = isUnread ? ICONS.markRead(14) : ICONS.markUnread(14); + // File the session into a project (projects-view.js). Sessions stay where + // they are unless moved, so this is the one way an old session joins one. + const moveBtn = document.createElement('button'); + moveBtn.className = 'session-move-btn'; + moveBtn.title = 'Move to project…'; + moveBtn.innerHTML = ''; + actions.appendChild(stopBtn); actions.appendChild(unreadBtn); if (session.type !== 'terminal') { actions.appendChild(forkBtn); actions.appendChild(jsonlBtn); actions.appendChild(archiveBtn); + actions.appendChild(moveBtn); actions.appendChild(launchConfigBtn); } diff --git a/public/style.css b/public/style.css index c2a9e5df..8c17013f 100644 --- a/public/style.css +++ b/public/style.css @@ -156,8 +156,6 @@ body { display: flex; flex-direction: column; } white-space: nowrap; } - - /* ========== SIDEBAR ========== */ #sidebar { width: 340px; @@ -386,7 +384,6 @@ body { display: flex; flex-direction: column; } color: #ffffff; } - /* ---- Scrollable content ---- */ #sidebar-content { flex: 1; @@ -1170,7 +1167,6 @@ body { display: flex; flex-direction: column; } display: none; } - /* ========== MAIN AREA ========== */ #main { flex: 1; @@ -1429,7 +1425,6 @@ body { display: flex; flex-direction: column; } overflow: hidden; } - .terminal-container.visible { display: block; } @@ -1690,7 +1685,6 @@ body { display: flex; flex-direction: column; } flex-shrink: 0; } - /* ========== SCROLLBAR ========== */ #sidebar-content::-webkit-scrollbar, #plans-content::-webkit-scrollbar { width: 5px; } #sidebar-content::-webkit-scrollbar-track, #plans-content::-webkit-scrollbar-track { background: transparent; } @@ -3943,7 +3937,6 @@ body { display: flex; flex-direction: column; } border-left: 1px solid rgba(255,255,255,0.06); } - .fp-toolbar-btn { background: transparent; border: 1px solid rgba(255,255,255,0.08); @@ -4314,3 +4307,2174 @@ body { display: flex; flex-direction: column; } .session-dismiss-btn:hover { color: #e5714d; } + +/* ========== PROJECTS TAB (shared dialogs and Sessions-tab bits) ========== */ +/* A project header carries a name and a muted line under it, where a folder + header only has a path. Same sticky header, same buttons. */ +.project-header--project .project-name { + display: flex; + flex-direction: column; + gap: 1px; + white-space: normal; +} + +.project-done-chip { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7a7a90; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 4px; + padding: 1px 4px; + line-height: 1.2; +} + +.project-header--in-project .project-name { + color: #a6abff; +} + +.projects-empty { + margin: 14px 12px; + padding: 16px 14px; + border: 1px dashed rgba(255,255,255,0.12); + border-radius: 10px; + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; +} + +.projects-empty-text { + font-size: 12px; + line-height: 1.5; + color: #8888a0; +} + +/* Move action on a session row */ +.session-move-btn { + cursor: pointer; + background: none; + border: none; + padding: 4px; + line-height: 1; + transition: all 0.15s; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + color: #6a6a90; +} + +.session-move-btn:hover { + color: #8088ff; + background: rgba(120,130,255,0.1); +} + +/* Popovers built on .task-popover: move, project settings */ +.task-popover-group { + padding: 8px 8px 3px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #6a6a80; +} + +.new-project-label { + display: block; + margin: 12px 0 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7a7a90; +} + +/* ========== WORKTREES (Projects) ========== */ +.project-worktree-icon { + display: inline-flex; + align-items: center; + color: #5ec46a; + flex-shrink: 0; +} + +.project-worktree-icon svg { + display: block; +} + +/* ========== PROJECTS TAB v2 ========== */ +/* Sidebar: a list of projects, nothing else. */ +.proj-toolbar { + padding: 4px 10px 6px; +} + +.proj-new-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + height: 30px; + border-radius: 7px; + border: 1px solid rgba(120,130,255,0.35); + background: rgba(120,130,255,0.12); + color: #a6abff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all 0.15s; +} + +.proj-new-btn:hover { + background: rgba(120,130,255,0.2); + border-color: rgba(120,130,255,0.5); +} + +.proj-section { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5a5a72; + padding: 10px 12px 4px; + user-select: none; +} + +.proj-section--toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + margin-top: 6px; +} + +.proj-section--toggle svg { + transition: transform 0.15s; +} + +.proj-section--toggle.open svg { + transform: rotate(90deg); +} + +.proj-section--toggle:hover { + color: #8888a0; +} + +.proj-row { + display: flex; + align-items: center; + gap: 10px; + margin: 0 8px; + padding: 9px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s; +} + +.proj-row:hover { + background: rgba(255,255,255,0.05); +} + +.proj-row.selected { + background: rgba(120,130,255,0.14); +} + +.proj-row.done { + opacity: 0.6; +} + +.proj-row.done:hover, +.proj-row.done.selected { + opacity: 1; +} + +.proj-mark { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 7px; + font-weight: 700; + letter-spacing: 0.02em; + flex-shrink: 0; + line-height: 1; +} + +.proj-mark--lg { border-radius: 9px; } +.proj-mark--sm { border-radius: 5px; } + +.proj-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.proj-name { + font-size: 13px; + font-weight: 600; + color: #d0d0e8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.proj-sub { + font-size: 11px; + color: #7a7a90; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.proj-status { + display: flex; + align-items: center; + gap: 5px; + flex-shrink: 0; +} + +.proj-count { + font-size: 11px; + color: #7a7a90; +} + +.proj-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + background: transparent; +} + +.proj-dot--lg { width: 8px; height: 8px; } + +/* Working: the same spinning blue ring the session rows use, so a project + that has something running is unmistakable next to a quiet green one. */ +.proj-dot.running { + position: relative; + background: transparent; + box-shadow: none; +} +.proj-dot.running::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 50%; + border: 1.5px solid #4fc3f7; + border-top-color: transparent; + animation: spin-dot 0.8s linear infinite; +} + +.proj-dot.attention { + background: #f0a050; + box-shadow: 0 0 6px rgba(240,160,80,0.5); +} + +/* Finished and unread: the same blue the Sessions panel uses. */ +.proj-dot.ready { + background: #4fc3f7; + box-shadow: 0 0 6px rgba(79,195,247,0.6); +} + +/* A live session doing none of those: green, but quiet. */ +.proj-dot.idle { + background: rgba(62,207,90,0.4); + box-shadow: none; +} + +/* ---- Workspace page ---- */ +#project-viewer { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background: #111118; + min-height: 0; +} + +.ws-header { + padding: 18px 28px 0; + display: flex; + flex-direction: column; + gap: 14px; + background: #131319; + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; +} + +.ws-title-row { + display: flex; + align-items: center; + gap: 14px; +} + +.ws-title-text { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + flex: 1; +} + +.ws-title-line { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.ws-title { + font-size: 18px; + font-weight: 700; + color: #e6e6f4; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ws-chips { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.ws-chip { + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); + color: #b0b0c4; + font-size: 11px; + white-space: nowrap; + font-family: inherit; +} + +.ws-chip.mono { font-family: 'SF Mono', 'Fira Code', Menlo, monospace; font-size: 10.5px; } +.ws-chip svg { flex-shrink: 0; } +.ws-chip--worktree { color: #9fe0a8; } +.ws-chip--status { height: 20px; font-weight: 600; } +.ws-chip--status.active { color: #5ec46a; border-color: rgba(62,207,90,0.3); background: rgba(62,207,90,0.08); } +.ws-chip--status.done { color: #9090a8; } +.ws-chip--add { color: #6a6a80; border-style: dashed; cursor: pointer; } +.ws-chip--add:hover { color: #b0b0c4; border-color: rgba(255,255,255,0.2); } +.ws-chip--cli { height: 18px; padding: 0 6px; font-size: 10px; font-weight: 600; } +.ws-chip--cli.claude { color: #a6abff; } +.ws-chip--cli.codex { color: #2cc19e; } + +.ws-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.ws-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 12px; + border-radius: 7px; + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.04); + color: #c0c0d8; + font-size: 12px; + font-weight: 600; + font-family: inherit; + white-space: nowrap; + cursor: pointer; + transition: all 0.15s; +} + +.ws-btn:hover { background: rgba(255,255,255,0.08); color: #e0e0f0; } +.ws-btn svg { flex-shrink: 0; } +.ws-btn--primary { background: rgba(120,130,255,0.16); border-color: rgba(120,130,255,0.4); color: #a6abff; } +.ws-btn--primary:hover { background: rgba(120,130,255,0.26); color: #c0c4ff; } +.ws-btn--icon { width: 30px; padding: 0; justify-content: center; } +.ws-btn--sm { height: 26px; padding: 0 10px; font-size: 11.5px; } +.ws-btn--sm.ws-btn--icon { width: 26px; } +.ws-btn--danger { color: #e05070; border-color: rgba(224,80,112,0.3); } +.ws-btn--danger:hover { background: rgba(224,80,112,0.1); color: #f07090; } +.ws-btn.project-task-btn { opacity: 1; min-width: 0; } +.ws-btn.project-task-btn.running { color: #59d46d; border-color: rgba(62,207,90,0.34); background: rgba(62,207,90,0.09); } +.ws-btn.project-task-btn.error { color: #e36a78; border-color: rgba(227,106,120,0.35); } + +.ws-badge { + font-size: 10px; + font-weight: 700; + color: #59d46d; + background: rgba(62,207,90,0.12); + border-radius: 4px; + padding: 1px 5px; + line-height: 1.2; +} + +.ws-tabs { + display: flex; + align-items: center; + gap: 22px; +} + +.ws-tab { + padding: 10px 2px; + font-size: 12.5px; + font-weight: 600; + color: #7a7a90; + border: none; + border-bottom: 2px solid transparent; + background: none; + font-family: inherit; + cursor: pointer; + transition: color 0.15s; +} + +.ws-tab:hover:not([disabled]) { color: #b0b0c4; } +.ws-tab.active { color: #d0d0e8; border-bottom-color: #8088ff; } +.ws-tab[disabled] { color: #4e4e62; cursor: default; } +.ws-tab-meta { font-weight: 500; color: #6a6a80; margin-left: 4px; } + +.ws-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 22px 28px 28px; +} + +.ws-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + gap: 20px; + align-items: start; +} + +.ws-body { container-type: inline-size; } +@container (max-width: 860px) { + .ws-grid { grid-template-columns: minmax(0, 1fr); } +} + +.ws-main, .ws-side { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.ws-section-head { + display: flex; + align-items: center; + gap: 10px; +} + +.ws-section-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6a6a80; +} + +.ws-flex { flex: 1; } + +.ws-ghost { + display: inline-flex; + align-items: center; + gap: 5px; + height: 26px; + padding: 0 9px; + border-radius: 6px; + border: 1px solid transparent; + background: none; + color: #9090a8; + font-size: 11.5px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + white-space: nowrap; + transition: all 0.12s; +} + +.ws-ghost:hover { background: rgba(255,255,255,0.06); color: #d0d0e8; } +.ws-ghost--sm { height: 22px; padding: 0 7px; font-size: 11px; } +.ws-ghost--muted { color: #6a6a80; } + +/* One card per row: General first, then the tracks in order. */ +.ws-cards { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ws-hint { + grid-column: 1 / -1; + font-size: 12px; + color: #7a7a90; + padding: 4px 2px; +} + +/* ---- Track cards ---- */ +.tcard { + background: #18181f; + border: 1px solid rgba(255,255,255,0.06); + border-radius: 10px; + display: flex; + flex-direction: column; + min-width: 0; +} + +.tcard--general { + border-style: dashed; + border-color: rgba(255,255,255,0.1); + background: transparent; +} + +.tcard--done { opacity: 0.6; } +.tcard--done:hover { opacity: 1; } + +.tcard-h { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px 6px; + min-width: 0; +} + +.tcard-name { + font-size: 13px; + font-weight: 700; + color: #d0d0e8; + letter-spacing: 0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tcard-meta { + font-size: 11px; + color: #7a7a90; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.tcard-meta.mono { font-family: 'SF Mono', 'Fira Code', Menlo, monospace; font-size: 10.5px; } + +.tcard-more { + background: none; + border: none; + color: #6a6a80; + width: 22px; + height: 22px; + border-radius: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: all 0.12s; +} + +.tcard:hover .tcard-more { opacity: 1; } +.tcard-more:hover { background: rgba(255,255,255,0.06); color: #c0c0d8; } + +.tcard .pane-session { margin: 0 6px; } + +.tcard-empty { + padding: 8px 14px 6px 29px; + font-size: 11.5px; + color: #6a6a80; +} + +.tcard-f { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 10px 10px; + border-top: 1px solid rgba(255,255,255,0.05); + margin-top: 6px; +} + +/* ---- Side cards, settings cards ---- */ +.ws-card { + background: #18181f; + border: 1px solid rgba(255,255,255,0.06); + border-radius: 10px; + display: flex; + flex-direction: column; + padding-bottom: 6px; + min-width: 0; +} + +.ws-card-h { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px 8px; +} + +.ws-card-title { + font-size: 13px; + font-weight: 700; + color: #d0d0e8; + letter-spacing: 0.01em; +} + +.ws-card-meta { + font-size: 11px; + color: #7a7a90; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ws-card-meta.mono { font-family: 'SF Mono', 'Fira Code', Menlo, monospace; font-size: 10.5px; } + +.ws-card-text { + padding: 0 14px 8px; + font-size: 12px; + line-height: 1.55; + color: #a8a8bc; +} + +.ws-card-text.muted { color: #6a6a80; } + +.ws-progress { + height: 5px; + border-radius: 3px; + background: rgba(255,255,255,0.08); + overflow: hidden; + margin: 0 14px 4px; +} + +.ws-progress--sm { height: 4px; margin: 0; flex: 1; } + +.ws-progress-fill { + height: 100%; + background: #8088ff; + border-radius: 3px; + transition: width 0.3s; +} + +.ws-card-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px 6px; + min-width: 0; +} + +.ws-card-strong { + font-size: 12px; + color: #d0d0e8; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ws-todo { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 5px 14px; + font-size: 12px; + color: #c8c8dc; + line-height: 1.4; + cursor: pointer; +} + +.ws-todo:hover { background: rgba(255,255,255,0.03); } +.ws-todo input { margin: 3px 0 0; accent-color: #8088ff; } + +.ws-input { + height: 30px; + padding: 0 10px; + border-radius: 6px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + color: #d0d0e0; + font-size: 12.5px; + font-family: inherit; + outline: none; + min-width: 0; +} + +.ws-input:focus { border-color: rgba(120,130,255,0.4); } +.ws-input.mono { font-family: 'SF Mono', 'Fira Code', Menlo, monospace; font-size: 11.5px; } +.ws-todo-input { margin: 4px 14px 6px; width: calc(100% - 28px); } + +.ws-frow { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + font-size: 12px; + color: #c8c8dc; + min-width: 0; +} + +.ws-frow-icon { display: inline-flex; color: #7a7a90; flex-shrink: 0; } +.ws-frow-icon.is-worktree { color: #5ec46a; } +.ws-frow-name { flex: 0 0 auto; font-weight: 600; } +.ws-frow-detail { flex: 1; min-width: 0; } +.ws-frow .ws-ghost { opacity: 0; } +.ws-frow:hover .ws-ghost { opacity: 1; } + +/* ---- Settings tab ---- */ +.ws-settings { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 760px; +} + +.ws-field { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 14px 10px; +} + +.ws-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7a7a90; +} + +.ws-inline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ws-inline .ws-input { flex: 1; } +.ws-path { font-size: 11.5px; color: #9090a8; font-family: 'SF Mono', 'Fira Code', Menlo, monospace; } +.ws-help { font-size: 11px; color: #6a6a80; line-height: 1.45; } + +.ws-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #b0b0c4; + white-space: nowrap; + cursor: pointer; +} + +.ws-check input { margin: 0; accent-color: #8088ff; } + +.ws-select { + height: 30px; + padding: 0 8px; + border-radius: 6px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + color: #d0d0e0; + font-size: 12px; + font-family: inherit; + outline: none; + min-width: 0; +} + +.ws-select:focus { border-color: rgba(120,130,255,0.4); } + +.ws-cwd { display: flex; flex-direction: column; gap: 6px; } + +/* ---- Read-only Git tab ---- */ +.git-tab-body { padding-top: 20px; } + +.git-tab-shell { + width: 100%; + max-width: 1100px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +.git-tab-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.git-tab-title { + color: #e0e0ee; + font-size: 16px; + font-weight: 700; + line-height: 1.3; +} + +.git-tab-path { + margin-top: 3px; + color: #6f6f84; + font-size: 10.5px; + line-height: 1.4; + word-break: break-all; +} + +.git-readonly { + flex-shrink: 0; + color: #6f6f84; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + padding-top: 3px; +} + +.git-repo-picker { + display: flex; + gap: 4px; + padding: 3px; + align-self: flex-start; + max-width: 100%; + overflow-x: auto; + border-radius: 7px; + background: rgba(255,255,255,0.035); +} + +.git-repo-option { + border: 0; + border-radius: 5px; + padding: 5px 10px; + background: transparent; + color: #77778c; + font-family: inherit; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; +} + +.git-repo-option:hover { color: #bcbccc; } +.git-repo-option.active { color: #d8d8e8; background: rgba(255,255,255,0.075); } + +.git-alert { + border: 1px solid rgba(233,166,76,0.28); + border-radius: 8px; + padding: 8px 11px; + color: #e3ad67; + background: rgba(233,166,76,0.07); + font-size: 11.5px; +} + +.git-summary, +.git-section { + border: 1px solid rgba(255,255,255,0.065); + border-radius: 10px; + background: #18181f; + overflow: hidden; +} + +.git-summary { padding: 14px 16px; } + +.git-summary-top, +.git-summary-meta { + display: flex; + align-items: center; + gap: 10px; +} + +.git-summary-top { justify-content: space-between; } + +.git-branch { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + color: #d6d6e8; + font: 650 13px 'SF Mono', 'Fira Code', Menlo, monospace; +} + +.git-branch span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.git-branch svg { color: #83cd92; flex-shrink: 0; } + +.git-clean-state { + flex-shrink: 0; + border-radius: 5px; + padding: 3px 7px; + font-size: 10.5px; + font-weight: 650; +} + +.git-clean-state.clean { color: #74cf86; background: rgba(74,190,96,0.1); } +.git-clean-state.changed { color: #e0aa63; background: rgba(224,170,99,0.1); } + +.git-summary-meta { + margin: 7px 0 13px 21px; + color: #77778d; + font-size: 11px; +} + +.git-stat { display: inline-flex; gap: 6px; font-family: 'SF Mono', 'Fira Code', Menlo, monospace; } +.git-plus { color: #72c986; } +.git-minus { color: #df7987; } + +.git-latest { + display: grid; + grid-template-columns: 92px minmax(0, 1fr) auto; + align-items: baseline; + gap: 10px; + padding-top: 12px; + border-top: 1px solid rgba(255,255,255,0.055); +} + +.git-kicker, +.git-section-heading { + color: #747489; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.git-latest-subject { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #c9c9dc; + font-size: 12px; +} + +.git-latest-meta { + color: #6f6f84; + font-size: 10.5px; + white-space: nowrap; +} + +.git-latest-meta .mono { color: #8989a0; } +.git-empty-inline { color: #6f6f84; font-size: 11.5px; } + +.git-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 38px; + padding: 0 14px; + border-bottom: 1px solid rgba(255,255,255,0.055); +} + +.git-section-meta { + color: #66667b; + font-weight: 550; + letter-spacing: 0; + text-transform: none; +} + +.git-changes { + display: grid; + grid-template-columns: minmax(230px, 34%) minmax(0, 1fr); + align-items: stretch; +} + +.git-file-list { + min-width: 0; + border-right: 1px solid rgba(255,255,255,0.055); + background: rgba(0,0,0,0.08); +} + +.git-file-group { + padding: 10px 12px 5px; + color: #606075; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.git-file-row { + width: 100%; + display: flex; + align-items: flex-start; + gap: 9px; + border: 0; + padding: 7px 10px; + background: transparent; + color: inherit; + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.git-file-row:hover { background: rgba(255,255,255,0.035); } +.git-file-row.selected { background: rgba(122,132,255,0.11); } + +.git-file-code { + width: 17px; + flex: 0 0 17px; + padding-top: 1px; + color: #d6a45e; + font: 700 10.5px 'SF Mono', 'Fira Code', Menlo, monospace; + text-align: center; +} + +.git-file-code.added, +.git-file-code.untracked { color: #69c47c; } +.git-file-code.deleted, +.git-file-code.conflicted { color: #df7180; } +.git-file-code.renamed { color: #8fa7e8; } + +.git-file-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.git-file-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #bdbdd0; font-size: 10.5px; } +.git-file-meta { color: #636378; font-size: 9.5px; } + +.git-diff-pane { + min-width: 0; + background: #14141a; +} + +.git-diff-title { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-bottom: 1px solid rgba(255,255,255,0.055); + background: #17171e; + color: #9292a8; + font-size: 10px; +} + +.git-diff { + margin: 0; + padding: 8px 0 14px; + color: #9999aa; + font: 10.5px/1.55 'SF Mono', 'Fira Code', Menlo, monospace; + tab-size: 2; +} + +.git-diff-line { + display: block; + min-height: 1.55em; + padding: 0 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.git-diff-line.header { color: #777790; } +.git-diff-line.hunk { color: #8992dc; background: rgba(100,112,215,0.07); } +.git-diff-line.add { color: #91d19d; background: rgba(58,155,76,0.075); } +.git-diff-line.remove { color: #e2939e; background: rgba(185,63,82,0.075); } + +.git-diff-empty, +.git-empty-row { + padding: 18px 14px; + color: #6c6c80; + font-size: 11.5px; +} + +.git-diff-empty { display: flex; align-items: center; justify-content: center; min-height: 180px; text-align: center; } + +.git-commits { display: flex; flex-direction: column; } + +.git-commit-row { + display: grid; + grid-template-columns: 66px minmax(0, 1fr); + gap: 10px; + align-items: baseline; + padding: 8px 14px; +} + +.git-commit-row + .git-commit-row { border-top: 1px solid rgba(255,255,255,0.04); } +.git-commit-hash { color: #7f86c8; font-size: 10.5px; } +.git-commit-text { display: flex; align-items: baseline; gap: 10px; min-width: 0; } +.git-commit-subject { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #bdbdd0; font-size: 11.5px; } +.git-commit-meta { flex-shrink: 0; color: #65657a; font-size: 10px; } + +.git-loading, +.git-empty-state { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 7px; + color: #6f6f84; + font-size: 11.5px; + text-align: center; +} + +.git-empty-title { color: #b7b7ca; font-size: 13px; font-weight: 650; } +.git-loading { flex-direction: row; } +.git-loading-dot { width: 6px; height: 6px; border-radius: 50%; background: #7c84ec; animation: git-loading-pulse 1.2s ease-in-out infinite; } +@keyframes git-loading-pulse { 50% { opacity: 0.3; transform: scale(0.8); } } + +@container (max-width: 720px) { + .git-latest { grid-template-columns: 1fr; gap: 4px; } + .git-latest-meta { white-space: normal; } + .git-changes { grid-template-columns: 1fr; } + .git-file-list { border-right: 0; border-bottom: 1px solid rgba(255,255,255,0.055); } + .git-diff-pane { min-height: 260px; } + .git-commit-text { align-items: flex-start; flex-direction: column; gap: 2px; } +} + + +/* ---- Working mode: strip + pane beside the terminal ---- */ +#project-strip { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 44px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + background: #131319; + border-bottom: 1px solid rgba(255,255,255,0.06); + z-index: 3; +} + +.strip-name { + font-size: 13px; + font-weight: 700; + color: #e6e6f4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.strip-branch { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: #9fe0a8; + font-family: 'SF Mono', 'Fira Code', Menlo, monospace; + white-space: nowrap; +} + +#project-panes { + position: absolute; + top: 44px; + left: 0; + bottom: 0; + width: var(--pane-w, 288px); + display: flex; + flex-direction: column; + background: #15151c; + border-right: 1px solid rgba(255,255,255,0.06); + z-index: 2; +} + +#main.project-working #terminal-area { + top: 44px; + left: var(--pane-w, 288px); +} + +/* Drag the pane's right edge, like the sidebar's. */ +#pane-resize-handle { + display: none; + position: absolute; + top: 44px; + bottom: 0; + left: calc(var(--pane-w, 288px) - 2px); + width: 5px; + cursor: col-resize; + z-index: 3; + background: transparent; + transition: background 0.15s; +} +#main.project-working #pane-resize-handle { display: block; } +#pane-resize-handle:hover, #pane-resize-handle.dragging { background: rgba(120,130,255,0.3); } + +.pane { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.pane-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6a6a80; + padding: 12px 12px 8px; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.pane-seg { + display: inline-flex; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + overflow: hidden; +} + +.pane-seg-btn { + padding: 3px 9px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; + color: #7a7a90; + background: none; + border: none; + font-family: inherit; + cursor: pointer; +} + +.pane-seg-btn:hover { color: #b0b0c4; } +.pane-seg-btn.on { background: rgba(120,130,255,0.16); color: #a6abff; } + +.pane-icon-btn { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid transparent; + background: none; + color: #7a7a90; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.12s; + flex-shrink: 0; +} + +.pane-icon-btn:hover { background: rgba(255,255,255,0.06); color: #c0c0d8; } +.pane-icon-btn--primary { border-color: rgba(120,130,255,0.4); background: rgba(120,130,255,0.16); color: #a6abff; } +.pane-icon-btn--primary:hover { background: rgba(120,130,255,0.26); } + +.pane-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 8px; +} + +.pane-sec { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5a5a72; + padding: 10px 14px 4px; + display: flex; + align-items: center; + gap: 8px; + user-select: none; +} + +.pane-sec.done { opacity: 0.6; } +.pane-sec-count { font-weight: 500; color: #6a6a80; } +.pane-sec-plus { opacity: 0; width: 18px; height: 18px; } +.pane-sec:hover .pane-sec-plus { opacity: 1; } + +.pane-session { + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px 12px; + margin: 0 6px; + border-radius: 7px; + cursor: pointer; + transition: background 0.12s; + min-width: 0; +} + +.pane-session:hover { background: rgba(255,255,255,0.05); } +.pane-session.here { background: rgba(120,130,255,0.14); } + +.pane-title { + font-size: 12.5px; + color: #c8c8dc; + line-height: 1.3; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} + +.pane-meta { + font-size: 10.5px; + color: #7a7a90; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + overflow: hidden; + min-width: 0; +} + +.pane-dot { + position: static; + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: transparent; + margin-right: -2px; +} + +.pane-dot.running { background: #3ecf5a; box-shadow: 0 0 5px rgba(62,207,90,0.5); } +.pane-dot.needs-attention { background: #f0a050; box-shadow: 0 0 5px rgba(240,160,80,0.5); } +.pane-dot:not(.running):not(.needs-attention) { display: none; } + +.pane-tag { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.pane-sep { color: #4e4e62; } + +.pane-more { + display: block; + background: none; + border: none; + color: #6a6a80; + font-size: 11px; + font-family: inherit; + padding: 4px 18px 6px; + cursor: pointer; + text-align: left; +} + +.pane-more:hover { color: #b0b0c4; } + +.pane-empty { + padding: 6px 18px 8px; + font-size: 11.5px; + color: #6a6a80; +} + +.pane-foot { + padding: 10px 14px; + border-top: 1px solid rgba(255,255,255,0.06); + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} + +.pane-foot-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: #9090a8; + cursor: pointer; + border-radius: 5px; +} + +.pane-foot-row:hover { color: #d0d0e8; } +.pane-foot-row b { color: #d0d0e8; font-weight: 600; } + +/* ---- Context menus ---- */ +.ctx-menu { + position: fixed; + z-index: 10040; + min-width: 220px; + max-width: 320px; + background: #1b1b26; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 9px; + padding: 5px; + box-shadow: 0 14px 40px rgba(0,0,0,0.48); + display: flex; + flex-direction: column; + gap: 1px; + max-height: calc(100vh - 16px); + overflow-y: auto; +} + +.ctx-item { + display: flex; + align-items: center; + gap: 9px; + height: 30px; + padding: 0 9px; + border-radius: 6px; + font-size: 12.5px; + color: #c8c8dc; + cursor: pointer; + white-space: nowrap; + user-select: none; +} + +.ctx-item:hover, .ctx-item.open { background: rgba(255,255,255,0.06); color: #e6e6f4; } +.ctx-item.danger { color: #e05070; } +.ctx-item.danger:hover { background: rgba(224,80,112,0.1); } +.ctx-item.disabled { color: #5a5a72; cursor: default; } +.ctx-item.disabled:hover { background: none; } +.ctx-item.muted { color: #8888a0; } +.ctx-icon { width: 14px; height: 14px; display: inline-flex; align-items: center; justify-content: center; color: #8088ff; flex-shrink: 0; } +.ctx-item.danger .ctx-icon { color: #e05070; } +.ctx-item.muted .ctx-icon, .ctx-item.disabled .ctx-icon { color: #6a6a80; } +.ctx-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.ctx-hint { font-size: 11px; color: #7a7a90; margin-left: 10px; } +.ctx-arrow { color: #6a6a80; display: inline-flex; margin-left: 6px; } +.ctx-sep { height: 1px; background: rgba(255,255,255,0.08); margin: 4px 6px; } +.ctx-head { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #7a7a90; + padding: 6px 9px 4px; +} + +.ws-prompt { width: 420px; } +.ws-prompt .folder-input-row input { width: 100%; } + +/* Thin scrollbars on the project pages, matching the sidebar. */ +.ws-body::-webkit-scrollbar, .pane-scroll::-webkit-scrollbar { width: 5px; } +.ws-body::-webkit-scrollbar-track, .pane-scroll::-webkit-scrollbar-track { background: transparent; } +.ws-body::-webkit-scrollbar-thumb, .pane-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; } +.ws-body::-webkit-scrollbar-thumb:hover, .pane-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.1); } + +/* Settings: the two attach choices, each with a line saying what it does. */ + +/* Tab strip: Projects, Sessions, and a "more" button for the rest. The collapse button keeps to the right edge. */ +#sidebar-tabs #sidebar-collapse-btn { margin-left: auto; } +.ctx-icon svg { max-width: 100%; max-height: 100%; } + +/* Working mode with no open session: the placeholder sits beside the pane, under the strip. */ +#main.project-working #placeholder { + box-sizing: border-box; + padding: 44px 0 0 var(--pane-w, 288px); +} + +/* The CLI mark in session rows: the same logo the status bar uses, no word. */ +.pane-cli-icon { display: inline-flex; align-items: center; flex-shrink: 0; color: #9090a8; } +.pane-cli-icon.is-codex { color: #10a37f; } +.pane-cli-icon.is-terminal { color: #5ec46a; } +.ws-chip--cli { padding: 0 5px; } +.ws-chip--cli.codex { color: #10a37f; } + +/* Track card rows: a hairline between sessions, so the card reads as rows instead of one block of text. */ +.tcard .tcard-session { margin: 0; padding: 9px 14px; border-radius: 0; position: relative; } +.tcard .tcard-session + .tcard-session::before { + content: ''; + position: absolute; + left: 14px; + right: 14px; + top: 0; + height: 1px; + background: rgba(255,255,255,0.05); +} +.tcard .tcard-session:hover { background: rgba(255,255,255,0.035); } +.tcard .tcard-session .pane-meta { margin-top: 1px; } + +/* ========== PROJECT PAGE: FILES TAB ========== */ +.ws-body--files { + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 20px; +} + +.ws-files { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 16px; +} + +@media (max-width: 900px) { + .ws-files { grid-template-columns: 200px minmax(0, 1fr); } +} + +.ws-files-side { + display: flex; + flex-direction: column; + min-height: 0; + background: #18181f; + border: 1px solid rgba(255,255,255,0.06); + border-radius: 10px; +} + +.ws-files-head { + display: flex; + align-items: center; + padding: 10px 12px 6px; +} + +.ws-files-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 2px 6px 8px; +} + +.ws-files-list::-webkit-scrollbar { width: 5px; } +.ws-files-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; } + +.ws-file-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + color: #b0b0c4; + cursor: pointer; + white-space: nowrap; + overflow: hidden; +} + +.ws-file-row:hover { + background: rgba(255,255,255,0.04); + color: #d8d8f0; +} + +.ws-file-row.selected { + background: rgba(120,130,255,0.14); + color: #d8d8f0; +} + +.ws-file-row.is-dir { + color: #c0c0d8; + font-weight: 600; +} + +.ws-file-row.is-binary { + opacity: 0.5; + cursor: default; +} + +.ws-file-icon { + width: 12px; + flex-shrink: 0; + text-align: center; + font-size: 10px; + color: #8088ff; +} + +.ws-file-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.ws-files-hint { + padding: 6px 12px 10px; +} + +.ws-editor { + display: flex; + flex-direction: column; + min-height: 0; + background: #18181f; + border: 1px solid rgba(255,255,255,0.06); + border-radius: 10px; + overflow: hidden; +} + +.ws-editor-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + text-align: center; + font-size: 12px; + color: #6a6a80; +} + +.ws-editor-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.ws-editor-panel .viewer-panel-editor, +.ws-editor-panel .markdown-preview { + flex: 1; + min-height: 0; +} + +/* ========== PROJECT PAGE: PLAN TAB ========== */ +.ws-plan { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr); + gap: 20px; + align-items: start; +} + +@media (max-width: 1100px) { + .ws-plan { grid-template-columns: minmax(0, 1fr); } +} + +.ws-plan-card .ws-card-h { + gap: 6px; +} + +.ws-phases { + display: flex; + flex-direction: column; + padding: 4px 8px 8px; + gap: 2px; +} + +.ws-phase { + border-radius: 8px; + border: 1px solid transparent; +} + +.ws-phase.next { + border-color: rgba(240,176,96,0.28); + background: rgba(240,176,96,0.05); +} + +.ws-phase-head { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 8px; + cursor: default; + font-size: 13px; + color: #d0d0e0; +} + +.ws-phase:hover .ws-phase-head { + background: rgba(255,255,255,0.03); +} + +.ws-phase.done .ws-phase-title { + color: #7a7a90; + text-decoration: line-through; + text-decoration-color: rgba(255,255,255,0.25); +} + +.ws-phase-box { + margin: 0; + flex-shrink: 0; +} + +.ws-phase-title { + font-weight: 600; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-phase-meta { + font-size: 11px; + color: #7a7a90; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ws-phase.next .ws-phase-meta { + color: #f0b060; +} + +.ws-phase-chevron { + color: #7a7a90; + display: inline-flex; + cursor: pointer; +} + +.ws-phase-start { + opacity: 0; + transition: opacity 0.12s; +} + +.ws-phase:hover .ws-phase-start, +.ws-phase.next .ws-phase-start { + opacity: 1; +} + +.ws-phase-items { + display: none; + flex-direction: column; + padding: 0 10px 8px 30px; +} + +.ws-phase.expanded .ws-phase-items { + display: flex; +} + +.ws-phase-item.done span { + color: #7a7a90; + text-decoration: line-through; + text-decoration-color: rgba(255,255,255,0.25); +} + +.ws-todos { + display: flex; + flex-direction: column; + padding: 4px 12px 8px; +} + +.ws-todo.done span { + color: #7a7a90; + text-decoration: line-through; + text-decoration-color: rgba(255,255,255,0.25); +} + +.ws-plan-links { + background: rgba(120,130,255,0.12); + border: 1px solid rgba(120,130,255,0.25); + color: #a6abff; + border-radius: 999px; + padding: 1px 7px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + font-family: inherit; +} + +.ws-plan-links:hover { + background: rgba(120,130,255,0.22); +} + +.ws-item-start { + margin-left: auto; + background: transparent; + border: none; + color: #7a7a90; + width: 20px; + height: 20px; + border-radius: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.12s; + padding: 0; +} + +.ws-todo:hover .ws-item-start, +.ws-phase-item:hover .ws-item-start { + opacity: 1; +} + +.ws-item-start:hover { + color: #59d46d; + background: rgba(62,207,90,0.1); +} + +/* ========== NEW PROJECT DIALOG: form + what you get ========== */ +.add-project-dialog.np-dialog { + width: 820px; + max-width: 94vw; + padding: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + overflow: hidden; +} + +.np-form { + padding: 20px; + min-width: 0; + display: flex; + flex-direction: column; +} + +.np-form h3 { margin: 0 0 14px; } +.np-form .new-project-label { margin: 20px 0 6px; } +.np-form > .new-project-label[for="np-name"] { margin-top: 6px; } + +.np-chips { display: flex; gap: 6px; flex-wrap: wrap; } + +.np-chip { + font-size: 12px; + padding: 5px 11px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.1); + background: transparent; + color: #9090a8; + cursor: pointer; + font-family: inherit; + transition: all 0.15s; +} +.np-chip:hover { color: #b0b0c4; } +.np-chip.on { border-color: rgba(120,130,255,0.45); background: rgba(120,130,255,0.12); color: #a6abff; font-weight: 600; } + +.np-help { font-size: 11.5px; color: #6a6a80; line-height: 1.4; margin-top: 8px; min-height: 16px; } + +.np-row-head { display: flex; align-items: baseline; justify-content: space-between; } + +.np-link { + display: inline-flex; + align-items: center; + gap: 5px; + background: none; + border: none; + padding: 0; + color: #8088ff; + font-size: 12px; + cursor: pointer; + font-family: inherit; +} +.np-link:hover { color: #a6abff; } + +.np-folders { display: flex; flex-direction: column; gap: 4px; } + +.np-folder { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 7px 10px; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 6px; +} +.np-folder-name { font-weight: 600; color: #d0d0e0; font-size: 13px; } +.np-folder-path { font-size: 11px; color: #7a7a90; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.np-folder-none { font-size: 12px; color: #666; padding: 6px 0; } +.np-folder-branch { grid-column: 1 / -1; display: flex; align-items: center; gap: 6px; padding-top: 2px; font-size: 11px; color: #7a7a90; } +.np-folder-branch svg { color: #8088ff; flex-shrink: 0; } +.np-folder-branch .np-input { flex: 0 1 240px; padding: 4px 8px; font-size: 11.5px; } + +.np-folder-env { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 5px; padding-top: 4px; } +.np-env-files { display: flex; flex-wrap: wrap; gap: 6px 14px; } +.np-env-file { font-size: 11.5px; color: #9090a8; gap: 5px; } +.np-env-file .mono { color: #c8c8dc; } + +.np-x { background: none; border: none; color: #7a7a90; font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px; } +.np-x:hover { color: #e05070; } + +.np-inline { display: flex; align-items: center; gap: 8px; } + +.np-input { + flex: 1; + min-width: 0; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + padding: 6px 8px; + color: #d0d0e0; + font-size: 12px; + font-family: inherit; + outline: none; +} +.np-input.mono { font-family: 'SF Mono', 'Fira Code', Menlo, monospace; } +.np-input:focus { border-color: rgba(120,130,255,0.4); } +.np-input:disabled { color: #555; } +.np-input::placeholder { color: #555; } + +.np-preview { + background: #17171f; + border-left: 1px solid rgba(255,255,255,0.06); + padding: 20px 18px; + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.np-preview-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7a7a90; +} + +.np-tree { font-size: 11px; line-height: 1.75; color: #9090a8; display: flex; flex-direction: column; } +.np-tree-root { color: #c8c8dc; word-break: break-all; } +.np-tree-item { padding-left: 14px; } +.np-tree-dir { color: #c8c8dc; } +.np-tree-sub { padding-left: 28px; display: inline-flex; align-items: center; gap: 6px; color: #9090a8; } +.np-tree-sub svg { color: #8088ff; flex-shrink: 0; } +.np-tree-env { padding-left: 46px; } +.np-tree em { font-style: normal; color: #5e5e74; } + +.np-preview-sec { display: flex; flex-direction: column; gap: 6px; } +.np-preview-label { font-size: 11px; color: #6a6a80; } +.np-preview-row { font-size: 11px; color: #9090a8; display: inline-flex; align-items: center; gap: 6px; min-width: 0; } +.np-preview-row svg { color: #7a7a90; flex-shrink: 0; } +.np-preview-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.np-tags { display: flex; gap: 6px; flex-wrap: wrap; } +.np-preview-foot { margin-top: auto; padding-top: 10px; font-size: 11px; line-height: 1.45; color: #5e5e74; } + + +/* Todo rows: only the box ticks; the text edits on double-click. */ +.ws-todo input[type="checkbox"] { cursor: pointer; } +.ws-todo-text { flex: 1; min-width: 0; } +.ws-todo-edit { flex: 1; height: 22px; padding: 0 6px; font-size: 12px; margin: -2px 0; } + +/* A quiet line at the foot of a card. */ +.ws-card-hint { padding: 2px 14px 10px; font-size: 11px; line-height: 1.45; color: #5e5e74; } + +/* ========== SETTINGS TAB: rows that save as you go ========== */ +.ws-rows { max-width: 720px; display: flex; flex-direction: column; } + +.ws-sec { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 0 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7a7a90; +} +.ws-rows > .ws-sec:first-child { padding-top: 2px; } +.ws-sec .ws-ghost { text-transform: none; letter-spacing: 0; } +.ws-ghost--accent { color: #8088ff; } +.ws-ghost--accent:hover { color: #a6abff; } + +.ws-row { + display: grid; + grid-template-columns: 200px minmax(0, 1fr); + align-items: center; + gap: 16px; + padding: 10px 14px; + min-height: 44px; + box-sizing: border-box; +} +.ws-row-k { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.ws-row-k b { font-size: 12.5px; font-weight: 600; color: #d0d0e0; } +.ws-row-k span { font-size: 11px; color: #6a6a80; line-height: 1.4; } +.ws-row-v { display: flex; align-items: center; gap: 8px; justify-content: flex-end; min-width: 0; } +.ws-row-v .ws-input { flex: 1; min-width: 0; height: 28px; } +.ws-row-v .ws-cwd { flex: 1; min-width: 0; align-items: flex-end; } +.ws-row-v .ws-cwd .ws-label { display: none; } +.ws-row-v .ws-select { width: 260px; max-width: 100%; height: 28px; } +.ws-path--clip { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; } + +.ws-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + min-height: 40px; + box-sizing: border-box; + font-size: 12.5px; +} +.ws-item--empty { color: #6a6a80; font-size: 12px; } +.ws-item .ws-frow-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws-item .ws-item-open, .ws-item .ws-item-x { opacity: 0; transition: opacity 0.12s; } +.ws-item:hover .ws-item-open, .ws-item:hover .ws-item-x { opacity: 1; } +.ws-card > .ws-row + .ws-row, .ws-card > .ws-item + .ws-item, .ws-card > .ws-item + .ws-row, .ws-card > div + .ws-row, .ws-card > .ws-row + .ws-item { + border-top: 1px solid rgba(255,255,255,0.05); +} +#ws-set-folders .ws-item + .ws-item { border-top: 1px solid rgba(255,255,255,0.05); } + +.ws-saved { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: #59d46d; + opacity: 0; + transition: opacity 0.2s; + white-space: nowrap; +} +.ws-saved.on { opacity: 1; } + +.ws-check--switch { position: relative; cursor: pointer; } +.ws-switch-input { position: absolute; opacity: 0; width: 0; height: 0; } +.ws-switch { + position: relative; + width: 26px; + height: 15px; + border-radius: 8px; + background: rgba(255,255,255,0.14); + flex-shrink: 0; + transition: background 0.15s; +} +.ws-switch::after { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 11px; + height: 11px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s; +} +.ws-switch-input:checked + .ws-switch { background: #8088ff; } +.ws-switch-input:checked + .ws-switch::after { transform: translateX(11px); } +.ws-switch-input:focus-visible + .ws-switch { outline: 2px solid rgba(120,130,255,0.5); outline-offset: 2px; } + +.ws-trow { + display: grid; + grid-template-columns: auto minmax(120px, 1fr) minmax(150px, 1fr) minmax(110px, auto) auto auto; + gap: 8px; + align-items: center; +} +.ws-trow.done { opacity: 0.6; } +.ws-trow-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.ws-trow .ws-cwd .ws-label { display: none; } +.ws-trow .ws-input, .ws-trow .ws-select { height: 26px; font-size: 12px; } +.ws-trow-field { min-width: 0; } + +.ws-foot { display: flex; align-items: center; gap: 14px; padding: 22px 2px 0; font-size: 12px; } +.ws-link-danger { background: none; border: none; padding: 0; color: #e05070; font-size: 12px; cursor: pointer; font-family: inherit; } +.ws-link-danger:hover { color: #f07090; text-decoration: underline; } +.ws-foot-note { margin-left: auto; font-size: 11px; color: #5e5e74; } + +/* Pane and track card rows carry the Sessions tab's states: green dot while a + CLI is alive, blue text while it works or has an unread answer, orange when + it needs you. Same colours and motion as the Sessions tab. */ +.pane-session.running .pane-dot { display: block; background: #3ecf5a; box-shadow: 0 0 5px rgba(62,207,90,0.5); } +/* The spinner ring is wider than the 6px dot it replaces, so this state gets a + box the ring actually fits in. Drawn outside the box it crowded the track tag + beside it and bled into the row's left padding. */ +.pane-session.cli-busy:not(.needs-attention) .pane-dot { + display: block; + position: relative; + width: 10px; + height: 10px; + margin-right: 0; + background: transparent; + box-shadow: none; +} +.pane-session.cli-busy:not(.needs-attention) .pane-dot::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + border: 1.5px solid #4fc3f7; + border-top-color: transparent; + animation: spin-dot 0.8s linear infinite; +} +.pane-session.cli-busy:not(.needs-attention) .pane-title { + background: linear-gradient(110deg, #f0f0ff 44%, #4fc3f7 48%, #ffffff 50%, #4fc3f7 52%, #f0f0ff 56%); + background-size: 300% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer-text 3s linear infinite; +} +.pane-session.response-ready:not(.needs-attention) .pane-dot { display: block; background: #4fc3f7; box-shadow: 0 0 8px rgba(79,195,247,0.7); } +.pane-session.response-ready:not(.needs-attention) .pane-title { color: #4fc3f7; font-weight: 600; } +.pane-session.needs-attention .pane-dot { display: block; position: relative; background: #f0a050; box-shadow: none; } +.pane-session.needs-attention .pane-dot::before, +.pane-session.needs-attention .pane-dot::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 50%; + border: 1.5px solid rgba(240,160,80,0.7); + animation: ripple-out 2s ease-out infinite; +} +.pane-session.needs-attention .pane-dot::after { animation-delay: 1s; } +.pane-session.needs-attention .pane-title { color: #f0a050; font-weight: 600; } + +/* The card's status dot keeps its place in the header whether it is lit or not + (it is transparent when idle), so the session rows below are indented to the + same edge as the card name: 14px padding + a 7px dot + the 8px gap. */ +.tcard .tcard-session { padding-left: 29px; } + +/* New project: a repository asks how the project should work in it, once, on its own row. */ +.np-folder { grid-template-columns: auto auto minmax(0, 1fr) auto; } +.np-folder-icon { display: inline-flex; color: #7a7a90; } +.np-folder-icon.is-worktree { color: #8088ff; } +.np-folder--ask { border-color: rgba(120,130,255,0.35); background: rgba(120,130,255,0.06); } +.np-ask { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 10px; padding-top: 4px; } +.np-ask-q { font-size: 12px; color: #b0b0c4; line-height: 1.45; } +.np-ask-opts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.np-ask-opt { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.np-ask-opt .ws-btn { justify-content: center; height: 28px; font-size: 12px; } +.np-ask-btn--wt { color: #a6abff; border-color: rgba(120,130,255,0.4); background: rgba(120,130,255,0.12); } +.np-ask-btn--wt:hover { background: rgba(120,130,255,0.2); } +.np-branch-line { font-size: 11.5px; color: #6a6a80; margin-top: 8px; line-height: 1.5; } +.np-branch-line b { font-weight: 500; color: #b0b0c4; } +.np-branch-line .np-link { font-size: 11.5px; } +.np-ask-dialog .np-ask-opts { margin-top: 12px; } +.np-ask-dialog .add-project-hint { margin-top: 6px; } + +.add-project-add-btn:disabled { opacity: 0.45; cursor: default; } +.add-project-add-btn:disabled:hover { background: rgba(120,130,255,0.15); } + +/* Archived sessions: a closed line at the foot of the pane and under a track + card's foot. One click opens the dimmed rows in place. */ +.pane-scroll { display: flex; flex-direction: column; } +.pane-scroll > * { flex-shrink: 0; } +/* Sits at the foot when the list is short, after the list when it is long; the gap above the hairline is padding so it never collapses. */ +.pane-scroll > .archived-sec { margin: auto 14px 0; padding-top: 14px; } +.pane-scroll > .archived-sec::before { content: ''; display: block; height: 1px; background: rgba(255,255,255,0.06); margin-bottom: 6px; } +.pane-scroll > .archived-sec .pane-session { margin: 0 -8px; } +.archived-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 0; + background: none; + border: none; + font-family: inherit; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5a5a72; + cursor: pointer; + text-align: left; +} +.archived-toggle b { font-weight: 500; color: #6a6a80; } +.archived-toggle svg { transition: transform 0.12s; } +.archived-toggle.open svg { transform: rotate(90deg); } +.archived-toggle:hover { color: #9090a8; } +.pane-session--archived { opacity: 0.45; } +.pane-session--archived:hover { opacity: 0.8; } +.tcard-archived { margin-left: auto; } +.tcard-archived.open { color: #9090a8; } +.tcard-archived-list { border-top: 1px solid rgba(255,255,255,0.05); padding-bottom: 4px; } + +/* A floating archive button on every session row, shown on hover. */ +.pane-session { position: relative; } +.pane-row-archive { + position: absolute; + top: 5px; + right: 6px; + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.1); + background: #1b1b26; + color: #9090a8; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; + cursor: pointer; + padding: 0; + transition: opacity 0.12s, color 0.12s; +} +.pane-session:hover .pane-row-archive { opacity: 1; } +.pane-row-archive:hover { color: #d0d0e8; background: #23232f; } +.pane-session:hover .pane-title { padding-right: 30px; } +.tcard .tcard-session .pane-row-archive { right: 10px; top: 7px; } + +/* The plan card borrows the dot shape but not the session states: these are + "the next phase" and "all phases done", and neither of them spins. */ +.proj-dot--next { background: #f0a050; box-shadow: 0 0 6px rgba(240,160,80,0.5); } +.proj-dot--done { background: #3ecf5a; box-shadow: 0 0 6px rgba(62,207,90,0.5); } diff --git a/public/task-runner.js b/public/task-runner.js index 55fe13f8..0389226f 100644 --- a/public/task-runner.js +++ b/public/task-runner.js @@ -5,6 +5,10 @@ const taskLogViews = new Map(); let activeTaskView = null; let openTaskPopover = null; +// Every folder's tasks by path, including folders that only a project knows +// about (attached folders and project roots with no sessions of their own). +// The Projects tab builds its combined task menu from this. +const tasksByPath = new Map(); function taskViewKey(projectPath, label) { return `${projectPath}\0${label}`; @@ -15,15 +19,21 @@ function findProject(projectPath) { const project = projects.find(item => item.projectPath === projectPath); if (project) return project; } + const entry = tasksByPath.get(projectPath); + if (entry) return { projectPath, tasks: entry.tasks, taskError: entry.error, hasTaskFile: entry.hasTaskFile }; return null; } -async function hydrateProjectTasks(projectLists) { +async function hydrateProjectTasks(projectLists, extraPaths = []) { const projects = projectLists.flat(); - const paths = [...new Set(projects.map(project => project.projectPath))]; + const paths = [...new Set([...projects.map(project => project.projectPath), ...extraPaths])]; if (!paths.length) return; let results; try { results = await window.api.listTasksForProjects(paths); } catch { return; } + for (const p of paths) { + const result = results[p] || { tasks: [], error: null, hasTaskFile: false }; + tasksByPath.set(p, { tasks: result.tasks || [], error: result.error || null, hasTaskFile: !!result.hasTaskFile }); + } for (const project of projects) { const result = results[project.projectPath] || { tasks: [], error: null, hasTaskFile: false }; project.tasks = result.tasks || []; @@ -40,6 +50,9 @@ function createProjectTaskButton(project, worktree = false) { const button = document.createElement('button'); button.className = `project-task-btn${worktree ? ' worktree-task-btn' : ''}`; button.dataset.projectPath = project.projectPath; + // A project's button spans several folders; remember them so a change in any + // one of them refreshes the badge (updateProjectTaskButtons). + if (Array.isArray(project.projectPaths)) button.dataset.projectPaths = project.projectPaths.join('\n'); button.title = project.taskError ? 'Task file has an error' : 'Run project task'; button.innerHTML = ` @@ -67,6 +80,14 @@ function updateTaskButton(button, project) { function updateProjectTaskButtons(projectPath) { const project = findProject(projectPath); document.querySelectorAll('.project-task-btn').forEach(button => { + if (button.dataset.projectPaths) { + // Projects tab: the button is a union over several folders. + if (!button.dataset.projectPaths.split('\n').includes(projectPath)) return; + if (typeof findTreeProject !== 'function' || typeof taskPseudoProject !== 'function') return; + const node = findTreeProject(button.dataset.projectId); + if (node) updateTaskButton(button, taskPseudoProject(node)); + return; + } if (button.dataset.projectPath === projectPath) updateTaskButton(button, project); }); } @@ -101,7 +122,9 @@ function renderTaskPopover(project, popover) { stopAllButton.addEventListener('click', async event => { event.stopPropagation(); stopAllButton.disabled = true; - await window.api.stopAllTasks(project.projectPath); + for (const p of project.projectPaths || [project.projectPath]) { + await window.api.stopAllTasks(p); + } }); header.appendChild(stopAllButton); } @@ -125,7 +148,17 @@ function renderTaskPopover(project, popover) { return; } + let lastGroup = null; for (const task of project.tasks || []) { + // A project's menu combines several folders; each task remembers its own. + const taskPath = task.projectPath || project.projectPath; + if (task.groupLabel && task.groupLabel !== lastGroup) { + const group = document.createElement('div'); + group.className = 'task-popover-group'; + group.textContent = task.groupLabel; + popover.appendChild(group); + lastGroup = task.groupLabel; + } const row = document.createElement('div'); row.className = 'task-popover-row'; row.classList.toggle('unsupported', task.supported === false); @@ -155,14 +188,14 @@ function renderTaskPopover(project, popover) { action.disabled = task.supported === false; action.addEventListener('click', async event => { event.stopPropagation(); + // Start, restart and stop all keep the menu open: the row's state + // column is the feedback. The log is a click on the name away. if (task.run?.running) { - await window.api.stopTask(project.projectPath, task.label); + await window.api.stopTask(taskPath, task.label); } else if (task.run) { - await restartProjectTask(project.projectPath, task.label); - closeTaskPopover(); + await restartProjectTask(taskPath, task.label, { showLog: false }); } else { - await runProjectTask(project.projectPath, task.label); - closeTaskPopover(); + await runProjectTask(taskPath, task.label, { showLog: false }); } }); row.append(copy, state, action); @@ -171,10 +204,9 @@ function renderTaskPopover(project, popover) { if (task.supported === false) return; if (task.run) { closeTaskPopover(); - await showTaskLog(project.projectPath, task.label); + await showTaskLog(taskPath, task.label); } else { - await runProjectTask(project.projectPath, task.label); - closeTaskPopover(); + await runProjectTask(taskPath, task.label, { showLog: false }); } }); popover.appendChild(row); @@ -196,7 +228,10 @@ function showTaskPopover(project, anchor) { if (top + popover.offsetHeight > window.innerHeight - 8) top = Math.max(8, rect.top - popover.offsetHeight - 6); popover.style.left = `${left}px`; popover.style.top = `${top}px`; - openTaskPopover = { projectPath: project.projectPath, element: popover, anchor }; + openTaskPopover = { + projectPath: project.projectPath, element: popover, anchor, + projectPaths: project.projectPaths || null, projectId: project.id || null, + }; setTimeout(() => document.addEventListener('pointerdown', dismissTaskPopover, { once: true }), 0); } @@ -276,7 +311,9 @@ async function showTaskLog(projectPath, label) { setActiveSession(null); document.querySelectorAll('.session-item.active').forEach(item => item.classList.remove('active')); document.querySelectorAll('.terminal-container').forEach(element => element.classList.remove('visible')); - hideAllViewers(); + // Inside a project the log opens beside the session pane, not over it. + hideViewerPanels(); + if (typeof onTaskLogShown === 'function') onTaskLogShown(); placeholder.style.display = 'none'; gridViewer.style.display = 'none'; terminalHeader.style.display = ''; @@ -323,9 +360,10 @@ function leaveTaskLogView() { if (restartButton) restartButton.style.display = 'none'; } -async function runProjectTask(projectPath, label) { +async function runProjectTask(projectPath, label, { showLog = true } = {}) { const run = await window.api.startTask(projectPath, label); applyTaskRun(run); + if (!showLog) return; await showTaskLog(projectPath, label); if (run.error && !run.running) { const entry = taskLogViews.get(taskViewKey(projectPath, label)); @@ -333,7 +371,7 @@ async function runProjectTask(projectPath, label) { } } -async function restartProjectTask(projectPath, label) { +async function restartProjectTask(projectPath, label, { showLog = true } = {}) { const entry = taskLogViews.get(taskViewKey(projectPath, label)); if (entry) { entry.terminal.reset(); @@ -341,8 +379,9 @@ async function restartProjectTask(projectPath, label) { entry.loading = false; entry.queued = []; } - await window.api.restartTask(projectPath, label); - await showTaskLog(projectPath, label); + const run = await window.api.restartTask(projectPath, label); + if (run) applyTaskRun(run); + if (showLog) await showTaskLog(projectPath, label); } function applyTaskRun(run) { @@ -352,9 +391,14 @@ function applyTaskRun(run) { const task = project?.tasks?.find(item => item.label === run.label); if (task) task.run = run; } + const byPath = tasksByPath.get(run.projectPath)?.tasks?.find(item => item.label === run.label); + if (byPath) byPath.run = run; updateProjectTaskButtons(run.projectPath); updateTaskHeader(run); - if (openTaskPopover?.projectPath === run.projectPath) { + if (openTaskPopover?.projectPaths?.includes(run.projectPath)) { + const node = typeof findTreeProject === 'function' ? findTreeProject(openTaskPopover.projectId) : null; + if (node && typeof taskPseudoProject === 'function') renderTaskPopover(taskPseudoProject(node), openTaskPopover.element); + } else if (openTaskPopover?.projectPath === run.projectPath) { const project = findProject(run.projectPath); if (project) renderTaskPopover(project, openTaskPopover.element); } @@ -386,6 +430,7 @@ window.api.onTaskStateChanged(run => applyTaskRun(run)); window.api.onProjectTasksChanged(async projectPath => { let result; try { result = await window.api.listProjectTasks(projectPath); } catch { return; } + tasksByPath.set(projectPath, { tasks: result.tasks || [], error: result.error || null, hasTaskFile: !!result.hasTaskFile }); for (const projects of [cachedProjects, cachedAllProjects]) { const project = projects.find(item => item.projectPath === projectPath); if (!project) continue; diff --git a/public/terminal-manager.js b/public/terminal-manager.js index a75dd613..b7f550ef 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -160,10 +160,13 @@ function safeFit(entry) { function fitAndScroll(entry) { const wasAtBottom = isAtBottom(entry.terminal); requestAnimationFrame(() => { - safeFit(entry); - if (wasAtBottom) { - entry.terminal.scrollToBottom(); - } + // A session stopped between the call and this frame has no renderer left; + // fitting or scrolling it then throws inside xterm. + if (!entry.element?.isConnected) return; + try { + safeFit(entry); + if (wasAtBottom) entry.terminal.scrollToBottom(); + } catch {} }); } @@ -414,6 +417,8 @@ function showSession(sessionId) { fitAndScroll(entry); } } + // The Projects tab keeps the project around the terminal (projects-view.js). + if (typeof onSessionShown === 'function') onSessionShown(sessionId); } function setupDragAndDrop(container, getSessionId) { diff --git a/session-cache.js b/session-cache.js index f203b257..57222148 100644 --- a/session-cache.js +++ b/session-cache.js @@ -374,6 +374,10 @@ function buildProjectsFromCache(showArchived) { name: meta?.name || null, starred: meta?.starred || 0, archived: meta?.archived || 0, + // Explicit filing into a project / track (projects.js). Null when the + // session was never filed; the tree builder then falls back to cwd. + projectId: meta?.projectId || null, + trackId: meta?.trackId || null, }; if (!showArchived && s.archived) continue; if (!projectMap.has(row.projectPath)) { @@ -456,10 +460,16 @@ function buildProjectsFromCache(showArchived) { } -function notifyRendererProjectsChanged() { +/** + * Tell the renderer the project data moved. `reason` is 'sessions' when only + * the session list did — a transcript write, a title, a folder rescan. That + * fires several times a minute while a session runs, and the project page + * patches itself instead of rebuilding. Anything else defaults to 'project'. + */ +function notifyRendererProjectsChanged(reason = 'project') { const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('projects-changed'); + mainWindow.webContents.send('projects-changed', reason === 'sessions' ? 'sessions' : 'project'); } } diff --git a/session-transitions.js b/session-transitions.js index 68c81465..517aeb84 100644 --- a/session-transitions.js +++ b/session-transitions.js @@ -5,7 +5,7 @@ const fs = require('fs'); * Fork / plan-accept detection for active PTY sessions. * Call init(ctx) once with shared context. */ -let PROJECTS_DIR, activeSessions, getMainWindow, log, rekeyMcpServer; +let PROJECTS_DIR, activeSessions, getMainWindow, log, rekeyMcpServer, copySessionAssignment; function init(ctx) { PROJECTS_DIR = ctx.PROJECTS_DIR; @@ -13,6 +13,9 @@ function init(ctx) { getMainWindow = ctx.getMainWindow; log = ctx.log; rekeyMcpServer = ctx.rekeyMcpServer; + // A fork inherits its parent's project and track (db.js). Optional so the + // tests that exercise detection alone need not provide it. + copySessionAssignment = ctx.copySessionAssignment || null; } // --- Fork / plan-accept detection --- @@ -182,6 +185,14 @@ function detectSessionTransitions(folder) { activeSessions.set(newId, session); // Re-key MCP server to match new session ID rekeyMcpServer(sessionId, newId); + if (copySessionAssignment) { + // The launch id carries the filing when the session was started from + // a project; otherwise a fork inherits the filing of its source. + try { + const copied = copySessionAssignment(sessionId, newId); + if (!copied && session.forkFrom) copySessionAssignment(session.forkFrom, newId); + } catch (err) { log.error?.('[session-transition] assignment copy failed', err); } + } const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('session-forked', sessionId, newId); diff --git a/task-config.js b/task-config.js index fe437a7a..ad5b356d 100644 --- a/task-config.js +++ b/task-config.js @@ -261,11 +261,15 @@ function worktreeParentPath(workspaceFolder) { return workspaceFolder.slice(0, markerIndex); } -function taskFileForWorkspace(workspaceFolder, existsSync = fs.existsSync) { +// A worktree without its own tasks.json inherits its parent repo's. The parent +// is either known to the caller (a project worktree under /repos/, +// whose source repo is recorded in the database) or inferred from the +// `.claude/worktrees/` layout the Claude CLI uses. +function taskFileForWorkspace(workspaceFolder, existsSync = fs.existsSync, knownParentPath = null) { const localFile = path.join(workspaceFolder, TASKS_RELATIVE_PATH); if (existsSync(localFile)) return { filePath: localFile, inherited: false }; - const parentPath = worktreeParentPath(workspaceFolder); + const parentPath = knownParentPath || worktreeParentPath(workspaceFolder); if (!parentPath) return null; const parentFile = path.join(parentPath, TASKS_RELATIVE_PATH); if (!existsSync(parentFile)) return null; @@ -273,7 +277,7 @@ function taskFileForWorkspace(workspaceFolder, existsSync = fs.existsSync) { } function loadProjectTasks(workspaceFolder, options = {}) { - const source = taskFileForWorkspace(workspaceFolder); + const source = taskFileForWorkspace(workspaceFolder, fs.existsSync, options.parentPath || null); if (!source) return []; const { filePath } = source; const text = fs.readFileSync(filePath, 'utf8'); diff --git a/task-manager.js b/task-manager.js index b74d9503..e1f7ab90 100644 --- a/task-manager.js +++ b/task-manager.js @@ -25,6 +25,9 @@ function createTaskManager(options) { const baseEnv = options.baseEnv || process.env; const logger = options.log || console; const send = options.send || (() => {}); + // Project worktrees live under /repos/, which the path-shape + // inference in task-config cannot see; the caller looks their parent up. + const resolveParent = options.resolveWorktreeParent || (() => null); function serializeRun(run, includeOutput = false) { if (!run) return null; @@ -360,7 +363,7 @@ function createTaskManager(options) { } })); watchVscodeDirectory(); - const taskSource = taskFileForWorkspace(projectPath); + const taskSource = taskFileForWorkspace(projectPath, fs.existsSync, resolveParent(projectPath)); if (taskSource?.inherited) { projectWatchers.push(fs.watch(path.dirname(taskSource.filePath), (_event, filename) => { if (!filename || String(filename) === 'tasks.json') changed(); @@ -374,9 +377,10 @@ function createTaskManager(options) { function listTasks(projectPath) { ensureWatch(projectPath); - const taskSource = taskFileForWorkspace(projectPath); + const parentPath = resolveParent(projectPath); + const taskSource = taskFileForWorkspace(projectPath, fs.existsSync, parentPath); try { - const tasks = loadTasks(projectPath); + const tasks = loadTasks(projectPath, { parentPath }); return { tasks: tasks.map(task => ({ ...task, diff --git a/templates/customer/CLAUDE.md b/templates/customer/CLAUDE.md new file mode 100644 index 00000000..88814f65 --- /dev/null +++ b/templates/customer/CLAUDE.md @@ -0,0 +1,11 @@ +# {{name}} + + + +Project folder: {{root}} + +## How this project works +- Discovery track: what was learned about the customer goes in + {{root}}/contacts.md and {{root}}/notes.md. +- Proposal track: drafts live in {{root}}/proposals, one file per version. +- Build track: the work itself, in the attached repositories. diff --git a/templates/customer/contacts.md b/templates/customer/contacts.md new file mode 100644 index 00000000..0ce9aea8 --- /dev/null +++ b/templates/customer/contacts.md @@ -0,0 +1,4 @@ +# Contacts for {{name}} + +| Name | Role | How to reach them | +|---|---|---| diff --git a/templates/customer/proposals/README.md b/templates/customer/proposals/README.md new file mode 100644 index 00000000..c09a4e32 --- /dev/null +++ b/templates/customer/proposals/README.md @@ -0,0 +1,3 @@ +# Proposals for {{name}} + +One file per version, newest last. diff --git a/templates/customer/template.json b/templates/customer/template.json new file mode 100644 index 00000000..274ad7bc --- /dev/null +++ b/templates/customer/template.json @@ -0,0 +1,5 @@ +{ + "name": "Customer", + "description": "Work for one customer: discovery, a proposal, then the build.", + "tracks": ["Discovery", "Proposal", "Build"] +} diff --git a/templates/feature/CLAUDE.md b/templates/feature/CLAUDE.md new file mode 100644 index 00000000..bc4768e6 --- /dev/null +++ b/templates/feature/CLAUDE.md @@ -0,0 +1,12 @@ +# {{name}} + + + +Project folder: {{root}} + +## How this project works +- Plan track: agree the scope and write the plan before touching code. +- Build track: work in the attached repositories on the project branch. Keep + changes small enough to review. +- Review track: check the result against the plan; anything left over goes to + the todos. diff --git a/templates/feature/template.json b/templates/feature/template.json new file mode 100644 index 00000000..f712c6c2 --- /dev/null +++ b/templates/feature/template.json @@ -0,0 +1,5 @@ +{ + "name": "Feature", + "description": "One feature across one or more repositories: plan it, build it on the project branch, review it.", + "tracks": ["Plan", "Build", "Review"] +} diff --git a/templates/research/CLAUDE.md b/templates/research/CLAUDE.md new file mode 100644 index 00000000..ae746942 --- /dev/null +++ b/templates/research/CLAUDE.md @@ -0,0 +1,12 @@ +# {{name}} + + + +Project folder: {{root}} + +## How this project works +- Reading track: sources and notes go in {{root}}/notes, one file per source. +- Experiments track: each experiment gets a folder under {{root}}/experiments + with what was tried, the result, and what it means. +- Write-up track: the answer, in {{root}}/writeup.md, with pointers back to the + notes and experiments it rests on. diff --git a/templates/research/notes/README.md b/templates/research/notes/README.md new file mode 100644 index 00000000..90627a29 --- /dev/null +++ b/templates/research/notes/README.md @@ -0,0 +1,3 @@ +# Notes for {{name}} + +One file per source: what it says, why it matters, where it is. diff --git a/templates/research/template.json b/templates/research/template.json new file mode 100644 index 00000000..9b2550cd --- /dev/null +++ b/templates/research/template.json @@ -0,0 +1,5 @@ +{ + "name": "Research", + "description": "A question to answer: gather sources, run experiments, write it up.", + "tracks": ["Reading", "Experiments", "Write-up"] +} diff --git a/test/db-schema-reconcile.test.js b/test/db-schema-reconcile.test.js index 2b06cc45..9446cbfd 100644 --- a/test/db-schema-reconcile.test.js +++ b/test/db-schema-reconcile.test.js @@ -31,6 +31,9 @@ function inspectDb(dataDir) { const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db'), { readonly: true }); console.log(JSON.stringify({ cols: db.prepare('PRAGMA table_info(session_cache)').all().map(c => c.name), + metaCols: db.prepare('PRAGMA table_info(session_meta)').all().map(c => c.name), + projectCols: db.prepare('PRAGMA table_info(projects)').all().map(c => c.name), + tables: db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(t => t.name), cacheCount: db.prepare('SELECT COUNT(*) AS n FROM session_cache').get().n, metaCount: db.prepare('SELECT COUNT(*) AS n FROM cache_meta').get().n, version: db.prepare("SELECT value FROM settings WHERE key = 'db_version'").get()?.value, @@ -40,12 +43,77 @@ function inspectDb(dataDir) { return JSON.parse(r.stdout.trim().split('\n').pop()); } +const PROJECT_TABLES = ['projects', 'project_folders', 'tracks']; + test('fresh database gets fileMtime column', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-db-fresh-')); try { const r = loadDbModule(dir); assert.equal(r.status, 0, r.stderr); - assert.ok(inspectDb(dir).cols.includes('fileMtime')); + const state = inspectDb(dir); + assert.ok(state.cols.includes('fileMtime')); + for (const t of PROJECT_TABLES) assert.ok(state.tables.includes(t), `${t} table created`); + assert.ok(state.metaCols.includes('projectId') && state.metaCols.includes('trackId'), 'session_meta carries the assignment columns'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// The project tables and the two session_meta columns are added by inspecting +// the schema, not by db_version, so a database from before projects existed +// gets them without losing its names, stars or archive flags. +test('projects tables and session_meta columns are added to an older database', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-db-projects-')); + try { + const seed = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db')); + db.exec('CREATE TABLE session_meta (sessionId TEXT PRIMARY KEY, name TEXT, starred INTEGER DEFAULT 0, archived INTEGER DEFAULT 0)'); + db.exec(\`CREATE TABLE session_cache ( + sessionId TEXT PRIMARY KEY, folder TEXT NOT NULL, projectPath TEXT, + summary TEXT, firstPrompt TEXT, created TEXT, modified TEXT, + messageCount INTEGER DEFAULT 0, slug TEXT, aiTitle TEXT, fileMtime TEXT, + runtime TEXT DEFAULT 'claude', sessionFile TEXT + )\`); + db.exec('CREATE TABLE cache_meta (folder TEXT PRIMARY KEY, projectPath TEXT, indexMtimeMs REAL)'); + db.exec('CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)'); + db.prepare("INSERT INTO settings (key, value) VALUES ('db_version', '4')").run(); + db.prepare("INSERT INTO session_meta (sessionId, name, starred, archived) VALUES ('s1', 'kept', 1, 0)").run(); + // A projects table from the first Phase 1 build, before defaultCwd existed. + db.exec(\`CREATE TABLE projects ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, root TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', sharedBranch INTEGER NOT NULL DEFAULT 1, + branchName TEXT, created TEXT NOT NULL, modified TEXT NOT NULL + )\`); + db.prepare("INSERT INTO projects (id, name, slug, root, created, modified) VALUES ('p1', 'P', 'p', '/tmp/p', 'x', 'x')").run(); + `, dir); + assert.equal(seed.status, 0, seed.stderr); + + const r = loadDbModule(dir); + assert.equal(r.status, 0, r.stderr); + + const state = inspectDb(dir); + for (const t of PROJECT_TABLES) assert.ok(state.tables.includes(t), `${t} table created`); + assert.ok(state.metaCols.includes('projectId') && state.metaCols.includes('trackId')); + assert.ok(state.projectCols.includes('defaultCwd'), 'defaultCwd added to an existing projects table'); + + const after = JSON.parse(runInElectronNode(` + const db = require(${JSON.stringify(path.join(APP_DIR, 'db.js'))}); + db.setSessionAssignment('s1', 'p1', null); + db.setSessionAssignment('s2', 'p1', 't1'); + db.copySessionAssignment('s1', 's3'); + db.moveSessionAssignment('s2', 's4'); + console.log(JSON.stringify({ + s1: db.getMeta('s1'), s2: db.getMeta('s2'), s3: db.getMeta('s3'), s4: db.getMeta('s4'), + })); + `, dir).stdout.trim().split('\n').pop()); + assert.equal(after.s1.name, 'kept', 'assignment does not touch the name'); + assert.equal(after.s1.starred, 1, 'assignment does not touch the star'); + assert.equal(after.s1.projectId, 'p1'); + assert.equal(after.s3.projectId, 'p1', 'copy creates the fork\'s row'); + assert.equal(after.s2.projectId, null, 'move clears the temporary id'); + assert.equal(after.s4.projectId, 'p1'); + assert.equal(after.s4.trackId, 't1'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/test/git.test.js b/test/git.test.js new file mode 100644 index 00000000..5b101304 --- /dev/null +++ b/test/git.test.js @@ -0,0 +1,142 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { spawnSync } = require('child_process'); +const git = require('../git'); + +const haveGit = spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0; + +// A throwaway repository with one commit, so branches and worktrees have +// something to point at. Identity is set locally so the test does not depend +// on the machine's git config. +async function makeRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-git-')); + await git.run(['init', '-q', '-b', 'main'], repo); + await git.run(['config', 'user.email', 'test@example.com'], repo); + await git.run(['config', 'user.name', 'Test'], repo); + fs.writeFileSync(path.join(repo, 'README.md'), '# hello\n'); + await git.run(['add', 'README.md'], repo); + await git.run(['commit', '-q', '-m', 'init'], repo); + return repo; +} + +test('repoRoot and isGitRepo tell a checkout from a plain folder', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + const plain = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-plain-')); + try { + assert.equal(await git.isGitRepo(repo), true); + assert.equal(await git.isGitRepo(plain), false); + assert.equal(fs.realpathSync(await git.repoRoot(path.join(repo))), fs.realpathSync(repo)); + await assert.rejects(git.repoRoot(plain)); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(plain, { recursive: true, force: true }); + } +}); + +test('worktreeAdd creates the branch once and reuses it; remove keeps the branch', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + const target = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-wt-')), 'repos', 'app'); + try { + assert.equal(await git.branchExists(repo, 'feature-x'), false); + fs.mkdirSync(path.dirname(target), { recursive: true }); + await git.worktreeAdd(repo, target, 'feature-x'); + assert.ok(fs.existsSync(path.join(target, 'README.md')), 'checkout populated'); + assert.equal((await git.status(target)).branch, 'feature-x'); + assert.equal(await git.branchExists(repo, 'feature-x'), true); + assert.equal(fs.realpathSync(await git.gitCommonDir(target)), fs.realpathSync(path.join(repo, '.git'))); + + await git.worktreeRemove(repo, target); + assert.ok(!fs.existsSync(target), 'worktree directory removed'); + assert.equal(await git.branchExists(repo, 'feature-x'), true, 'branch survives removal'); + + // Second attach of the same branch checks out the existing branch. + await git.worktreeAdd(repo, target, 'feature-x'); + assert.equal((await git.status(target)).branch, 'feature-x'); + } finally { + try { await git.worktreeRemove(repo, target, { force: true }); } catch {} + fs.rmSync(path.dirname(path.dirname(target)), { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('a dirty worktree is refused without force and reported as dirty', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + const target = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-wt-')), 'app'); + try { + await git.worktreeAdd(repo, target, 'wip'); + assert.equal((await git.status(target)).dirty, false); + fs.writeFileSync(path.join(target, 'notes.txt'), 'unsaved\n'); + assert.equal((await git.status(target)).dirty, true); + let caught = null; + try { await git.worktreeRemove(repo, target); } catch (err) { caught = err; } + assert.ok(caught, 'refused'); + assert.equal(git.isDirtyWorktreeError(caught), true); + assert.ok(fs.existsSync(target), 'still there'); + await git.worktreeRemove(repo, target, { force: true }); + assert.ok(!fs.existsSync(target)); + } finally { + try { await git.worktreeRemove(repo, target, { force: true }); } catch {} + fs.rmSync(path.dirname(target), { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('snapshot reports the branch, working changes, line counts, and recent commits', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), '# hello\n\nChanged here.\n'); + fs.writeFileSync(path.join(repo, 'notes.txt'), 'one\ntwo\n'); + + const info = await git.snapshot(repo); + assert.equal(info.git, true); + assert.equal(info.branch, 'main'); + assert.equal(info.detached, false); + assert.equal(info.dirty, true); + assert.equal(info.changes.length, 2); + assert.equal(info.changes.find(change => change.path === 'README.md').status, 'modified'); + assert.equal(info.changes.find(change => change.path === 'notes.txt').status, 'untracked'); + assert.ok(info.stats.insertions >= 4); + assert.equal(info.commits[0].subject, 'init'); + assert.equal(info.commits[0].author, 'Test'); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('fileDiff reads tracked and untracked changes but refuses unchanged files', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), '# changed\n'); + fs.writeFileSync(path.join(repo, 'new file.txt'), 'new line\n'); + + const tracked = await git.fileDiff(repo, 'README.md'); + assert.match(tracked.diff, /[-]# hello/); + assert.match(tracked.diff, /[+]# changed/); + + const untracked = await git.fileDiff(repo, 'new file.txt'); + assert.match(untracked.diff, /[+]new line/); + + await assert.rejects(git.fileDiff(repo, 'missing.txt'), /not currently changed/); + await assert.rejects(git.fileDiff(repo, '../outside.txt'), /outside the repository/); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('snapshot preserves rename paths from porcelain output', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + await git.run(['mv', 'README.md', 'README-new.md'], repo); + const info = await git.snapshot(repo); + assert.equal(info.changes.length, 1); + assert.equal(info.changes[0].status, 'renamed'); + assert.equal(info.changes[0].path, 'README-new.md'); + assert.equal(info.changes[0].oldPath, 'README.md'); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/test/harness-claude.test.js b/test/harness-claude.test.js index 12c67394..2236734c 100644 --- a/test/harness-claude.test.js +++ b/test/harness-claude.test.js @@ -301,3 +301,14 @@ test('readLaunchSignals survives a truncated final line', () => { test('a missing file yields nothing rather than throwing', () => { assert.equal(claude.readLaunchSignals('/definitely/not/here.jsonl'), null); }); + +// --- initialPrompt (project page: start a session on a phase or a todo) --- +test('initialPrompt is the last positional argument for a fresh session only', () => { + const H = typeof claude !== 'undefined' ? claude : codex; + const fresh = H.buildLaunchArgs({ sessionId: 'abc', isNew: true, options: { initialPrompt: 'Work on phase 2' } }); + assert.equal(fresh[fresh.length - 1], 'Work on phase 2'); + const resumed = H.buildLaunchArgs({ sessionId: 'abc', isNew: false, options: { initialPrompt: 'Work on phase 2' } }); + assert.ok(!resumed.includes('Work on phase 2'), 'a resume keeps its conversation'); + const forked = H.buildLaunchArgs({ sessionId: 'new', isNew: true, options: { forkFrom: 'src', initialPrompt: 'x' } }); + assert.ok(!forked.includes('x'), 'a fork carries its parent prompt'); +}); diff --git a/test/harness-codex.test.js b/test/harness-codex.test.js index d59a9293..1074bb6f 100644 --- a/test/harness-codex.test.js +++ b/test/harness-codex.test.js @@ -225,10 +225,28 @@ test('skipping permissions replaces the sandbox and approval flags', () => { assert.deepEqual(args, ['resume', ID, '--dangerously-bypass-approvals-and-sandbox']); }); -test('addDirs splits and trims the same way Claude does', () => { +test('addDirs splits and trims the same way Claude does, when the sandbox can take them', () => { assert.deepEqual( - argsWithoutForcedConfig({ sessionId: ID, isNew: false, options: { addDirs: ' /one , , /two ' } }), - ['resume', ID, '--add-dir', '/one', '--add-dir', '/two'] + argsWithoutForcedConfig({ sessionId: ID, isNew: false, options: { codexSandbox: 'workspace-write', addDirs: ' /one , , /two ' } }), + ['resume', ID, '--sandbox', 'workspace-write', '--add-dir', '/one', '--add-dir', '/two'] + ); + assert.deepEqual( + argsWithoutForcedConfig({ sessionId: ID, isNew: false, options: { codexSandbox: 'danger-full-access', addDirs: '/one' } }), + ['resume', ID, '--sandbox', 'danger-full-access', '--add-dir', '/one'] + ); +}); + +test('addDirs are dropped when codex would refuse them', () => { + // Codex exits with "effective permissions do not allow additional writable + // roots" for --add-dir under read-only, which is also its default. + assert.deepEqual(argsWithoutForcedConfig({ sessionId: ID, isNew: false, options: { addDirs: '/one' } }), ['resume', ID]); + assert.deepEqual( + argsWithoutForcedConfig({ sessionId: ID, isNew: false, options: { codexSandbox: 'read-only', addDirs: '/one' } }), + ['resume', ID, '--sandbox', 'read-only'] + ); + assert.deepEqual( + argsWithoutForcedConfig({ sessionId: ID, isNew: false, options: { dangerouslySkipPermissions: true, addDirs: '/one' } }), + ['resume', ID, '--dangerously-bypass-approvals-and-sandbox'] ); }); @@ -401,7 +419,7 @@ test('"Action Required" in the title means the session is blocked on the user', // The signal the user sees. It must not depend on OSC 9, which only fires // when the CLI's notifications are on — a session started before Switchboard // began forcing them has none. - assert.equal(codex.parseTitleState('[ . ] Action Required | MyClaude'), 'attention'); + assert.equal(codex.parseTitleState('[ . ] Action Required | my-app'), 'attention'); assert.equal(codex.parseTitleState('[ ! ] Action Required | Open link'), 'attention'); assert.equal(codex.parseTitleState('[ ! ] action required'), 'attention'); }); @@ -588,3 +606,14 @@ test('a codex fork is matched by its parent, not the inherited tag', () => { assert.equal(codex.matchesLaunch({ ...sig, startedAt: '2026-08-01T00:00:00Z' }, { tag: ourTag, forkFrom: sig.forkedFrom, projectPath: '/p', spawnedAt: at }), false); }); + +// --- initialPrompt (project page: start a session on a phase or a todo) --- +test('initialPrompt is the last positional argument for a fresh session only', () => { + const H = typeof claude !== 'undefined' ? claude : codex; + const fresh = H.buildLaunchArgs({ sessionId: 'abc', isNew: true, options: { initialPrompt: 'Work on phase 2' } }); + assert.equal(fresh[fresh.length - 1], 'Work on phase 2'); + const resumed = H.buildLaunchArgs({ sessionId: 'abc', isNew: false, options: { initialPrompt: 'Work on phase 2' } }); + assert.ok(!resumed.includes('Work on phase 2'), 'a resume keeps its conversation'); + const forked = H.buildLaunchArgs({ sessionId: 'new', isNew: true, options: { forkFrom: 'src', initialPrompt: 'x' } }); + assert.ok(!forked.includes('x'), 'a fork carries its parent prompt'); +}); diff --git a/test/plan-parser.test.js b/test/plan-parser.test.js new file mode 100644 index 00000000..2c65614e --- /dev/null +++ b/test/plan-parser.test.js @@ -0,0 +1,73 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { parsePlan, parseTodos, toggleLine, appendItem, tickedTexts } = require('../public/plan-parser'); + +const TRACKER = `# Feature plan tracker + +## Phase 1: Groundwork +- [x] Read the code +- [x] Write the design + +## [ ] Phase 2: Build +- [x] Database +- [ ] Renderer + - [ ] nested item counts too + +## Phase 3: Ship +- [ ] Release notes + +## [x] Phase 4: Follow-ups +`; + +test('parsePlan reads level-2 headings as phases and knows which is next', () => { + const plan = parsePlan(TRACKER); + assert.equal(plan.total, 4); + assert.deepEqual(plan.phases.map(p => p.title), ['Phase 1: Groundwork', 'Phase 2: Build', 'Phase 3: Ship', 'Phase 4: Follow-ups']); + assert.deepEqual(plan.phases.map(p => p.done), [true, false, false, true], 'all items ticked, heading unticked, open, heading ticked'); + assert.deepEqual(plan.phases.map(p => `${p.ticked}/${p.items.length}`), ['2/2', '1/3', '0/1', '0/0']); + assert.equal(plan.done, 2); + assert.equal(plan.next.title, 'Phase 2: Build'); + assert.equal(plan.phases[1].line, 6, 'heading line is recorded for ticking'); + assert.equal(plan.phases[1].items[1].line, 8); +}); + +test('parsePlan with no headings treats top-level items as phases; empty input is empty', () => { + const plan = parsePlan('- [x] first\n- [ ] second\n - [ ] nested\n'); + assert.deepEqual(plan.phases.map(p => [p.title, p.done]), [['first', true], ['second', false]]); + assert.equal(plan.next.title, 'second'); + assert.deepEqual(parsePlan(''), { phases: [], done: 0, total: 0, next: null }); + assert.deepEqual(parsePlan(null).phases, []); +}); + +test('parseTodos lists every checkbox with its line', () => { + const items = parseTodos('# Todos\r\n\r\n- [ ] one\r\n* [x] two\r\nplain text\r\n'); + assert.deepEqual(items, [{ line: 2, text: 'one', done: false }, { line: 3, text: 'two', done: true }]); +}); + +test('toggleLine flips one item or heading and leaves the rest untouched', () => { + const item = toggleLine(TRACKER, 8, true); + assert.equal(item.ok, true); + assert.equal(item.text, 'Renderer'); + assert.equal(item.content.split('\n')[8], '- [x] Renderer'); + assert.equal(item.content.length, TRACKER.length, 'same size, one character changed'); + + const heading = toggleLine(TRACKER, 6, true); + assert.equal(heading.content.split('\n')[6], '## [x] Phase 2: Build'); + const plain = toggleLine(TRACKER, 2, true); + assert.equal(plain.content.split('\n')[2], '## [x] Phase 1: Groundwork', 'a heading without a box gets one'); + assert.equal(toggleLine(TRACKER, 1, true).ok, false, 'blank line is not a checkbox'); + assert.equal(toggleLine(TRACKER, 999, true).ok, false); +}); + +test('appendItem starts an empty file with a heading and appends a line', () => { + assert.equal(appendItem('', 'Add ledger', 'Site todos'), '# Site todos\n\n- [ ] Add ledger\n'); + assert.equal(appendItem('# T\n- [ ] a', 'b'), '# T\n- [ ] a\n- [ ] b\n'); +}); + +test('tickedTexts diffs which items and phases got done', () => { + const before = tickedTexts(TRACKER); + const after = tickedTexts(toggleLine(toggleLine(TRACKER, 8, true).content, 6, true).content); + const newly = [...after].filter(t => !before.has(t)); + assert.deepEqual(newly.sort(), ['Phase 2: Build', 'Renderer']); +}); diff --git a/test/projects.test.js b/test/projects.test.js new file mode 100644 index 00000000..3a16f500 --- /dev/null +++ b/test/projects.test.js @@ -0,0 +1,869 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const projects = require('../projects'); + +const haveGit = spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0; + +// An in-memory stand-in for the project tables in db.js. Same function names, +// same return shapes, no SQLite (db.js needs Electron's ABI to load). +function makeFakeDb({ global = {} } = {}) { + const rows = { projects: [], folders: [], tracks: [], meta: new Map() }; + const db = { + rows, + getSetting: (key) => (key === 'global' ? global : null), + listProjects: () => rows.projects.map(r => ({ ...r })), + getProject: (id) => rows.projects.find(r => r.id === id) || null, + getProjectBySlug: (slug) => rows.projects.find(r => r.slug === slug) || null, + insertProject: (row) => { rows.projects.push({ ...row, sharedBranch: row.sharedBranch ? 1 : 0 }); }, + updateProject: (id, patch) => { + const row = rows.projects.find(r => r.id === id); + if (!row) return 0; + for (const key of ['name', 'status', 'sharedBranch', 'branchName', 'defaultCwd', 'modified']) { + if (key in patch) row[key] = key === 'sharedBranch' ? (patch[key] ? 1 : 0) : patch[key]; + } + return 1; + }, + deleteProject: (id) => { + rows.projects = rows.projects.filter(r => r.id !== id); + rows.folders = rows.folders.filter(f => f.projectId !== id); + rows.tracks = rows.tracks.filter(t => t.projectId !== id); + for (const [sid, m] of rows.meta) if (m.projectId === id) rows.meta.set(sid, { projectId: null, trackId: null }); + }, + listProjectFolders: (projectId) => rows.folders.filter(f => f.projectId === projectId).sort((a, b) => a.sortOrder - b.sortOrder), + listAllProjectFolders: () => rows.folders.slice(), + upsertProjectFolder: (row) => { + const i = rows.folders.findIndex(f => f.projectId === row.projectId && f.path === row.path); + const clean = { mode: 'in-place', sourcePath: null, branch: null, sortOrder: 0, ...row }; + if (i === -1) rows.folders.push(clean); else rows.folders[i] = clean; + }, + deleteProjectFolder: (projectId, p) => { rows.folders = rows.folders.filter(f => !(f.projectId === projectId && f.path === p)); }, + listTracks: (projectId) => rows.tracks.filter(t => t.projectId === projectId), + listAllTracks: () => rows.tracks.slice(), + getTrack: (id) => rows.tracks.find(t => t.id === id) || null, + insertTrack: (row) => { rows.tracks.push({ ...row }); }, + updateTrack: (id, patch) => { + const t = rows.tracks.find(r => r.id === id); + if (!t) return 0; + for (const key of ['name', 'cwd', 'cli', 'status', 'sortOrder']) if (key in patch) t[key] = patch[key]; + return 1; + }, + deleteTrack: (id) => { + rows.tracks = rows.tracks.filter(t => t.id !== id); + for (const [sid, m] of rows.meta) if (m.trackId === id) rows.meta.set(sid, { ...m, trackId: null }); + }, + setSessionAssignment: (sessionId, projectId, trackId) => { rows.meta.set(sessionId, { projectId, trackId }); }, + insertPlanLink: (row) => { rows.links = rows.links || []; rows.links.push({ ...row }); }, + listPlanLinks: (projectId) => (rows.links || []).filter(l => l.projectId === projectId), + }; + return db; +} + +function session(id, projectPath, extra = {}) { + return { sessionId: id, projectPath, modified: '2026-09-01T00:00:00.000Z', summary: id, ...extra }; +} + +function setup({ folders = [], global = {} } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-projects-')); + const db = makeFakeDb({ global: { projectsRoot: root, ...global } }); + let notified = 0; + projects.init({ + db, + log: { info() {}, error() {} }, + buildProjectsFromCache: () => folders, + notifyRendererProjectsChanged: () => { notified++; }, + }); + return { root, db, notifications: () => notified, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }; +} + +function tmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function rm(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function gitIn(repo, ...args) { + const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr || `git ${args[0]} failed`); + return r.stdout.trim(); +} + +/** A throwaway repository with one commit. */ +function makeRepo(name = 'switchboard-repo-') { + const repo = tmpDir(name); + gitIn(repo, 'init', '-q', '-b', 'main'); + gitIn(repo, 'config', 'user.email', 'test@example.com'); + gitIn(repo, 'config', 'user.name', 'Test'); + fs.writeFileSync(path.join(repo, 'README.md'), '# hello\n'); + gitIn(repo, 'add', 'README.md'); + gitIn(repo, 'commit', '-q', '-m', 'init'); + return repo; +} + +test('slugify lowercases, collapses punctuation, trims and caps length', () => { + assert.equal(projects.slugify('Website Redesign Launch'), 'website-redesign-launch'); + assert.equal(projects.slugify(' Search → Filters!! '), 'search-filters'); + assert.equal(projects.slugify('café_au lait'), 'caf-au-lait'); + assert.equal(projects.slugify('!!!'), ''); + const long = projects.slugify('a'.repeat(70) + ' tail'); + assert.equal(long.length, 60); + assert.ok(!long.endsWith('-')); +}); + +test('uniqueSlug appends -2, -3 while a project uses the slug', () => { + const t = setup(); + try { + t.db.rows.projects.push({ id: 'p1', slug: 'bugs' }, { id: 'p2', slug: 'bugs-2' }); + assert.equal(projects.uniqueSlug('Bugs'), 'bugs-3'); + assert.equal(projects.uniqueSlug('!!!'), 'project'); + } finally { t.cleanup(); } +}); + +test('createProject writes the four files, the rows, and notifies', async () => { + const t = setup(); + try { + const repo = tmpDir('switchboard-repo-'); + try { + const result = await projects.createProject({ name: 'Website upkeep', folders: [{ path: repo }] }); + assert.equal(result.ok, true); + assert.deepEqual(result.errors, []); + const p = result.project; + assert.equal(p.slug, 'website-upkeep'); + assert.equal(p.root, path.join(t.root, 'website-upkeep')); + assert.equal(p.status, 'active'); + assert.equal(p.sharedBranch, true); + assert.deepEqual(p.folders.map(f => [f.path, f.mode]), [[repo, 'in-place']]); + for (const name of ['CLAUDE.md', 'AGENTS.md']) { + assert.ok(fs.existsSync(path.join(p.root, name)), name + ' exists'); + } + for (const name of ['plan.md', 'plan-tracker.md', 'todos.md', 'memory.md']) { + assert.ok(!fs.existsSync(path.join(p.root, name)), name + ' is left for the agent to create'); + } + const brief = fs.readFileSync(path.join(p.root, 'CLAUDE.md'), 'utf8'); + assert.match(brief, /^# Website upkeep\n/); + assert.match(brief, /plan\.md/); + assert.match(brief, /todos\.md/); + assert.equal(fs.readFileSync(path.join(p.root, 'AGENTS.md'), 'utf8'), brief); + assert.equal(t.notifications(), 1); + } finally { rm(repo); } + } finally { t.cleanup(); } +}); + +test('createProject refuses a blank name, a missing folder, an existing root folder, a bad branch', async () => { + const t = setup(); + try { + assert.match((await projects.createProject({ name: ' ' })).error, /name/i); + assert.match((await projects.createProject({ name: 'x', folders: [{ path: '/definitely/not/here' }] })).error, /Not a directory/); + assert.match((await projects.createProject({ name: 'x', folders: [{ path: 'relative' }] })).error, /absolute/); + assert.match((await projects.createProject({ name: 'x', folders: [{ path: os.tmpdir(), mode: 'zip' }] })).error, /Unknown folder mode/); + assert.match((await projects.createProject({ name: 'x', branchName: '-bad' })).error, /Invalid branch name/); + fs.mkdirSync(path.join(t.root, 'taken')); + assert.match((await projects.createProject({ name: 'Taken' })).error, /already exists/); + } finally { t.cleanup(); } +}); + +test('updateProject, attach/detach, deleteProject keep the folder on disk', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'Auth refactor' }); + assert.equal(projects.updateProject(project.id, { status: 'bogus' }).error, 'Unknown status: bogus'); + const updated = projects.updateProject(project.id, { name: 'Auth refactor v2', status: 'done', sharedBranch: false, branchName: ' auth ' }); + assert.equal(updated.project.name, 'Auth refactor v2'); + assert.equal(updated.project.status, 'done'); + assert.equal(updated.project.sharedBranch, false); + assert.equal(updated.project.branchName, 'auth'); + assert.equal(updated.project.slug, 'auth-refactor', 'slug does not follow a rename'); + assert.deepEqual(updated.worktrees, [], 'marking done reports the worktrees to remove'); + + const attached = (await projects.attachFolder(project.id, { path: os.tmpdir() })).project; + assert.equal(attached.folders.length, 1); + assert.equal((await projects.attachFolder(project.id, { path: os.tmpdir() })).project.folders.length, 1, 'idempotent'); + const detached = await projects.detachFolder(project.id, path.resolve(os.tmpdir())); + assert.equal(detached.project.folders.length, 0); + assert.equal(detached.worktreeRemoved, false); + + const removed = projects.deleteProject(project.id); + assert.equal(removed.ok, true); + assert.equal(removed.root, project.root); + assert.ok(fs.existsSync(project.root), 'folder survives removal'); + assert.equal(projects.deleteProject(project.id).error, 'Project not found'); + } finally { t.cleanup(); } +}); + +test('projectForCwd matches the root and nested paths, not a sibling with a common prefix', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'foo' }); + fs.mkdirSync(path.join(t.root, 'foobar')); + assert.equal(projects.projectForCwd(project.root)?.id, project.id); + assert.equal(projects.projectForCwd(path.join(project.root, 'repos', 'x'))?.id, project.id); + assert.equal(projects.projectForCwd(path.join(t.root, 'foobar')), null); + assert.equal(projects.projectForCwd('/somewhere/else'), null); + assert.equal(projects.projectForCwd(''), null); + } finally { t.cleanup(); } +}); + +test('buildProjectTree files by explicit id, then by cwd, and leaves the rest out', async () => { + const t = setup(); + try { + const { project: a } = await projects.createProject({ name: 'A' }); + const { project: b } = await projects.createProject({ name: 'B' }); + t.db.rows.tracks.push({ id: 'trk', projectId: a.id, name: 'Build', cwd: null, cli: null, status: 'active', sortOrder: 0, created: '2026-09-01T00:00:00.000Z' }); + const folders = [ + { projectPath: '/repo/one', sessions: [ + session('s-explicit', '/repo/one', { projectId: a.id, modified: '2026-09-03T00:00:00.000Z' }), + session('s-track', '/repo/one', { projectId: a.id, trackId: 'trk' }), + session('s-stale-track', '/repo/one', { projectId: a.id, trackId: 'gone' }), + session('s-none', '/repo/one'), + ] }, + { projectPath: path.join(b.root, 'repos', 'x'), sessions: [ + session('s-cwd', path.join(b.root, 'repos', 'x'), { modified: '2026-09-02T00:00:00.000Z' }), + // explicit id wins over the folder it sits in + session('s-cwd-but-explicit', path.join(b.root, 'repos', 'x'), { projectId: a.id }), + ] }, + { projectPath: '/repo/one', sessions: [ session('s-stale-project', '/repo/one', { projectId: 'deleted' }) ] }, + ]; + projects.init({ db: t.db, log: { info() {}, error() {} }, buildProjectsFromCache: () => folders, notifyRendererProjectsChanged() {} }); + + const tree = projects.buildProjectTree(false); + const byName = Object.fromEntries(tree.projects.map(p => [p.name, p])); + assert.deepEqual(byName.A.sessions.map(s => s.sessionId), ['s-explicit', 's-stale-track', 's-cwd-but-explicit']); + assert.deepEqual(byName.A.tracks[0].sessions.map(s => s.sessionId), ['s-track']); + assert.deepEqual(byName.B.sessions.map(s => s.sessionId), ['s-cwd']); + assert.equal(byName.A.sessionCount, 4); + assert.equal(byName.A.lastActivity, '2026-09-03T00:00:00.000Z'); + assert.deepEqual(tree.projects.map(p => p.name), ['A', 'B'], 'most recent activity first'); + } finally { t.cleanup(); } +}); + +test('done projects sort after active ones', async () => { + const t = setup(); + try { + const { project: old } = await projects.createProject({ name: 'Old' }); + await projects.createProject({ name: 'New' }); + projects.updateProject(old.id, { status: 'done' }); + const tree = projects.buildProjectTree(false); + assert.deepEqual(tree.projects.map(p => [p.name, p.status]), [['New', 'active'], ['Old', 'done']]); + } finally { t.cleanup(); } +}); + +test('assignSession validates the project and the track, and can clear', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'P' }); + t.db.rows.tracks.push({ id: 'trk', projectId: project.id, name: 'T', created: 'x' }); + t.db.rows.tracks.push({ id: 'other', projectId: 'someone-else', name: 'T', created: 'x' }); + assert.equal(projects.assignSession('s1', 'nope', null).error, 'Project not found'); + assert.match(projects.assignSession('s1', project.id, 'other').error, /Track not found/); + assert.equal(projects.assignSession('s1', project.id, 'trk').ok, true); + assert.deepEqual(t.db.rows.meta.get('s1'), { projectId: project.id, trackId: 'trk' }); + assert.equal(projects.assignSession('s1', null, null).ok, true); + assert.deepEqual(t.db.rows.meta.get('s1'), { projectId: null, trackId: null }); + } finally { t.cleanup(); } +}); + +test('recordLaunchAssignment ignores unknown projects and mismatched tracks', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'P' }); + t.db.rows.tracks.push({ id: 'trk', projectId: 'elsewhere', name: 'T', created: 'x' }); + assert.equal(projects.recordLaunchAssignment('s1', { projectId: 'nope' }), null); + assert.equal(projects.recordLaunchAssignment('s1', {}), null); + assert.deepEqual(projects.recordLaunchAssignment('s1', { projectId: project.id, trackId: 'trk' }), { projectId: project.id, trackId: null }); + assert.deepEqual(t.db.rows.meta.get('s1'), { projectId: project.id, trackId: null }); + } finally { t.cleanup(); } +}); + +test('createTrack validates the project, name, folder and CLI', async () => { + const t = setup(); + try { + projects.init({ + db: t.db, log: { info() {}, error() {} }, buildProjectsFromCache: () => [], + notifyRendererProjectsChanged() {}, isHarnessId: (id) => id === 'claude' || id === 'codex', + }); + const repo = tmpDir('switchboard-repo-'); + try { + const { project } = await projects.createProject({ name: 'Feature', folders: [{ path: repo }] }); + assert.equal(projects.createTrack('nope', { name: 'Build' }).error, 'Project not found'); + assert.match(projects.createTrack(project.id, { name: ' ' }).error, /name/i); + assert.match(projects.createTrack(project.id, { name: 'Build', cwd: os.tmpdir() }).error, /inside/); + assert.match(projects.createTrack(project.id, { name: 'Build', cwd: 'relative' }).error, /absolute/); + assert.match(projects.createTrack(project.id, { name: 'Build', cli: 'gemini' }).error, /Unknown CLI/); + + const root = projects.createTrack(project.id, { name: 'Plan' }); + assert.equal(root.ok, true); + assert.equal(root.track.cwd, null, 'no cwd means the project default'); + assert.equal(root.track.cli, null); + assert.equal(root.track.sortOrder, 0); + + const sub = path.join(repo, 'packages', 'web'); + fs.mkdirSync(sub, { recursive: true }); + const build = projects.createTrack(project.id, { name: 'Build', cwd: sub, cli: 'codex' }); + assert.equal(build.ok, true); + assert.equal(build.track.cwd, sub, 'inside an attached folder is allowed'); + assert.equal(build.track.cli, 'codex'); + assert.equal(build.track.sortOrder, 1); + + const inRoot = projects.createTrack(project.id, { name: 'Notes', cwd: project.root }); + assert.equal(inRoot.ok, true); + assert.deepEqual(build.project.tracks.map(x => x.name), ['Plan', 'Build']); + } finally { rm(repo); } + } finally { t.cleanup(); } +}); + +test('updateTrack and deleteTrack; deleting keeps sessions in the project', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'Feature' }); + const { track } = projects.createTrack(project.id, { name: 'Build' }); + assert.equal(projects.updateTrack('nope', { name: 'x' }).error, 'Track not found'); + assert.match(projects.updateTrack(track.id, { name: ' ' }).error, /name/i); + assert.match(projects.updateTrack(track.id, { cwd: os.tmpdir() }).error, /inside/); + assert.equal(projects.updateTrack(track.id, { status: 'later' }).error, 'Unknown status: later'); + const updated = projects.updateTrack(track.id, { name: 'Build v2', status: 'done', cwd: project.root, cli: '' }).track; + assert.equal(updated.name, 'Build v2'); + assert.equal(updated.status, 'done'); + assert.equal(updated.cwd, project.root); + assert.equal(updated.cli, null); + + projects.assignSession('s1', project.id, track.id); + projects.assignSession('s2', project.id, null); + const removed = projects.deleteTrack(track.id); + assert.equal(removed.ok, true); + assert.equal(removed.projectId, project.id); + assert.deepEqual(t.db.rows.meta.get('s1'), { projectId: project.id, trackId: null }, 'session stays in the project'); + assert.deepEqual(t.db.rows.meta.get('s2'), { projectId: project.id, trackId: null }); + assert.equal(projects.deleteTrack(track.id).error, 'Track not found'); + } finally { t.cleanup(); } +}); + +test('the brief uses absolute paths and keeps its attached-folder list current', async () => { + const t = setup(); + try { + const a = tmpDir('switchboard-repo-a-'); + const b = tmpDir('switchboard-repo-b-'); + try { + const { project } = await projects.createProject({ name: 'Feature', folders: [{ path: a }] }); + const claudeMd = path.join(project.root, 'CLAUDE.md'); + const agentsMd = path.join(project.root, 'AGENTS.md'); + let brief = fs.readFileSync(claudeMd, 'utf8'); + assert.ok(brief.includes(`Project folder: ${project.root}`)); + assert.ok(brief.includes(path.join(project.root, 'plan.md')), 'plan path is absolute'); + assert.ok(brief.includes(path.join(project.root, 'plan-tracker.md')), 'tracker path is absolute'); + assert.ok(brief.includes(path.join(project.root, 'todos.md')), 'todos path is absolute'); + assert.ok(!brief.includes('./plan.md'), 'no relative paths'); + assert.match(brief, /only when the user refers to the plan/, 'not read every session'); + assert.match(brief, /"## Phase N: title"/, 'tracker format spelled out'); + assert.ok(brief.includes(path.join(project.root, 'memory.md')), 'memory file named'); + assert.match(brief, /does not belong in the todos as well/, 'plan items are not duplicated as todos'); + assert.match(brief, /Create them when you first need them/); + assert.ok(brief.includes(`- ${path.resolve(a)}`), 'attached folder listed'); + assert.ok(brief.includes('read its own instructions'), 'tells the agent to read the folder\'s own md'); + assert.equal(fs.readFileSync(agentsMd, 'utf8'), brief); + + // The user's own edits outside the managed block survive a resync. + fs.writeFileSync(claudeMd, '# My own heading\nMy notes.\n\n' + brief, 'utf8'); + await projects.attachFolder(project.id, { path: b }); + brief = fs.readFileSync(claudeMd, 'utf8'); + assert.ok(brief.startsWith('# My own heading\nMy notes.'), 'user text kept'); + assert.ok(brief.includes(`- ${path.resolve(a)}`) && brief.includes(`- ${path.resolve(b)}`), 'both folders listed'); + assert.equal((brief.match(//g) || []).length, 1, 'one managed block'); + + await projects.detachFolder(project.id, path.resolve(a)); + brief = fs.readFileSync(claudeMd, 'utf8'); + assert.ok(!brief.includes(`- ${path.resolve(a)}`)); + assert.ok(brief.includes(`- ${path.resolve(b)}`)); + + // A brief whose block was deleted gets it appended again. + fs.writeFileSync(agentsMd, '# Rewritten by hand\n', 'utf8'); + await projects.detachFolder(project.id, path.resolve(b)); + const agents = fs.readFileSync(agentsMd, 'utf8'); + assert.ok(agents.startsWith('# Rewritten by hand')); + assert.ok(agents.includes('No folders are attached yet')); + } finally { rm(a); rm(b); } + } finally { t.cleanup(); } +}); + +test('defaultCwd is validated, and detaching a folder resets cwds that pointed into it', async () => { + const t = setup(); + try { + const repo = tmpDir('switchboard-repo-'); + try { + const { project } = await projects.createProject({ name: 'Feature', folders: [{ path: repo }] }); + assert.equal(project.defaultCwd, null, 'sessions start in the project folder by default'); + assert.match(projects.updateProject(project.id, { defaultCwd: os.tmpdir() }).error, /inside/); + assert.equal(projects.updateProject(project.id, { defaultCwd: path.resolve(repo) }).project.defaultCwd, path.resolve(repo)); + assert.equal(projects.updateProject(project.id, { defaultCwd: '' }).project.defaultCwd, null); + projects.updateProject(project.id, { defaultCwd: path.resolve(repo) }); + const { track } = projects.createTrack(project.id, { name: 'Build', cwd: path.resolve(repo) }); + const after = (await projects.detachFolder(project.id, path.resolve(repo))).project; + assert.equal(after.defaultCwd, null, 'project default falls back to the project folder'); + assert.equal(after.tracks.find(x => x.id === track.id).cwd, null, 'track falls back too'); + } finally { rm(repo); } + } finally { t.cleanup(); } +}); + +test('launchContext adds the project folder and every attached folder the cwd is not in', async () => { + const t = setup(); + try { + const a = tmpDir('switchboard-repo-a-'); + const b = tmpDir('switchboard-repo-b-'); + try { + const { project } = await projects.createProject({ name: 'Feature', folders: [{ path: a }, { path: b }] }); + const A = path.resolve(a), B = path.resolve(b), R = project.root; + assert.deepEqual(projects.launchContext(project.id, R).addDirs, [A, B], 'from the project folder: every repo'); + assert.deepEqual(projects.launchContext(project.id, A).addDirs, [R, B], 'from a repo: the project folder and the other repo'); + assert.deepEqual(projects.launchContext(project.id, path.join(A, 'sub', 'dir')).addDirs, [R, A, B], 'from inside a repo: the repo itself too, so the whole repo is editable'); + assert.deepEqual(projects.launchContext(project.id, '/somewhere/else').addDirs, [R, A, B]); + assert.deepEqual(projects.launchContext(project.id, R).env, { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1' }); + assert.equal(projects.launchContext(project.id, R).worktree, false); + assert.equal(projects.launchContext('nope', R), null); + + const { project: bare } = await projects.createProject({ name: 'Bare' }); + assert.deepEqual(projects.launchContext(bare.id, bare.root), { addDirs: [], env: {}, worktree: false }); + + assert.equal(projects.mergeAddDirs('', [A]), A); + assert.equal(projects.mergeAddDirs(' /x , ' + A, [A, B]), `/x,${A},${B}`); + assert.equal(projects.mergeAddDirs(undefined, []), ''); + } finally { rm(a); rm(b); } + } finally { t.cleanup(); } +}); + +test('attaching on a branch makes a worktree under repos/ with the Codex bridge file', { skip: !haveGit && 'git not installed' }, async () => { + const t = setup(); + const repo = makeRepo(); + const other = makeRepo('switchboard-repo-other-'); + try { + const plain = tmpDir('switchboard-plain-'); + try { + assert.match((await projects.createProject({ name: 'Plain', folders: [{ path: plain, mode: 'worktree' }] })).errors[0], /not inside a git repository/); + } finally { rm(plain); } + + fs.writeFileSync(path.join(repo, 'AGENTS.md'), '# Repo rules\nBe tidy.\n'); + gitIn(repo, 'add', 'AGENTS.md'); + gitIn(repo, 'commit', '-q', '-m', 'agents'); + + const created = await projects.createProject({ + name: 'Feature X', + folders: [{ path: repo, mode: 'worktree' }, { path: other, mode: 'worktree' }], + }); + assert.equal(created.ok, true); + assert.deepEqual(created.errors, []); + const project = created.project; + assert.equal(project.folders.length, 2); + const [wt, wt2] = project.folders; + assert.equal(wt.mode, 'worktree'); + assert.equal(wt.branch, 'feature-x', 'shared branch defaults to the slug'); + assert.equal(wt2.branch, 'feature-x', 'same branch in every repo'); + assert.equal(wt.sourcePath, path.resolve(repo)); + assert.equal(wt.path, path.join(project.root, 'repos', path.basename(repo))); + assert.ok(fs.existsSync(path.join(wt.path, 'README.md')), 'checkout populated'); + assert.equal(gitIn(wt.path, 'rev-parse', '--abbrev-ref', 'HEAD'), 'feature-x'); + assert.equal(gitIn(repo, 'rev-parse', '--abbrev-ref', 'HEAD'), 'main', 'source repo untouched'); + + // The brief lists the worktree path, and the bridge carries brief + repo rules. + const brief = fs.readFileSync(path.join(project.root, 'CLAUDE.md'), 'utf8'); + assert.ok(brief.includes(`- ${wt.path}`)); + const bridge = fs.readFileSync(path.join(wt.path, projects.CODEX_BRIDGE_FILE), 'utf8'); + assert.ok(bridge.includes('# Feature X'), 'project brief'); + assert.ok(bridge.includes('Be tidy.'), 'repo AGENTS.md appended'); + const exclude = fs.readFileSync(path.join(repo, '.git', 'info', 'exclude'), 'utf8'); + assert.ok(exclude.split('\n').includes(projects.CODEX_BRIDGE_FILE), 'excluded from git'); + assert.equal(gitIn(wt.path, 'status', '--porcelain'), '', 'bridge file does not show as untracked'); + + assert.match((await projects.attachFolder(project.id, { path: repo, mode: 'worktree' })).error, /already attached/); + + // A session in the worktree: project folder and the other worktree are added. + const ctx = projects.launchContext(project.id, wt.path); + assert.equal(ctx.worktree, true); + assert.deepEqual(ctx.addDirs, [project.root, wt2.path]); + assert.equal(projects.worktreeParentFor(wt.path), path.resolve(repo)); + assert.equal(projects.worktreeParentFor(repo), null); + + // Dirty worktree: refused without force, removed with it. + fs.writeFileSync(path.join(wt.path, 'scratch.txt'), 'wip\n'); + const refused = await projects.detachFolder(project.id, wt.path, { removeWorktree: true }); + assert.equal(refused.dirty, true); + assert.ok(fs.existsSync(wt.path)); + const forced = await projects.detachFolder(project.id, wt.path, { removeWorktree: true, force: true }); + assert.equal(forced.ok, true); + assert.equal(forced.worktreeRemoved, true); + assert.ok(!fs.existsSync(wt.path), 'checkout removed'); + assert.equal(gitIn(repo, 'rev-parse', '--verify', 'refs/heads/feature-x').length, 40, 'branch kept'); + + // Marking done reports the remaining worktree; detaching without removal keeps it on disk. + const done = projects.updateProject(project.id, { status: 'done' }); + assert.deepEqual(done.worktrees, [wt2.path]); + const kept = await projects.detachFolder(project.id, wt2.path, {}); + assert.equal(kept.worktreeRemoved, false); + assert.ok(fs.existsSync(wt2.path), 'kept on disk'); + assert.ok(!fs.existsSync(path.join(wt2.path, projects.CODEX_BRIDGE_FILE)), 'bridge file removed'); + gitIn(other, 'worktree', 'remove', '--force', wt2.path); + } finally { + t.cleanup(); + rm(repo); + rm(other); + } +}); + +test('per-repo branch names when the shared branch is off', { skip: !haveGit && 'git not installed' }, async () => { + const t = setup(); + const repo = makeRepo(); + try { + const { project } = await projects.createProject({ name: 'Split', sharedBranch: false }); + const attached = await projects.attachFolder(project.id, { path: repo, mode: 'worktree', branch: 'my/branch' }); + assert.equal(attached.ok, true, attached.error); + assert.equal(attached.folder.branch, 'my/branch'); + assert.equal(gitIn(attached.folder.path, 'rev-parse', '--abbrev-ref', 'HEAD'), 'my/branch'); + assert.match((await projects.attachFolder(project.id, { path: repo, mode: 'worktree', branch: '-x' })).error, /Invalid branch name/); + assert.match((await projects.attachFolder(project.id, { path: repo, mode: 'worktree', branch: 'again' })).error, /already attached/); + await projects.detachFolder(project.id, attached.folder.path, { removeWorktree: true }); + assert.ok(!fs.existsSync(attached.folder.path)); + } finally { + t.cleanup(); + rm(repo); + } +}); + +test('saveBrief writes CLAUDE.md and AGENTS.md alike and restores the folder block', async () => { + const t = setup(); + try { + const repo = tmpDir('switchboard-repo-'); + try { + const { project } = await projects.createProject({ name: 'Brief', folders: [{ path: repo }] }); + assert.equal((await projects.saveBrief('nope', 'x')).error, 'Project not found'); + assert.match((await projects.saveBrief(project.id, 42)).error, /text/); + + const saved = await projects.saveBrief(project.id, '# Brief\n\nShip the thing.'); + assert.equal(saved.ok, true); + assert.ok(saved.content.startsWith('# Brief\n\nShip the thing.\n'), 'user text first, newline added'); + assert.ok(saved.content.includes(`- ${path.resolve(repo)}`), 'folder block put back'); + assert.equal(fs.readFileSync(path.join(project.root, 'CLAUDE.md'), 'utf8'), saved.content); + assert.equal(fs.readFileSync(path.join(project.root, 'AGENTS.md'), 'utf8'), saved.content); + assert.equal((saved.content.match(//g) || []).length, 1); + } finally { rm(repo); } + } finally { t.cleanup(); } +}); + +test('readProjectPlan, setPlanItem, appendPlanItem and links work on the tracker and todos', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'Tracked' }); + let read = projects.readProjectPlan(project.id); + assert.equal(read.ok, true); + assert.deepEqual([read.hasPlan, read.hasTracker, read.hasTodos], [false, false, false], 'nothing exists up front'); + assert.equal(read.plan.total, 0); + + assert.match(projects.setPlanItem(project.id, 'plan', 0, true).error, /does not exist/); + assert.match(projects.setPlanItem(project.id, 'notes', 0, true).error, /Unknown file/); + + fs.writeFileSync(path.join(project.root, 'plan-tracker.md'), '# Tracker\n\n## Phase 1: Start\n- [ ] one\n- [ ] two\n\n## Phase 2: Finish\n- [ ] three\n'); + read = projects.readProjectPlan(project.id); + assert.equal(read.plan.total, 2); + assert.equal(read.plan.next.title, 'Phase 1: Start'); + + const ticked = projects.setPlanItem(project.id, 'plan', 3, true); + assert.equal(ticked.ok, true); + assert.equal(ticked.text, 'one'); + assert.ok(fs.readFileSync(path.join(project.root, 'plan-tracker.md'), 'utf8').includes('- [x] one')); + assert.match(projects.setPlanItem(project.id, 'plan', 1, true).error, /not a checkbox/); + + assert.match(projects.appendPlanItem(project.id, 'todos', ' ').error, /Nothing/); + assert.equal(projects.appendPlanItem(project.id, 'todos', 'Call back').ok, true); + read = projects.readProjectPlan(project.id); + assert.equal(read.hasTodos, true, 'todos.md created on first append'); + assert.deepEqual(read.todos.map(x => [x.text, x.done]), [['Call back', false]]); + assert.ok(fs.readFileSync(path.join(project.root, 'todos.md'), 'utf8').startsWith('# Tracked todos\n')); + + assert.equal(projects.recordPlanLink(project.id, 'plan', 'Phase 1: Start', 'sess-1', 'started'), true); + assert.equal(projects.recordPlanLink(project.id, 'plan', '', 'sess-1', 'started'), false); + read = projects.readProjectPlan(project.id); + assert.deepEqual(read.links.map(l => [l.file, l.itemText, l.sessionId, l.kind]), [['plan', 'Phase 1: Start', 'sess-1', 'started']]); + assert.equal(projects.readProjectPlan('nope').error, 'Project not found'); + } finally { t.cleanup(); } +}); + +test('the plan watcher credits a tick to the running project session', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'Watched' }); + const sent = []; + const activeSessions = new Map([ + ['s-old', { projectId: project.id, _openedAt: 1, exited: false }], + ['s-new', { projectId: project.id, _openedAt: 2, exited: false }], + ['s-other', { projectId: 'someone-else', _openedAt: 3, exited: false }], + ['s-dead', { projectId: project.id, _openedAt: 4, exited: true }], + ]); + projects.initPlanWatch({ activeSessions, send: (channel, id) => sent.push([channel, id]) }); + try { + const tracker = path.join(project.root, 'plan-tracker.md'); + fs.writeFileSync(tracker, '## Phase 1: A\n- [ ] one\n'); + await new Promise(r => setTimeout(r, 700)); + fs.writeFileSync(tracker, '## Phase 1: A\n- [x] one\n'); + await new Promise(r => setTimeout(r, 700)); + const links = projects.readProjectPlan(project.id).links; + assert.deepEqual(links.map(l => [l.itemText, l.sessionId, l.kind]), [['one', 's-new', 'ticked']], 'newest running session of the project'); + assert.ok(sent.some(([channel, id]) => channel === 'project-plan-changed' && id === project.id)); + + // A tick made through setPlanItem is the page's, not a session's. + const before = links.length; + projects.setPlanItem(project.id, 'plan', 0, true); + await new Promise(r => setTimeout(r, 700)); + assert.equal(projects.readProjectPlan(project.id).links.length, before); + } finally { + projects.stopPlanWatchers(); + } + } finally { t.cleanup(); } +}); + +test('adoptPlan copies a plan-mode plan into plan.md and refuses to clobber one', async () => { + const t = setup(); + const plans = tmpDir('switchboard-plans-'); + try { + projects.init({ db: t.db, log: { info() {}, error() {} }, buildProjectsFromCache: () => [], notifyRendererProjectsChanged() {}, plansDir: plans }); + fs.writeFileSync(path.join(plans, 'big-idea.md'), '# Big idea\n\nDo the thing.\n'); + const { project } = await projects.createProject({ name: 'Adopter' }); + assert.match(projects.adoptPlan(project.id, 'missing.md').error, /no longer exists/); + assert.equal(projects.adoptPlan(project.id, '../big-idea.md').ok, true, 'basename only'); + assert.equal(fs.readFileSync(path.join(project.root, 'plan.md'), 'utf8'), '# Big idea\n\nDo the thing.\n'); + fs.writeFileSync(path.join(plans, 'other.md'), '# Other\n\nSomething else.\n'); + const refused = projects.adoptPlan(project.id, 'other.md'); + assert.equal(refused.exists, true); + assert.equal(projects.adoptPlan(project.id, 'other.md', { replace: true }).ok, true); + assert.ok(fs.readFileSync(path.join(project.root, 'plan.md'), 'utf8').startsWith('# Other')); + } finally { + rm(plans); + t.cleanup(); + } +}); + +test('templates: listed from the bundled folder, applied with tokens, tracks created, rules appended', async () => { + const t = setup(); + const bundled = path.join(__dirname, '..', 'templates'); + try { + projects.init({ + db: t.db, log: { info() {}, error() {} }, buildProjectsFromCache: () => [], + notifyRendererProjectsChanged() {}, templatesDir: bundled, isHarnessId: (id) => id === 'claude' || id === 'codex', + }); + const listed = projects.listTemplates(); + assert.equal(listed.dir, bundled); + assert.deepEqual(listed.templates.map(x => x.kind), ['customer', 'feature', 'research']); + assert.deepEqual(listed.templates.find(x => x.kind === 'feature').tracks.map(x => x.name), ['Plan', 'Build', 'Review']); + + assert.match((await projects.createProject({ name: 'x', template: 'nope' })).error, /Unknown template/); + assert.match((await projects.createProject({ name: 'x', template: '../etc' })).error, /Unknown template/); + + const { project } = await projects.createProject({ name: 'Onboarding flow', template: 'customer' }); + assert.deepEqual(project.tracks.map(x => x.name), ['Discovery', 'Proposal', 'Build']); + assert.deepEqual(project.tracks.map(x => x.cwd), [null, null, null], 'template tracks start in the project folder'); + const brief = fs.readFileSync(path.join(project.root, 'CLAUDE.md'), 'utf8'); + assert.ok(brief.startsWith('# Onboarding flow\n'), 'template heading with the name filled in'); + assert.ok(brief.includes('## How this project works'), 'template text kept'); + assert.ok(brief.includes(`Project folder: ${project.root}`), 'root token filled in'); + assert.ok(!brief.includes('{{'), 'no tokens left'); + assert.ok(brief.includes('## Working rules'), 'rules still appended'); + assert.ok(brief.includes(''), 'folder block still appended'); + assert.equal(fs.readFileSync(path.join(project.root, 'AGENTS.md'), 'utf8'), brief); + assert.ok(fs.readFileSync(path.join(project.root, 'contacts.md'), 'utf8').startsWith('# Contacts for Onboarding flow')); + assert.ok(fs.existsSync(path.join(project.root, 'proposals', 'README.md'))); + assert.ok(!fs.existsSync(path.join(project.root, 'template.json')), 'metadata not copied'); + assert.ok(!fs.existsSync(path.join(project.root, 'plan.md')), 'still no plan until a session writes one'); + } finally { t.cleanup(); } +}); + +test('templates are seeded once into a missing folder and never overwritten', () => { + const dir = path.join(tmpDir('switchboard-tpl-'), 'templates'); + try { + projects.init({ db: makeFakeDb(), log: { info() {}, error() {} }, buildProjectsFromCache: () => [], templatesDir: dir }); + const first = projects.listTemplates(); + assert.equal(first.templates.length, 3, 'seeded from the bundle'); + fs.writeFileSync(path.join(dir, 'feature', 'CLAUDE.md'), '# {{name}}\n\nMine now.\n'); + fs.mkdirSync(path.join(dir, 'mine')); + fs.writeFileSync(path.join(dir, 'mine', 'template.json'), JSON.stringify({ name: 'Mine', tracks: [{ name: 'Only', cli: 'codex' }] })); + fs.mkdirSync(path.join(dir, 'broken')); + fs.writeFileSync(path.join(dir, 'broken', 'template.json'), '{ not json'); + const second = projects.listTemplates(); + assert.deepEqual(second.templates.map(x => x.name), ['Customer', 'Feature', 'Mine', 'Research'], 'user template listed, broken one skipped'); + assert.ok(fs.readFileSync(path.join(dir, 'feature', 'CLAUDE.md'), 'utf8').includes('Mine now.'), 'edit kept'); + assert.deepEqual(second.templates.find(x => x.kind === 'mine').tracks, [{ name: 'Only', cli: 'codex' }]); + } finally { + rm(path.dirname(dir)); + } +}); + +test('projectsRoot expands ~ and falls back to ~/Switchboard', () => { + const home = os.homedir(); + projects.init({ db: { getSetting: () => ({ projectsRoot: '~/Work/projects' }) }, buildProjectsFromCache: () => [] }); + assert.equal(projects.projectsRoot(), path.join(home, 'Work', 'projects')); + projects.init({ db: { getSetting: () => ({}) }, buildProjectsFromCache: () => [] }); + assert.equal(projects.projectsRoot(), path.join(home, 'Switchboard')); + projects.init({ db: { getSetting: () => null }, buildProjectsFromCache: () => [] }); + assert.equal(projects.projectsRoot(), path.join(home, 'Switchboard')); +}); + +test('folderGitStatus reads the live branch of an in-place repo and skips plain folders', { skip: !haveGit && 'git not installed' }, async () => { + const t = setup(); + const repo = makeRepo(); + const plain = tmpDir('switchboard-plain-'); + try { + const created = await projects.createProject({ + name: 'Live branch', + folders: [{ path: repo, mode: 'in-place' }, { path: plain, mode: 'in-place' }], + }); + assert.equal(created.ok, true); + + const first = await projects.folderGitStatus(created.project.id); + assert.equal(first.ok, true); + assert.equal(first.byPath[repo].git, true); + assert.equal(first.byPath[repo].branch, 'main'); + assert.equal(first.byPath[repo].dirty, false); + assert.deepEqual(first.byPath[plain], { git: false }); + + // The cache hides a change until it is forced or expires. + gitIn(repo, 'checkout', '-q', '-b', 'topic'); + fs.writeFileSync(path.join(repo, 'notes.txt'), 'wip\n'); + assert.equal((await projects.folderGitStatus(created.project.id)).byPath[repo].branch, 'main'); + const forced = await projects.folderGitStatus(created.project.id, { force: true }); + assert.equal(forced.byPath[repo].branch, 'topic'); + assert.equal(forced.byPath[repo].dirty, true); + + await assert.rejects(projects.folderGitStatus('missing'), /not found/); + } finally { + rm(plain); + rm(repo); + t.cleanup(); + } +}); + +test('projectGitInfo and projectGitDiff expose read-only data for attached repositories only', { skip: !haveGit && 'git not installed' }, async () => { + const t = setup(); + const repo = makeRepo(); + const other = makeRepo('switchboard-other-repo-'); + try { + const created = await projects.createProject({ + name: 'Git details', + folders: [{ path: repo, mode: 'in-place' }], + }); + fs.writeFileSync(path.join(repo, 'README.md'), '# changed\n'); + + const info = await projects.projectGitInfo(created.project.id); + assert.equal(info.ok, true); + assert.equal(info.repositories.length, 1); + assert.equal(info.repositories[0].path, repo); + assert.equal(info.repositories[0].changes[0].path, 'README.md'); + + const diff = await projects.projectGitDiff(created.project.id, repo, 'README.md'); + assert.equal(diff.ok, true); + assert.match(diff.diff, /[+]# changed/); + + await assert.rejects(projects.projectGitDiff(created.project.id, other, 'README.md'), /not attached/); + } finally { + rm(other); + rm(repo); + t.cleanup(); + } +}); + +test('editPlanItem rewrites a checkbox line and keeps its tick', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'Edited' }); + assert.match(projects.editPlanItem(project.id, 'todos', 0, 'x').error, /does not exist/); + assert.equal(projects.appendPlanItem(project.id, 'todos', 'Call back').ok, true); + assert.equal(projects.appendPlanItem(project.id, 'todos', 'Send invoice').ok, true); + let read = projects.readProjectPlan(project.id); + const first = read.todos[0]; + assert.equal(projects.setPlanItem(project.id, 'todos', first.line, true).ok, true); + + const edited = projects.editPlanItem(project.id, 'todos', first.line, ' Call the client back '); + assert.equal(edited.ok, true); + assert.equal(edited.text, 'Call the client back'); + assert.equal(edited.previous, 'Call back'); + read = projects.readProjectPlan(project.id); + assert.deepEqual(read.todos.map(x => [x.text, x.done]), [['Call the client back', true], ['Send invoice', false]], 'text changes, tick and order stay'); + + assert.match(projects.editPlanItem(project.id, 'todos', first.line, ' ').error, /Nothing/); + assert.match(projects.editPlanItem(project.id, 'todos', 0, 'Heading').error, /not a checkbox/); + assert.match(projects.editPlanItem(project.id, 'notes', 0, 'x').error, /Unknown file/); + } finally { + t.cleanup(); + } +}); + +test('listEnvFiles finds .env and .env.*, sorted, samples excluded from the default pick', () => { + const dir = tmpDir('switchboard-env-'); + try { + assert.deepEqual(projects.listEnvFiles(dir), [], 'a folder with no .env has none'); + fs.writeFileSync(path.join(dir, '.env'), 'A=1\n'); + fs.writeFileSync(path.join(dir, '.env.local'), 'B=2\n'); + fs.writeFileSync(path.join(dir, '.env.example'), 'A=\n'); + fs.writeFileSync(path.join(dir, 'env'), 'not one\n'); + fs.writeFileSync(path.join(dir, '.environment'), 'not one either\n'); + fs.mkdirSync(path.join(dir, '.env.d')); + + assert.deepEqual(projects.listEnvFiles(dir), ['.env', '.env.example', '.env.local'], + 'files only, sorted, nothing that merely starts with .env'); + assert.deepEqual(projects.defaultEnvSelection(dir), ['.env', '.env.local'], + 'a sample is listed but not ticked'); + assert.deepEqual(projects.listEnvFiles(path.join(dir, 'nope')), [], 'a missing folder has none'); + } finally { rm(dir); } +}); + +test('copyEnvFiles copies only the named files, never overwrites, ignores names the source lacks', () => { + const source = tmpDir('switchboard-env-src-'); + const target = tmpDir('switchboard-env-dst-'); + try { + fs.writeFileSync(path.join(source, '.env'), 'A=1\n'); + fs.writeFileSync(path.join(source, '.env.local'), 'B=2\n'); + fs.writeFileSync(path.join(source, '.env.test'), 'C=3\n'); + fs.writeFileSync(path.join(target, '.env.local'), 'KEEP=me\n'); + + const result = projects.copyEnvFiles(source, target, ['.env', '.env.local', '../../secrets', '.env.missing']); + assert.deepEqual(result.copied, ['.env']); + assert.deepEqual(result.skipped, ['.env.local', '../../secrets', '.env.missing']); + assert.equal(fs.readFileSync(path.join(target, '.env'), 'utf8'), 'A=1\n'); + assert.equal(fs.readFileSync(path.join(target, '.env.local'), 'utf8'), 'KEEP=me\n', 'an existing file is left alone'); + assert.equal(fs.existsSync(path.join(target, '.env.test')), false, 'a file that was not asked for is not copied'); + assert.deepEqual(projects.copyEnvFiles(source, target, []), { copied: [], skipped: [] }); + } finally { rm(source); rm(target); } +}); + +test('a new worktree gets the .env files the caller picked', { skip: !haveGit && 'git not installed' }, async () => { + const t = setup(); + const repo = makeRepo('switchboard-repo-env-'); + try { + fs.writeFileSync(path.join(repo, '.env'), 'TOKEN=abc\n'); + fs.writeFileSync(path.join(repo, '.env.local'), 'PORT=3000\n'); + fs.writeFileSync(path.join(repo, '.env.example'), 'TOKEN=\n'); + + const created = await projects.createProject({ + name: 'Env Feature', + folders: [{ path: repo, mode: 'worktree', copyEnv: ['.env', '.env.local'] }], + }); + assert.equal(created.ok, true); + assert.deepEqual(created.errors, []); + const wt = created.project.folders[0]; + assert.equal(fs.readFileSync(path.join(wt.path, '.env'), 'utf8'), 'TOKEN=abc\n'); + assert.equal(fs.readFileSync(path.join(wt.path, '.env.local'), 'utf8'), 'PORT=3000\n'); + assert.equal(fs.existsSync(path.join(wt.path, '.env.example')), false, 'an unticked file stays behind'); + + // Attaching a second repo without copyEnv leaves its .env behind. + const other = makeRepo('switchboard-repo-env2-'); + try { + fs.writeFileSync(path.join(other, '.env'), 'X=1\n'); + const attached = await projects.attachFolder(created.project.id, { path: other, mode: 'worktree' }); + assert.equal(attached.ok, true); + const wt2 = attached.project.folders.find(f => f.sourcePath === path.resolve(other)); + assert.equal(fs.existsSync(path.join(wt2.path, '.env')), false, 'nothing is copied unless it was asked for'); + gitIn(other, 'worktree', 'remove', '--force', wt2.path); + } finally { rm(other); } + + gitIn(repo, 'worktree', 'remove', '--force', wt.path); + } finally { rm(repo); t.cleanup(); } +}); diff --git a/test/task-config.test.js b/test/task-config.test.js index 7c8b903d..45bd814e 100644 --- a/test/task-config.test.js +++ b/test/task-config.test.js @@ -1,8 +1,42 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); const path = require('path'); -const { loadTasksFromText, resolveTaskGraph, taskFileForWorkspace } = require('../task-config'); +const { loadProjectTasks, loadTasksFromText, resolveTaskGraph, taskFileForWorkspace } = require('../task-config'); + +test('a known parent (project worktree under repos/) inherits without the .claude/worktrees layout', () => { + const worktree = '/projects/feature/repos/web'; + const parent = '/src/web'; + const parentTasks = path.join(parent, '.vscode', 'tasks.json'); + const source = taskFileForWorkspace(worktree, filePath => filePath === parentTasks, parent); + assert.deepEqual(source, { filePath: parentTasks, inherited: true, parentPath: parent }); + assert.equal(taskFileForWorkspace(worktree, () => false, parent), null, 'parent without tasks.json inherits nothing'); + const localTasks = path.join(worktree, '.vscode', 'tasks.json'); + assert.equal(taskFileForWorkspace(worktree, filePath => filePath === localTasks, parent).inherited, false, 'local file wins'); +}); + +test('loadProjectTasks reads an inherited file through options.parentPath', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-task-parent-')); + try { + const parent = path.join(root, 'src', 'web'); + const worktree = path.join(root, 'proj', 'repos', 'web'); + fs.mkdirSync(path.join(parent, '.vscode'), { recursive: true }); + fs.mkdirSync(worktree, { recursive: true }); + fs.writeFileSync(path.join(parent, '.vscode', 'tasks.json'), JSON.stringify({ + version: '2.0.0', + tasks: [{ label: 'Dev', type: 'shell', command: 'echo dev' }], + })); + assert.equal(loadProjectTasks(worktree).length, 0, 'nothing without a parent'); + const tasks = loadProjectTasks(worktree, { parentPath: parent }); + assert.equal(tasks.length, 1); + assert.equal(tasks[0].label, 'Dev'); + assert.equal(tasks[0].cwd, worktree, 'runs in the worktree, not the parent'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); const workspaceFolder = path.resolve('/tmp/example-project'); From 0705984a79fbb6a14a6db89724d70b1cb5f41401 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Fri, 4 Sep 2026 14:15:01 -0700 Subject: [PATCH 02/24] Polish Project View session workflows Keep per-project navigation and raw terminal state across reloads, add dropped project files and lazy session previews, and tighten archived-session handling. --- main.js | 120 +++++++--- preload.js | 2 + projects.js | 100 +++++++- public/app.js | 135 ++++++++++- public/dialogs.js | 38 +-- public/project-git-view.js | 2 +- public/projects-view.js | 438 +++++++++++++++++++++++++++++----- public/sidebar.js | 4 + public/style.css | 150 +++++++++++- public/terminal-manager.js | 208 +++++++++++++++- session-cache.js | 2 + session-launch.js | 7 +- session-preview.js | 35 +++ test/projects.test.js | 36 +++ test/reconcile-cache.test.js | 24 ++ test/session-launch.test.js | 28 ++- test/session-preview.test.js | 34 +++ test/terminal-history.test.js | 102 ++++++++ 18 files changed, 1326 insertions(+), 139 deletions(-) create mode 100644 session-preview.js create mode 100644 test/session-preview.test.js create mode 100644 test/terminal-history.test.js diff --git a/main.js b/main.js index c6cea268..0393409c 100644 --- a/main.js +++ b/main.js @@ -28,7 +28,7 @@ try { require('electron-reloader')(module, { watchRenderer: true }); } catch {}; // LC_CTYPE when the launch environment has no locale. See pty-env.js. const { buildPtyEnv } = require('./pty-env'); const cleanPtyEnv = buildPtyEnv(process.env); -const { shouldStartFresh } = require('./session-launch'); +const { shouldStartFresh, shouldBlockArchivedSession } = require('./session-launch'); // Shell profiles → shell-profiles.js const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles'); @@ -36,6 +36,7 @@ const { startScheduler } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); const { listProjectDirectory, readProjectFile } = require('./project-files'); const { createTaskManager } = require('./task-manager'); +const { lastAssistantMessage } = require('./session-preview'); // --- Auto-updater (only in packaged builds) --- @@ -91,6 +92,7 @@ const MAX_BUFFER_SIZE = 256 * 1024; // Active PTY sessions const activeSessions = new Map(); let mainWindow = null; +let appIsQuitting = false; function createWindow() { // Restore saved window bounds @@ -404,8 +406,22 @@ ipcMain.handle('update-project', guarded((id, patch) => projects.updateProject(i ipcMain.handle('delete-project', guarded((id) => projects.deleteProject(id))); ipcMain.handle('attach-project-folder', guarded((id, spec) => projects.attachFolder(id, spec || {}))); ipcMain.handle('detach-project-folder', guarded((id, folderPath, opts) => projects.detachFolder(id, folderPath, opts || {}))); -ipcMain.handle('set-session-assignment', guarded((sessionId, projectId, trackId) => - projects.assignSession(sessionId, projectId || null, trackId || null))); +ipcMain.handle('set-session-assignment', guarded((sessionId, projectId, trackId) => { + const cleanProjectId = projectId || null; + const cleanTrackId = trackId || null; + const result = projects.assignSession(sessionId, cleanProjectId, cleanTrackId); + // Raw terminals have no transcript to rehydrate this relationship from. + // Keep the live PTY metadata aligned with the durable renderer descriptor so + // a renderer reload cannot put a moved terminal back in its old location. + if (!result?.error) { + const session = activeSessions.get(sessionId); + if (session?.isPlainTerminal) { + session.projectId = cleanProjectId; + session.trackId = cleanProjectId ? cleanTrackId : null; + } + } + return result; +})); ipcMain.handle('create-track', guarded((projectId, spec) => projects.createTrack(projectId, spec || {}))); ipcMain.handle('update-track', guarded((id, patch) => projects.updateTrack(id, patch || {}))); ipcMain.handle('delete-track', guarded((id) => projects.deleteTrack(id))); @@ -423,6 +439,7 @@ ipcMain.handle('list-env-files', guarded((folderPath) => ({ }))); ipcMain.handle('save-project-brief', guarded((id, content) => projects.saveBrief(id, content))); ipcMain.handle('create-project-file', guarded((id, name, content) => projects.createProjectFile(id, name, content))); +ipcMain.handle('add-project-files', guarded((id, sourcePaths) => projects.addProjectFiles(id, sourcePaths))); ipcMain.handle('get-project-plan', guarded((id) => projects.readProjectPlan(id))); ipcMain.handle('set-plan-item', guarded((id, kind, line, done) => projects.setPlanItem(id, kind, line, done))); ipcMain.handle('append-plan-item', guarded((id, kind, text) => projects.appendPlanItem(id, kind, text))); @@ -1109,7 +1126,12 @@ ipcMain.handle('get-active-terminals', () => { const terminals = []; for (const [sessionId, session] of activeSessions) { if (!session.exited && session.isPlainTerminal) { - terminals.push({ sessionId, projectPath: session.projectPath }); + terminals.push({ + sessionId, + projectPath: session.projectPath, + projectId: session.projectId || null, + trackId: session.trackId || null, + }); } } return terminals; @@ -1140,29 +1162,42 @@ ipcMain.handle('rename-session', (_event, sessionId, name) => { return { name: name || null }; }); -// --- IPC: archive-session --- +function readSessionViewerEntries(row) { + // The harness owns its transcript naming and its on-disk format. Normalize + // here so neither transcript consumer needs runtime-specific branches. + const jsonlPath = transcriptPath(row); + const harness = getHarness(row.runtime); + 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 harness.toViewerEntries(entries); +} + ipcMain.handle('read-session-jsonl', (_event, sessionId) => { const row = getCachedSession(sessionId); if (!row) return { error: 'Session not found in cache' }; - // The harness owns its transcript naming — Claude uses .jsonl, - // others do not — so resolve through it rather than rebuilding the path here. - const jsonlPath = transcriptPath(row); - const harness = getHarness(row.runtime); 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 {} - } - // Normalised here rather than in the renderer, so the viewer never has to - // know which CLI wrote the transcript it is showing. - return { entries: harness.toViewerEntries(entries) }; + return { entries: readSessionViewerEntries(row) }; + } catch (err) { + return { error: err.message }; + } +}); + +ipcMain.handle('get-session-last-message', (_event, sessionId) => { + const row = getCachedSession(sessionId); + if (!row) return { error: 'Session not found in cache' }; + try { + return { text: lastAssistantMessage(readSessionViewerEntries(row)) }; } catch (err) { return { error: err.message }; } }); +// --- IPC: archive-session --- + ipcMain.handle('archive-session', (_event, sessionId, archived) => { const val = archived ? 1 : 0; setArchived(sessionId, val); @@ -1173,6 +1208,15 @@ ipcMain.handle('archive-session', (_event, sessionId, archived) => { ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, sessionOptions) => { if (!mainWindow) return { ok: false, error: 'no window' }; + const isPlainTerminal = sessionOptions?.type === 'terminal'; + if (shouldBlockArchivedSession({ + isNew, + isPlainTerminal, + archived: getMeta(sessionId)?.archived, + })) { + return { ok: false, error: 'Session is archived. Unarchive it before resuming.' }; + } + // Reattach to existing session if (activeSessions.has(sessionId)) { const session = activeSessions.get(sessionId); @@ -1203,8 +1247,6 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se return { ok: false, error: `project directory no longer exists: ${projectPath}` }; } - const isPlainTerminal = sessionOptions?.type === 'terminal'; - // A session that never wrote a transcript cannot be resumed — the CLI has no // record of the id, and asking it to resume one produces an error the user // can do nothing about ("No saved session found with ID ..."). Start it @@ -1329,30 +1371,21 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se try { if (isPlainTerminal) { // Plain terminal: interactive login shell, no claude command - // Inject a shell function to override `claude` with a helpful message - const claudeShim = 'claude() { echo "\\033[33mTo start a Claude session, use the + button in the sidebar.\\033[0m"; return 1; }; export -f claude 2>/dev/null;'; + // Do not inherit Claude Code's nested-session marker if Switchboard was + // itself launched from Claude; this is the user's shell and `claude` + // should resolve normally from their profile/PATH. + const terminalEnv = { + ...cleanPtyEnv, + TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', + }; + delete terminalEnv.CLAUDECODE; ptyProcess = pty.spawn(shell, shellArgs(shell, undefined, shellExtraArgs), { name: 'xterm-256color', cols: 120, rows: 30, cwd: isWsl ? os.homedir() : projectPath, - env: { - ...cleanPtyEnv, - TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', - CLAUDECODE: '1', - // ZDOTDIR trick won't work reliably; instead inject via ENV (sh/bash) or precmd - ENV: claudeShim, - BASH_ENV: claudeShim, - }, + env: terminalEnv, }); - // For zsh, ENV/BASH_ENV don't apply — write the function after shell starts - setTimeout(() => { - if (!ptyProcess._isDisposed) { - try { - ptyProcess.write(claudeShim + ' clear\n'); - } catch {} - } - }, 300); } else { // Argv is built by the harness and quoted here, so a value can never be // spliced into the command line as shell syntax. @@ -1418,6 +1451,11 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se projectPath, firstResize: true, projectFolder, knownJsonlFiles, sessionSlug, isPlainTerminal, runtime: runtimeId, forkFrom: sessionOptions?.forkFrom || null, + // Plain terminals have no transcript row to carry their project filing. + // Keep the launch context on the live PTY so renderer reloads can put a + // terminal started in an attached folder back into the same project/track. + projectId: sessionOptions?.projectId || null, + trackId: sessionOptions?.trackId || null, // Set for a harness whose real session id only appears once its transcript // does; cleared by resolvePendingLaunches when the transcript is matched. pendingLaunch: (harness?.needsIdDetection?.({ isNew: startFresh, options: sessionOptions })) ? { @@ -1598,7 +1636,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se // A signal kill reports exitCode 0, so pass the signal and the // stop-session flag along rather than making it guess from the code. const stopRequested = !!session.stopRequested; - if (mainWindow && !mainWindow.isDestroyed()) { + if (!appIsQuitting && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('process-exited', realId, exitCode, signal, stopRequested); // If a fork/plan-accept transition re-keyed this session under realId // but the PTY exited before transition detection ran, also notify the @@ -2025,6 +2063,10 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { + // The renderer persists raw-terminal descriptors and scrollback during its + // unload. Do not report the shutdown kills as user-initiated terminal exits, + // or that renderer cleanup would delete the descriptors before restart. + appIsQuitting = true; // Shut down all MCP servers shutdownAllMcp(); taskManager.shutdown(); diff --git a/preload.js b/preload.js index 300d4433..3751c426 100644 --- a/preload.js +++ b/preload.js @@ -23,6 +23,7 @@ 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), + getSessionLastMessage: (sessionId) => ipcRenderer.invoke('get-session-last-message', sessionId), // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), @@ -63,6 +64,7 @@ contextBridge.exposeInMainWorld('api', { listEnvFiles: (folderPath) => ipcRenderer.invoke('list-env-files', folderPath), saveProjectBrief: (id, content) => ipcRenderer.invoke('save-project-brief', id, content), createProjectFile: (id, name, content) => ipcRenderer.invoke('create-project-file', id, name, content), + addProjectFiles: (id, sourcePaths) => ipcRenderer.invoke('add-project-files', id, sourcePaths), getProjectPlan: (id) => ipcRenderer.invoke('get-project-plan', id), setPlanItem: (id, kind, line, done) => ipcRenderer.invoke('set-plan-item', id, kind, line, done), appendPlanItem: (id, kind, text) => ipcRenderer.invoke('append-plan-item', id, kind, text), diff --git a/projects.js b/projects.js index 38b6d1f7..23af4fa7 100644 --- a/projects.js +++ b/projects.js @@ -25,6 +25,7 @@ const planParser = require('./public/plan-parser'); const DEFAULT_ROOT_NAME = 'Switchboard'; const REPOS_DIR = 'repos'; +const ADDED_FILES_DIR = 'added-files'; // Codex reads AGENTS.override.md instead of AGENTS.md at the same level, and // only from the git root down, so a worktree gets a copy of the project brief // under that name (excluded from git). Claude needs nothing: it walks up. @@ -103,7 +104,7 @@ function foldersBlock(folderPaths) { if (!folderPaths.length) { lines.push('No folders are attached yet. Attach the repos this project works in from the project settings in Switchboard.'); } else { - lines.push('The project works in these folders. Before changing files in one of them, read its own instructions (CLAUDE.md or AGENTS.md at its root) and follow them there.'); + lines.push("These attached folders are part of the project's working context. Unless the user specifies otherwise, interpret their requests—including questions, explanations, investigations, reviews, planning, and changes—in the context of these folders. Inspect the relevant attached folders to understand the request and ground your response in their contents. The project folder holds shared plans, notes, drafts, and context; it is not the full scope of the work. Before working with an attached folder, read its own instructions (CLAUDE.md or AGENTS.md at its root) and follow them there."); for (const p of folderPaths) lines.push(`- ${p}`); } lines.push(FOLDERS_END); @@ -287,6 +288,95 @@ function createProjectFile(projectId, name, content) { return { ok: true, filePath, created: true }; } +/** Files the user has dropped into this project's durable context folder. */ +function addedFilesAtRoot(root) { + const dirPath = path.join(root, ADDED_FILES_DIR); + let entries; + try { + if (!fs.lstatSync(dirPath).isDirectory()) return { dirPath, files: [] }; + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { entries = []; } + const files = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + const filePath = path.join(dirPath, entry.name); + let stat; + try { stat = fs.lstatSync(filePath); } catch { continue; } + files.push({ + name: entry.name, + relativePath: path.join(ADDED_FILES_DIR, entry.name), + type: 'file', + size: stat.size, + modified: stat.mtime.toISOString(), + }); + } + files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + return { dirPath, files }; +} + +function listAddedFiles(projectId) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + return { ok: true, ...addedFilesAtRoot(project.root) }; +} + +/** Pick a non-destructive destination such as "brief (2).pdf" on collisions. */ +function uniqueAddedFilePath(dirPath, name) { + const first = path.join(dirPath, name); + if (!fs.existsSync(first)) return first; + const ext = path.extname(name); + const stem = name.slice(0, name.length - ext.length); + for (let n = 2; ; n++) { + const candidate = path.join(dirPath, `${stem} (${n})${ext}`); + if (!fs.existsSync(candidate)) return candidate; + } +} + +/** + * Copy dropped files into /added-files without overwriting anything. + * Source paths come from Electron's webUtils.getPathForFile; main still + * validates every one before touching the project folder. + */ +async function addProjectFiles(projectId, sourcePaths) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + if (!Array.isArray(sourcePaths) || !sourcePaths.length) return { error: 'No files were dropped' }; + + const dirPath = path.join(project.root, ADDED_FILES_DIR); + const added = []; + const errors = []; + const seen = new Set(); + for (const raw of sourcePaths) { + const source = typeof raw === 'string' && path.isAbsolute(raw) ? path.resolve(raw) : ''; + if (!source) { + errors.push(`${path.basename(String(raw || 'File')) || 'File'}: Source path must be absolute`); + continue; + } + if (seen.has(source)) continue; + seen.add(source); + const label = path.basename(source) || String(raw || 'File'); + try { + const stat = await fs.promises.lstat(source); + if (!stat.isFile()) throw new Error('Only files can be added'); + await fs.promises.mkdir(dirPath, { recursive: true }); + const dirStat = await fs.promises.lstat(dirPath); + if (!dirStat.isDirectory()) throw new Error(`${ADDED_FILES_DIR} must be a regular folder`); + const [rootReal, dirReal] = await Promise.all([ + fs.promises.realpath(project.root), + fs.promises.realpath(dirPath), + ]); + if (path.dirname(dirReal) !== rootReal) throw new Error(`${ADDED_FILES_DIR} must be inside the project folder`); + const destination = uniqueAddedFilePath(dirPath, label); + await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL); + added.push(path.basename(destination)); + } catch (err) { + errors.push(`${label}: ${err.message}`); + } + } + if (added.length) notifyRendererProjectsChanged(); + return { ok: true, ...addedFilesAtRoot(project.root), added, errors }; +} + /** Replace the managed block in one brief file, or append it when missing. */ function syncBriefFile(filePath, block) { if (!fs.existsSync(filePath)) return false; @@ -442,6 +532,7 @@ function trackNode(row) { } function projectNode(row, folderRows = [], trackRows = []) { + const added = addedFilesAtRoot(row.root); return { id: row.id, name: row.name, slug: row.slug, root: row.root, status: row.status || 'active', @@ -450,6 +541,8 @@ function projectNode(row, folderRows = [], trackRows = []) { defaultCwd: row.defaultCwd || null, created: row.created, modified: row.modified, lastActivity: null, sessionCount: 0, + addedFilesPath: added.dirPath, + addedFiles: added.files, folders: folderRows.map(f => ({ path: f.path, mode: f.mode || 'in-place', sourcePath: f.sourcePath || null, branch: f.branch || null, sortOrder: f.sortOrder || 0, @@ -1279,8 +1372,9 @@ module.exports = { projectsRoot, slugify, uniqueSlug, defaultBrief, createProject, updateProject, deleteProject, attachFolder, detachFolder, folderGitStatus, folderGitInfo, projectGitInfo, projectGitDiff, - syncProjectBrief, saveBrief, createProjectFile, launchContext, mergeAddDirs, worktreeParentFor, - CODEX_BRIDGE_FILE, PROJECT_FILES, + syncProjectBrief, saveBrief, createProjectFile, addProjectFiles, listAddedFiles, + launchContext, mergeAddDirs, worktreeParentFor, + CODEX_BRIDGE_FILE, PROJECT_FILES, ADDED_FILES_DIR, readProjectPlan, setPlanItem, appendPlanItem, recordPlanLink, adoptPlan, initPlanWatch, refreshPlanWatchers, stopPlanWatchers, listTemplates, templatesRoot, renderTemplateText, editPlanItem, diff --git a/public/app.js b/public/app.js index e539edf6..35466a74 100644 --- a/public/app.js +++ b/public/app.js @@ -53,11 +53,19 @@ let gridViewActive = localStorage.getItem('gridViewActive') === '1'; // Map const openSessions = new Map(); window._openSessions = openSessions; -let activeSessionId = sessionStorage.getItem('activeSessionId') || null; +// sessionStorage covers renderer reloads; localStorage also restores the last +// terminal after the Electron process itself restarts. +const ACTIVE_SESSION_KEY = 'activeSessionId'; +let activeSessionId = sessionStorage.getItem(ACTIVE_SESSION_KEY) || localStorage.getItem(ACTIVE_SESSION_KEY) || null; function setActiveSession(id) { activeSessionId = id; - if (id) sessionStorage.setItem('activeSessionId', id); - else sessionStorage.removeItem('activeSessionId'); + if (id) { + sessionStorage.setItem(ACTIVE_SESSION_KEY, id); + localStorage.setItem(ACTIVE_SESSION_KEY, id); + } else { + sessionStorage.removeItem(ACTIVE_SESSION_KEY); + localStorage.removeItem(ACTIVE_SESSION_KEY); + } // Update file panel to show this session's open files/diffs if (typeof switchPanel === 'function') switchPanel(id); } @@ -210,6 +218,7 @@ function setActivity(sessionId, active) { const wasActive = sessionBusyState.get(sessionId) || false; sessionBusyState.set(sessionId, active); + if (active && typeof hideSessionHoverPreview === 'function') hideSessionHoverPreview(sessionId); if (wasActive && !active) { bumpSessionEvent(sessionId); @@ -306,6 +315,8 @@ window.api.onSessionDetected((tempId, realId) => { entry.session.sessionId = realId; if (activeSessionId === tempId) setActiveSession(realId); rekeySessionActivity(tempId, realId); + rekeyTerminalHistory(tempId, realId); + if (typeof rekeyProjectSessionState === 'function') rekeyProjectSessionState(tempId, realId); // Re-key in openSessions openSessions.delete(tempId); @@ -346,6 +357,8 @@ window.api.onSessionForked((oldId, newId) => { entry.session.sessionId = newId; if (activeSessionId === oldId) setActiveSession(newId); rekeySessionActivity(oldId, newId); + rekeyTerminalHistory(oldId, newId); + if (typeof rekeyProjectSessionState === 'function') rekeyProjectSessionState(oldId, newId); openSessions.delete(oldId); openSessions.set(newId, entry); @@ -408,9 +421,10 @@ window.api.onProcessExited((sessionId, exitCode, signal, userStopped) => { return; } - // Everything else — plain terminals (always ephemeral) and Claude sessions - // the user ended themselves — goes away. - if (entry) destroySession(sessionId); + // Everything else — a raw shell that exited and harness sessions the user + // ended themselves — goes away, including its retained terminal history. + // Run cleanup even if the pane was already detached from the renderer. + destroySession(sessionId); if (gridViewActive) { gridViewerCount.textContent = gridCards.size + ' session' + (gridCards.size !== 1 ? 's' : ''); } else if (activeSessionId === sessionId) { @@ -686,6 +700,7 @@ function isDismissibleSession(sessionId) { /** Drop such a row. Purely renderer state, so it cannot come back. */ function dismissSession(sessionId) { + const session = sessionMap.get(sessionId); pendingSessions.delete(sessionId); sessionMap.delete(sessionId); for (const projList of [cachedProjects, cachedAllProjects]) { @@ -695,6 +710,10 @@ function dismissSession(sessionId) { } if (typeof removeSessionFromTrees === 'function') removeSessionFromTrees(sessionId); if (openSessions.has(sessionId)) destroySession(sessionId); + else { + forgetTerminalHistory(sessionId); + if (session?.type === 'terminal') forgetPersistedTerminalSession(sessionId); + } if (activeSessionId === sessionId) { setActiveSession(null); terminalHeader.style.display = 'none'; @@ -839,6 +858,49 @@ function dedup(projects) { } } +/** + * Raw terminals have no transcript/database row. Recreate their renderer rows + * from localStorage before the sidebar/project panes render after a restart. + */ +function injectPersistedTerminalRows() { + for (const saved of persistedTerminalSessions()) { + if (pendingSessions.has(saved.sessionId)) continue; + const session = sessionMap.get(saved.sessionId) || saved; + Object.assign(session, saved); + sessionMap.set(session.sessionId, session); + const folder = encodeProjectPath(session.projectPath); + pendingSessions.set(session.sessionId, { + session, + projectPath: session.projectPath, + folder, + restored: true, + }); + for (const projList of [cachedProjects, cachedAllProjects]) { + let proj = projList.find(p => p.projectPath === session.projectPath); + if (!proj) { + proj = { folder, projectPath: session.projectPath, sessions: [] }; + projList.unshift(proj); + } + if (!proj.sessions.some(item => item.sessionId === session.sessionId)) proj.sessions.unshift(session); + } + injectPendingIntoTree(session); + } +} + +/** Reopen every saved raw terminal as a fresh shell, initially hidden. */ +async function restorePersistedTerminalProcesses() { + const jobs = []; + for (const saved of persistedTerminalSessions()) { + if (openSessions.has(saved.sessionId)) continue; + const session = sessionMap.get(saved.sessionId) || saved; + jobs.push(openRawTerminalSession(session, { show: false })); + } + if (jobs.length) { + await Promise.all(jobs); + await pollActiveSessions(); + } +} + async function loadProjects({ resort = false, reason = 'project' } = {}) { const wasEmpty = cachedProjects.length === 0; if (wasEmpty) { @@ -890,7 +952,7 @@ async function loadProjects({ resort = false, reason = 'project' } = {}) { // Track active plain terminals in pendingSessions/sessionMap (data now comes from backend) try { const activeTerminals = await window.api.getActiveTerminals(); - for (const { sessionId, projectPath } of activeTerminals) { + for (const { sessionId, projectPath, projectId, trackId } of activeTerminals) { if (pendingSessions.has(sessionId)) continue; // already tracked const folder = encodeProjectPath(projectPath); // Find the session object already injected by the backend @@ -900,11 +962,23 @@ async function loadProjects({ resort = false, reason = 'project' } = {}) { if (session) break; } if (!session) continue; + // An attached folder can sit outside the project's root, so cwd alone is + // not enough to restore where this ephemeral terminal belongs. + if (projectId) session.projectId = projectId; + if (trackId) session.trackId = trackId; + // Also adopts terminals that were already running when this persistence + // feature was introduced; the next restart should retain them too. + persistTerminalSession(session); pendingSessions.set(sessionId, { session, projectPath, folder }); sessionMap.set(sessionId, session); } } catch {} + // A full app exit kills raw PTYs, so the main-process active list is empty on + // the next launch. Their durable descriptors still put them back in the same + // project/track and starting folder. + injectPersistedTerminalRows(); + // Project roots and attached folders get their tasks too, even with no // sessions of their own, so a project's task menu is complete. await hydrateProjectTasks([cachedProjects, cachedAllProjects], treeTaskPaths(cachedProjectTreeAll)); @@ -1011,16 +1085,38 @@ async function showTerminalHeader(session) { // Terminal lifecycle (createTerminalEntry, destroySession, showSession, setupDragAndDrop) → terminal-manager.js +async function unarchiveSessionBeforeOpen(session) { + if (!session.archived) return true; + + const displayName = cleanDisplayName(session.name || session.aiTitle || session.summary) || 'This session'; + if (!confirm(`“${displayName}” is archived.\n\nUnarchive it and open it?`)) return false; + + const result = await window.api.archiveSession(session.sessionId, 0); + if (result?.error) { + alert(result.error); + return false; + } + session.archived = 0; + await loadProjects(); + return true; +} + async function openSession(session, customOptions) { + if (!await unarchiveSessionBeforeOpen(session)) return; + const { sessionId, projectPath } = session; // If already open, handle closed-session cleanup or just show it if (openSessions.has(sessionId)) { const entry = openSessions.get(sessionId); if (entry.closed) { - destroySession(sessionId); + destroySession(sessionId, { + forgetPersisted: session.type !== 'terminal', + preserveHistory: true, + }); if (session.type === 'terminal') { - launchTerminalSession({ projectPath: session.projectPath }); + await openRawTerminalSession(session); + pollActiveSessions(); return; } } else { @@ -1029,6 +1125,12 @@ async function openSession(session, customOptions) { } } + if (session.type === 'terminal') { + await openRawTerminalSession(session); + pollActiveSessions(); + return; + } + // Create new terminal entry (hidden until showSession) const entry = createTerminalEntry(session); @@ -1300,14 +1402,23 @@ loadProjects().then(async () => { const lastTab = rememberedTab(); if (lastTab !== activeTab) document.querySelector(`.sidebar-tab[data-tab="${lastTab}"]`)?.click(); await restoreActiveTaskView(); + await restorePersistedTerminalProcesses(); // Restore grid view preference before opening sessions so they enter grid mode if (!activeTaskView && localStorage.getItem('gridViewActive') === '1') { showGridView(); } - // Restore active session after reload - if (activeSessionId && !openSessions.has(activeSessionId)) { + // Restore the active session after a renderer reload or full app restart. + // Raw terminals were reopened above but deliberately left hidden until this + // point, so an already-open entry still needs showSession(). + if (activeSessionId) { const session = sessionMap.get(activeSessionId); - if (session) openSession(session); + if (session?.archived) { + setActiveSession(null); + } else if (session) { + if (openSessions.has(activeSessionId)) showSession(activeSessionId); + else openSession(session); + } + else setActiveSession(null); } }); diff --git a/public/dialogs.js b/public/dialogs.js index e6b66dec..002812c7 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -166,14 +166,32 @@ function showNewSessionPopover(project, anchorEl) { setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0); } +/** Start or reattach a raw terminal without routing it through a CLI harness. */ +async function openRawTerminalSession(session, { show = true } = {}) { + const { sessionId, projectPath } = session; + const entry = openSessions.get(sessionId) || createTerminalEntry(session); + const result = await window.api.openTerminal(sessionId, projectPath, true, { + type: 'terminal', + projectId: session.projectId || null, + trackId: session.trackId || null, + }); + if (!result.ok) { + entry.terminal.write(`\r\nError: ${result.error}\r\n`); + entry.closed = true; + return result; + } + entry.closed = false; + persistTerminalSession(session); + if (show) showSession(sessionId); + return result; +} + async function launchTerminalSession(project) { - const sessionId = crypto.randomUUID(); - const projectPath = project.projectPath; const session = { - sessionId, + sessionId: crypto.randomUUID(), summary: 'Terminal', firstPrompt: '', - projectPath, + projectPath: project.projectPath, name: null, starred: 0, archived: 0, @@ -190,6 +208,7 @@ async function launchTerminalSession(project) { } // Track as pending + const { sessionId, projectPath } = session; const folder = encodeProjectPath(projectPath); pendingSessions.set(sessionId, { session, projectPath, folder }); @@ -206,16 +225,7 @@ async function launchTerminalSession(project) { if (typeof injectPendingIntoTree === 'function') injectPendingIntoTree(session); refreshSidebar(); - const entry = createTerminalEntry(session); - - const result = await window.api.openTerminal(sessionId, projectPath, true, { type: 'terminal' }); - if (!result.ok) { - entry.terminal.write(`\r\nError: ${result.error}\r\n`); - entry.closed = true; - return; - } - - showSession(sessionId); + await openRawTerminalSession(session); pollActiveSessions(); } diff --git a/public/project-git-view.js b/public/project-git-view.js index fbca595d..a729dd92 100644 --- a/public/project-git-view.js +++ b/public/project-git-view.js @@ -251,7 +251,7 @@ function renderProjectGitTab(project, body) { if (!result?.ok) throw new Error(result?.error || 'Could not read Git information.'); state.diffs.clear(); state.repositories = result.repositories || []; - if (body.isConnected && selectedProject()?.id === project.id && projectsUi.tab === 'git') { + if (body.isConnected && selectedProject()?.id === project.id && projectTab(project) === 'git') { paintGitRepository(project, state, body); } }).catch((err) => { diff --git a/public/projects-view.js b/public/projects-view.js index 15fe55e1..f1e7e4a9 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -16,9 +16,11 @@ // sessionBusyState, sessionMap, pendingSessions, openSessions, activeTab, // gridViewActive, loadProjects, refreshSidebar, launchNewSession, // openSession, pollActiveSessions, placeholder, terminalArea, memoryViewer, -// memoryPanel, hideAllViewers, showSession, resolveDefaultSessionOptions +// memoryPanel, hideAllViewers, showSession, fitAndScroll, +// resolveDefaultSessionOptions // Depends on sidebar.js: isSessionRunning -// Depends on task-runner.js: showTaskPopover, tasksByPath, updateTaskButton +// Depends on task-runner.js: showTaskPopover, tasksByPath, updateTaskButton, +// activeTaskView // Depends on dialogs.js: showNewSessionDialog, launchTerminalSession, // launchScheduleCreator, forkSession // Depends on utils.js / icons.js: escapeHtml, formatDate, cleanDisplayName, ICONS @@ -28,7 +30,9 @@ let openCtxMenu = null; const projectsUi = { selectedProjectId: sessionStorage.getItem('projects.selected') || null, - tab: sessionStorage.getItem('projects.tab') || 'overview', + // Each project owns its runtime-only navigation state. Nothing here is + // persisted: projects start on their Overview tab after an app restart. + navigationByProject: new Map(), // projectId → { tab, mode, sessionId } trackByProject: (() => { try { return JSON.parse(sessionStorage.getItem('projects.track') || '{}'); } catch { return {}; } })(), groupBy: (() => { try { return JSON.parse(sessionStorage.getItem('projects.groupBy') || '{}'); } catch { return {}; } })(), expandedLists: {}, @@ -42,15 +46,166 @@ const projectsUi = { paneWidth: null, // working-mode session pane width in px; also kept in the global setting projectPaneWidth }; +// Last completed assistant turns are loaded lazily when an overview row is +// hovered. The event clock changes at the end of every turn, so it also makes a +// natural cache key without polling or rereading a transcript on mouse moves. +const sessionHoverPreviewCache = new Map(); // sessionId -> { eventTime, text } +let sessionHoverPreviewEl = null; +let sessionHoverPreviewRow = null; +let sessionHoverPreviewTimer = null; +let sessionHoverPreviewRequest = 0; + +function sessionIsBusy(sessionId) { + return typeof sessionBusyState !== 'undefined' && sessionBusyState.get(sessionId) === true; +} + +function ensureSessionHoverPreview() { + if (sessionHoverPreviewEl) return sessionHoverPreviewEl; + const el = document.createElement('div'); + el.className = 'session-turn-preview'; + el.setAttribute('role', 'tooltip'); + el.setAttribute('aria-hidden', 'true'); + el.innerHTML = '
Last AI message
'; + document.body.appendChild(el); + sessionHoverPreviewEl = el; + return el; +} + +function positionSessionHoverPreview(row) { + const el = ensureSessionHoverPreview(); + const rowRect = row.getBoundingClientRect(); + const margin = 12; + const gap = 5; + const maxWidth = Math.min(620, window.innerWidth - margin * 2); + const width = Math.min(maxWidth, Math.max(360, rowRect.width + 80)); + el.style.width = width + 'px'; + + // Keep the preview visually attached to the row instead of sending the eye + // across the page to a side-aligned tooltip. + const left = Math.max(margin, Math.min(rowRect.left, window.innerWidth - width - margin)); + el.style.left = Math.round(left) + 'px'; + + const height = el.offsetHeight; + let top = rowRect.bottom + gap; + if (top + height > window.innerHeight - margin) top = rowRect.top - height - gap; + top = Math.max(margin, top); + el.style.top = Math.round(top) + 'px'; +} + +function hideSessionHoverPreview(sessionId = null) { + if (sessionId && sessionHoverPreviewRow?.dataset.sessionId !== sessionId) return; + if (sessionHoverPreviewTimer) clearTimeout(sessionHoverPreviewTimer); + sessionHoverPreviewTimer = null; + sessionHoverPreviewRow = null; + sessionHoverPreviewRequest++; + if (!sessionHoverPreviewEl) return; + sessionHoverPreviewEl.classList.remove('visible'); + sessionHoverPreviewEl.setAttribute('aria-hidden', 'true'); +} + +async function showSessionHoverPreview(row, session) { + const id = session.sessionId; + if (sessionIsBusy(id) || !row.matches(':hover')) return; + + const eventTime = sessionEventTime(session); + let cached = sessionHoverPreviewCache.get(id); + const request = ++sessionHoverPreviewRequest; + if (!cached || cached.eventTime !== eventTime) { + let result; + try { result = await window.api.getSessionLastMessage(id); } catch { return; } + if (request !== sessionHoverPreviewRequest) return; + if (result?.error) return; + cached = { eventTime, text: result?.text || '' }; + sessionHoverPreviewCache.set(id, cached); + } + + if (!cached.text || sessionIsBusy(id) || !row.matches(':hover')) return; + const el = ensureSessionHoverPreview(); + const textEl = el.querySelector('.session-turn-preview-text'); + textEl.innerHTML = typeof renderJsonlText === 'function' + ? renderJsonlText(cached.text) + : escapeHtml(cached.text); + sessionHoverPreviewRow = row; + el.classList.add('visible'); + el.setAttribute('aria-hidden', 'false'); + positionSessionHoverPreview(row); +} + +function attachSessionHoverPreview(row, session) { + row.addEventListener('mouseenter', () => { + hideSessionHoverPreview(); + if (sessionIsBusy(session.sessionId)) return; + sessionHoverPreviewRow = row; + sessionHoverPreviewTimer = setTimeout(() => { + sessionHoverPreviewTimer = null; + showSessionHoverPreview(row, session); + }, 350); + }); + row.addEventListener('mouseleave', () => hideSessionHoverPreview(session.sessionId)); +} + function saveProjectsUi() { if (projectsUi.selectedProjectId) sessionStorage.setItem('projects.selected', projectsUi.selectedProjectId); else sessionStorage.removeItem('projects.selected'); - sessionStorage.setItem('projects.tab', projectsUi.tab); + // Tab selection belongs to each project and intentionally resets on restart. + sessionStorage.removeItem('projects.tab'); sessionStorage.setItem('projects.track', JSON.stringify(projectsUi.trackByProject)); sessionStorage.setItem('projects.groupBy', JSON.stringify(projectsUi.groupBy)); sessionStorage.setItem('projects.archivedOpen', JSON.stringify(projectsUi.archivedOpen)); } +const PROJECT_WORKSPACE_TABS = new Set(['overview', 'plan', 'files', 'git', 'settings']); + +function projectTab(projectOrId = projectsUi.selectedProjectId) { + const projectId = typeof projectOrId === 'string' ? projectOrId : projectOrId?.id; + return projectNavigation(projectId).tab; +} + +function setProjectTab(projectOrId, tab) { + const projectId = typeof projectOrId === 'string' ? projectOrId : projectOrId?.id; + if (!projectId || !PROJECT_WORKSPACE_TABS.has(tab)) return; + projectNavigation(projectId).tab = tab; +} + +function projectNavigation(projectOrId = projectsUi.selectedProjectId) { + const projectId = typeof projectOrId === 'string' ? projectOrId : projectOrId?.id; + if (!projectId) return { tab: 'overview', mode: 'overview', sessionId: null }; + if (!projectsUi.navigationByProject.has(projectId)) { + projectsUi.navigationByProject.set(projectId, { tab: 'overview', mode: 'overview', sessionId: null }); + } + return projectsUi.navigationByProject.get(projectId); +} + +function rememberProjectOverview(projectOrId) { + const state = projectNavigation(projectOrId); + state.mode = 'overview'; + state.sessionId = null; +} + +function rememberProjectSession(projectOrId, sessionId) { + if (!sessionId) return; + const state = projectNavigation(projectOrId); + state.mode = 'session'; + state.sessionId = sessionId; +} + +/** Keep remembered sessions valid when a newly launched/forked CLI gets its real id. */ +function rekeyProjectSessionState(oldId, newId) { + if (!oldId || !newId || oldId === newId) return; + for (const state of projectsUi.navigationByProject.values()) { + if (state.sessionId === oldId) state.sessionId = newId; + } +} + +/** A destroyed terminal cannot be restored as a project's selected session. */ +function forgetProjectSessionState(sessionId) { + for (const state of projectsUi.navigationByProject.values()) { + if (state.sessionId !== sessionId) continue; + state.mode = 'overview'; + state.sessionId = null; + } +} + const projectViewer = document.getElementById('project-viewer'); const projectStrip = document.getElementById('project-strip'); const projectPanes = document.getElementById('project-panes'); @@ -66,6 +221,7 @@ const PICONS = { dots: (s = 14) => ``, play: (s = 12) => ``, folder: (s = 12) => ``, + file: (s = 12) => ``, branch: (s = 12) => ``, pencil: (s = 14) => ``, check: (s = 14) => ``, @@ -353,12 +509,13 @@ async function toggleArchiveSession(session) { const newVal = session.archived ? 0 : 1; if (newVal && activePtyIds.has(session.sessionId)) { await window.api.stopSession(session.sessionId); pollActiveSessions(); } await window.api.archiveSession(session.sessionId, newVal); + if (newVal) forgetTerminalHistory(session.sessionId); session.archived = newVal; loadProjects(); } /** The J row: title over one meta line (dot, track label, CLI, age, size). */ -function buildSessionRow(project, session, { showTrack = true, className = 'pane-session' } = {}) { +function buildSessionRow(project, session, { showTrack = true, className = 'pane-session', hoverPreview = false } = {}) { const row = document.createElement('div'); const id = session.sessionId; // The same state classes the Sessions tab uses, so the two rows read alike. @@ -392,8 +549,14 @@ function buildSessionRow(project, session, { showTrack = true, className = 'pane btn.onclick = (e) => { e.stopPropagation(); if (dismissible) dismissSession(id); else toggleArchiveSession(session); }; row.appendChild(btn); } - row.onclick = () => openSession(session); - row.oncontextmenu = (e) => { e.preventDefault(); e.stopPropagation(); showContextMenu(sessionMenuItems(session), { x: e.clientX, y: e.clientY }); }; + row.onclick = () => { if (hoverPreview) hideSessionHoverPreview(id); openSession(session); }; + row.oncontextmenu = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (hoverPreview) hideSessionHoverPreview(id); + showContextMenu(sessionMenuItems(session), { x: e.clientX, y: e.clientY }); + }; + if (hoverPreview) attachSessionHoverPreview(row, session); return row; } @@ -601,10 +764,13 @@ function selectProject(id, { tab } = {}) { if (typeof terminalHeader !== 'undefined') terminalHeader.style.display = 'none'; } projectsUi.selectedProjectId = id; - if (tab) projectsUi.tab = tab; + if (tab) { + setProjectTab(id, tab); + rememberProjectOverview(id); + } saveProjectsUi(); projectsContent.querySelectorAll('.proj-row').forEach(r => r.classList.toggle('selected', r.dataset.projectId === id)); - showProjectOverview(); + showRememberedProjectView(); } // --- Main area: which project view to show --- @@ -615,18 +781,32 @@ function hideProjectChrome() { leaveWorking(); } -/** The main area for the Projects tab: the working session, else the overview, else nothing. */ -function showProjectHome() { - const activeSession = activeSessionId ? sessionMap.get(activeSessionId) : null; - if (activeSession && openSessions.has(activeSessionId) && !gridViewActive && projectForSession(activeSession)) { - showSession(activeSessionId); +/** Restore this project's own last view without relaunching a closed session. */ +function showRememberedProjectView() { + const project = selectedProject(); + if (project) { + const state = projectNavigation(project); + if (state.mode === 'session' && state.sessionId && !gridViewActive) { + const entry = openSessions.get(state.sessionId); + const session = sessionMap.get(state.sessionId) || entry?.session; + const owner = projectForSession(session); + if (entry && owner?.project?.id === project.id) { + showSession(state.sessionId); + return; + } + } + showProjectOverview(); return; } - if (selectedProject()) { showProjectOverview(); return; } hideAllViewers(); placeholder.style.display = ''; } +/** The main area for the Projects tab: that project's remembered view. */ +function showProjectHome() { + showRememberedProjectView(); +} + /** Called when the user leaves the Projects tab. */ function leaveProjectViews() { if (projectViewer) projectViewer.style.display = 'none'; @@ -653,7 +833,11 @@ function refreshProjectViews({ reason = 'project' } = {}) { // the Sessions tab keeps its list. The main area shows whatever that tab // would show: the placeholder, or the terminal behind its exit banner. const project = info?.project || selectedProject(); - if (project) { renderStrip(project); renderPanes(project); } else leaveWorking(); + if (project) { + renderStrip(project); + renderPanes(project); + ensureWorkingTerminalVisible(project); + } else leaveWorking(); } else if (projectViewer && projectViewer.style.display !== 'none') { const project = selectedProject(); if (!project) { projectViewer.style.display = 'none'; placeholder.style.display = ''; } @@ -670,6 +854,55 @@ function refreshProjectViews({ reason = 'project' } = {}) { updateProjectStatusDots(); } +/** + * Project refreshes rebuild the strip and session pane, not the terminal. Keep + * the live terminal as an explicit invariant anyway: another viewer can leave + * the terminal area hidden, and a class lost during a surrounding re-render + * otherwise produces a blank session view until the row is clicked again. + * + * This is called by renderPanes itself, rather than only by the outer project + * refresh. Busy/idle changes, plan-file changes and pane controls all render + * the pane directly and used to bypass the repair. + */ +function ensureWorkingTerminalVisible(project, { refit = false } = {}) { + if (!projectsUi.working || gridViewActive || activeTaskView || !activeSessionId) return; + const entry = openSessions.get(activeSessionId); + const session = sessionMap.get(activeSessionId) || entry?.session; + const rememberedHere = projectNavigation(project).mode === 'session' && + projectNavigation(project).sessionId === activeSessionId; + if (!entry || (projectForSession(session)?.project?.id !== project.id && !rememberedHere)) return; + + let repaired = false; + if (!mainEl.classList.contains('project-working')) { + mainEl.classList.add('project-working'); + repaired = true; + } + if (projectStrip.style.display === 'none') { + projectStrip.style.display = ''; + repaired = true; + } + if (projectPanes.style.display === 'none') { + projectPanes.style.display = ''; + repaired = true; + } + if (terminalArea.style.display === 'none') { + terminalArea.style.display = ''; + repaired = true; + } + if (!entry.element.classList.contains('visible')) { + document.querySelectorAll('.terminal-container.visible').forEach(el => el.classList.remove('visible')); + entry.element.classList.add('visible'); + repaired = true; + } + placeholder.style.display = 'none'; + const header = document.getElementById('terminal-header'); + if (header && header.style.display === 'none') header.style.display = ''; + // Replacing the pane can change the terminal's available geometry even when + // none of its visibility classes changed. Refit on that path so xterm's + // canvas cannot remain sized for the pre-project layout and look blank. + if (repaired || refit) fitAndScroll(entry); +} + /** * True while the user is typing a todo on the page. Neither input carries an * id, so the focus restore above cannot bring them back; nothing may rebuild @@ -714,7 +947,7 @@ function applySessionStatus(project) { } // Only the Overview tab shows sessions; the others do not care. - if (projectsUi.tab !== 'overview') return; + if (projectTab(project) !== 'overview') return; for (const card of projectViewer.querySelectorAll('.tcard[data-track-key]')) { const key = card.dataset.trackKey; const track = key === 'general' ? null : (project.tracks || []).find(t => t.id === key); @@ -750,7 +983,10 @@ function enterWorking(project, session) { projectsUi.selectedProjectId = project.id; projectsContent.querySelectorAll('.proj-row').forEach(r => r.classList.toggle('selected', r.dataset.projectId === project.id)); } - if (session) projectsUi.trackByProject[project.id] = session.trackId || 'general'; + if (session) { + projectsUi.trackByProject[project.id] = session.trackId || 'general'; + rememberProjectSession(project, session.sessionId); + } saveProjectsUi(); if (projectViewer) projectViewer.style.display = 'none'; const wasWorking = projectsUi.working; @@ -828,6 +1064,7 @@ function leaveWorking() { function showProjectOverview() { const project = selectedProject(); if (!project) return; + rememberProjectOverview(project); leaveWorking(); hideAllViewers(); terminalArea.style.display = 'none'; @@ -905,6 +1142,8 @@ function applyGitStatus(project) { function renderOverview() { const project = selectedProject(); if (!project || !projectViewer) return; + hideSessionHoverPreview(); + const tabName = projectTab(project); const running = runningTasksFor(project); const sessions = projectSessionsAll(project); const state = groupState(sessions); @@ -931,24 +1170,24 @@ function renderOverview() {
- - - - - + + + + +
`; const body = projectViewer.querySelector('#ws-body'); - if (projectsUi.tab === 'settings') renderSettings(project, body); - else if (projectsUi.tab === 'files') renderFilesTab(project, body); - else if (projectsUi.tab === 'plan') renderPlanTab(project, body); - else if (projectsUi.tab === 'git') renderProjectGitTab(project, body); + if (tabName === 'settings') renderSettings(project, body); + else if (tabName === 'files') renderFilesTab(project, body); + else if (tabName === 'plan') renderPlanTab(project, body); + else if (tabName === 'git') renderProjectGitTab(project, body); else renderOverviewBody(project, body); projectViewer.querySelectorAll('.ws-tab:not([disabled])').forEach(tab => { - tab.onclick = () => { projectsUi.tab = tab.dataset.tab; saveProjectsUi(); renderOverview(); }; + tab.onclick = () => { setProjectTab(project, tab.dataset.tab); renderOverview(); }; }); projectViewer.querySelector('#ws-new').onclick = (e) => showNewSessionMenu(project, null, e.currentTarget); projectViewer.querySelector('#ws-more').onclick = (e) => showContextMenu(projectMenuItems(project, { fromPage: true }), { anchor: e.currentTarget }); @@ -986,7 +1225,7 @@ function renderOverviewBody(project, body) { body.querySelector('#ws-new-track').onclick = () => promptNewTrack(project); renderSideCards(project, body.querySelector('#ws-side')); loadProjectFiles(project).then(() => { - if (selectedProject()?.id !== project.id || projectsUi.tab !== 'overview') return; + if (selectedProject()?.id !== project.id || projectTab(project) !== 'overview') return; if (editingInPage()) return; const side = projectViewer.querySelector('#ws-side'); if (side) renderSideCards(project, side); @@ -999,7 +1238,7 @@ function sessionsOfTrack(project, trackKey) { } function buildCardSessionRow(project, session) { - return buildSessionRow(project, session, { showTrack: false, className: 'pane-session tcard-session' }); + return buildSessionRow(project, session, { showTrack: false, className: 'pane-session tcard-session', hoverPreview: true }); } function buildTrackCard(project, track) { @@ -1022,6 +1261,7 @@ function buildTrackCard(project, track) { `${track ? escapeHtml(cwdLabel) : 'sessions not in a track'}` + `` + (track?.cli ? `${track.cli === 'codex' ? ICONS.codex(12) : ICONS.claude(12)}` : '') + + `` + (track ? `` : ''); card.appendChild(head); @@ -1037,10 +1277,8 @@ function buildTrackCard(project, track) { const foot = document.createElement('div'); foot.className = 'tcard-f'; foot.innerHTML = - (sessions.length ? `` : '') + - `` + - `` + - (sessions.length > 3 ? `` : ''); + (sessions.length > 3 ? `` : '') + + ``; card.appendChild(foot); // Archived sessions of this track sit under the foot, closed until asked for. const archived = archivedSessionsOf(project, key); @@ -1061,14 +1299,14 @@ function buildTrackCard(project, track) { if (open) { const list = document.createElement('div'); list.className = 'tcard-archived-list'; - for (const s of archived) list.appendChild(buildSessionRow(project, s, { showTrack: false, className: 'pane-session tcard-session pane-session--archived' })); + for (const s of archived) list.appendChild(buildSessionRow(project, s, { showTrack: false, className: 'pane-session tcard-session pane-session--archived', hoverPreview: true })); card.appendChild(list); } } - const resume = foot.querySelector('.tcard-resume'); - if (resume) resume.onclick = () => openSession(sessions[0]); - foot.querySelector('.tcard-new').onclick = (e) => launchFromTrack(project, track, e.currentTarget); + // Nothing left to show in the foot once New session moved up to the head. + if (!foot.querySelector('button')) foot.remove(); + head.querySelector('.tcard-new').onclick = (e) => launchFromTrack(project, track, e.currentTarget); const more = foot.querySelector('.tcard-more-sessions'); if (more) more.onclick = () => openTrackInPanes(project, key); const moreBtn = head.querySelector('.tcard-more'); @@ -1184,8 +1422,7 @@ async function appendTodo(project, text) { /** Open one of the project's own files on the Files tab, in the page's editor. */ function openProjectFileInEditor(project, name) { filesState(project).selected = name; - projectsUi.tab = 'files'; - saveProjectsUi(); + setProjectTab(project, 'files'); if (!projectViewer || projectViewer.style.display === 'none') showProjectOverview(); else renderOverview(); } @@ -1259,7 +1496,7 @@ function startItemRows(project, track, prompt, planItem) { const where = track ? track.name : (project.defaultCwd ? pathBasename(project.defaultCwd) : 'project folder'); return [ { label: 'Claude', icon: ICONS.claude(14), hint: `in ${where}`, onClick: () => launch('claude') }, - { label: 'Codex', icon: ICONS.codex(14), onClick: () => launch('codex') }, + { label: 'Codex', icon: ICONS.codex(14, 'codex-icon'), onClick: () => launch('codex') }, ]; } @@ -1314,7 +1551,7 @@ function renderPlanTab(project, body) { else body.innerHTML = '
Loading the plan…
'; const before = planSignature(state); loadProjectPlan(project).then(() => { - if (selectedProject()?.id !== project.id || projectsUi.tab !== 'plan') return; + if (selectedProject()?.id !== project.id || projectTab(project) !== 'plan') return; if (!projectViewer.contains(body)) return; // Redraw only when the plan or the todos actually moved, so a field being // typed into survives a refresh that changed nothing. A field that is open @@ -1548,7 +1785,7 @@ async function reloadPlanAndRender(project, state) { await loadProjectPlan(project); await loadProjectFiles(project, { force: true }); const body = projectViewer?.querySelector('#ws-body'); - if (!body || selectedProject()?.id !== project.id || projectsUi.tab !== 'plan') return; + if (!body || selectedProject()?.id !== project.id || projectTab(project) !== 'plan') return; renderPlanBody(project, body, state); renderPlanMeta(project); } @@ -1564,7 +1801,7 @@ window.api.onProjectPlanChanged?.((projectId) => { if (projectsUi.working) { renderPanes(project); return; } if (projectViewer && projectViewer.style.display !== 'none') { if (editingInPage()) return; - if (projectsUi.tab === 'plan') renderPlanTab(project, projectViewer.querySelector('#ws-body')); + if (projectTab(project) === 'plan') renderPlanTab(project, projectViewer.querySelector('#ws-body')); else renderOverview(); } }); @@ -1727,7 +1964,7 @@ async function renderFilesTab(project, body) { body.innerHTML = `
-
Project folder
+
Project folder
CLAUDE.md is the brief every session reads. The agent creates plan.md, plan-tracker.md, todos.md and memory.md when it needs them. Attached repositories are not listed here.
@@ -1735,6 +1972,10 @@ async function renderFilesTab(project, body) {
`; const state = filesState(project); const list = body.querySelector('#ws-files-list'); + body.querySelector('#ws-files-open').onclick = async () => { + const result = await window.api.openPath(project.root); + if (result?.error) alert(result.error); + }; body.querySelector('#ws-files-refresh').onclick = async () => { state.cache.clear(); await renderFileTree(project, list, state); @@ -1751,6 +1992,66 @@ function hasPlanText(content) { return stripped.length > 0; } +function bindAddedFilesCard(project, side) { + const dropZone = side.querySelector('#ws-added-files-drop'); + if (!dropZone) return; + let dragDepth = 0; + const setOver = (over) => dropZone.classList.toggle('is-over', over); + + dropZone.addEventListener('dragenter', (event) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth++; + setOver(true); + }); + dropZone.addEventListener('dragover', (event) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'copy'; + }); + dropZone.addEventListener('dragleave', (event) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth = Math.max(0, dragDepth - 1); + if (!dragDepth) setOver(false); + }); + dropZone.addEventListener('drop', async (event) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth = 0; + setOver(false); + const sourcePaths = Array.from(event.dataTransfer.files || []).map(file => { + try { return window.api.getPathForFile(file); } catch { return ''; } + }).filter(Boolean); + if (!sourcePaths.length) return; + + dropZone.classList.add('is-busy'); + const label = dropZone.querySelector('.ws-added-drop-label'); + if (label) label.textContent = `Adding ${sourcePaths.length} file${sourcePaths.length === 1 ? '' : 's'}…`; + let result; + try { result = await window.api.addProjectFiles(project.id, sourcePaths); } + catch (err) { result = { error: err.message }; } + if (!result?.ok) { + dropZone.classList.remove('is-busy'); + if (label) label.textContent = 'Drop files here'; + alert(result?.error || 'Could not add the files.'); + return; + } + + project.addedFiles = result.files || []; + project.addedFilesPath = result.dirPath || project.addedFilesPath; + filesState(project).cache.clear(); + renderSideCards(project, side); + if (result.errors?.length) alert(`Some items could not be added:\n\n${result.errors.join('\n')}`); + }); + + const open = side.querySelector('#ws-added-files-open'); + if (open) open.onclick = async () => { + const result = await window.api.openPath(project.addedFilesPath); + if (result?.error) alert(result.error); + }; +} + function renderSideCards(project, side) { const brief = briefSummary(fileContent(project, 'CLAUDE.md')); const plan = parsePlan(fileContent(project, 'plan-tracker.md')); @@ -1759,6 +2060,7 @@ function renderSideCards(project, side) { const open = todos.filter(t => !t.done); const doneTodos = todos.length - open.length; const pct = plan.phases.length ? Math.round((plan.done / plan.phases.length) * 100) : 0; + const addedFiles = project.addedFiles || []; side.innerHTML = `
@@ -1778,19 +2080,32 @@ function renderSideCards(project, side) {
Any session can add here. Try "add a todo: …" in a session of this project.
-
Folders
+
Attached Folders
${(project.folders || []).length ? (project.folders || []).map(f => `
${f.mode === 'worktree' ? PICONS.branch(13) : PICONS.folder(13)} ${escapeHtml(pathBasename(f.path))} ${escapeHtml(folderModeText(project, f))}
`).join('') : '
No folders attached. Sessions run in the project folder.
'} +
+
+
Added Files${addedFiles.length ? `${addedFiles.length} file${addedFiles.length === 1 ? '' : 's'}` : ''}${addedFiles.length ? '' : ''}
+
+ ${PICONS.plus(14)} + Drop files hereCopied into added-files/ in the project folder +
+ ${addedFiles.length ? `
${addedFiles.map(file => ` +
+ ${PICONS.file(13)} + ${escapeHtml(file.name)} +
`).join('')}
` : ''}
`; side.querySelector('#ws-brief-edit').onclick = () => openProjectFileInEditor(project, 'CLAUDE.md'); side.querySelector('#ws-plan-open').onclick = () => openProjectFileInEditor(project, 'plan.md'); side.querySelector('#ws-plan-tracker').onclick = () => openProjectFileInEditor(project, 'plan-tracker.md'); side.querySelector('#ws-folders-add').onclick = () => attachFolderAsk(project); + bindAddedFilesCard(project, side); side.querySelectorAll('.ws-todo input').forEach(box => { box.onchange = async () => { box.disabled = true; @@ -2046,7 +2361,7 @@ function renderStrip(project) { `; - projectStrip.querySelector('#strip-back').onclick = () => { projectsUi.tab = 'overview'; saveProjectsUi(); showProjectOverview(); }; + projectStrip.querySelector('#strip-back').onclick = () => { setProjectTab(project, 'overview'); showProjectOverview(); }; projectStrip.querySelector('#strip-tasks').onclick = (e) => showTaskPopover(taskPseudoProject(project), e.currentTarget); projectStrip.querySelector('#strip-new').onclick = (e) => showNewSessionMenu(project, currentTrack(project), e.currentTarget); projectStrip.querySelector('#strip-more').onclick = (e) => showContextMenu(projectMenuItems(project, { fromPage: true }), { anchor: e.currentTarget }); @@ -2220,6 +2535,10 @@ function renderPanes(project) { projectsUi.lastStateKey = projectSessionsAll(project).map(s => s.sessionId + ':' + sessionState(s)).join('|'); const active = pane.querySelector('.pane-session.here'); if (active) active.scrollIntoView({ block: 'nearest' }); + // renderPanes has several direct callers (most notably the first busy signal + // from a newly started CLI). They do not pass through refreshProjectViews, + // so reassert the terminal invariant here after every pane replacement. + ensureWorkingTerminalVisible(project, { refit: true }); loadProjectFiles(project).then(() => { if (!projectsUi.working || selectedProject()?.id !== project.id) return; @@ -2363,7 +2682,7 @@ function newSessionItems(project, track) { const where = track ? track.name : (project.defaultCwd ? pathBasename(project.defaultCwd) : 'project folder'); return [ { label: 'Claude', icon: ICONS.claude(14), hint: `in ${where}`, onClick: async () => { const o = await resolveDefaultSessionOptions(target); o.runtime = 'claude'; launchNewSession(target, o); } }, - { label: 'Codex', icon: ICONS.codex(14), onClick: async () => { const o = await resolveDefaultSessionOptions(target); o.runtime = 'codex'; launchNewSession(target, o); } }, + { label: 'Codex', icon: ICONS.codex(14, 'codex-icon'), onClick: async () => { const o = await resolveDefaultSessionOptions(target); o.runtime = 'codex'; launchNewSession(target, o); } }, ...terminalLaunchItems(project, target), { sep: true }, { label: 'Claude, configure…', muted: true, onClick: () => showNewSessionDialog(target, 'claude') }, @@ -2454,7 +2773,7 @@ function trackMenuItems(project, track) { submenu: [ { label: 'Ask each time', muted: !track.cli, onClick: () => patch({ cli: null }) }, { label: 'Claude', icon: ICONS.claude(14), muted: track.cli === 'claude', onClick: () => patch({ cli: 'claude' }) }, - { label: 'Codex', icon: ICONS.codex(14), muted: track.cli === 'codex', onClick: () => patch({ cli: 'codex' }) }, + { label: 'Codex', icon: ICONS.codex(14, 'codex-icon'), muted: track.cli === 'codex', onClick: () => patch({ cli: 'codex' }) }, ] }, { sep: true }, { label: track.status === 'done' ? 'Reopen track' : 'Mark as done', icon: PICONS.check(14), onClick: () => patch({ status: track.status === 'done' ? 'active' : 'done' }) }, @@ -2471,6 +2790,7 @@ function sessionMoveItems(session) { if (result?.error) { alert(result.error); return; } session.projectId = projectId; session.trackId = trackId; + if (session.type === 'terminal') persistTerminalSession(session); loadProjects(); }; const moveItems = []; @@ -2519,19 +2839,24 @@ function sessionMenuItems(session) { const forkTarget = info ? { ...launchTargetFor(info.project, (info.project.tracks || []).find(t => t.id === session.trackId) || null), projectPath: session.projectPath } : folder; - return [ - { label: 'Open', icon: PICONS.play(13), onClick: () => openSession(session) }, + const sessionActions = [ session.type !== 'terminal' ? { label: 'Fork', icon: PICONS.fork(14), onClick: () => forkSession(session, forkTarget) } : null, session.type !== 'terminal' ? { label: unread ? 'Mark as read' : 'Mark as unread', icon: unread ? ICONS.markRead(14) : ICONS.markUnread(14), onClick: () => { if (unread) clearUnread(session.sessionId); else markUnread(session.sessionId); refreshSidebar(); } } : null, session.type !== 'terminal' ? { label: 'View messages', icon: PICONS.messages(14), onClick: () => showJsonlViewer(session) } : null, - session.type !== 'terminal' ? { label: 'Resume with config…', icon: ICONS.launchConfig(14), onClick: () => showResumeSessionDialog(session) } : null, - { sep: true }, - ...moveItems, - { sep: true }, + session.type !== 'terminal' && !running ? { label: 'Resume with config…', icon: ICONS.launchConfig(14), onClick: () => showResumeSessionDialog(session) } : null, + ].filter(Boolean); + const stateActions = [ running ? { label: 'Stop', icon: '', onClick: () => confirmAndStopSession(session.sessionId) } : null, isDismissibleSession(session.sessionId) ? { label: 'Dismiss', icon: PICONS.x(14), hint: 'never started', onClick: () => dismissSession(session.sessionId) } : null, session.type !== 'terminal' ? { label: session.archived ? 'Unarchive' : 'Archive', icon: PICONS.archive(14), onClick: () => toggleArchiveSession(session) } : null, ].filter(Boolean); + return [ + ...sessionActions, + sessionActions.length && moveItems.length ? { sep: true } : null, + ...moveItems, + moveItems.length && stateActions.length ? { sep: true } : null, + ...stateActions, + ].filter(Boolean); } // --- Flows (create, rename, attach, detach, done, remove) --- @@ -2653,6 +2978,7 @@ async function removeProjectFlow(project) { if (!confirm(`Remove ${project.name} from Switchboard?\n\nThe folder ${project.root} and all sessions stay on disk.`)) return; const result = await window.api.deleteProject(project.id); if (result?.error) { alert(result.error); return; } + projectsUi.navigationByProject.delete(project.id); if (projectsUi.selectedProjectId === project.id) { projectsUi.selectedProjectId = null; saveProjectsUi(); leaveProjectViews(); placeholder.style.display = ''; } loadProjects(); } @@ -3009,7 +3335,7 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo if (!result || result.error) { showError(result?.error || 'Could not create the project.'); return; } close(); projectsUi.selectedProjectId = result.project?.id || null; - projectsUi.tab = 'overview'; + setProjectTab(projectsUi.selectedProjectId, 'overview'); saveProjectsUi(); await loadProjects(); const tab = document.querySelector('.sidebar-tab[data-tab="projects"]'); diff --git a/public/sidebar.js b/public/sidebar.js index acbb4b9e..5be49023 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -520,6 +520,7 @@ function rebindSidebarEvents(projects) { await window.api.stopSession(s.sessionId); } await window.api.archiveSession(s.sessionId, 1); + forgetTerminalHistory(s.sessionId); s.archived = 1; } pollActiveSessions(); @@ -582,6 +583,7 @@ function bindSessionListEvents(container) { if (!session || session.archived) continue; if (activePtyIds.has(sid)) await window.api.stopSession(sid); await window.api.archiveSession(sid, 1); + forgetTerminalHistory(sid); session.archived = 1; } pollActiveSessions(); @@ -632,6 +634,7 @@ function bindSessionListEvents(container) { await window.api.stopSession(session.sessionId); } await window.api.archiveSession(session.sessionId, 1); + forgetTerminalHistory(session.sessionId); session.archived = 1; } pollActiveSessions(); @@ -734,6 +737,7 @@ function bindSessionListEvents(container) { pollActiveSessions(); } await window.api.archiveSession(session.sessionId, newVal); + if (newVal) forgetTerminalHistory(session.sessionId); session.archived = newVal; loadProjects(); }; diff --git a/public/style.css b/public/style.css index 8c17013f..d47cc03e 100644 --- a/public/style.css +++ b/public/style.css @@ -4830,11 +4830,7 @@ body { display: flex; flex-direction: column; } min-width: 0; } -.tcard--general { - border-style: dashed; - border-color: rgba(255,255,255,0.1); - background: transparent; -} +.tcard--general { border-color: rgba(255,255,255,0.09); } .tcard--done { opacity: 0.6; } .tcard--done:hover { opacity: 1; } @@ -4843,15 +4839,19 @@ body { display: flex; flex-direction: column; } display: flex; align-items: center; gap: 8px; - padding: 12px 14px 6px; + padding: 9px 14px; min-width: 0; + background: rgba(255,255,255,0.022); + border-bottom: 1px solid rgba(255,255,255,0.06); + border-radius: 9px 9px 0 0; } .tcard-name { - font-size: 13px; + font-size: 11.5px; font-weight: 700; color: #d0d0e8; - letter-spacing: 0.01em; + letter-spacing: 0.09em; + text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -4882,11 +4882,9 @@ body { display: flex; flex-direction: column; } align-items: center; justify-content: center; cursor: pointer; - opacity: 0; transition: all 0.12s; } -.tcard:hover .tcard-more { opacity: 1; } .tcard-more:hover { background: rgba(255,255,255,0.06); color: #c0c0d8; } .tcard .pane-session { margin: 0 6px; } @@ -4906,6 +4904,9 @@ body { display: flex; flex-direction: column; } margin-top: 6px; } +.tcard-h .tcard-new { flex: none; } + + /* ---- Side cards, settings cards ---- */ .ws-card { background: #18181f; @@ -5033,6 +5034,55 @@ body { display: flex; flex-direction: column; } .ws-frow .ws-ghost { opacity: 0; } .ws-frow:hover .ws-ghost { opacity: 1; } +.ws-added-files-card { padding-bottom: 8px; } + +.ws-added-drop { + display: flex; + align-items: center; + gap: 9px; + margin: 2px 12px 6px; + padding: 10px; + border: 1px dashed rgba(128,136,255,0.3); + border-radius: 8px; + color: #a6abff; + background: rgba(120,130,255,0.045); + transition: border-color 0.12s, background 0.12s, color 0.12s, opacity 0.12s; +} + +.ws-added-drop.is-over { + border-color: rgba(128,136,255,0.85); + background: rgba(120,130,255,0.14); + color: #d0d3ff; +} + +.ws-added-drop.is-busy { opacity: 0.65; } +.ws-added-drop-icon { display: inline-flex; flex-shrink: 0; } +.ws-added-drop-label { display: block; font-size: 12px; font-weight: 650; } +.ws-added-drop-hint { display: block; margin-top: 2px; color: #6f6f88; font-size: 10.5px; line-height: 1.35; } + +.ws-added-list { + max-height: 190px; + overflow-y: auto; + padding: 2px 0; +} + +.ws-added-file { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 5px 14px; + color: #b8b8cc; + font-size: 12px; +} + +.ws-added-file-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ---- Settings tab ---- */ .ws-settings { display: flex; @@ -5733,6 +5783,7 @@ body { display: flex; flex-direction: column; } .ctx-item.disabled:hover { background: none; } .ctx-item.muted { color: #8888a0; } .ctx-icon { width: 14px; height: 14px; display: inline-flex; align-items: center; justify-content: center; color: #8088ff; flex-shrink: 0; } +.ctx-icon .codex-icon { color: #10a37f; } .ctx-item.danger .ctx-icon { color: #e05070; } .ctx-item.muted .ctx-icon, .ctx-item.disabled .ctx-icon { color: #6a6a80; } .ctx-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } @@ -5790,6 +5841,85 @@ body { display: flex; flex-direction: column; } .tcard .tcard-session:hover { background: rgba(255,255,255,0.035); } .tcard .tcard-session .pane-meta { margin-top: 1px; } +/* Lazy transcript preview for session rows on the project Overview page. */ +.session-turn-preview { + position: fixed; + z-index: 10030; + box-sizing: border-box; + display: none; + padding: 11px 13px 13px; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 9px; + background: #191923; + box-shadow: 0 10px 32px rgba(0,0,0,0.38); + pointer-events: none; +} +.session-turn-preview.visible { display: block; } +.session-turn-preview-label { + margin-bottom: 7px; + color: #77778e; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.session-turn-preview-text { + max-height: 342px; + overflow: hidden; + color: #c9c9d8; + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} +.session-turn-preview-text > :first-child { margin-top: 0; } +.session-turn-preview-text > :last-child { margin-bottom: 0; } +.session-turn-preview-text p { margin: 0 0 7px; } +.session-turn-preview-text h1, +.session-turn-preview-text h2, +.session-turn-preview-text h3, +.session-turn-preview-text h4, +.session-turn-preview-text h5, +.session-turn-preview-text h6 { + margin: 9px 0 4px; + font-size: inherit; + line-height: inherit; + font-weight: 700; +} +.session-turn-preview-text h1 { color: #8ebbe8; } +.session-turn-preview-text h2 { color: #86cbb5; } +.session-turn-preview-text h3 { color: #b7a0df; } +.session-turn-preview-text h4 { color: #d6b078; } +.session-turn-preview-text h5 { color: #d994a5; } +.session-turn-preview-text h6 { color: #9fa8c7; } +.session-turn-preview-text strong { color: #e0e0ee; font-weight: 700; } +.session-turn-preview-text em { color: #d2d2e2; } +.session-turn-preview-text ul, +.session-turn-preview-text ol { margin: 3px 0 7px; padding-left: 19px; } +.session-turn-preview-text li { margin: 1px 0; } +.session-turn-preview-text code { + padding: 1px 3px; + border-radius: 3px; + background: rgba(255,255,255,0.07); + color: #d7d7e8; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 0.92em; +} +.session-turn-preview-text pre { + margin: 5px 0 8px; + padding: 7px 8px; + border-radius: 5px; + background: rgba(0,0,0,0.24); + white-space: pre-wrap; +} +.session-turn-preview-text pre code { padding: 0; background: none; } +.session-turn-preview-text blockquote { + margin: 5px 0 8px; + padding-left: 9px; + border-left: 2px solid rgba(255,255,255,0.16); + color: #a8a8bd; +} +.session-turn-preview-text a { color: #8eb8e8; text-decoration: none; } + /* ========== PROJECT PAGE: FILES TAB ========== */ .ws-body--files { display: flex; diff --git a/public/terminal-manager.js b/public/terminal-manager.js index b7f550ef..a622cb9d 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -177,6 +177,187 @@ const ESC_SYNC_END = '\x1b[?2026l'; const SYNC_BUFFER_TIMEOUT = 500; // max ms to hold data waiting for sync end const terminalWriteBuffers = new Map(); // sessionId → { chunks, syncDepth, rafId, timerId } +// A small, plain-text tail of each session's terminal survives a process/app +// restart. It is deliberately not a terminal serialization: replaying old +// cursor movement and alternate-screen escapes would let a stale TUI repaint +// over the newly resumed one. Plain text can be shown safely in grey, then an +// SGR reset hands color control back to live output. +const TERMINAL_HISTORY_KEY = 'terminalHistory.v1'; +const TERMINAL_HISTORY_MAX_SESSIONS = 20; +const TERMINAL_HISTORY_MAX_LINES = 120; +const TERMINAL_HISTORY_MAX_CHARS = 48 * 1024; +const TERMINAL_HISTORY_SAVE_DELAY = 1200; +const PERSISTED_TERMINALS_KEY = 'persistedRawTerminals.v1'; + +/** Extract the visible buffer tail as plain text, joining wrapped rows. */ +function terminalBufferText(buffer, maxLines = TERMINAL_HISTORY_MAX_LINES, maxChars = TERMINAL_HISTORY_MAX_CHARS) { + if (!buffer || !Number.isFinite(buffer.length) || typeof buffer.getLine !== 'function') return ''; + const start = Math.max(0, buffer.length - Math.max(1, maxLines)); + const logical = []; + for (let i = start; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (!line || typeof line.translateToString !== 'function') continue; + const text = line.translateToString(true); + if (line.isWrapped && logical.length) logical[logical.length - 1] += text; + else logical.push(text); + } + while (logical.length && !logical[0].trim()) logical.shift(); + while (logical.length && !logical[logical.length - 1].trim()) logical.pop(); + let text = logical.join('\n'); + if (text.length > maxChars) { + text = text.slice(-maxChars); + const firstBreak = text.indexOf('\n'); + if (firstBreak >= 0) text = text.slice(firstBreak + 1); + } + return text; +} + +/** Grey restored text followed by an explicit reset, so live output is normal. */ +function restoredTerminalHistoryAnsi(text) { + if (!text) return ''; + const safe = String(text).replace(/\x1b/g, '').replace(/\r?\n/g, '\r\n'); + return `\x1b[90m${safe}\x1b[0m\r\n\x1b[2;90m── restored terminal history ──\x1b[0m\r\n`; +} + +function readTerminalHistoryStore() { + try { + const value = JSON.parse(localStorage.getItem(TERMINAL_HISTORY_KEY) || '{}'); + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } catch { return {}; } +} + +function writeTerminalHistoryStore(store) { + try { localStorage.setItem(TERMINAL_HISTORY_KEY, JSON.stringify(store)); } catch {} +} + +/** Remove retained scrollback and cancel a delayed save for this session. */ +function forgetTerminalHistory(sessionId) { + if (!sessionId) return; + if (typeof openSessions !== 'undefined') { + const entry = openSessions.get(sessionId); + if (entry) { + clearTimeout(entry.historySaveTimer); + entry.historySaveTimer = null; + } + } + const store = readTerminalHistoryStore(); + if (!Object.prototype.hasOwnProperty.call(store, sessionId)) return; + delete store[sessionId]; + writeTerminalHistoryStore(store); +} + +function saveTerminalHistory(entry) { + if (!entry) return; + clearTimeout(entry.historySaveTimer); + entry.historySaveTimer = null; + const sessionId = entry.session?.sessionId; + if (entry.session?.archived) { + forgetTerminalHistory(sessionId); + return; + } + const text = terminalBufferText(entry.terminal?.buffer?.active); + if (!sessionId || !text) return; + const store = readTerminalHistoryStore(); + store[sessionId] = { text, savedAt: Date.now() }; + const keep = Object.entries(store) + .sort((a, b) => Number(b[1]?.savedAt || 0) - Number(a[1]?.savedAt || 0)) + .slice(0, TERMINAL_HISTORY_MAX_SESSIONS); + writeTerminalHistoryStore(Object.fromEntries(keep)); +} + +function scheduleTerminalHistorySave(entry) { + if (!entry) return; + if (entry.session?.archived) { + forgetTerminalHistory(entry.session.sessionId); + return; + } + clearTimeout(entry.historySaveTimer); + entry.historySaveTimer = setTimeout(() => saveTerminalHistory(entry), TERMINAL_HISTORY_SAVE_DELAY); +} + +function restoreTerminalHistory(entry) { + if (!entry) return; + // A renderer reload reattaches to a still-live main-process PTY, whose own + // buffer is replayed in full. Only add the persisted tail when a new PTY is + // about to resume the session, otherwise the same output appears twice. + if (activePtyIds.has(entry.session.sessionId)) return; + const saved = readTerminalHistoryStore()[entry.session.sessionId]; + if (!saved?.text) return; + entry.terminal.write(restoredTerminalHistoryAnsi(saved.text)); + entry.historyRestored = true; +} + +/** The durable subset needed to reopen a raw shell after the app exits. */ +function persistedTerminalRecord(session) { + if (!session || session.type !== 'terminal' || !session.sessionId || !session.projectPath) return null; + return { + sessionId: String(session.sessionId), + summary: 'Terminal', + firstPrompt: '', + projectPath: String(session.projectPath), + projectId: session.projectId ? String(session.projectId) : null, + trackId: session.trackId ? String(session.trackId) : null, + name: null, + starred: 0, + archived: 0, + messageCount: 0, + modified: session.modified || new Date().toISOString(), + created: session.created || session.modified || new Date().toISOString(), + type: 'terminal', + }; +} + +function parsePersistedTerminalSessions(raw) { + let rows; + try { rows = JSON.parse(raw || '[]'); } catch { return []; } + if (!Array.isArray(rows)) return []; + const seen = new Set(); + const sessions = []; + for (const row of rows) { + const session = persistedTerminalRecord(row); + if (!session || seen.has(session.sessionId)) continue; + seen.add(session.sessionId); + sessions.push(session); + } + return sessions; +} + +function persistedTerminalSessions() { + try { return parsePersistedTerminalSessions(localStorage.getItem(PERSISTED_TERMINALS_KEY)); } + catch { return []; } +} + +function writePersistedTerminalSessions(sessions) { + try { localStorage.setItem(PERSISTED_TERMINALS_KEY, JSON.stringify(sessions)); } catch {} +} + +function persistTerminalSession(session) { + const record = persistedTerminalRecord(session); + if (!record) return; + const sessions = persistedTerminalSessions().filter(item => item.sessionId !== record.sessionId); + sessions.push(record); + writePersistedTerminalSessions(sessions); +} + +function forgetPersistedTerminalSession(sessionId) { + if (!sessionId) return; + const sessions = persistedTerminalSessions(); + const kept = sessions.filter(session => session.sessionId !== sessionId); + if (kept.length !== sessions.length) writePersistedTerminalSessions(kept); +} + +/** Move early output saved under a temporary Codex/fork id to its real id. */ +function rekeyTerminalHistory(oldId, newId) { + if (!oldId || !newId || oldId === newId) return; + const store = readTerminalHistoryStore(); + if (!store[oldId]) return; + if (!store[newId] || Number(store[oldId].savedAt || 0) > Number(store[newId].savedAt || 0)) { + store[newId] = store[oldId]; + } + delete store[oldId]; + writeTerminalHistoryStore(store); +} + function flushTerminalBuffer(sessionId) { const buf = terminalWriteBuffers.get(sessionId); if (!buf) return; @@ -191,6 +372,7 @@ function flushTerminalBuffer(sessionId) { const wasAtBottom = isAtBottom(entry.terminal); const savedViewportY = entry.terminal.buffer.active.viewportY; entry.terminal.write(data, () => { + scheduleTerminalHistorySave(entry); if (sessionId !== activeSessionId) return; if (wasAtBottom) { entry.terminal.scrollToBottom(); @@ -332,6 +514,7 @@ function createTerminalEntry(session) { const entry = { terminal, element: container, fitAddon, searchAddon, openSearchBar, closeSearchBar, session, closed: false }; openSessions.set(sessionId, entry); + restoreTerminalHistory(entry); // Wire up IPC (use entry.session.sessionId so fork re-keying works) terminal.onData(data => { @@ -365,17 +548,34 @@ function wasIntentionalExit({ exitCode, signal, userStopped }) { } // Clean up a closed session entry (dispose terminal, remove DOM, remove from maps). -function destroySession(sessionId) { +function destroySession(sessionId, { forgetPersisted = true, preserveHistory = false } = {}) { const entry = openSessions.get(sessionId); + const session = entry?.session || sessionMap.get(sessionId); + if (preserveHistory) { + if (entry) saveTerminalHistory(entry); + } else { + forgetTerminalHistory(sessionId); + } + if (forgetPersisted && session?.type === 'terminal') forgetPersistedTerminalSession(sessionId); if (!entry) return; window.api.closeTerminal(sessionId); entry.terminal.dispose(); entry.element.remove(); openSessions.delete(sessionId); + if (typeof forgetProjectSessionState === 'function') forgetProjectSessionState(sessionId); const card = gridCards.get(sessionId); if (card) { card.remove(); gridCards.delete(sessionId); } } +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + for (const entry of openSessions.values()) { + saveTerminalHistory(entry); + if (entry.session?.type === 'terminal' && !entry.closed) persistTerminalSession(entry.session); + } + }); +} + // Make a session visible in the current view mode (grid or single). // Handles sidebar highlight, notifications, header, fit, and focus. function showSession(sessionId) { @@ -453,5 +653,9 @@ function setupDragAndDrop(container, getSessionId) { // Expose pure key-handling predicates to Node for unit testing. No-op in the // browser, where this file is loaded as a plain + diff --git a/public/project-view/new-session-menu.png b/public/project-view/new-session-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..69810bc36e05c7983cd6ccf2f553276843501533 GIT binary patch literal 69101 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
qJI59#Iu)JtIa;Lh@Au9aba4R*%%fD zcsM<4RDakGVAu{VNP^NwoT<(53#G1EiCy0;L)qTxmAO>&Dgc>HB8mSV_!)R$E^2@B zf&l2X7@?GMi<6u`oml}4QA{&uqalnGLjbGTfY_d3JGaqZrP?<;d5Qv)Y_0dx8uHl7 zCCYttdJxK5`O1~6_`f(W`-|119T6NW;(*IF$>JTw zb7w+dJ87w2Y@$bUo(JMJajSJk+k_)gx9Ws{CP}c-VA&H98YZ4BbRLQSv=1)LQ+uq{ zYU-amts1@j=e+fa0GBl<%DwSO#z-ZBdDy769w;ztu+*g>FgesJNUr+42+2C$Aq@!N z{i|VIU(wZi&!NnVE8S*SnZGS|!g0{p!MXSn7zOiNk)J#-a3pacAz~N906*d)(!!;J HKfV43oQEo0 literal 0 HcmV?d00001 diff --git a/public/project-view/overview.png b/public/project-view/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..350ada5773da2bfc88def792ea2a8ec39e43e914 GIT binary patch literal 108689 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%0A2K!aA;XV%EnwEeL6pJu5mm9=%ku)*bH0kor= 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 E4*}QAq5uE@ literal 0 HcmV?d00001 diff --git a/public/project-view/plan.png b/public/project-view/plan.png new file mode 100644 index 0000000000000000000000000000000000000000..926eca461dfb0e690c70d7659631997346838c46 GIT binary patch literal 60155 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 PArJ*wl?S=^o_PNQrt7nb literal 0 HcmV?d00001 diff --git a/public/project-view/session.png b/public/project-view/session.png new file mode 100644 index 0000000000000000000000000000000000000000..d7fdbd37193092f544b29aee9030cc0bfc5a0c2c GIT binary patch literal 96075 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

1o7&sjg2XgGeApxC*Kwi< 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 ec==*kzg$oet=y6(;rKsr3PHpG literal 0 HcmV?d00001 diff --git a/public/whats-new.css b/public/whats-new.css new file mode 100644 index 00000000..50caa3da --- /dev/null +++ b/public/whats-new.css @@ -0,0 +1,207 @@ +.whats-new-overlay { + position: fixed; + inset: 0; + z-index: 12000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(4, 4, 9, 0.78); + backdrop-filter: blur(9px); +} + +.whats-new-dialog { + position: relative; + width: min(1080px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + overflow: auto; + border: 1px solid rgba(145, 151, 255, 0.28); + border-radius: 16px; + background: #17171f; + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.62); +} + +.whats-new-header { + padding: 25px 64px 20px 28px; +} + +.whats-new-kicker { + margin-bottom: 5px; + color: #9298ff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.whats-new-header h2 { + color: #f0f0ff; + font-size: 25px; + line-height: 1.2; +} + +.whats-new-header p { + max-width: 720px; + margin-top: 8px; + color: #9898b0; + font-size: 13px; + line-height: 1.5; +} + +.whats-new-close { + position: absolute; + z-index: 2; + top: 17px; + right: 18px; + width: 34px; + height: 34px; + border: 0; + border-radius: 8px; + background: transparent; + color: #8888a0; + font-size: 25px; + line-height: 30px; + cursor: pointer; +} + +.whats-new-close:hover, +.whats-new-close:focus-visible { + background: rgba(255, 255, 255, 0.07); + color: #e0e0f0; +} + +.whats-new-stage { + position: relative; + display: grid; + place-items: center; + min-height: 280px; + margin: 0 28px; + aspect-ratio: 1440 / 760; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 11px; + background: #101017; +} + +.whats-new-image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + user-select: none; +} + +.whats-new-arrow { + position: absolute; + top: 50%; + display: grid; + place-items: center; + width: 42px; + height: 58px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(14, 14, 22, 0.86); + color: #f0f0ff; + font-size: 34px; + line-height: 1; + cursor: pointer; + transform: translateY(-50%); + opacity: 0.82; + transition: opacity 120ms ease, background 120ms ease, transform 120ms ease; +} + +.whats-new-arrow:hover, +.whats-new-arrow:focus-visible { + background: rgba(35, 35, 55, 0.96); + opacity: 1; +} + +.whats-new-arrow:active { + transform: translateY(-50%) scale(0.96); +} + +.whats-new-arrow--previous { left: 14px; } +.whats-new-arrow--next { right: 14px; } + +.whats-new-footer { + display: flex; + align-items: center; + gap: 24px; + padding: 18px 28px 22px; +} + +.whats-new-slide-copy { + min-width: 0; + flex: 1; +} + +.whats-new-caption { + min-height: 19px; + overflow: hidden; + color: #c0c0d8; + font-size: 13px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.whats-new-dots { + display: flex; + gap: 7px; + margin-top: 10px; +} + +.whats-new-dot { + width: 7px; + height: 7px; + padding: 0; + border: 0; + border-radius: 999px; + background: #4f4f65; + cursor: pointer; + transition: width 140ms ease, background 140ms ease; +} + +.whats-new-dot.active { + width: 20px; + background: #8088ff; +} + +.whats-new-dismiss { + flex: none; + min-width: 92px; + border: 1px solid rgba(128, 136, 255, 0.45); + border-radius: 8px; + padding: 8px 16px; + background: rgba(128, 136, 255, 0.18); + color: #d8d8f0; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.whats-new-dismiss:hover, +.whats-new-dismiss:focus-visible { + background: rgba(128, 136, 255, 0.28); + border-color: rgba(128, 136, 255, 0.7); +} + +.whats-new-close:focus-visible, +.whats-new-arrow:focus-visible, +.whats-new-dot:focus-visible, +.whats-new-dismiss:focus-visible { + outline: 2px solid #9298ff; + outline-offset: 2px; +} + +@media (max-width: 760px), (max-height: 650px) { + .whats-new-overlay { padding: 12px; } + .whats-new-dialog { + width: min(1080px, calc(100vw - 24px)); + max-height: calc(100vh - 24px); + } + .whats-new-header { padding: 18px 54px 14px 18px; } + .whats-new-header h2 { font-size: 21px; } + .whats-new-stage { min-height: 180px; margin: 0 18px; } + .whats-new-footer { padding: 14px 18px 17px; } +} diff --git a/public/whats-new.js b/public/whats-new.js new file mode 100644 index 00000000..42d92445 --- /dev/null +++ b/public/whats-new.js @@ -0,0 +1,144 @@ +(() => { + const ANNOUNCEMENT_KEY = 'whats-new:project-view:v1:dismissed'; + const SLIDES = [ + { + src: 'project-view/overview.png', + alt: 'Project View overview with tracks, sessions, plan progress, todos, and attached folders', + caption: 'Keep every track, session, plan, and follow-up together.', + }, + { + src: 'project-view/session.png', + alt: 'Project View working session with a project strip, grouped session list, and terminal', + caption: 'Work in a session without losing the project around it.', + }, + { + src: 'project-view/new-session-menu.png', + alt: 'Project View new-session menu with Claude, Codex, and terminal launch choices', + caption: 'Start Claude, Codex, or a terminal in the right project folder.', + }, + { + src: 'project-view/plan.png', + alt: 'Project View plan with phases, linked sessions, and open todos', + caption: 'See phase progress, linked sessions, and open todos at a glance.', + }, + ]; + + function shouldShowAnnouncement(storage) { + try { return storage.getItem(ANNOUNCEMENT_KEY) !== '1'; } + catch { return true; } + } + + function markAnnouncementDismissed(storage) { + try { storage.setItem(ANNOUNCEMENT_KEY, '1'); } + catch {} + } + + function wrappedSlideIndex(index, delta, length = SLIDES.length) { + return (index + delta + length) % length; + } + + function showProjectViewAnnouncement() { + if (document.querySelector('.whats-new-overlay')) return null; + + const previousFocus = document.activeElement; + const overlay = document.createElement('div'); + overlay.className = 'whats-new-overlay modal-overlay'; + overlay.innerHTML = ` + `; + + const image = overlay.querySelector('.whats-new-image'); + const caption = overlay.querySelector('.whats-new-caption'); + const dots = overlay.querySelector('.whats-new-dots'); + let slideIndex = 0; + + for (let index = 0; index < SLIDES.length; index++) { + const dot = document.createElement('button'); + dot.type = 'button'; + dot.className = 'whats-new-dot'; + dot.setAttribute('aria-label', `Show screenshot ${index + 1} of ${SLIDES.length}`); + dot.onclick = () => renderSlide(index); + dots.appendChild(dot); + } + + function renderSlide(index) { + slideIndex = index; + const slide = SLIDES[slideIndex]; + image.src = slide.src; + image.alt = slide.alt; + caption.textContent = slide.caption; + [...dots.children].forEach((dot, dotIndex) => { + const active = dotIndex === slideIndex; + dot.classList.toggle('active', active); + dot.setAttribute('aria-current', active ? 'true' : 'false'); + }); + } + + function move(delta) { + renderSlide(wrappedSlideIndex(slideIndex, delta)); + } + + function dismiss() { + markAnnouncementDismissed(window.localStorage); + document.removeEventListener('keydown', onKeyDown); + overlay.remove(); + if (previousFocus instanceof HTMLElement) previousFocus.focus(); + } + + function onKeyDown(event) { + if (event.key === 'Escape') dismiss(); + else if (event.key === 'ArrowLeft') move(-1); + else if (event.key === 'ArrowRight') move(1); + else return; + event.preventDefault(); + } + + overlay.querySelector('.whats-new-arrow--previous').onclick = () => move(-1); + overlay.querySelector('.whats-new-arrow--next').onclick = () => move(1); + overlay.querySelector('.whats-new-close').onclick = dismiss; + overlay.querySelector('.whats-new-dismiss').onclick = dismiss; + document.addEventListener('keydown', onKeyDown); + document.body.appendChild(overlay); + renderSlide(0); + overlay.querySelector('.whats-new-dismiss').focus(); + return overlay; + } + + function maybeShowProjectViewAnnouncement() { + if (!shouldShowAnnouncement(window.localStorage)) return; + window.setTimeout(showProjectViewAnnouncement, 250); + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + ANNOUNCEMENT_KEY, + SLIDES, + shouldShowAnnouncement, + markAnnouncementDismissed, + wrappedSlideIndex, + }; + } + + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', maybeShowProjectViewAnnouncement, { once: true }); + else maybeShowProjectViewAnnouncement(); + } +})(); diff --git a/test/whats-new.test.js b/test/whats-new.test.js new file mode 100644 index 00000000..3996877d --- /dev/null +++ b/test/whats-new.test.js @@ -0,0 +1,33 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + ANNOUNCEMENT_KEY, + SLIDES, + shouldShowAnnouncement, + markAnnouncementDismissed, + wrappedSlideIndex, +} = require('../public/whats-new'); + +function memoryStorage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }; +} + +test('Project View announcement shows until it is dismissed', () => { + const storage = memoryStorage(); + assert.equal(shouldShowAnnouncement(storage), true); + markAnnouncementDismissed(storage); + assert.equal(storage.getItem(ANNOUNCEMENT_KEY), '1'); + assert.equal(shouldShowAnnouncement(storage), false); +}); + +test('screenshot navigation wraps in both directions', () => { + assert.equal(SLIDES.length, 4); + assert.equal(wrappedSlideIndex(0, -1), 3); + assert.equal(wrappedSlideIndex(3, 1), 0); + assert.equal(wrappedSlideIndex(1, 1), 2); +}); From 6c0f73d96ab028c1f882906ee22e50a4567ea091 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 5 Sep 2026 18:58:19 -0700 Subject: [PATCH 04/24] Copy nested environment files into project worktrees Discover nested .env files and preserve their relative paths when copying to a worktree. Skip dependencies and symlinks, and protect existing destination files. --- projects.js | 42 +++++++++++++++++++++++++++++++----------- test/projects.test.js | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/projects.js b/projects.js index 23af4fa7..3dcbe5e4 100644 --- a/projects.js +++ b/projects.js @@ -36,6 +36,7 @@ const FOLDER_MODES = new Set(['in-place', 'worktree']); const PROJECT_STATUSES = new Set(['active', 'done']); // A .env is a few lines; anything larger under that name is something else. const ENV_FILE_MAX_BYTES = 1024 * 1024; +const ENV_SKIP_DIRS = new Set(['.git', 'node_modules', 'vendor', '.venv', 'venv']); let db, log, buildProjectsFromCache, notifyRendererProjectsChanged, isHarnessId, plansDir; @@ -700,25 +701,36 @@ async function attachFolder(id, spec) { /** A sample file is listed but never picked by default: it is usually tracked already. */ function isEnvSample(name) { - return /^\.env\.(example|sample|template|dist)$/i.test(name); + return /^\.env\.(example|sample|template|dist)$/i.test(path.basename(name)); } /** - * The .env files at the top of a folder: `.env` and `.env.*`, sorted, files - * only. Big files are left out — a .env is a few lines, and anything large + * The .env files recursively within a folder: `.env` and `.env.*`, sorted, files + * only, as relative paths. Hidden directories, dependency folders and symlinks are skipped. + * Big files are left out — a .env is a few lines, and anything large * under that name is something else. */ function listEnvFiles(dir) { const root = typeof dir === 'string' ? path.resolve(dir.trim()) : ''; if (!root || !isDirectory(root)) return []; - let entries; - try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; } const names = []; - for (const entry of entries) { - if (!entry.isFile()) continue; - if (entry.name !== '.env' && !entry.name.startsWith('.env.')) continue; - try { if (fs.statSync(path.join(root, entry.name)).size > ENV_FILE_MAX_BYTES) continue; } catch { continue; } - names.push(entry.name); + const pending = ['']; + while (pending.length) { + const relativeDir = pending.pop(); + let entries; + try { entries = fs.readdirSync(path.join(root, relativeDir), { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + const relativePath = path.join(relativeDir, entry.name); + if (entry.isDirectory()) { + if (!entry.name.startsWith('.') && !ENV_SKIP_DIRS.has(entry.name)) pending.push(relativePath); + continue; + } + // Do not follow symlinks, including links to directories outside the repo. + if (!entry.isFile()) continue; + if (entry.name !== '.env' && !entry.name.startsWith('.env.')) continue; + try { if (fs.statSync(path.join(root, relativePath)).size > ENV_FILE_MAX_BYTES) continue; } catch { continue; } + names.push(relativePath); + } } names.sort(); return names; @@ -745,7 +757,15 @@ function copyEnvFiles(source, target, names) { const dest = path.join(target, name); if (fs.existsSync(dest)) { skipped.push(name); continue; } try { - fs.copyFileSync(path.join(source, name), dest); + // Create missing parents, but never copy through a destination symlink. + let parent = target; + for (const part of name.split(path.sep).slice(0, -1)) { + parent = path.join(parent, part); + try { fs.mkdirSync(parent); } catch (err) { if (err.code !== 'EEXIST') throw err; } + const stat = fs.lstatSync(parent); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Unsafe destination directory'); + } + fs.copyFileSync(path.join(source, name), dest, fs.constants.COPYFILE_EXCL); fs.chmodSync(dest, fs.statSync(path.join(source, name)).mode & 0o777); copied.push(name); } catch (err) { diff --git a/test/projects.test.js b/test/projects.test.js index a62cde67..cf19a45f 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -870,6 +870,39 @@ test('copyEnvFiles copies only the named files, never overwrites, ignores names } finally { rm(source); rm(target); } }); +test('recursive env discovery and copying preserve paths and skip hidden directories, dependencies and symlinks', () => { + const t = setup(); + const source = tmpDir('switchboard-env-src-'); + const target = tmpDir('switchboard-env-dst-'); + const outside = tmpDir('switchboard-env-outside-'); + const nested = path.join('apps', 'web', '.env'); + const sample = path.join('apps', 'web', '.env.example'); + try { + for (const name of [nested, sample, 'node_modules/pkg/.env', 'apps/web/node_modules/pkg/.env', '.git/.env', '.claude/worktrees/feature/backend/.env', 'apps/web/.hidden/.env', 'vendor/pkg/.env', '.venv/.env', 'venv/.env']) { + fs.mkdirSync(path.dirname(path.join(source, name)), { recursive: true }); + fs.writeFileSync(path.join(source, name), 'A=1\n'); + } + fs.writeFileSync(path.join(outside, '.env'), 'KEEP=me\n'); + fs.symlinkSync(outside, path.join(source, 'linked'), 'dir'); + fs.symlinkSync(path.join(outside, '.env'), path.join(source, '.env.link')); + assert.deepEqual(projects.listEnvFiles(source), [nested, sample]); + assert.deepEqual(projects.defaultEnvSelection(source), [nested]); + const hiddenEnv = path.join('.claude', 'worktrees', 'feature', 'backend', '.env'); + assert.deepEqual(projects.copyEnvFiles(source, target, [hiddenEnv]), { copied: [], skipped: [hiddenEnv] }); + assert.equal(fs.existsSync(path.join(target, '.claude')), false); + assert.deepEqual(projects.copyEnvFiles(source, target, [nested]), { copied: [nested], skipped: [] }); + assert.equal(fs.readFileSync(path.join(target, nested), 'utf8'), 'A=1\n'); + assert.equal(fs.existsSync(path.join(target, sample)), false); + assert.deepEqual(projects.copyEnvFiles(source, target, [nested]), { copied: [], skipped: [nested] }); + + fs.rmSync(path.join(target, 'apps'), { recursive: true }); + fs.symlinkSync(outside, path.join(target, 'apps'), 'dir'); + assert.deepEqual(projects.copyEnvFiles(source, target, [nested]), { copied: [], skipped: [nested] }); + assert.equal(fs.existsSync(path.join(outside, 'web')), false); + assert.equal(fs.readFileSync(path.join(outside, '.env'), 'utf8'), 'KEEP=me\n'); + } finally { rm(source); rm(target); rm(outside); t.cleanup(); } +}); + test('a new worktree gets the .env files the caller picked', { skip: !haveGit && 'git not installed' }, async () => { const t = setup(); const repo = makeRepo('switchboard-repo-env-'); @@ -877,16 +910,20 @@ test('a new worktree gets the .env files the caller picked', { skip: !haveGit && fs.writeFileSync(path.join(repo, '.env'), 'TOKEN=abc\n'); fs.writeFileSync(path.join(repo, '.env.local'), 'PORT=3000\n'); fs.writeFileSync(path.join(repo, '.env.example'), 'TOKEN=\n'); + const nestedEnv = path.join('apps', 'web', '.env'); + fs.mkdirSync(path.dirname(path.join(repo, nestedEnv)), { recursive: true }); + fs.writeFileSync(path.join(repo, nestedEnv), 'NESTED=1\n'); const created = await projects.createProject({ name: 'Env Feature', - folders: [{ path: repo, mode: 'worktree', copyEnv: ['.env', '.env.local'] }], + folders: [{ path: repo, mode: 'worktree', copyEnv: ['.env', '.env.local', nestedEnv] }], }); assert.equal(created.ok, true); assert.deepEqual(created.errors, []); const wt = created.project.folders[0]; assert.equal(fs.readFileSync(path.join(wt.path, '.env'), 'utf8'), 'TOKEN=abc\n'); assert.equal(fs.readFileSync(path.join(wt.path, '.env.local'), 'utf8'), 'PORT=3000\n'); + assert.equal(fs.readFileSync(path.join(wt.path, nestedEnv), 'utf8'), 'NESTED=1\n'); assert.equal(fs.existsSync(path.join(wt.path, '.env.example')), false, 'an unticked file stays behind'); // Attaching a second repo without copyEnv leaves its .env behind. From 88b94d327d415056baf089f35491d289a08fc621 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 5 Sep 2026 18:58:19 -0700 Subject: [PATCH 05/24] Track busy state for plain terminal sessions Use shell prompt marks, with foreground-process polling as a fallback, to report plain terminal activity. Filter out short commands to avoid noisy busy and unread transitions. --- main.js | 65 ++++++++++++++ terminal-activity.js | 159 +++++++++++++++++++++++++++++++++ test/terminal-activity.test.js | 107 ++++++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 terminal-activity.js create mode 100644 test/terminal-activity.test.js diff --git a/main.js b/main.js index 0393409c..2881e1f6 100644 --- a/main.js +++ b/main.js @@ -29,6 +29,7 @@ try { require('electron-reloader')(module, { watchRenderer: true }); } catch {}; const { buildPtyEnv } = require('./pty-env'); const cleanPtyEnv = buildPtyEnv(process.env); const { shouldStartFresh, shouldBlockArchivedSession } = require('./session-launch'); +const { createTerminalActivity } = require('./terminal-activity'); // Shell profiles → shell-profiles.js const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles'); @@ -1451,6 +1452,15 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se projectPath, firstResize: true, projectFolder, knownJsonlFiles, sessionSlug, isPlainTerminal, runtime: runtimeId, forkFrom: sessionOptions?.forkFrom || null, + // A plain terminal has no CLI to report a busy state, so one is derived + // from the shell's own OSC 133 prompt marks, or from the PTY's foreground + // process when the shell sends none. A harness session already has the + // OSC 0 / OSC 9;4 handlers below. + activity: isPlainTerminal ? createTerminalActivity({ + shellName: path.basename(shell), + // On Windows `pty.process` is the console title, not a process name. + canPollProcess: process.platform !== 'win32', + }) : null, // Plain terminals have no transcript row to carry their project filing. // Keep the launch context on the live PTY so renderer reloads can put a // terminal started in an attached folder back into the same project/track. @@ -1469,6 +1479,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se mcpServer, _openedAt: Date.now(), }; activeSessions.set(sessionId, session); + if (session.activity) startTerminalActivitySweep(); // A session launched from a project is filed there. Recorded under whatever // id the session has right now; resolvePendingLaunches moves it to the real @@ -1496,6 +1507,13 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se ptyProcess.onData(data => { const currentId = session.realSessionId || sessionId; + // Prompt marks for a plain terminal. Cheap on every chunk: the parse + // returns immediately unless the chunk actually contains an OSC 133. + if (session.activity) { + const busy = session.activity.feedData(data); + if (busy !== null) sendTerminalBusy(session, currentId, busy); + } + // Parse OSC sequences (title changes, progress, notifications, etc.) if (data.includes('\x1b]')) { const oscMatches = data.matchAll(/\x1b\](\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/g); @@ -1705,6 +1723,53 @@ ipcMain.on('close-terminal', (_event, sessionId) => { } }); +// --- Plain-terminal activity: working, or waiting at the prompt? --- + +/** One place to tell the renderer a terminal started or stopped working. */ +function sendTerminalBusy(session, sessionId, busy) { + session._cliBusy = busy; + log.debug(`[terminal-activity] session=${sessionId} → ${busy ? 'BUSY' : 'IDLE'} ${JSON.stringify(session.activity.state())}`); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('cli-busy-state', sessionId, busy); + } +} + +// One sweep for every tracked terminal rather than a timer each. Reading +// `pty.process` costs ~18us, and only a shell that sends no prompt marks is +// read at all, so 50 terminals stay well under a millisecond per sweep. +const TERMINAL_ACTIVITY_INTERVAL_MS = 500; +let terminalActivityTimer = null; + +function sweepTerminalActivity() { + let tracked = 0; + for (const [sessionId, session] of activeSessions) { + if (!session.activity || session.exited) continue; + tracked++; + let busy = null; + if (session.activity.needsPoll()) { + // A PTY that has just exited throws here rather than returning a name. + let name = null; + try { name = session.pty.process; } catch {} + busy = session.activity.feedProcess(name); + } + // Ticked either way: a silent command crosses the busy threshold with no + // output to settle on. + if (busy === null) busy = session.activity.tick(); + if (busy !== null) sendTerminalBusy(session, sessionId, busy); + } + if (tracked === 0) { + clearInterval(terminalActivityTimer); + terminalActivityTimer = null; + } +} + +/** Started when the first terminal appears, stopped when the last one goes. */ +function startTerminalActivitySweep() { + if (terminalActivityTimer) return; + terminalActivityTimer = setInterval(sweepTerminalActivity, TERMINAL_ACTIVITY_INTERVAL_MS); + terminalActivityTimer.unref?.(); +} + // Session transitions → session-transitions.js const sessionTransitions = require('./session-transitions'); sessionTransitions.init({ PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer, copySessionAssignment }); diff --git a/terminal-activity.js b/terminal-activity.js new file mode 100644 index 00000000..ed77d4ba --- /dev/null +++ b/terminal-activity.js @@ -0,0 +1,159 @@ +'use strict'; + +/** + * Is a plain terminal running a command, or sitting at its prompt? + * + * Two sources, preferred in this order: + * + * 1. OSC 133 prompt marks. The shell says so itself: C when a command + * starts, D when it ends, A/B while the prompt is drawn. Exact, instant, + * and free for anyone running powerlevel10k, starship, oh-my-posh or + * fish 3.4+, all of which emit them already. + * 2. The PTY's foreground process name, polled. Only for a shell that sends + * no marks. `pty.process` is a tcgetpgrp plus a name lookup — ~18us, no + * subprocess — so a sweep over 50 terminals costs under a millisecond. + * + * A harness session (Claude, codex) is not tracked here; its busy state comes + * from the OSC 0 title and OSC 9;4 progress that the CLI already sends. + */ + +// Marks arrive as ESC ] 133 ; [; payload] BEL-or-ST. The payload of a +// C mark carries the command line, so it is matched but thrown away. +const MARK_RE = /\x1b\]133;([A-D])(?:;[^\x07\x1b]*)?(?:\x07|\x1b\\)/g; + +const DEFAULTS = { + // How long a command must run before it counts as "working". Below this, + // `cd` and `ls` would flash a spinner and then mark the terminal unread. + minBusyMs: 300, + // How long to wait for a first prompt mark before falling back to polling. + markGraceMs: 4000, + // The shell must have stopped writing for this long before its foreground + // process name can be trusted as the idle baseline — otherwise the name + // learned is whatever the rc file happens to be running (nvm, pyenv). + baselineQuietMs: 300, +}; + +/** Prompt marks in one chunk of PTY output, in the order they appear. */ +function parsePromptMarks(data) { + if (typeof data !== 'string' || !data.includes('\x1b]133;')) return []; + const marks = []; + for (const m of data.matchAll(MARK_RE)) marks.push(m[1]); + return marks; +} + +/** + * Track one terminal. + * + * feedData/feedProcess/tick each return the new busy value when it changed, + * and null when it did not, so the caller only ever sends an IPC message on a + * real transition. + * + * @param {object} opts + * @param {string} [opts.shellName] basename of the spawned shell, used only as + * a provisional baseline; the name observed on the PTY replaces it. + * @param {boolean} [opts.canPollProcess] false where `pty.process` reports + * something other than a process name (Windows returns the console title), + * which leaves marks as the only source. + */ +function createTerminalActivity(opts = {}) { + const shellName = opts.shellName || null; + const canPollProcess = opts.canPollProcess !== false; + const minBusyMs = opts.minBusyMs ?? DEFAULTS.minBusyMs; + const markGraceMs = opts.markGraceMs ?? DEFAULTS.markGraceMs; + const baselineQuietMs = opts.baselineQuietMs ?? DEFAULTS.baselineQuietMs; + const startedAt = opts.startedAt ?? Date.now(); + + // 'unknown' until we learn whether this shell sends marks; then 'marks', + // 'poll', or 'off' when there is no usable source at all. + let mode = 'unknown'; + let baseline = null; + let baselineLearned = false; + let lastDataAt = startedAt; + let working = false; // what the source says right now + let workingSince = 0; + let reported = false; // what the caller has been told + + function setWorking(next, now) { + if (next === working) return; + working = next; + if (next) workingSince = now; + } + + function settle(now) { + if (mode === 'unknown') { + if (now - startedAt < markGraceMs) return null; + // No marks in the grace window. Fall back to the foreground process + // name, with the shell's own basename standing in until the PTY goes + // quiet long enough to learn the real one. + mode = canPollProcess ? 'poll' : 'off'; + if (!baseline && shellName) baseline = shellName; + } + if (mode === 'off') return null; + if (working && !reported && now - workingSince >= minBusyMs) { + reported = true; + return true; + } + if (!working && reported) { + reported = false; + return false; + } + return null; + } + + return { + /** Feed a chunk of PTY output. */ + feedData(data, now = Date.now()) { + lastDataAt = now; + const marks = parsePromptMarks(data); + if (marks.length) { + if (mode !== 'marks') { + // The shell speaks for itself from here on; anything the poll had + // concluded is discarded. + mode = 'marks'; + working = false; + } + for (const mark of marks) setWorking(mark === 'C', now); + } + return settle(now); + }, + + /** Feed one reading of the PTY's foreground process name. */ + feedProcess(name, now = Date.now()) { + if (mode === 'marks' || mode === 'off') return null; + if (!name) return settle(now); + if (!baselineLearned) { + // Wait for quiet: during rc-file execution the name is whatever the rc + // file is running. Learned rather than taken from the shell path + // because /bin/sh reports "bash" and a wrapper reports its target. + if (now - lastDataAt < baselineQuietMs) return settle(now); + baseline = name; + baselineLearned = true; + } + setWorking(name !== baseline, now); + return settle(now); + }, + + /** Let time pass, so a long-running command can cross minBusyMs. Must be + * called on every sweep, marks included: a silent command (`sleep 5`) + * produces no output to settle on. */ + tick(now = Date.now()) { + return settle(now); + }, + + /** False once the shell's own marks are driving this terminal, which is + * what keeps the sweep off most sessions. Ticking is still required. */ + needsPoll() { + if (mode === 'marks' || mode === 'off') return false; + // 'unknown' polls too, so the baseline is ready the moment the grace + // window closes. + return canPollProcess; + }, + + /** For logging and tests. */ + state() { + return { mode, baseline, busy: reported }; + }, + }; +} + +module.exports = { parsePromptMarks, createTerminalActivity, DEFAULTS }; diff --git a/test/terminal-activity.test.js b/test/terminal-activity.test.js new file mode 100644 index 00000000..c5b99cef --- /dev/null +++ b/test/terminal-activity.test.js @@ -0,0 +1,107 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { parsePromptMarks, createTerminalActivity } = require('../terminal-activity'); + +const ESC = '\x1b'; +const mark = (letter, payload) => `${ESC}]133;${letter}${payload ? ';' + payload : ''}\x07`; + +// Fixed timings, so a test never depends on the wall clock. +const opts = { startedAt: 0, minBusyMs: 300, markGraceMs: 4000, baselineQuietMs: 300 }; + +test('prompt marks are read out of a chunk in order', () => { + const chunk = `done${mark('D', '0')}${mark('A')}user@host $ ${mark('B')}`; + assert.deepEqual(parsePromptMarks(chunk), ['D', 'A', 'B']); +}); + +test('a C mark carrying the command line is still just a C', () => { + assert.deepEqual(parsePromptMarks(mark('C', 'npm test --watch')), ['C']); +}); + +test('a mark terminated by ST counts too', () => { + assert.deepEqual(parsePromptMarks(`${ESC}]133;A${ESC}\\`), ['A']); +}); + +test('ordinary output carries no marks', () => { + assert.deepEqual(parsePromptMarks('total 48\r\ndrwxr-xr-x home staff\r\n'), []); + assert.deepEqual(parsePromptMarks(`${ESC}]0;my title\x07`), []); +}); + +test('marks drive busy: C starts work, D ends it', () => { + const a = createTerminalActivity(opts); + assert.equal(a.feedData(mark('C'), 1000), null, 'not busy until it has run a while'); + assert.equal(a.tick(1400), true); + assert.equal(a.state().mode, 'marks'); + assert.equal(a.feedData(mark('D', '0'), 3000), false, 'finishing reports immediately'); + assert.equal(a.tick(3500), null); +}); + +test('a command shorter than the threshold never raises busy', () => { + // `cd` and `ls` would otherwise flash a spinner and mark the terminal unread. + const a = createTerminalActivity(opts); + assert.equal(a.feedData(mark('C'), 1000), null); + assert.equal(a.feedData(mark('D', '0') + mark('A'), 1100), null); + assert.equal(a.tick(2000), null); + assert.equal(a.state().busy, false); +}); + +test('a shell that sends marks is never polled', () => { + const a = createTerminalActivity(opts); + assert.equal(a.needsPoll(), true, 'polled until we know the shell speaks for itself'); + a.feedData(mark('A'), 500); + assert.equal(a.needsPoll(), false); + assert.equal(a.feedProcess('npm', 600), null, 'a process name cannot override the marks'); +}); + +test('the poll baseline is learned once the shell goes quiet, not from its rc file', () => { + const a = createTerminalActivity({ ...opts, shellName: 'zsh' }); + a.feedData('nvm: loading', 100); + assert.equal(a.feedProcess('nvm', 150), null); + assert.equal(a.state().baseline, null, 'rc-file noise is not the baseline'); + + a.feedData('prompt', 200); + assert.equal(a.feedProcess('zsh', 600), null); + assert.equal(a.state().baseline, 'zsh'); + + assert.equal(a.feedProcess('npm', 5000), null, 'work starts'); + assert.equal(a.tick(5400), true); + assert.equal(a.feedProcess('zsh', 6000), false, 'back at the prompt'); +}); + +test('a shell whose real name differs from its path corrects itself', () => { + // /bin/sh reports "bash" on macOS. The provisional baseline taken from the + // path must not leave the terminal stuck as permanently working. + const a = createTerminalActivity({ ...opts, shellName: 'sh' }); + for (let t = 100; t <= 3900; t += 400) a.feedData('startup chatter', t); + assert.equal(a.tick(4100), null); + assert.equal(a.state().mode, 'poll'); + assert.equal(a.state().baseline, 'sh', 'the path stands in until a quiet sample arrives'); + + assert.equal(a.feedProcess('bash', 4400), null); + assert.equal(a.state().baseline, 'bash'); + assert.equal(a.tick(5000), null, 'sitting at its prompt, not working'); +}); + +test('nothing is reported while it is still unknown whether marks will come', () => { + const a = createTerminalActivity({ ...opts, shellName: 'zsh' }); + a.feedProcess('zsh', 400); + assert.equal(a.feedProcess('npm', 1000), null); + assert.equal(a.tick(3900), null, 'inside the grace window'); + assert.equal(a.state().mode, 'unknown'); + assert.equal(a.tick(4100), true, 'grace over, the poll takes charge'); +}); + +test('with no usable process name, marks are the only source', () => { + // Windows: pty.process is the console title, so a name means nothing. + const a = createTerminalActivity({ ...opts, shellName: 'pwsh', canPollProcess: false }); + assert.equal(a.feedProcess('some window title', 100), null); + assert.equal(a.tick(5000), null); + assert.equal(a.state().mode, 'off'); + assert.equal(a.needsPoll(), false); + assert.equal(a.feedProcess('another title', 6000), null); + + // Marks still work if the shell happens to send them. + const b = createTerminalActivity({ ...opts, canPollProcess: false }); + b.feedData(mark('C'), 1000); + assert.equal(b.tick(1400), true); +}); From abe79fb44d44ce66ad5bbbc01faded99cf631b04 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 5 Sep 2026 18:58:19 -0700 Subject: [PATCH 06/24] Preserve deleted track names and optionally archive sessions Retain the former track name when a track is deleted, including for persisted raw terminals. Offer an option to archive the affected sessions and stop running processes. --- db.js | 14 +++++++--- main.js | 23 +++++++++++++++- preload.js | 2 +- projects.js | 8 +++--- public/projects-view.js | 46 +++++++++++++++++++++++++++++--- public/terminal-manager.js | 1 + session-cache.js | 2 ++ test/db-schema-reconcile.test.js | 40 +++++++++++++++++++++++++++ 8 files changed, 122 insertions(+), 14 deletions(-) diff --git a/db.js b/db.js index 1b5d20c4..96ed0eab 100644 --- a/db.js +++ b/db.js @@ -222,6 +222,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_tracks_project ON tracks(projectId)'); const cols = new Set(db.prepare('PRAGMA table_info(session_meta)').all().map(c => c.name)); if (!cols.has('projectId')) db.exec('ALTER TABLE session_meta ADD COLUMN projectId TEXT'); if (!cols.has('trackId')) db.exec('ALTER TABLE session_meta ADD COLUMN trackId TEXT'); + if (!cols.has('formerTrackName')) db.exec('ALTER TABLE session_meta ADD COLUMN formerTrackName TEXT'); } { // Where a project's sessions start by default (null = the project folder). @@ -658,13 +659,18 @@ function updateTrack(id, patch) { return updatePatch('tracks', TRACK_PATCH_KEYS, id, patch); } -const deleteTrackTx = db.transaction((id) => { - stmts.assignmentClearTrack.run(id); +const deleteTrackTx = db.transaction((id, archiveSessions) => { + const track = stmts.trackGet.get(id); + if (!track) return []; + const sessionIds = db.prepare('SELECT sessionId FROM session_meta WHERE trackId = ?').all(id).map(row => row.sessionId); + db.prepare(`UPDATE session_meta SET formerTrackName = ?, trackId = NULL, + archived = CASE WHEN ? THEN 1 ELSE archived END WHERE trackId = ?`).run(track.name, archiveSessions ? 1 : 0, id); stmts.trackDelete.run(id); + return sessionIds; }); -function deleteTrack(id) { - deleteTrackTx(id); +function deleteTrack(id, { archiveSessions = false } = {}) { + return deleteTrackTx(id, archiveSessions); } function setSessionAssignment(sessionId, projectId, trackId) { diff --git a/main.js b/main.js index 2881e1f6..f7b7d007 100644 --- a/main.js +++ b/main.js @@ -425,7 +425,28 @@ ipcMain.handle('set-session-assignment', guarded((sessionId, projectId, trackId) })); ipcMain.handle('create-track', guarded((projectId, spec) => projects.createTrack(projectId, spec || {}))); ipcMain.handle('update-track', guarded((id, patch) => projects.updateTrack(id, patch || {}))); -ipcMain.handle('delete-track', guarded((id) => projects.deleteTrack(id))); +ipcMain.handle('delete-track', guarded((id, options = {}) => { + const track = dbModule.getTrack(id); + if (!track) return { error: 'Track not found' }; + const archiveSessions = options.archiveSessions === true; + // Raw terminals have no transcript row, but participate in track deletion. + for (const [sid, session] of activeSessions) { + if (session.trackId === id && session.isPlainTerminal) dbModule.setSessionAssignment(sid, track.projectId, id); + } + const result = projects.deleteTrack(id, { archiveSessions }); + if (result.error) return result; + const affected = new Set(result.sessionIds); + for (const [sid, session] of activeSessions) { + if (session.trackId !== id && !affected.has(sid)) continue; + session.trackId = null; + session.formerTrackName = track.name; + if (archiveSessions && !session.exited) { + session.stopRequested = true; + try { session.pty.kill(); } catch (error) { log.error('[delete-track] stop failed', error); } + } + } + return result; +})); ipcMain.handle('get-projects-root', guarded(() => projects.projectsRoot())); ipcMain.handle('get-project-git-status', guarded((id, opts) => projects.folderGitStatus(id, opts || {}))); ipcMain.handle('get-project-git-info', guarded((id) => projects.projectGitInfo(id))); diff --git a/preload.js b/preload.js index 3751c426..75a8e025 100644 --- a/preload.js +++ b/preload.js @@ -76,7 +76,7 @@ contextBridge.exposeInMainWorld('api', { }, createTrack: (projectId, spec) => ipcRenderer.invoke('create-track', projectId, spec), updateTrack: (id, patch) => ipcRenderer.invoke('update-track', id, patch), - deleteTrack: (id) => ipcRenderer.invoke('delete-track', id), + deleteTrack: (id, options) => ipcRenderer.invoke('delete-track', id, options), openExternal: (url) => ipcRenderer.invoke('open-external', url), openPath: (target) => ipcRenderer.invoke('open-path', target), writeClipboard: (text) => ipcRenderer.invoke('clipboard-write-text', text), diff --git a/projects.js b/projects.js index 3dcbe5e4..af4a21cc 100644 --- a/projects.js +++ b/projects.js @@ -993,14 +993,14 @@ function updateTrack(id, patch) { return { ok: true, track: trackNode(db.getTrack(id)) }; } -/** Rows only. Its sessions keep their project and lose the track. */ -function deleteTrack(id) { +/** Sessions keep their project and a snapshot of the deleted track's name. */ +function deleteTrack(id, { archiveSessions = false } = {}) { const track = db.getTrack(id); if (!track) return { error: 'Track not found' }; - db.deleteTrack(id); + const sessionIds = db.deleteTrack(id, { archiveSessions }) || []; db.updateProject(track.projectId, { modified: new Date().toISOString() }); notifyRendererProjectsChanged(); - return { ok: true, projectId: track.projectId }; + return { ok: true, projectId: track.projectId, formerTrackName: track.name, sessionIds }; } // --- Session assignment --- diff --git a/public/projects-view.js b/public/projects-view.js index f1e7e4a9..803ba0ee 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -531,7 +531,9 @@ function buildSessionRow(project, session, { showTrack = true, className = 'pane const track = session.trackId ? (project.tracks || []).find(t => t.id === session.trackId) : null; const parts = []; parts.push(``); - if (showTrack) parts.push(trackTagHtml(project, track)); + if (!track && session.formerTrackName) { + parts.push(`Formerly: ${escapeHtml(session.formerTrackName)}`); + } else if (showTrack) parts.push(trackTagHtml(project, track)); // The CLI mark, same as the status bar: the logo says which CLI, no word needed. parts.push(`${cliIcon(session, 12)}`); parts.push(`${escapeHtml(formatDate(new Date(sessionEventTime(session))))}`); @@ -2946,11 +2948,47 @@ async function detachFolderFlow(project, folder) { } async function deleteTrackFlow(project, track) { - if (!confirm(`Delete track ${track.name}?\n\nIts sessions stay in ${project.name}.`)) return; - const result = await window.api.deleteTrack(track.id); + const choice = await new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'add-project-overlay'; + const dialog = document.createElement('div'); + dialog.className = 'add-project-dialog ws-prompt'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-labelledby', 'delete-track-title'); + dialog.innerHTML = ` +

Delete track ${escapeHtml(track.name)}?

+
Sessions stay in ${escapeHtml(project.name)} and retain their former track name.
+ +
If selected, running sessions will stop and raw terminals will close.
+
+ + +
`; + const finish = value => { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(value); }; + const onKey = e => { if (e.key === 'Escape') { e.stopPropagation(); finish(null); } }; + dialog.querySelector('.add-project-cancel-btn').onclick = () => finish(null); + dialog.querySelector('.add-project-add-btn').onclick = () => finish({ archiveSessions: dialog.querySelector('input').checked }); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + document.addEventListener('keydown', onKey); + dialog.querySelector('.add-project-cancel-btn').focus(); + }); + if (!choice) return; + const result = await window.api.deleteTrack(track.id, choice); if (result?.error) { alert(result.error); return; } + for (const session of sessionMap.values()) { + if (session.trackId !== track.id && !result.sessionIds?.includes(session.sessionId)) continue; + session.trackId = null; + session.formerTrackName = result.formerTrackName; + if (choice.archiveSessions) { + session.archived = 1; + forgetTerminalHistory(session.sessionId); + if (session.type === 'terminal') forgetPersistedTerminalSession(session.sessionId); + } else if (session.type === 'terminal') persistTerminalSession(session); + } if (projectsUi.trackByProject[project.id] === track.id) delete projectsUi.trackByProject[project.id]; - loadProjects(); + await loadProjects(); } /** After marking a project done: offer to remove its worktrees, one confirm each for dirty ones. */ diff --git a/public/terminal-manager.js b/public/terminal-manager.js index a622cb9d..05c8c53c 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -297,6 +297,7 @@ function persistedTerminalRecord(session) { projectPath: String(session.projectPath), projectId: session.projectId ? String(session.projectId) : null, trackId: session.trackId ? String(session.trackId) : null, + formerTrackName: session.formerTrackName || null, name: null, starred: 0, archived: 0, diff --git a/session-cache.js b/session-cache.js index 8b63e85c..d7edd20d 100644 --- a/session-cache.js +++ b/session-cache.js @@ -378,6 +378,7 @@ function buildProjectsFromCache(showArchived) { // session was never filed; the tree builder then falls back to cwd. projectId: meta?.projectId || null, trackId: meta?.trackId || null, + formerTrackName: meta?.formerTrackName || null, }; if (!showArchived && s.archived) continue; if (!projectMap.has(row.projectPath)) { @@ -439,6 +440,7 @@ function buildProjectsFromCache(showArchived) { type: 'terminal', projectId: session.projectId || null, trackId: session.trackId || null, + formerTrackName: session.formerTrackName || null, }); } } diff --git a/test/db-schema-reconcile.test.js b/test/db-schema-reconcile.test.js index 9446cbfd..c877df7e 100644 --- a/test/db-schema-reconcile.test.js +++ b/test/db-schema-reconcile.test.js @@ -59,6 +59,46 @@ test('fresh database gets fileMtime column', () => { } }); +test('deleting tracks retains their names and optionally archives only their sessions', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-delete-track-')); + try { + const r = runInElectronNode(` + const assert = require('node:assert/strict'); + const db = require('./db'); + db.insertTrack({ id: 'keep', projectId: 'project', name: 'Keep work', created: '2026-09-05' }); + db.insertTrack({ id: 'archive', projectId: 'project', name: 'Finished work', created: '2026-09-05' }); + db.setSessionAssignment('active', 'project', 'keep'); + db.setSessionAssignment('done', 'project', 'archive'); + db.setSessionAssignment('already-archived', 'project', 'archive'); + db.setArchived('already-archived', 1); + db.setSessionAssignment('unrelated', 'project', null); + assert.deepEqual(db.deleteTrack('keep'), ['active']); + assert.equal(db.getMeta('active').archived, 0); + assert.equal(db.getMeta('active').formerTrackName, 'Keep work'); + assert.equal(db.getMeta('active').trackId, null); + assert.deepEqual(new Set(db.deleteTrack('archive', { archiveSessions: true })), new Set(['done', 'already-archived'])); + for (const id of ['done', 'already-archived']) { + const meta = db.getMeta(id); + assert.equal(meta.projectId, 'project'); + assert.equal(meta.trackId, null); + assert.equal(meta.formerTrackName, 'Finished work'); + assert.equal(meta.archived, 1); + } + assert.equal(db.getTrack('keep'), null); + assert.equal(db.getTrack('archive'), null); + assert.equal(db.getMeta('unrelated').archived, 0); + db.closeDb(); + delete require.cache[require.resolve('./db')]; + const reopened = require('./db'); + assert.equal(reopened.getMeta('done').formerTrackName, 'Finished work'); + reopened.closeDb(); + `, dir); + assert.equal(r.status, 0, r.stderr); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + // The project tables and the two session_meta columns are added by inspecting // the schema, not by db_version, so a database from before projects existed // gets them without losing its names, stars or archive flags. From 2801495464eeb45ff0cfe1d9e0b9aa8a83cb4642 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 5 Sep 2026 18:58:19 -0700 Subject: [PATCH 07/24] Add project-scoped search across active and archived sessions Search titles and transcripts within the selected project, including archived sessions. Preserve focus and completed results during debounced searches, and start unassigned sessions from project-level actions. --- db.js | 22 +++- main.js | 5 + preload.js | 1 + public/projects-view.js | 180 ++++++++++++++++++++++------ public/style.css | 15 +++ test/db-schema-reconcile.test.js | 34 ++++++ test/project-session-search.test.js | 81 +++++++++++++ 7 files changed, 301 insertions(+), 37 deletions(-) create mode 100644 test/project-session-search.test.js diff --git a/db.js b/db.js index 96ed0eab..75b53ce5 100644 --- a/db.js +++ b/db.js @@ -333,9 +333,18 @@ const stmts = { FROM search_fts JOIN search_map ON search_fts.rowid = search_map.rowid WHERE search_map.type = ? AND search_fts MATCH ? + AND (? IS NULL OR search_map.id IN (SELECT value FROM json_each(?))) ORDER BY rank LIMIT ? `), + searchSessionIds: db.prepare(` + SELECT search_map.id + FROM search_map + CROSS JOIN search_fts ON search_fts.rowid = search_map.rowid + WHERE search_map.type = 'session' + AND search_map.id IN (SELECT value FROM json_each(?)) + AND search_fts MATCH ? + `), // Project statements projectList: db.prepare('SELECT * FROM projects ORDER BY created'), projectGet: db.prepare('SELECT * FROM projects WHERE id = ?'), @@ -510,19 +519,26 @@ function updateSearchTitle(id, type, title) { } catch {} } -function searchByType(type, query, limit = 50, titleOnly = false) { +function searchByType(type, query, limit = 50, titleOnly = false, sessionIds = null) { try { // Wrap in double quotes for exact substring matching with trigram tokenizer. // This prevents FTS5 from splitting on punctuation (e.g. "spec.md" → "spec" + "md") const escaped = '"' + query.replace(/"/g, '""') + '"'; // FTS5 column filter: prefix with "title:" to restrict match to title column const match = titleOnly ? 'title:' + escaped : escaped; - return stmts.searchQuery.all(type, match, limit); + const scope = sessionIds === null ? null : JSON.stringify(sessionIds); + return stmts.searchQuery.all(type, match, scope, scope, limit); } catch { return []; } } +function searchSessionIds(query, sessionIds) { + if (!query.trim() || !sessionIds.length) return []; + const match = '"' + query.replace(/"/g, '""') + '"'; + return stmts.searchSessionIds.all(JSON.stringify(sessionIds), match).map(row => row.id); +} + function isSearchIndexPopulated() { const row = db.prepare('SELECT COUNT(*) as cnt FROM search_map WHERE type = ?').get('session'); return row.cnt > 0; @@ -704,7 +720,7 @@ module.exports = { deleteCachedSession, deleteCachedFolder, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, - searchByType, isSearchIndexPopulated, searchFtsRecreated, + searchByType, searchSessionIds, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, listProjects, getProject, getProjectBySlug, insertProject, updateProject, deleteProject, listProjectFolders, listAllProjectFolders, upsertProjectFolder, deleteProjectFolder, diff --git a/main.js b/main.js index f7b7d007..99f5fca5 100644 --- a/main.js +++ b/main.js @@ -981,6 +981,11 @@ ipcMain.handle('save-memory', (_event, filePath, content) => { }); // --- IPC: search --- +ipcMain.handle('search-session-ids', (_event, query, sessionIds) => { + if (typeof query !== 'string' || !Array.isArray(sessionIds) || sessionIds.some(id => typeof id !== 'string')) return []; + return dbModule.searchSessionIds(query, sessionIds); +}); + ipcMain.handle('search', (_event, type, query, titleOnly) => { return searchByType(type, query, 50, !!titleOnly); }); diff --git a/preload.js b/preload.js index 75a8e025..b1cdd33a 100644 --- a/preload.js +++ b/preload.js @@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld('api', { archiveSession: (id, archived) => ipcRenderer.invoke('archive-session', id, archived), openTerminal: (id, projectPath, isNew, sessionOptions) => ipcRenderer.invoke('open-terminal', id, projectPath, isNew, sessionOptions), search: (type, query, titleOnly) => ipcRenderer.invoke('search', type, query, titleOnly), + searchSessionIds: (query, sessionIds) => ipcRenderer.invoke('search-session-ids', query, sessionIds), readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId), getSessionLastMessage: (sessionId) => ipcRenderer.invoke('get-session-last-message', sessionId), diff --git a/public/projects-view.js b/public/projects-view.js index 803ba0ee..2154c255 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -36,6 +36,7 @@ const projectsUi = { trackByProject: (() => { try { return JSON.parse(sessionStorage.getItem('projects.track') || '{}'); } catch { return {}; } })(), groupBy: (() => { try { return JSON.parse(sessionStorage.getItem('projects.groupBy') || '{}'); } catch { return {}; } })(), expandedLists: {}, + sessionSearchByProject: new Map(), lastStateKey: '', working: false, doneOpen: false, @@ -2365,21 +2366,10 @@ function renderStrip(project) { `; projectStrip.querySelector('#strip-back').onclick = () => { setProjectTab(project, 'overview'); showProjectOverview(); }; projectStrip.querySelector('#strip-tasks').onclick = (e) => showTaskPopover(taskPseudoProject(project), e.currentTarget); - projectStrip.querySelector('#strip-new').onclick = (e) => showNewSessionMenu(project, currentTrack(project), e.currentTarget); + projectStrip.querySelector('#strip-new').onclick = (e) => showNewSessionMenu(project, null, e.currentTarget); projectStrip.querySelector('#strip-more').onclick = (e) => showContextMenu(projectMenuItems(project, { fromPage: true }), { anchor: e.currentTarget }); } -function currentTrackKey(project) { - const key = projectsUi.trackByProject[project.id]; - if (key && key !== 'general' && !(project.tracks || []).some(t => t.id === key)) return 'general'; - return key || ((project.tracks || [])[0]?.id) || 'general'; -} - -function currentTrack(project) { - const key = currentTrackKey(project); - return key === 'general' ? null : (project.tracks || []).find(t => t.id === key) || null; -} - function trackCwdLabel(project, track) { if (!track?.cwd) return ''; if (!isPathInside(track.cwd, project.root)) return pathBasename(track.cwd); @@ -2463,31 +2453,98 @@ function groupSessions(project, mode) { * grouped by time, track or state, with plan progress and open todos at the * foot. */ -function renderPanes(project) { +function projectSessionSearch(project) { + if (!projectsUi.sessionSearchByProject.has(project.id)) { + projectsUi.sessionSearchByProject.set(project.id, { + query: '', displayQuery: '', titlesOnly: searchTitlesOnly, ids: new Set(), pending: false, error: false, version: 0, timer: null, + }); + } + return projectsUi.sessionSearchByProject.get(project.id); +} + +function updateProjectSessionSearch(project, state) { + clearTimeout(state.timer); + const version = ++state.version; + const query = state.query.trim(); + state.pending = !!query && !state.titlesOnly; + state.error = false; + // Keep the last completed query and its matches together while the next + // request is pending. Background pane rerenders must use that snapshot too. + if (!state.pending) { + state.ids = new Set(); + state.displayQuery = query; + } + const redraw = (updateResults = true) => { + if (projectsUi.working && selectedProject()?.id === project.id) { + const bar = projectPanes.querySelector('.pane-search'); + if (bar) { + const input = bar.querySelector('input'); + if (input.value !== state.query) input.value = state.query; + bar.querySelector('.pane-search-clear').hidden = !state.query; + const toggle = bar.querySelector('.pane-search-titles'); + toggle.classList.toggle('active', state.titlesOnly); + toggle.setAttribute('aria-pressed', String(state.titlesOnly)); + } + if (updateResults) { + const current = projectPanes.querySelector('.pane-scroll'); + if (current) { + const next = buildSessionPaneList(selectedProject()); + // Don't remount identical results or jump back to the top on each + // refinement of a query. + if (current.innerHTML !== next.innerHTML) { + const scrollTop = current.scrollTop; + current.replaceWith(next); + next.scrollTop = scrollTop; + } + } + } + } + }; + redraw(!state.pending); + if (!query || state.titlesOnly) return; + state.timer = setTimeout(async () => { + try { + const full = cachedProjectTreeAll?.projects.find(p => p.id === project.id) || project; + const ids = projectSessionsAll(full).map(s => s.sessionId); + const results = ids.length ? await window.api.searchSessionIds(query, ids) : []; + if (state.version !== version) return; + state.ids = new Set(results); + state.displayQuery = query; + } catch { + if (state.version !== version) return; + state.error = true; + } + state.pending = false; + redraw(); + }, 100); +} + +function buildSessionPaneList(project) { + const search = projectSessionSearch(project); + const searching = !!search.displayQuery; const mode = paneGroupMode(project); - const plan = parsePlan(fileContent(project, 'plan-tracker.md')); - const todos = parseTodos(fileContent(project, 'todos.md')); - const open = todos.filter(t => !t.done).length; - const pct = plan.phases.length ? Math.round((plan.done / plan.phases.length) * 100) : 0; const expanded = projectsUi.expandedLists; - - const pane = document.createElement('div'); - pane.className = 'pane'; - pane.innerHTML = `
Sessions` + - `${GROUP_MODES.map(([m, label]) => ``).join('')}` + - `
`; - pane.querySelectorAll('.pane-seg-btn').forEach(btn => { - btn.onclick = () => { projectsUi.groupBy[project.id] = btn.dataset.mode; saveProjectsUi(); renderPanes(project); }; - }); - pane.querySelector('#pane-new-session').onclick = (e) => showNewSessionMenu(project, currentTrack(project), e.currentTarget); - const scroll = document.createElement('div'); scroll.className = 'pane-scroll'; - const groups = groupSessions(project, mode); + let groups = groupSessions(project, mode); + if (searching) { + const full = cachedProjectTreeAll?.projects.find(p => p.id === project.id) || project; + const query = search.displayQuery.toLowerCase(); + const matches = projectSessionsAll(full).filter(s => search.ids.has(s.sessionId) || + (!search.titlesOnly && s.formerTrackName?.toLowerCase().includes(query)) || + [s.name, s.aiTitle, s.summary].some(title => title?.toLowerCase().includes(query))) + .sort((a, b) => sessionEventTime(b) - sessionEventTime(a)); + groups = [ + { key: 'matches', title: 'Active', sessions: matches.filter(s => !s.archived), unlimited: true }, + { key: 'archived-matches', title: 'Archived', sessions: matches.filter(s => s.archived), unlimited: true }, + ].filter(g => g.sessions.length); + } if (!groups.length) { const none = document.createElement('div'); none.className = 'pane-empty'; - none.textContent = 'No sessions yet. Start one from the + above.'; + none.textContent = searching + ? (search.error ? 'Search failed. Try again.' : 'No matching sessions in this project.') + : 'No sessions yet. Start one from the + above.'; scroll.appendChild(none); } for (const group of groups) { @@ -2504,7 +2561,10 @@ function renderPanes(project) { const id = `${project.id}:${mode}:${group.key}`; const limit = group.unlimited || expanded[id] ? Infinity : PANE_GROUP_LIMIT; - for (const s of group.sessions.slice(0, limit)) scroll.appendChild(buildSessionRow(project, s, { showTrack: mode !== 'track' })); + for (const s of group.sessions.slice(0, limit)) scroll.appendChild(buildSessionRow(project, s, { + showTrack: searching || mode !== 'track', + className: s.archived ? 'pane-session pane-session--archived' : 'pane-session', + })); if (!group.sessions.length) { const none = document.createElement('div'); none.className = 'pane-empty'; @@ -2521,8 +2581,56 @@ function renderPanes(project) { } } const archived = archivedSessionsOf(project); - if (archived.length) scroll.appendChild(buildArchivedSection(project, archived, `pane:${project.id}`, { showTrack: mode !== 'track', rowClass: 'pane-session pane-session--archived' })); - pane.appendChild(scroll); + if (!searching && archived.length) scroll.appendChild(buildArchivedSection(project, archived, `pane:${project.id}`, { showTrack: mode !== 'track', rowClass: 'pane-session pane-session--archived' })); + return scroll; +} + +function renderPanes(project) { + const search = projectSessionSearch(project); + const searching = !!search.query.trim(); + const oldInput = projectPanes.querySelector('#pane-search-input'); + const selection = oldInput === document.activeElement && oldInput?.dataset.projectId === project.id + ? [oldInput.selectionStart, oldInput.selectionEnd] : null; + const mode = paneGroupMode(project); + const plan = parsePlan(fileContent(project, 'plan-tracker.md')); + const todos = parseTodos(fileContent(project, 'todos.md')); + const open = todos.filter(t => !t.done).length; + const pct = plan.phases.length ? Math.round((plan.done / plan.phases.length) * 100) : 0; + const expanded = projectsUi.expandedLists; + + const pane = document.createElement('div'); + pane.className = 'pane'; + pane.innerHTML = `
Sessions` + + `${GROUP_MODES.map(([m, label]) => ``).join('')}` + + `
`; + pane.querySelectorAll('.pane-seg-btn').forEach(btn => { + btn.onclick = () => { projectsUi.groupBy[project.id] = btn.dataset.mode; saveProjectsUi(); renderPanes(project); }; + }); + pane.querySelector('#pane-new-session').onclick = (e) => showNewSessionMenu(project, null, e.currentTarget); + + const searchBar = document.createElement('div'); + searchBar.className = 'pane-search'; + searchBar.innerHTML = ` + + `; + const input = searchBar.querySelector('input'); + input.value = search.query; + input.dataset.projectId = project.id; + input.oninput = () => { search.query = input.value; updateProjectSessionSearch(project, search); }; + const clear = () => { + search.query = ''; + updateProjectSessionSearch(project, search); + projectPanes.querySelector('#pane-search-input')?.focus(); + }; + input.onkeydown = e => { if (e.key === 'Escape') { e.stopPropagation(); clear(); } }; + searchBar.querySelector('.pane-search-clear').onclick = clear; + searchBar.querySelector('.pane-search-titles').onclick = () => { + search.titlesOnly = !search.titlesOnly; + updateProjectSessionSearch(project, search); + }; + pane.appendChild(searchBar); + + pane.appendChild(buildSessionPaneList(project)); const foot = document.createElement('div'); foot.className = 'pane-foot'; @@ -2534,9 +2642,13 @@ function renderPanes(project) { pane.appendChild(foot); projectPanes.replaceChildren(pane); + if (selection) { + input.focus({ preventScroll: true }); + input.setSelectionRange(...selection); + } projectsUi.lastStateKey = projectSessionsAll(project).map(s => s.sessionId + ':' + sessionState(s)).join('|'); const active = pane.querySelector('.pane-session.here'); - if (active) active.scrollIntoView({ block: 'nearest' }); + if (active && !searching) active.scrollIntoView({ block: 'nearest' }); // renderPanes has several direct callers (most notably the first busy signal // from a newly started CLI). They do not pass through refreshProjectViews, // so reassert the terminal invariant here after every pane replacement. diff --git a/public/style.css b/public/style.css index d47cc03e..6d24bd54 100644 --- a/public/style.css +++ b/public/style.css @@ -5557,6 +5557,21 @@ body { display: flex; flex-direction: column; } height: 100%; } +.pane-search { position: relative; margin: 0 12px 8px; flex-shrink: 0; } +.pane-search input { + box-sizing: border-box; width: 100%; padding: 8px 54px 8px 10px; + border: 1px solid rgba(255,255,255,0.08); border-radius: 7px; + background: rgba(255,255,255,0.04); color: #c0c0d0; font: inherit; font-size: 12px; +} +.pane-search input:focus { outline: none; border-color: rgba(120,130,255,0.5); } +.pane-search button { + position: absolute; top: 0; bottom: 0; padding: 0 5px; + background: none; border: 0; color: #7a7a90; cursor: pointer; +} +.pane-search-clear { right: 27px; font-size: 16px; } +.pane-search-titles { right: 5px; font-size: 11px; font-weight: 700; } +.pane-search button:hover, .pane-search-titles.active { color: #fff; } + .pane-label { font-size: 10px; font-weight: 700; diff --git a/test/db-schema-reconcile.test.js b/test/db-schema-reconcile.test.js index c877df7e..33ea631e 100644 --- a/test/db-schema-reconcile.test.js +++ b/test/db-schema-reconcile.test.js @@ -45,6 +45,40 @@ function inspectDb(dataDir) { const PROJECT_TABLES = ['projects', 'project_folders', 'tracks']; +test('session search scopes before limiting and supports archived and title-only matches', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-scoped-search-')); + try { + const r = runInElectronNode(` + const assert = require('node:assert/strict'); + const db = require('./db'); + db.upsertSearchEntries([ + ...Array.from({ length: 1000 }, (_, i) => ({ id: 'outside-' + i, type: 'session', title: 'needle' })), + { id: 'active', type: 'session', title: 'needle active' }, + { id: 'archived', type: 'session', title: 'older session', body: 'needle in transcript' }, + ]); + db.setArchived('archived', 1); + const scope = ['active', 'archived']; + assert.deepEqual(new Set(db.searchByType('session', 'needle', 50, false, scope).map(r => r.id)), new Set(scope)); + assert.deepEqual(db.searchByType('session', 'needle', 50, true, scope).map(r => r.id), ['active']); + assert.deepEqual(db.searchByType('session', 'needle', 50, false, []), []); + assert.equal(db.searchByType('session', 'needle').length, 50); + assert.deepEqual(new Set(db.searchSessionIds('needle', scope)), new Set(scope)); + assert.deepEqual(db.searchSessionIds('transcript', scope), ['archived']); + assert.deepEqual(db.searchSessionIds('needle', []), []); + assert.deepEqual(db.searchSessionIds('', scope), []); + assert.deepEqual(db.searchSessionIds('" OR needle', scope), []); + const start = performance.now(); + for (let i = 0; i < 100; i++) db.searchSessionIds('needle', scope); + console.log('Scoped ID search average ms:', (performance.now() - start) / 100); + db.closeDb(); + `, dir); + assert.equal(r.status, 0, r.stderr); + console.log(r.stdout.trim()); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('fresh database gets fileMtime column', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-db-fresh-')); try { diff --git a/test/project-session-search.test.js b/test/project-session-search.test.js new file mode 100644 index 00000000..0623a7f0 --- /dev/null +++ b/test/project-session-search.test.js @@ -0,0 +1,81 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); + +// Exercise the real search controller with deterministic timers and IPC. +const source = fs.readFileSync(path.join(__dirname, '../public/projects-view.js'), 'utf8'); +const controller = source.slice(source.indexOf('function updateProjectSessionSearch('), source.indexOf('function buildSessionPaneList(')); + +function setup() { + const project = { id: 'project', sessions: [{ sessionId: 'active' }], tracks: [] }; + const full = { ...project, tracks: [{ sessions: [{ sessionId: 'archived', archived: 1 }] }] }; + const state = { query: 'needle', displayQuery: '', titlesOnly: false, version: 0, ids: new Set() }; + const timers = new Map(); + const calls = []; + let nextTimer = 0; + const context = vm.createContext({ + clearTimeout: id => timers.delete(id), + setTimeout: fn => { timers.set(++nextTimer, fn); return nextTimer; }, + projectsUi: { working: true }, + selectedProject: () => project, + projectPanes: { querySelector: () => null }, + cachedProjectTreeAll: { projects: [full, { id: 'other', sessions: [{ sessionId: 'outside' }] }] }, + projectSessionsAll: p => [...p.sessions, ...p.tracks.flatMap(t => t.sessions)], + window: { api: { searchSessionIds: (query, ids) => new Promise(resolve => calls.push({ query, ids, resolve })) } }, + }); + vm.runInContext(controller, context); + return { project, state, timers, calls, update: () => context.updateProjectSessionSearch(project, state) }; +} + +test('title-only search is immediate and cancels a queued transcript search', () => { + const s = setup(); + s.update(); + assert.equal(s.timers.size, 1); + s.state.titlesOnly = true; + s.update(); + assert.equal(s.timers.size, 0); + assert.equal(s.calls.length, 0); + assert.equal(s.state.pending, false); + assert.equal(s.state.displayQuery, 'needle'); +}); + +test('typing keeps completed results visible until the refined query finishes', async () => { + const s = setup(); + s.state.displayQuery = 'need'; + s.state.ids = new Set(['active', 'archived']); + s.update(); + assert.equal(s.state.pending, true); + assert.equal(s.state.displayQuery, 'need'); + assert.deepEqual([...s.state.ids], ['active', 'archived']); + const request = [...s.timers.values()].at(-1)(); + s.calls[0].resolve(['archived']); + await request; + assert.equal(s.state.displayQuery, 'needle'); + assert.deepEqual([...s.state.ids], ['archived']); + s.state.query = ''; + s.update(); + assert.equal(s.state.displayQuery, ''); + assert.equal(s.state.pending, false); +}); + +test('transcript search uses loaded active/archived IDs and ignores stale replies', async () => { + const s = setup(); + s.update(); + const first = [...s.timers.values()][0](); + assert.deepEqual(s.calls[0].ids, ['active', 'archived']); + s.state.query = ''; + s.update(); + s.calls[0].resolve(['archived']); + await first; + assert.equal(s.state.ids.size, 0); + assert.equal(s.state.pending, false); + s.state.query = 'fresh'; + s.update(); + const latest = [...s.timers.values()].at(-1)(); + s.calls[1].resolve(['active']); + await latest; + assert.deepEqual([...s.state.ids], ['active']); + assert.equal(s.state.pending, false); +}); From b4cefd635d6d2f5a3fae0b7f20261f747dd79196 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 5 Sep 2026 18:58:19 -0700 Subject: [PATCH 08/24] Keep message viewing inside the project workspace Show transcripts beside the project session list immediately while loading. Ignore stale reads after navigation and prevent terminal refreshes from covering the message viewer. --- public/jsonl-viewer.js | 16 +++++++++++- public/projects-view.js | 9 +++++++ public/style.css | 3 ++- test/project-messages-view.test.js | 41 ++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 test/project-messages-view.test.js diff --git a/public/jsonl-viewer.js b/public/jsonl-viewer.js index 0aa880ff..0407dfe0 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -553,7 +553,11 @@ function renderJsonlEntry(entry, toolResultMap) { return div; } +let jsonlViewRequest = 0; + async function showJsonlViewer(session) { + const request = ++jsonlViewRequest; + if (typeof leaveTaskLogView === 'function') leaveTaskLogView(); // Viewing a transcript is another way of navigating to a session. Keep the // sidebar highlight and persisted selection in sync even though the action // button stops the session row's normal click handler. @@ -562,7 +566,6 @@ async function showJsonlViewer(session) { if (item) item.classList.add('active'); setActiveSession(session.sessionId); - const result = await window.api.readSessionJsonl(session.sessionId); hideAllViewers(); placeholder.style.display = 'none'; terminalArea.style.display = 'none'; @@ -571,6 +574,17 @@ async function showJsonlViewer(session) { const displayName = session.name || session.aiTitle || session.summary || session.sessionId; jsonlViewerTitle.textContent = displayName; jsonlViewerSessionId.textContent = session.sessionId; + jsonlViewerBody.innerHTML = '
Loading messages…
'; + if (typeof onMessagesShown === 'function') onMessagesShown(session); + + let result; + try { + result = await window.api.readSessionJsonl(session.sessionId); + } catch (error) { + result = { error: error.message }; + } + // A slow transcript must not reopen the viewer or replace a newer selection. + if (request !== jsonlViewRequest || activeSessionId !== session.sessionId || jsonlViewer.style.display === 'none') return; jsonlViewerBody.innerHTML = ''; if (result.error) { diff --git a/public/projects-view.js b/public/projects-view.js index 2154c255..023a34bf 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -869,6 +869,8 @@ function refreshProjectViews({ reason = 'project' } = {}) { */ function ensureWorkingTerminalVisible(project, { refit = false } = {}) { if (!projectsUi.working || gridViewActive || activeTaskView || !activeSessionId) return; + // The transcript occupies the same content pane as the terminal. + if (jsonlViewer.style.display !== 'none') return; const entry = openSessions.get(activeSessionId); const session = sessionMap.get(activeSessionId) || entry?.session; const rememberedHere = projectNavigation(project).mode === 'session' && @@ -973,6 +975,13 @@ function onSessionShown(sessionId) { enterWorking(info.project, session); } +/** Messages use the working layout too, including sessions with no live PTY. */ +function onMessagesShown(session) { + if (activeTab !== 'projects') return; + const info = projectForSession(session); + if (info) enterWorking(info.project, session); +} + /** Called by showTaskLog. A task log opens beside the pane, not over the whole project. */ function onTaskLogShown() { if (activeTab !== 'projects') return; diff --git a/public/style.css b/public/style.css index 6d24bd54..af363225 100644 --- a/public/style.css +++ b/public/style.css @@ -5529,7 +5529,8 @@ body { display: flex; flex-direction: column; } z-index: 2; } -#main.project-working #terminal-area { +#main.project-working #terminal-area, +#main.project-working #jsonl-viewer { top: 44px; left: var(--pane-w, 288px); } diff --git a/test/project-messages-view.test.js b/test/project-messages-view.test.js new file mode 100644 index 00000000..caed4473 --- /dev/null +++ b/test/project-messages-view.test.js @@ -0,0 +1,41 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +test('messages open beside the project list before loading and ignore replies after navigation', async () => { + const source = fs.readFileSync(path.join(__dirname, '../public/jsonl-viewer.js'), 'utf8'); + const projects = fs.readFileSync(path.join(__dirname, '../public/projects-view.js'), 'utf8'); + const entered = []; + const requests = []; + const element = () => ({ style: {}, textContent: '', innerHTML: '' }); + const context = vm.createContext({ + document: { querySelectorAll: () => [], querySelector: () => null }, + activeTab: 'projects', activeSessionId: null, + placeholder: element(), terminalArea: element(), jsonlViewer: element(), + jsonlViewerTitle: element(), jsonlViewerSessionId: element(), jsonlViewerBody: element(), + hideAllViewers() {}, + setActiveSession(id) { context.activeSessionId = id; }, + projectForSession: () => ({ project: { id: 'project-1' } }), + enterWorking: (project, session) => entered.push([project.id, session.sessionId]), + window: { api: { readSessionJsonl: () => new Promise(resolve => requests.push(resolve)) } }, + escapeHtml: text => text, + }); + vm.runInContext(projects.slice(projects.indexOf('function onMessagesShown('), projects.indexOf('/** Called by showTaskLog.')), context); + vm.runInContext(source.slice(source.indexOf('let jsonlViewRequest =')), context); + const first = context.showJsonlViewer({ sessionId: 'first' }); + assert.deepEqual(entered, [['project-1', 'first']]); + assert.equal(context.jsonlViewer.style.display, 'flex'); + assert.equal(context.terminalArea.style.display, 'none'); + const second = context.showJsonlViewer({ sessionId: 'second' }); + requests[0]({ error: 'stale reply' }); + await first; + assert.equal(context.jsonlViewerBody.innerHTML.includes('stale reply'), false); + context.jsonlViewer.style.display = 'none'; + context.activeSessionId = 'terminal'; + requests[1]({ error: 'reply after leaving' }); + await second; + assert.equal(context.jsonlViewer.style.display, 'none'); + assert.equal(context.jsonlViewerBody.innerHTML.includes('reply after leaving'), false); +}); From 39b5e01fc13707633e7de9afb2e1e9895d9fc644 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 5 Sep 2026 18:58:20 -0700 Subject: [PATCH 09/24] Give sidebar session titles more room Put session titles and stars on their own line, with status and primary actions below. Move secondary actions into a compact menu with accessible button labels. --- public/sidebar.js | 127 ++++++++++++++++++---------------------------- public/style.css | 91 ++++++++++++++++++++++++--------- 2 files changed, 116 insertions(+), 102 deletions(-) diff --git a/public/sidebar.js b/public/sidebar.js index 5be49023..2bbb9145 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -650,14 +650,6 @@ function bindSessionListEvents(container) { item.onclick = () => openSession(session); - const moveBtn = item.querySelector('.session-move-btn'); - if (moveBtn) { - moveBtn.onclick = (e) => { - e.stopPropagation(); - if (typeof showMovePopover === 'function') showMovePopover(session, moveBtn); - }; - } - const pin = item.querySelector('.session-pin'); if (pin) { pin.onclick = async (e) => { @@ -697,14 +689,6 @@ function bindSessionListEvents(container) { }; } - const launchConfigBtn = item.querySelector('.session-launch-config-btn'); - if (launchConfigBtn) { - launchConfigBtn.onclick = (e) => { - e.stopPropagation(); - showResumeSessionDialog(session); - }; - } - const forkBtn = item.querySelector('.session-fork-btn'); if (forkBtn) { forkBtn.onclick = async (e) => { @@ -719,11 +703,26 @@ function bindSessionListEvents(container) { }; } - const jsonlBtn = item.querySelector('.session-jsonl-btn'); - if (jsonlBtn) { - jsonlBtn.onclick = (e) => { + const moreBtn = item.querySelector('.session-more-btn'); + if (moreBtn) { + moreBtn.onclick = (e) => { e.stopPropagation(); - showJsonlViewer(session); + const menuItems = [ + { label: 'Rename session', onClick: () => startRename(item.querySelector('.session-summary'), session) }, + ]; + if (session.type !== 'terminal') { + menuItems.push( + { label: 'View messages', onClick: () => showJsonlViewer(session) }, + { label: 'Move to project…', onClick: () => showMovePopover(session, moreBtn) }, + ); + if (!activePtyIds.has(session.sessionId)) { + menuItems.push({ label: 'Resume with config…', onClick: () => showResumeSessionDialog(session) }); + } + } + if (isDismissibleSession(session.sessionId)) { + menuItems.push({ label: 'Dismiss session', onClick: () => dismissSession(session.sessionId) }); + } + showContextMenu(menuItems, { anchor: moreBtn }); }; } @@ -775,17 +774,16 @@ function buildSessionItem(session) { const row = document.createElement('div'); row.className = 'session-row'; - // Pin - const pin = document.createElement('span'); + // Title and star share the top line; status and actions sit below. + const pin = document.createElement('button'); pin.className = 'session-pin' + (session.starred ? ' pinned' : ''); - pin.innerHTML = session.starred - ? '' - : ''; - - // Which CLI this session belongs to, and whether it is running — one mark in - // the gutter rather than a badge inline with the title, which pushed titles - // out of alignment with rows that had none. The dot keeps its own element so - // the busy/attention/response-ready states still drive it. + pin.type = 'button'; + pin.title = session.starred ? 'Unstar session' : 'Star session'; + pin.setAttribute('aria-label', pin.title); + pin.setAttribute('aria-pressed', String(!!session.starred)); + pin.innerHTML = ``; + + // Keep the status dot separate so running, busy and unread updates still apply. const mark = document.createElement('span'); mark.className = 'session-mark'; @@ -806,30 +804,23 @@ function buildSessionItem(session) { dot.className = 'session-status-dot' + (activePtyIds.has(session.sessionId) ? ' running' : ''); mark.append(markIcon, dot); - // Info block - const info = document.createElement('div'); - info.className = 'session-info'; - + const header = document.createElement('div'); + header.className = 'session-header'; const summaryEl = document.createElement('div'); summaryEl.className = 'session-summary'; summaryEl.textContent = displayName; + header.append(summaryEl, pin); - // Compact meta line: time + msgs on the left, first UUID segment on the right - // (replaces the full-width session-id line). The 30s label ticker in app.js - // updates .session-time only, so it must stay its own span. + const footer = document.createElement('div'); + footer.className = 'session-footer'; const metaEl = document.createElement('div'); metaEl.className = 'session-meta'; + // The 30-second ticker updates this span, including the message count. const timeEl = document.createElement('span'); timeEl.className = 'session-time'; + timeEl.title = session.sessionId; timeEl.textContent = timeStr + (session.messageCount ? ' \u00b7 ' + session.messageCount + ' msgs' : ''); - const shortIdEl = document.createElement('span'); - shortIdEl.className = 'session-short-id'; - shortIdEl.title = session.sessionId; - shortIdEl.textContent = session.sessionId.split('-')[0]; - metaEl.append(timeEl, shortIdEl); - - info.appendChild(summaryEl); - info.appendChild(metaEl); + metaEl.append(mark, timeEl); // Action buttons container const actions = document.createElement('div'); @@ -853,49 +844,29 @@ function buildSessionItem(session) { forkBtn.title = 'Fork session'; forkBtn.innerHTML = ''; - const jsonlBtn = document.createElement('button'); - jsonlBtn.className = 'session-jsonl-btn'; - jsonlBtn.title = 'View messages'; - jsonlBtn.innerHTML = ''; - - const launchConfigBtn = document.createElement('button'); - launchConfigBtn.className = 'session-launch-config-btn'; - launchConfigBtn.title = 'Resume with config'; - launchConfigBtn.innerHTML = ICONS.launchConfig(14); - const isUnread = responseReadySessions.has(session.sessionId); const unreadBtn = document.createElement('button'); unreadBtn.className = 'session-unread-btn'; unreadBtn.title = isUnread ? 'Mark as read' : 'Mark as unread'; unreadBtn.innerHTML = isUnread ? ICONS.markRead(14) : ICONS.markUnread(14); - // File the session into a project (projects-view.js). Sessions stay where - // they are unless moved, so this is the one way an old session joins one. - const moveBtn = document.createElement('button'); - moveBtn.className = 'session-move-btn'; - moveBtn.title = 'Move to project…'; - moveBtn.innerHTML = ''; + const moreBtn = document.createElement('button'); + moreBtn.className = 'session-more-btn'; + moreBtn.title = 'More session actions'; + moreBtn.setAttribute('aria-haspopup', 'menu'); + moreBtn.innerHTML = ''; - actions.appendChild(stopBtn); - actions.appendChild(unreadBtn); if (session.type !== 'terminal') { - actions.appendChild(forkBtn); - actions.appendChild(jsonlBtn); - actions.appendChild(archiveBtn); - actions.appendChild(moveBtn); - actions.appendChild(launchConfigBtn); + actions.append(archiveBtn, forkBtn); + } + actions.append(unreadBtn, stopBtn, moreBtn); + for (const button of actions.children) { + button.type = 'button'; + button.setAttribute('aria-label', button.title); } - // Logo and pin stack vertically in a single gutter column — the logo beside - // the title, the pin beside the meta line under it. Side by side they cost - // every row a second column of horizontal space the title needs more. - const gutter = document.createElement('div'); - gutter.className = 'session-gutter'; - gutter.append(mark, pin); - - row.appendChild(gutter); - row.appendChild(info); - row.appendChild(actions); + footer.append(metaEl, actions); + row.append(header, footer); item.appendChild(row); return item; diff --git a/public/style.css b/public/style.css index af363225..8c7bc36e 100644 --- a/public/style.css +++ b/public/style.css @@ -803,26 +803,65 @@ body { display: flex; flex-direction: column; } background: rgba(120,130,255,0.14); } -/* ---- Gutter: logo above, pin below ---- */ -.session-gutter { - display: flex; +/* Session cards use two lines, leaving the entire title width available. */ +.session-item[data-session-id] .session-row { flex-direction: column; + align-items: stretch; + gap: 3px; +} + +.session-header, +.session-footer { + display: flex; align-items: center; - gap: 4px; - flex-shrink: 0; + gap: 8px; + min-width: 0; } -/* Dim until the row is hovered or the session is actually pinned, so an - unpinned pin does not compete with the logo above it. */ -.session-gutter .session-pin { - opacity: 0.4; +.session-header .session-summary, +.session-header .session-rename-input { + flex: 1; + min-width: 0; } -.session-item:hover .session-gutter .session-pin, -.session-gutter .session-pin.pinned { +.session-header .session-pin { + background: none; + border: none; + padding: 3px; + opacity: 0.65; +} + +.session-header .session-pin.pinned, +.session-header .session-pin:hover, +.session-header .session-pin:focus-visible { opacity: 1; } +.session-footer { + flex-wrap: wrap; + row-gap: 4px; +} + +.session-item .session-footer .session-meta { + margin-top: 0; + align-items: center; + justify-content: flex-start; + flex: 1 0 auto; + white-space: nowrap; +} + +.session-footer .session-mark { + margin-top: 0; +} + +.session-footer .session-actions { + position: static; + opacity: 1; + background: none; + padding: 0; + margin-left: auto; +} + /* ---- Pin ---- */ .session-pin { flex-shrink: 0; @@ -846,10 +885,7 @@ body { display: flex; flex-direction: column; } } /* ---- Running dot ---- */ -/* Gutter mark: the session's CLI logo with its running dot tucked into the - corner. One column shared with the pin, so titles line up whatever the - session type is. The dot stays a separate element because the busy, - attention and response-ready states all restyle it. */ +/* The CLI logo and its status dot lead the session footer. */ .session-mark { position: relative; width: 16px; @@ -979,7 +1015,7 @@ body { display: flex; flex-direction: column; } margin-top: 3px; } -/* Sidebar session items: time on the left, short session id on the right */ +/* Shared session metadata layout */ .session-item .session-meta { display: flex; align-items: baseline; @@ -987,12 +1023,6 @@ body { display: flex; flex-direction: column; } gap: 8px; } -.session-short-id { - font-family: 'SF Mono', 'Fira Code', Menlo, monospace; - color: #5a5a72; - flex-shrink: 0; -} - /* ---- Session action buttons ---- */ .session-actions { flex-shrink: 0; @@ -1013,6 +1043,7 @@ body { display: flex; flex-direction: column; } opacity: 1; } +.session-more-btn, .session-stop-btn, .session-launch-config-btn, .session-fork-btn, @@ -1031,6 +1062,15 @@ body { display: flex; flex-direction: column; } justify-content: center; } +.session-more-btn { + color: #929bb2; +} + +.session-more-btn:hover { + color: #d0d0e8; + background: rgba(255,255,255,0.08); +} + .session-stop-btn { color: #9b4058; } @@ -5764,7 +5804,8 @@ body { display: flex; flex-direction: column; } .ctx-menu { position: fixed; z-index: 10040; - min-width: 220px; + min-width: 0; + width: max-content; max-width: 320px; background: #1b1b26; border: 1px solid rgba(255,255,255,0.12); @@ -5783,7 +5824,7 @@ body { display: flex; flex-direction: column; } align-items: center; gap: 9px; height: 30px; - padding: 0 9px; + padding: 0 6px; border-radius: 6px; font-size: 12.5px; color: #c8c8dc; @@ -5799,6 +5840,8 @@ body { display: flex; flex-direction: column; } .ctx-item.disabled:hover { background: none; } .ctx-item.muted { color: #8888a0; } .ctx-icon { width: 14px; height: 14px; display: inline-flex; align-items: center; justify-content: center; color: #8088ff; flex-shrink: 0; } +/* Text-only menus do not need an empty icon column. */ +.ctx-menu:not(:has(.ctx-icon > *)) .ctx-icon { display: none; } .ctx-icon .codex-icon { color: #10a37f; } .ctx-item.danger .ctx-icon { color: #e05070; } .ctx-item.muted .ctx-icon, .ctx-item.disabled .ctx-icon { color: #6a6a80; } From 9198b518be4859b8ee9092754f3e755f2f109a03 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 5 Sep 2026 18:58:20 -0700 Subject: [PATCH 10/24] Preview images, PDFs, and isolated interactive HTML Add image and native PDF previews plus interactive HTML in an isolated frame. Serve local web assets through a read-only scoped protocol and use icon actions in the file browser header. --- main.js | 11 ++- preview-assets.js | 77 ++++++++++++++++++ project-files.js | 47 +++++++++-- public/file-panel.js | 9 ++- public/projects-view.js | 9 ++- public/style.css | 32 ++++++++ public/viewer-panel.js | 155 +++++++++++++++++++++++++++++++----- test/preview-assets.test.js | 69 ++++++++++++++++ test/project-files.test.js | 55 ++++++++++++- test/viewer-panel.test.js | 86 ++++++++++++++++++++ 10 files changed, 510 insertions(+), 40 deletions(-) create mode 100644 preview-assets.js create mode 100644 test/preview-assets.test.js create mode 100644 test/viewer-panel.test.js diff --git a/main.js b/main.js index 99f5fca5..f9e2acfd 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron'); +const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, protocol, screen, shell } = require('electron'); const { Worker } = require('worker_threads'); const path = require('path'); const fs = require('fs'); @@ -35,7 +35,9 @@ const { createTerminalActivity } = require('./terminal-activity'); const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles'); const { startScheduler } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); -const { listProjectDirectory, readProjectFile } = require('./project-files'); +const { listProjectDirectory, readProjectFile, readPreviewFile } = require('./project-files'); +const { PREVIEW_SCHEME, PREVIEW_SCHEMES, handlePreviewAssetRequest } = require('./preview-assets'); +protocol.registerSchemesAsPrivileged(PREVIEW_SCHEMES); const { createTaskManager } = require('./task-manager'); const { lastAssistantMessage } = require('./session-preview'); @@ -128,6 +130,7 @@ function createWindow() { webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, + nodeIntegrationInSubFrames: false, contextIsolation: true, }, }); @@ -503,8 +506,7 @@ ipcMain.on('mcp-diff-response', (_event, sessionId, diffId, action, editedConten ipcMain.handle('read-file-for-panel', async (_event, filePath) => { try { - const content = fs.readFileSync(filePath, 'utf8'); - return { ok: true, content }; + return { ok: true, ...readPreviewFile(filePath) }; } catch (err) { return { ok: false, error: err.message }; } @@ -2091,6 +2093,7 @@ if (!gotSingleInstanceLock) { }); app.whenReady().then(() => { + protocol.handle(PREVIEW_SCHEME, handlePreviewAssetRequest); buildMenu(); initializeHiddenProjectTimestamps(); createWindow(); diff --git a/preview-assets.js b/preview-assets.js new file mode 100644 index 00000000..0b3e4f0f --- /dev/null +++ b/preview-assets.js @@ -0,0 +1,77 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { randomBytes } = require('node:crypto'); + +const PREVIEW_SCHEME = 'switchboard-preview'; +const PREVIEW_SCHEMES = [{ + scheme: PREVIEW_SCHEME, + privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true }, +}]; +const MAX_ASSET_BYTES = 32 * 1024 * 1024; +const MIME_TYPES = { + '.html': 'text/html', '.htm': 'text/html', + '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', + '.json': 'application/json', '.csv': 'text/csv', + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', + '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', + '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', +}; +// A preview receives an unguessable URL for its asset folder, never a file:// +// URL or an IPC bridge. The handler only serves read-only web assets in it. +const rootsByToken = new Map(); +const tokensByRoot = new Map(); + +function within(root, target) { + const relative = path.relative(root, target); + return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative); +} + +function createPreviewAssetUrl(filePath, projectRoot = path.dirname(filePath)) { + const root = fs.realpathSync(projectRoot); + const file = fs.realpathSync(filePath); + if (!within(root, file)) throw new Error('Preview file is outside its asset folder'); + let token = tokensByRoot.get(root); + if (!token) { + token = randomBytes(24).toString('hex'); + tokensByRoot.set(root, token); + rootsByToken.set(token, root); + } + const relative = path.relative(root, file).split(path.sep).map(encodeURIComponent).join('/'); + return `${PREVIEW_SCHEME}://${token}/${relative}`; +} + +async function handlePreviewAssetRequest(request) { + const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }; + const fail = status => new Response(null, { status, headers }); + if (request.method !== 'GET' && request.method !== 'HEAD') return fail(405); + try { + const url = new URL(request.url); + const root = rootsByToken.get(url.hostname); + if (url.protocol !== `${PREVIEW_SCHEME}:` || !root || url.username || url.password || url.port) return fail(404); + const relative = decodeURIComponent(url.pathname.slice(1)); + // Do not expose dotfiles, repository metadata or hidden configuration. + if (relative.split(/[\\/]/).some(part => part.startsWith('.'))) return fail(403); + const candidate = path.resolve(root, relative); + if (!within(root, candidate)) return fail(403); + const resolved = await fs.promises.realpath(candidate); + if (!within(root, resolved) || path.relative(root, resolved).split(path.sep).some(part => part.startsWith('.'))) return fail(403); + const mimeType = MIME_TYPES[path.extname(resolved).toLowerCase()]; + if (!mimeType) return fail(403); + const stat = await fs.promises.stat(resolved); + if (!stat.isFile()) return fail(404); + if (stat.size > MAX_ASSET_BYTES) return fail(413); + headers['Content-Type'] = mimeType; + const body = request.method === 'HEAD' ? null : await fs.promises.readFile(resolved); + return new Response(body, { headers }); + } catch { + return fail(404); + } +} + +module.exports = { PREVIEW_SCHEME, PREVIEW_SCHEMES, MAX_ASSET_BYTES, createPreviewAssetUrl, handlePreviewAssetRequest }; diff --git a/project-files.js b/project-files.js index 5fda73ba..22210efc 100644 --- a/project-files.js +++ b/project-files.js @@ -1,7 +1,27 @@ const fs = require('fs'); const path = require('path'); +const { pathToFileURL } = require('url'); +const { createPreviewAssetUrl } = require('./preview-assets'); const MAX_PREVIEW_BYTES = 2 * 1024 * 1024; +const MAX_MEDIA_PREVIEW_BYTES = 32 * 1024 * 1024; +const IMAGE_MIME_TYPES = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.avif': 'image/avif', +}; + +function previewType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (IMAGE_MIME_TYPES[ext]) return 'image'; + if (ext === '.pdf') return 'pdf'; + if (ext === '.html' || ext === '.htm') return 'html'; + return 'text'; +} + +function previewLimit(filePath) { + return ['image', 'pdf'].includes(previewType(filePath)) ? MAX_MEDIA_PREVIEW_BYTES : MAX_PREVIEW_BYTES; +} const BINARY_EXTENSIONS = new Set([ '.7z', '.a', '.avi', '.bin', '.bmp', '.class', '.db', '.dmg', '.dll', '.doc', '.docx', '.dylib', '.eot', '.exe', '.gif', '.gz', '.ico', '.jar', '.jpeg', @@ -40,7 +60,8 @@ function resolveProjectEntry(projectPath, relativePath = '') { } function isViewableFile(filePath, stat) { - if (!stat.isFile() || stat.size > MAX_PREVIEW_BYTES) return false; + if (!stat.isFile() || stat.size > previewLimit(filePath)) return false; + if (['image', 'pdf'].includes(previewType(filePath))) return true; if (BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())) return false; if (!stat.size) return true; @@ -75,6 +96,7 @@ function listProjectDirectory(projectPath, relativePath = '') { type, size: stat.size, viewable, + previewType: viewable ? previewType(absolutePath) : null, }; }).sort((a, b) => { if (a.type === 'directory' && b.type !== 'directory') return -1; @@ -84,20 +106,33 @@ function listProjectDirectory(projectPath, relativePath = '') { } function readProjectFile(projectPath, relativePath) { - const { resolved } = resolveProjectEntry(projectPath, relativePath); - const stat = fs.lstatSync(resolved); - if (!isViewableFile(resolved, stat)) { - throw new Error(stat.size > MAX_PREVIEW_BYTES + const { root, resolved } = resolveProjectEntry(projectPath, relativePath); + return readPreviewFile(resolved, root); +} + +function readPreviewFile(filePath, projectRoot) { + const stat = fs.statSync(filePath); + if (!isViewableFile(filePath, stat)) { + throw new Error(stat.size > previewLimit(filePath) ? 'File is too large to preview' : 'File type cannot be previewed'); } - return { filePath: resolved, content: fs.readFileSync(resolved, 'utf8') }; + const type = previewType(filePath); + const result = { filePath, previewType: type, fileUrl: pathToFileURL(filePath).href }; + if (type === 'html') result.previewUrl = createPreviewAssetUrl(filePath, projectRoot); + if (type === 'image' || type === 'pdf') { + const mimeType = type === 'pdf' ? 'application/pdf' : IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()]; + return { ...result, mimeType, base64: fs.readFileSync(filePath).toString('base64') }; + } + return { ...result, content: fs.readFileSync(filePath, 'utf8') }; } module.exports = { MAX_PREVIEW_BYTES, + MAX_MEDIA_PREVIEW_BYTES, isViewableFile, listProjectDirectory, readProjectFile, + readPreviewFile, resolveProjectEntry, }; diff --git a/public/file-panel.js b/public/file-panel.js index 7e9b1d76..73683160 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -332,6 +332,7 @@ function openFileTab(sessionId, data) { label: basename(data.filePath), filePath: data.filePath, content: data.content, + preview: data, }; state.panelVisible = true; @@ -362,7 +363,7 @@ function destroyCurrentTab(state) { async function openFileInPanel(sessionId, filePath) { const result = await window.api.readFileForPanel(filePath); if (!result.ok) return; - openFileTab(sessionId, { filePath, content: result.content }); + openFileTab(sessionId, { ...result, filePath }); } async function openProjectFile(sessionId, relativePath) { @@ -380,7 +381,7 @@ async function openProjectFile(sessionId, relativePath) { renderProjectBrowser(sessionId); const result = await window.api.readProjectFile(requestedRoot, relativePath); - if (state.projectPath !== requestedRoot) return; + if (state.projectPath !== requestedRoot || state.selectedPath !== relativePath) return; const effectiveSessionId = sessionIdForState(state, sessionId); if (!effectiveSessionId) return; if (!result.ok) { @@ -390,7 +391,7 @@ async function openProjectFile(sessionId, relativePath) { return; } - openFileTab(effectiveSessionId, { filePath: result.filePath, content: result.content }); + openFileTab(effectiveSessionId, result); } function closeAllDiffs(sessionId) { @@ -509,7 +510,7 @@ function renderTabContent(sessionId, tab) { // Use ViewerPanel diffContainer.style.display = 'none'; vpContainer.style.display = 'flex'; - fpViewerPanel.open(tab.label, tab.filePath, tab.content); + fpViewerPanel.open(tab.label, tab.filePath, tab.content, tab.preview); } else { // Diff mode vpContainer.style.display = 'none'; diff --git a/public/projects-view.js b/public/projects-view.js index 023a34bf..5c254167 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -215,6 +215,7 @@ const mainEl = document.getElementById('main'); // --- Icons (stroke SVG, 24 grid) --- const PICONS = { + refresh: (s = 14) => ``, plus: (s = 14) => ``, chevronDown: (s = 11) => ``, chevronRight: (s = 11) => ``, @@ -1888,6 +1889,7 @@ async function saveProjectEditorFile(filePath, content) { async function openFileInProjectEditor(project, rel) { const state = filesState(project); state.selected = rel; + const request = state.openRequest = (state.openRequest || 0) + 1; const host = projectViewer?.querySelector('#ws-editor'); if (!host) return; projectViewer.querySelectorAll('.ws-file-row').forEach(r => r.classList.toggle('selected', r.dataset.rel === rel)); @@ -1909,6 +1911,7 @@ async function openFileInProjectEditor(project, rel) { filesState(project).cache.clear(); } } + if (state.openRequest !== request || !host.isConnected || selectedProject()?.id !== project.id) return; if (!result?.ok) { host.innerHTML = `
${escapeHtml(result?.error || 'Could not open the file.')}
`; return; @@ -1916,7 +1919,7 @@ async function openFileInProjectEditor(project, rel) { ed.projectId = project.id; ed.rel = rel; host.replaceChildren(ed.host); - ed.panel.open(`${project.name} · ${rel}`, result.filePath || filePath, result.content); + ed.panel.open(`${project.name} · ${rel}`, result.filePath || filePath, result.content, result); } async function listProjectDir(project, state, rel) { @@ -1956,7 +1959,7 @@ async function renderFileTree(project, list, state) { (state.selected === entry.relativePath ? ' selected' : ''); row.dataset.rel = entry.relativePath; row.style.paddingLeft = `${10 + depth * 14}px`; - row.title = !isDir && entry.viewable === false ? `${entry.relativePath} (not a text file)` : entry.relativePath; + row.title = !isDir && entry.viewable === false ? `${entry.relativePath} (preview unavailable: unsupported type or too large)` : entry.relativePath; row.innerHTML = `${isDir ? (open ? '▾' : '▸') : ''}${escapeHtml(entry.name)}`; row.onclick = async () => { if (isDir) { @@ -1976,7 +1979,7 @@ async function renderFilesTab(project, body) { body.innerHTML = `
-
Project folder
+
Project folder
CLAUDE.md is the brief every session reads. The agent creates plan.md, plan-tracker.md, todos.md and memory.md when it needs them. Attached repositories are not listed here.
diff --git a/public/style.css b/public/style.css index 8c7bc36e..7da8ec7e 100644 --- a/public/style.css +++ b/public/style.css @@ -6012,7 +6012,39 @@ body { display: flex; flex-direction: column; } display: flex; align-items: center; padding: 10px 12px 6px; + gap: 4px; +} + +.ws-files-head .ws-card-title { white-space: nowrap; } +.ws-files-head .ws-files-action { + flex: 0 0 26px; + width: 26px; + height: 26px; + padding: 0; + justify-content: center; +} + +.viewer-media-preview { + flex: 1; + min-height: 0; + overflow: auto; + position: relative; +} +.viewer-media-preview--image { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: repeating-conic-gradient(#202028 0% 25%, #262630 0% 50%) 0 / 20px 20px; +} +.viewer-media-preview img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; } +.viewer-preview-frame { display: block; width: 100%; height: 100%; border: 0; background: white; } +.viewer-preview-error { padding: 24px; text-align: center; color: #b0b0c4; } .ws-files-list { flex: 1; diff --git a/public/viewer-panel.js b/public/viewer-panel.js index c123f97e..9c561888 100644 --- a/public/viewer-panel.js +++ b/public/viewer-panel.js @@ -6,8 +6,9 @@ * Watches files for external changes and reloads automatically. * * Toolbar buttons are shown/hidden automatically based on file type: - * - Preview: shown for markdown files - * - Wrap: always shown (defaults on for markdown, off for others) + * - Preview: shown for markdown and HTML files + * - Images and PDFs: read-only previews with text editing controls hidden + * - Wrap: shown for text (defaults on for markdown, off for others) * - Save: shown if onSave is provided * - Close: shown if onClose is provided * - Copy path/content: shown if opted in @@ -37,6 +38,9 @@ class ViewerPanel { this.wrapMode = false; this._watchedPath = null; this._saving = false; + this.previewType = 'text'; + this._objectUrl = null; + this._openVersion = 0; // Create toolbar — always include preview, wrap, save; visibility managed in open() this.toolbar = window.createViewerToolbar({ @@ -127,29 +131,56 @@ class ViewerPanel { /** * Open a file in the viewer. */ - open(title, filePath, content) { + open(title, filePath, content = '', preview = {}) { this._unwatchFile(); + this._clearPreview(); + this._openVersion++; + const previousPath = this.filePath; this.filePath = filePath; + this.previewUrl = preview.previewUrl || ''; + this.previewType = preview.previewType || (/\.html?$/i.test(filePath) ? 'html' : 'text'); this.toolbar.setTitle(title); this.toolbar.setPath(filePath); const isMd = this._isMarkdown(filePath); + const isMedia = this._isMedia(); + const isHtml = this.previewType === 'html'; // Show/hide preview button based on file type if (this.toolbar.previewBtn) { - this.toolbar.previewBtn.style.display = isMd ? '' : 'none'; + this.toolbar.previewBtn.style.display = !isMedia && (isMd || isHtml) ? '' : 'none'; + } + for (const key of ['wrapBtn', 'gotoLineBtn', 'saveBtn', 'copyContentBtn']) { + if (this.toolbar[key]) this.toolbar[key].style.display = isMedia ? 'none' : ''; } // Save preview preference before resetting - const wantPreview = isMd && this.opts.storageKey && localStorage.getItem(this.opts.storageKey) === 'true'; + const wantPreview = isHtml || (isMd && this.opts.storageKey && localStorage.getItem(this.opts.storageKey) === 'true'); // Reset to edit mode before updating content (without touching localStorage) - if (this.previewMode) { - this.previewEl.style.display = 'none'; - this.editorEl.style.display = ''; - if (this.toolbar.previewBtn) this.toolbar.previewBtn.classList.remove('active'); - this.previewMode = false; + this.editorEl.style.display = isMedia ? 'none' : ''; + this.previewMode = false; + this.toolbar.setPreviewMode(false); + if (this.toolbar.previewBtn) { + this.toolbar.previewBtn.title = isHtml ? 'Preview HTML' : 'Toggle markdown preview'; + this.toolbar.previewBtn.setAttribute('aria-label', this.toolbar.previewBtn.title); + this.toolbar.previewBtn.setAttribute('aria-pressed', 'false'); + } + + // Recreate when changing files so language, undo history and search state + // cannot leak from the previous file into this one. + if (this.editorView && (previousPath !== filePath || isMedia)) { + this._destroyEditor(); + } + this.wrapMode = isMd; + if (isMedia) { + const bytes = Uint8Array.from(atob(preview.base64 || ''), char => char.charCodeAt(0)); + this._objectUrl = URL.createObjectURL(new Blob([bytes], { type: preview.mimeType })); + this.previewMode = true; + this._renderPreview(); + this._watchFile(filePath); + return; } // Create or update editor @@ -197,14 +228,82 @@ class ViewerPanel { } _togglePreview() { - this.previewMode = toggleMarkdownPreview({ - editorEl: this.editorEl, - previewEl: this.previewEl, - toggleBtn: this.toolbar.previewBtn, - editorView: this.editorView, - isPreview: this.previewMode, - storageKey: this.opts.storageKey, - }); + if (this._isMedia() || (!this._isMarkdown(this.filePath) && this.previewType !== 'html')) return; + this.previewMode = !this.previewMode; + if (this.previewMode) this._renderPreview(); + else this._clearPreview(); + this.editorEl.style.display = this.previewMode ? 'none' : ''; + this.toolbar.setPreviewMode(this.previewMode); + const label = this.previewMode ? 'Back to editor' : this.previewType === 'html' ? 'Preview HTML' : 'Toggle markdown preview'; + this.toolbar.previewBtn.title = label; + this.toolbar.previewBtn.setAttribute('aria-label', label); + this.toolbar.previewBtn.setAttribute('aria-pressed', String(this.previewMode)); + if (this._isMarkdown(this.filePath) && this.opts.storageKey) { + localStorage.setItem(this.opts.storageKey, String(this.previewMode)); + } + } + + _isMedia() { + return this.previewType === 'image' || this.previewType === 'pdf'; + } + + _renderPreview() { + this.previewEl.replaceChildren(); + if (this.previewType === 'image') { + this.previewEl.className = 'viewer-media-preview viewer-media-preview--image'; + this.previewEl.style.display = 'flex'; + const img = document.createElement('img'); + img.alt = this.filePath.split(/[\\/]/).pop(); + img.onerror = () => { + const error = document.createElement('div'); + error.className = 'viewer-preview-error'; + error.textContent = 'This image could not be displayed. The file may be damaged or use an unsupported image format.'; + this.previewEl.replaceChildren(error); + }; + img.src = this._objectUrl; + this.previewEl.appendChild(img); + } else if (this.previewType === 'pdf' || this.previewType === 'html') { + this.previewEl.className = 'viewer-media-preview'; + this.previewEl.style.display = 'block'; + const frame = document.createElement('iframe'); + frame.className = 'viewer-preview-frame'; + frame.title = `${this.previewType === 'pdf' ? 'PDF' : 'HTML'} preview: ${this.filePath.split(/[\\/]/).pop()}`; + if (this.previewType === 'pdf') { + // Chromium's built-in PDF viewer provides zoom, page navigation and print. + frame.src = this._objectUrl; + } else { + // Scripts run with an opaque origin: no parent DOM, app APIs or + // shared storage. Never combine allow-scripts with allow-same-origin. + frame.setAttribute('sandbox', 'allow-scripts'); + frame.referrerPolicy = 'no-referrer'; + const doc = new DOMParser().parseFromString(this.getContent(), 'text/html'); + const policy = doc.createElement('meta'); + policy.httpEquiv = 'Content-Security-Policy'; + // Only the current asset scope can be fetched. No file:// reads or + // connections to local app services; CDN scripts/images still work. + const assetOrigin = /^switchboard-preview:\/\/[a-f0-9]+\//.exec(this.previewUrl)?.[0] || ''; + policy.content = `default-src 'none'; script-src 'unsafe-inline' ${assetOrigin} https: http:; img-src ${assetOrigin} data: blob: https: http:; style-src 'unsafe-inline' ${assetOrigin} https: http:; font-src ${assetOrigin} data: https: http:; connect-src ${assetOrigin || "'none'"}; base-uri ${assetOrigin || "'none'"}; form-action 'none'`; + if (assetOrigin) { + const base = doc.createElement('base'); + base.href = this.previewUrl; + doc.head.prepend(base); + } + doc.head.prepend(policy); + frame.srcdoc = '\n' + doc.documentElement.outerHTML; + } + this.previewEl.appendChild(frame); + } else { + this.previewEl.className = 'markdown-preview'; + this.previewEl.style.display = 'block'; + this.previewEl.innerHTML = window.marked.parse(this.getContent()); + } + } + + _clearPreview() { + this.previewEl.replaceChildren(); + this.previewEl.style.display = 'none'; + if (this._objectUrl) URL.revokeObjectURL(this._objectUrl); + this._objectUrl = null; } _setPreview(show) { @@ -224,7 +323,7 @@ class ViewerPanel { } async _save() { - if (!this.opts.onSave || !this.filePath) return; + if (!this.opts.onSave || !this.filePath || this._isMedia()) return; this._saving = true; const content = this.getContent(); try { @@ -243,6 +342,14 @@ class ViewerPanel { destroy() { this._unwatchFile(); + this._openVersion++; + this._destroyEditor(); + this._clearPreview(); + this.filePath = ''; + this.previewMode = false; + } + + _destroyEditor() { if (this.editorView) { this.editorView.destroy(); this.editorView = null; @@ -251,8 +358,6 @@ class ViewerPanel { delete this.editorEl._cmSearchBar; delete this.editorEl._cmGotoLine; this.editorEl.innerHTML = ''; - this.previewEl.innerHTML = ''; - this.previewEl.style.display = 'none'; } // ── File Watching ────────────────────────────────────────────────── @@ -272,8 +377,14 @@ class ViewerPanel { async _reloadFromDisk() { if (!this.filePath || !window.api.readFileForPanel) return; + const version = this._openVersion; const result = await window.api.readFileForPanel(this.filePath); + if (version !== this._openVersion) return; if (!result.ok) return; + if (this._isMedia()) { + this.open(this.toolbar.titleEl.textContent, this.filePath, result.content, result); + return; + } const newContent = result.content; const currentContent = this.getContent(); @@ -286,7 +397,7 @@ class ViewerPanel { } if (this.previewMode) { - this.previewEl.innerHTML = window.marked.parse(newContent); + this._renderPreview(); } } diff --git a/test/preview-assets.test.js b/test/preview-assets.test.js new file mode 100644 index 00000000..58539541 --- /dev/null +++ b/test/preview-assets.test.js @@ -0,0 +1,69 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { MAX_ASSET_BYTES, createPreviewAssetUrl, handlePreviewAssetRequest } = require('../preview-assets'); +const { readProjectFile } = require('../project-files'); + +function setup(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-preview-assets-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const root = path.join(dir, 'project'); + fs.mkdirSync(path.join(root, 'pages'), { recursive: true }); + fs.writeFileSync(path.join(root, 'pages', 'demo #1.html'), '

Demo

'); + const preview = readProjectFile(root, 'pages/demo #1.html'); + const request = (relative, method = 'GET') => handlePreviewAssetRequest({ url: new URL(relative, preview.previewUrl).href, method }); + return { dir, root, preview, request }; +} + +test('HTML gets a scoped asset URL; relative CSS, modules and data can load without file access', async t => { + const { root, preview, request } = setup(t); + fs.writeFileSync(path.join(root, 'style.css'), 'body { color: teal }'); + fs.writeFileSync(path.join(root, 'app.mjs'), 'export const value = 42;'); + fs.writeFileSync(path.join(root, 'data.json'), '{"count":2}'); + assert.match(preview.previewUrl, /^switchboard-preview:\/\/[a-f0-9]{48}\/pages\/demo%20%231.html$/); + const css = await request('../style.css'); + assert.equal(css.status, 200); + assert.equal(css.headers.get('content-type'), 'text/css'); + assert.equal(css.headers.get('access-control-allow-origin'), '*'); + assert.equal(await css.text(), 'body { color: teal }'); + assert.equal((await request('../app.mjs')).headers.get('content-type'), 'text/javascript'); + assert.deepEqual(await (await request('../data.json')).json(), { count: 2 }); + assert.equal(createPreviewAssetUrl(path.join(root, 'pages/demo #1.html'), root), preview.previewUrl); +}); + +test('asset requests cannot write, read hidden/non-web files or leave the registered folder', async t => { + const { dir, root, request } = setup(t); + fs.writeFileSync(path.join(dir, 'outside.js'), 'secret'); + fs.writeFileSync(path.join(root, 'ok.js'), 'public'); + fs.writeFileSync(path.join(root, '.env.json'), '{"token":"secret"}'); + fs.writeFileSync(path.join(root, 'private.pem'), 'secret'); + fs.symlinkSync(path.join(dir, 'outside.js'), path.join(root, 'escape.js')); + fs.symlinkSync(path.join(root, '.env.json'), path.join(root, 'hidden.json')); + assert.equal((await request('../ok.js', 'POST')).status, 405); + assert.equal(fs.readFileSync(path.join(root, 'ok.js'), 'utf8'), 'public'); + assert.equal((await request('../.env.json')).status, 403); + assert.equal((await request('../private.pem')).status, 403); + assert.equal((await request('../escape.js')).status, 403); + assert.equal((await request('../hidden.json')).status, 403); + assert.equal((await request('/..%2foutside.js')).status, 403); + assert.equal((await request('/..%5coutside.js')).status, 403); + assert.equal((await request('switchboard-preview://unknown/ok.js')).status, 404); + assert.equal((await request('file://' + path.join(dir, 'outside.js'))).status, 404); +}); + +test('asset loader bounds reads, supports HEAD and uses distinct scopes per folder', async t => { + const { dir, root, preview, request } = setup(t); + const file = path.join(root, 'huge.png'); + fs.writeFileSync(file, ''); + fs.truncateSync(file, MAX_ASSET_BYTES + 1); + assert.equal((await request('../huge.png')).status, 413); + const head = await request('demo%20%231.html', 'HEAD'); + assert.equal(head.status, 200); + assert.equal(await head.text(), ''); + assert.equal((await request('../pages')).status, 403); + fs.writeFileSync(path.join(dir, 'other.html'), '

Other

'); + assert.notEqual(new URL(createPreviewAssetUrl(path.join(dir, 'other.html'))).hostname, new URL(preview.previewUrl).hostname); + assert.throws(() => createPreviewAssetUrl(path.join(dir, 'other.html'), root), /outside/); +}); diff --git a/test/project-files.test.js b/test/project-files.test.js index a862fd45..de7daf46 100644 --- a/test/project-files.test.js +++ b/test/project-files.test.js @@ -6,6 +6,7 @@ const path = require('path'); const { MAX_PREVIEW_BYTES, + MAX_MEDIA_PREVIEW_BYTES, listProjectDirectory, readProjectFile, resolveProjectEntry, @@ -21,12 +22,64 @@ test('project directory listing sorts folders first and marks previewable files' const entries = listProjectDirectory(root); assert.deepEqual(entries.map(entry => entry.name), ['src', 'image.png', 'notes.md']); assert.equal(entries.find(entry => entry.name === 'notes.md').viewable, true); - assert.equal(entries.find(entry => entry.name === 'image.png').viewable, false); + assert.equal(entries.find(entry => entry.name === 'image.png').viewable, true); + assert.equal(entries.find(entry => entry.name === 'image.png').previewType, 'image'); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); +test('image and PDF previews preserve bytes and HTML preserves editable source and encoded URLs', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-project-files-')); + try { + const media = [ + ['photo.PNG', 'image/png', 'image'], ['photo.jpeg', 'image/jpeg', 'image'], + ['animated.gif', 'image/gif', 'image'], ['icon.svg', 'image/svg+xml', 'image'], + ['photo.webp', 'image/webp', 'image'], ['photo.avif', 'image/avif', 'image'], + ['report.PDF', 'application/pdf', 'pdf'], + ]; + const bytes = Buffer.from([0, 1, 127, 128, 254, 255]); + for (const [name, mimeType, type] of media) { + fs.writeFileSync(path.join(root, name), bytes); + const result = readProjectFile(root, name); + assert.equal(result.previewType, type); + assert.equal(result.mimeType, mimeType); + assert.deepEqual(Buffer.from(result.base64, 'base64'), bytes); + assert.equal(result.content, undefined, 'binary files must never enter the text editor'); + } + const html = '

Hello

'; + fs.writeFileSync(path.join(root, 'page #1.htm'), html); + const result = readProjectFile(root, 'page #1.htm'); + assert.equal(result.previewType, 'html'); + assert.equal(result.content, html); + assert.ok(result.fileUrl.endsWith('/page%20%231.htm')); + assert.equal(result.base64, undefined); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('media previews have a separate size limit and stay confined to the project', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-project-files-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-outside-files-')); + try { + fs.writeFileSync(path.join(root, 'large.pdf'), Buffer.alloc(MAX_PREVIEW_BYTES + 1)); + assert.equal(readProjectFile(root, 'large.pdf').previewType, 'pdf'); + fs.writeFileSync(path.join(root, 'oversized.png'), ''); + fs.truncateSync(path.join(root, 'oversized.png'), MAX_MEDIA_PREVIEW_BYTES + 1); + assert.throws(() => readProjectFile(root, 'oversized.png'), /too large/); + assert.equal(listProjectDirectory(root).find(entry => entry.name === 'oversized.png').viewable, false); + fs.writeFileSync(path.join(outside, 'private.pdf'), '%PDF'); + fs.symlinkSync(path.join(outside, 'private.pdf'), path.join(root, 'escape.pdf')); + assert.throws(() => readProjectFile(root, 'escape.pdf'), /outside/); + assert.throws(() => readProjectFile(root, '../private.pdf'), /outside/); + assert.throws(() => readProjectFile(root, '.'), /cannot be previewed/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + test('project file reads are confined to the project and reject binary or oversized files', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-project-files-')); try { diff --git a/test/viewer-panel.test.js b/test/viewer-panel.test.js new file mode 100644 index 00000000..9f7c3c5a --- /dev/null +++ b/test/viewer-panel.test.js @@ -0,0 +1,86 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function setup() { + const element = () => ({ + style: {}, children: [], textContent: '', + classList: { add() {}, remove() {}, toggle() {} }, + addEventListener() {}, setAttribute() {}, + appendChild(child) { this.children.push(child); }, + insertBefore(child) { this.children.unshift(child); }, + replaceChildren(...children) { this.children = children; }, + }); + const toolbar = { setTitle(title) { this.titleEl.textContent = title; }, setPath() {}, setWrapMode() {}, setPreviewMode() {} }; + for (const key of ['el', 'titleEl', 'previewBtn', 'wrapBtn', 'saveBtn', 'gotoLineBtn', 'copyContentBtn']) toolbar[key] = element(); + const revoked = []; + let nextUrl = 0; + let saved = 0; + const api = { watchFile() {}, unwatchFile() {} }; + const context = vm.createContext({ + document: { createElement: element }, localStorage: { getItem() { return null; } }, + Uint8Array, Blob, atob, setTimeout, + URL: { createObjectURL: () => `blob:${++nextUrl}`, revokeObjectURL: url => revoked.push(url) }, + window: { api, createViewerToolbar: () => toolbar, createEditableViewer: (_el, content) => ({ + state: { doc: { toString: () => content, length: content.length } }, + dispatch({ changes }) { if (changes) { content = changes.insert; this.state.doc.length = content.length; } }, + destroy() { this.destroyed = true; }, + }) }, + }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/viewer-panel.js'), 'utf8'), context); + const panel = new context.window.ViewerPanel(element(), { language: 'auto', onSave: async () => { saved++; return { ok: true }; } }); + const media = { previewType: 'image', mimeType: 'image/png', base64: 'AAEC' }; + return { panel, toolbar, revoked, api, media, saved: () => saved }; +} + +test('media previews cannot save binary files and switching files restores editing', async () => { + const { panel, toolbar, media, revoked, saved } = setup(); + panel.open('Code', '/app.js', 'const original = true;'); + const oldEditor = panel.editorView; + panel.open('Image', '/image.png', undefined, media); + assert.equal(oldEditor.destroyed, true); + assert.equal(panel.editorView, null); + assert.equal(toolbar.saveBtn.style.display, 'none'); + assert.equal(toolbar.copyContentBtn.style.display, 'none'); + await panel._save(); + assert.equal(saved(), 0); + assert.equal(panel.previewEl.children[0].src, 'blob:1'); + panel.open('Text', '/notes.txt', 'New content'); + assert.equal(panel.getContent(), 'New content'); + assert.equal(toolbar.saveBtn.style.display, ''); + assert.equal(panel.editorEl.style.display, ''); + assert.deepEqual(revoked, ['blob:1']); +}); + +test('media reloads replace and release object URLs; destroy releases the last preview', async () => { + const { panel, media, api, revoked } = setup(); + panel.open('Image', '/image.png', undefined, media); + api.readFileForPanel = async () => ({ ok: true, ...media, base64: 'AwQF' }); + await panel._reloadFromDisk(); + assert.equal(panel.previewEl.children[0].src, 'blob:2'); + assert.deepEqual(revoked, ['blob:1']); + panel.destroy(); + assert.deepEqual(revoked, ['blob:1', 'blob:2']); + assert.equal(panel.filePath, ''); +}); + +test('a delayed reload cannot replace a newly selected file or revive a destroyed preview', async () => { + const { panel, api, media } = setup(); + let resolve; + api.readFileForPanel = () => new Promise(done => { resolve = done; }); + panel.open('Image', '/image.png', undefined, media); + const pending = panel._reloadFromDisk(); + panel.open('Code', '/app.js', 'keep this'); + resolve({ ok: true, ...media }); + await pending; + assert.equal(panel.filePath, '/app.js'); + assert.equal(panel.getContent(), 'keep this'); + const second = panel._reloadFromDisk(); + panel.destroy(); + resolve({ ok: true, content: 'stale' }); + await second; + assert.equal(panel.editorView, null); + assert.equal(panel.filePath, ''); +}); From 7ac078e75d89bb36894060da75b7c17524f3e461 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 5 Sep 2026 21:02:24 -0700 Subject: [PATCH 11/24] Polish project setup controls and add Slack community links --- public/app.js | 2 ++ public/index.html | 2 +- public/projects-view.js | 55 +++++++++++++++++++++++++++++++++++------ public/style.css | 33 ++++++++++++++++++++++--- 4 files changed, 79 insertions(+), 13 deletions(-) diff --git a/public/app.js b/public/app.js index 35466a74..1103de9b 100644 --- a/public/app.js +++ b/public/app.js @@ -574,6 +574,7 @@ function updateMoreButton() { } sidebarMoreBtn.addEventListener('click', (e) => { + const slackLink = document.getElementById('status-bar-slack'); const tabItem = (name) => { const tab = tabButton(name); return { label: tab.title, icon: menuIcon(tab.innerHTML), muted: activeTab === name, onClick: () => tab.click() }; @@ -582,6 +583,7 @@ sidebarMoreBtn.addEventListener('click', (e) => { ...MORE_TABS.map(tabItem), { sep: true }, { label: 'Global settings', icon: ICONS.gear(14), onClick: () => globalSettingsBtn.click() }, + { label: 'Join Slack', icon: slackLink.querySelector('svg').outerHTML, onClick: () => window.api.openExternal(slackLink.href) }, ], { anchor: e.currentTarget }); }); diff --git a/public/index.html b/public/index.html index 75b0ac35..59409a86 100644 --- a/public/index.html +++ b/public/index.html @@ -105,7 +105,7 @@
-
+
diff --git a/public/projects-view.js b/public/projects-view.js index 5c254167..9b432d76 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -3301,7 +3301,7 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo branchLine.style.display = any ? '' : 'none'; if (!any) branchSection.style.display = 'none'; branchLine.innerHTML = sharedBox.checked - ? `Worktrees check out on ${escapeHtml(branchName())} · ` + ? `Worktrees use branch ${escapeHtml(branchName())} · ` : `Each worktree names its own branch · `; branchLine.querySelector('#np-branch-change').onclick = () => { const open = branchSection.style.display === 'none'; @@ -3362,12 +3362,48 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo if (folder.mode === 'worktree' && folder.envFiles.length) { const env = document.createElement('div'); env.className = 'np-folder-env'; - const head = document.createElement('div'); - head.className = 'np-help'; - head.textContent = 'The worktree starts with tracked files only. Copy these across:'; - env.appendChild(head); + const toolbar = document.createElement('div'); + toolbar.className = 'np-env-toolbar'; + const title = document.createElement('span'); + title.className = 'np-env-title'; + title.textContent = 'Environment files'; + const count = document.createElement('span'); + count.className = 'np-env-count'; + count.setAttribute('aria-live', 'polite'); + const actions = document.createElement('div'); + actions.className = 'np-env-actions'; + const all = document.createElement('button'); + all.type = 'button'; + all.className = 'np-link'; + all.textContent = 'Select all'; + const none = document.createElement('button'); + none.type = 'button'; + none.className = 'np-link'; + none.textContent = 'Select none'; + actions.append(all, none); + toolbar.append(title, count, actions); + const help = document.createElement('div'); + help.className = 'np-env-help'; + help.textContent = 'Copy selected files into the new worktree. Existing files are kept.'; const boxes = document.createElement('div'); boxes.className = 'np-env-files'; + boxes.setAttribute('role', 'group'); + boxes.setAttribute('aria-label', `Environment files for ${pathBasename(folder.path)}`); + const inputs = []; + const updateSelection = () => { + const selected = folder.envFiles.filter(name => folder.copyEnv.has(name)).length; + count.textContent = `${selected} of ${folder.envFiles.length} selected`; + all.disabled = selected === folder.envFiles.length; + none.disabled = selected === 0; + }; + const selectFiles = selected => { + folder.copyEnv = new Set(selected ? folder.envFiles : []); + for (const input of inputs) input.checked = selected; + updateSelection(); + renderPreview(); + }; + all.onclick = () => selectFiles(true); + none.onclick = () => selectFiles(false); for (const name of folder.envFiles) { const label = document.createElement('label'); label.className = 'ws-check np-env-file'; @@ -3376,13 +3412,16 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo box.checked = folder.copyEnv.has(name); box.onchange = () => { if (box.checked) folder.copyEnv.add(name); else folder.copyEnv.delete(name); + updateSelection(); renderPreview(); }; + inputs.push(box); label.appendChild(box); label.appendChild(Object.assign(document.createElement('span'), { className: 'mono', textContent: name })); boxes.appendChild(label); } - env.appendChild(boxes); + updateSelection(); + env.append(toolbar, help, boxes); row.appendChild(env); } if (folder.mode === 'worktree' && !sharedBox.checked) { @@ -3511,8 +3550,8 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo q('#np-add-folder').onclick = async () => addFolder(await window.api.browseFolder()); q('.add-project-cancel-btn').onclick = close; createBtn.onclick = create; - nameInput.addEventListener('input', () => { branchInput.placeholder = slug(); renderPreview(); }); - branchInput.addEventListener('input', () => { renderPreview(); const b = branchLine.querySelector('b'); if (b) b.textContent = branchName(); }); + nameInput.addEventListener('input', () => { renderBranchLine(); renderPreview(); }); + branchInput.addEventListener('input', () => { renderBranchLine(); renderPreview(); }); sharedBox.addEventListener('change', () => { renderFolders(); renderPreview(); }); function onKey(e) { diff --git a/public/style.css b/public/style.css index 7da8ec7e..7a84cd52 100644 --- a/public/style.css +++ b/public/style.css @@ -97,6 +97,19 @@ body { display: flex; flex-direction: column; } text-overflow: ellipsis; } +#status-bar-slack { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + color: #9090a8; + text-decoration: none; + white-space: nowrap; +} +#status-bar-slack svg { flex-shrink: 0; } +#status-bar-slack:hover { color: #d0d0e0; } +#status-bar-slack:focus-visible { outline: 2px solid #8088ff; outline-offset: 3px; border-radius: 2px; } + #status-bar-activity { white-space: nowrap; color: #8088ff; @@ -6379,10 +6392,22 @@ body { display: flex; flex-direction: column; } .np-folder-branch svg { color: #8088ff; flex-shrink: 0; } .np-folder-branch .np-input { flex: 0 1 240px; padding: 4px 8px; font-size: 11.5px; } -.np-folder-env { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 5px; padding-top: 4px; } -.np-env-files { display: flex; flex-wrap: wrap; gap: 6px 14px; } -.np-env-file { font-size: 11.5px; color: #9090a8; gap: 5px; } -.np-env-file .mono { color: #c8c8dc; } +.np-folder-env { grid-column: 1 / -1; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.06); } +.np-env-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; } +.np-env-title { font-size: 12px; font-weight: 600; color: #d0d0e0; } +.np-env-count { font-size: 11px; color: #9090a8; } +.np-env-actions { display: flex; gap: 12px; margin-left: auto; } +.np-env-actions .np-link { font-size: 11.5px; padding: 3px 0; } +.np-env-actions .np-link:disabled { color: #7a7a90; opacity: 0.6; cursor: default; } +.np-env-actions .np-link:focus-visible { outline: 2px solid #8088ff; outline-offset: 3px; border-radius: 2px; } +.np-env-help { font-size: 11.5px; color: #9090a8; line-height: 1.4; } +.np-env-files { display: flex; flex-direction: column; max-height: 224px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid rgba(255,255,255,0.08); border-radius: 5px; background: rgba(0,0,0,0.1); } +.np-env-file { white-space: normal; flex-shrink: 0; min-width: 0; margin: 0; padding: 7px 9px; font-size: 11.5px; color: #9090a8; gap: 8px; align-items: flex-start; line-height: 1.5; } +.np-env-file + .np-env-file { border-top: 1px solid rgba(255,255,255,0.04); } +.np-env-file:hover { background: rgba(128,136,255,0.06); } +.np-env-file:focus-within { background: rgba(128,136,255,0.1); } +.np-env-file input { flex-shrink: 0; margin-top: 2px; } +.np-env-file .mono { min-width: 0; color: #c8c8dc; overflow-wrap: anywhere; } .np-x { background: none; border: none; color: #7a7a90; font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px; } .np-x:hover { color: #e05070; } From 19db3f03def93775e53e132e8a4b9c7f9a644da8 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sun, 6 Sep 2026 00:38:38 -0700 Subject: [PATCH 12/24] docs: reorganize README and drop the Codex design doc Restructure the README so install, supported CLIs, and a short feature list come first, then Sessions and Projects each get their own section with subsections instead of one long block. Development topics are grouped under one heading. Add a table of contents, the Slack community link, the test and dev-data-dir commands, and a license section. Remove docs/codex-support-design.md; it was an internal design note that no longer needs to ship with the repo. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRojpqL8kVjSmKFiw4pVz --- README.md | 507 +++++++++++++++++------------------ docs/codex-support-design.md | 421 ----------------------------- 2 files changed, 244 insertions(+), 684 deletions(-) delete mode 100644 docs/codex-support-design.md diff --git a/README.md b/README.md index e6174167..1755f960 100644 --- a/README.md +++ b/README.md @@ -2,363 +2,344 @@ Your command center for CLI coding sessions. -Switchboard is a desktop app that gives you a unified view of all your coding agent sessions across every project. Launch, resume, fork, and monitor sessions from a single window — no more juggling terminal tabs or digging through `~/.claude/projects` and `~/.codex/sessions` to find that one conversation from last week. +Switchboard is a desktop app that puts every Claude Code and Codex session, across every project, in one window. Launch, resume, fork, and monitor sessions without juggling terminal tabs or digging through `~/.claude/projects` and `~/.codex/sessions` for that one conversation from last week. ![Switchboard](build/screenshot.png) -### Supported CLIs +**[Download the latest release](https://github.com/doctly/switchboard/releases/latest)** · **[Join the Slack community](https://join.slack.com/t/switchboard-jg96485/shared_invite/zt-490s7hh3p-lktCuVxj2MIjRKUUj2hYWQ)** + +## Contents + +- [Install](#install) +- [Supported CLIs](#supported-clis) +- [Features at a glance](#features-at-a-glance) +- [Sessions](#sessions) +- [Projects](#projects) +- [Project Tasks and Server Logs](#project-tasks-and-server-logs) +- [Keyboard shortcuts](#keyboard-shortcuts) +- [Development](#development) +- [Further reading](#further-reading) + +## Install + +Grab the release for your platform from the [releases page](https://github.com/doctly/switchboard/releases/latest): + +| Platform | Package | +|---|---| +| macOS | `.dmg` (Apple Silicon and Intel) | +| Windows | `.exe` installer | +| Linux | `.AppImage`, `.deb`, or `.pacman` (Arch/Manjaro) | + +On Arch the package is named **`switchboard-doctly`**. The app is still called Switchboard everywhere you see it. Uninstall with `sudo pacman -R switchboard-doctly`. + +Packaged builds check GitHub Releases for updates on launch and every 4 hours, download in the background, and show a toast when an update is ready. Restart to install immediately, or dismiss and it installs on next quit. To build from source instead, see [Development](#development). + +## Supported CLIs | | Claude Code (`claude`) | Codex (`codex`) | |---|---|---| -| Browse & search history | ✅ | ✅ | +| Browse and search history | ✅ | ✅ | | Resume a session | ✅ | ✅ | | Start a new session | ✅ | ✅ | | Fork a session | ✅ | ✅ | | Read in the message viewer | ✅ | ✅ | -| Status & activity indicators | ✅ | ✅ | +| Status and activity indicators | ✅ | ✅ | +| Session names from `/rename` | ✅ | — | | IDE emulation (diff review) | ✅ | — | -| Plans & memory files | ✅ | — | - -Sessions from both CLIs share one sidebar, each row marked with its own logo. A -fork runs on the CLI that wrote the session being forked. - -Turn either CLI off under **CLI Agents** in Settings. A switched-off CLI is not -scanned, not watched, not offered when you start a session, and its sessions are -hidden — but its history is kept, so switching it back on restores everything -straight away. At least one CLI always stays on. - -### Key Features - -- **Session Browser** — All your sessions from every supported CLI, organized by folder, searchable by content -- **Projects** — Group the work, not the folders: a project has a folder on disk with a brief the agent reads, a plan, a todo list, and the sessions filed under it -- **Built-in Terminal** — Connect to running sessions or launch new ones without leaving the app -- **Project Tasks & Server Logs** — Run `.vscode/tasks.json` commands from a compact project or worktree menu and view their live logs in the built-in terminal -- **Status Notifications** — In-app alerts when a session is waiting for permission approval or user input -- **Fork & Resume** — Branch off from any point in a session's history -- **Full-Text Search** — Find any session by what was discussed, not just when it happened -- **Per-CLI Launch Options** — Claude sessions offer permission mode, worktree and Chrome; Codex sessions offer sandbox policy, approval policy and model. Set them per session, per project, or globally -- **IDE Emulation** (Claude only) — Switchboard acts as an IDE for Claude CLI, showing file diffs and opens in a side panel where you can accept, reject, or edit changes before they're applied. Supports both inline and side-by-side diff views. Disable this in Global Settings if you prefer Claude to use your own editor (VS Code, Cursor, etc.) -- **Plans & Memory** (Claude only) — Browse and edit your plan files and CLAUDE.md memory in one place -- **Activity Stats** — Heatmap of your coding activity across all projects -- **Session Names** (Claude only) — Picks up session names from Claude Code's `/rename` command automatically +| Plans and memory files | ✅ | — | -## Project Tasks and Server Logs +Sessions from both CLIs share one sidebar, each row marked with its CLI's logo. A fork runs on the CLI that wrote the session being forked. -Switchboard can run the commands in a project's `.vscode/tasks.json` without -requiring a debugger. Use it for development servers, workers, asset watchers, -test suites, or any other long-running project command whose output you want to -keep beside your coding sessions. - -- **Compact task launcher** — A play button appears in each project header. In - worktree headers it appears between the hide and new-session buttons when you - hover, and stays visible while a task is running. -- **Live, retained logs** — Running a task keeps the menu open and shows its - state on the row. Clicking a task that has run opens its terminal in the - main pane; inside a project it opens beside the session list. Output is - retained when you switch to another session or task, and the selected task - view is restored after a renderer reload. -- **Independent lifecycle** — Tasks run separately from Claude and Codex - sessions. Stop or restart them from the terminal header without altering an - agent transcript. -- **Project and worktree scope** — Commands run with the selected project or - worktree as `${workspaceFolder}`. A worktree inherits its parent project's - tasks when it has no `.vscode/tasks.json` of its own; a worktree-local file - overrides the inherited one. -- **Compound stacks** — `dependsOn` tasks can start several services in - parallel, making one-click API + worker + frontend stacks possible. Stopping - the compound stops its child tasks. - -For example: +Turn either CLI off under **CLI Agents** in Settings. A switched-off CLI is not scanned, not watched, and not offered when you start a session, and its sessions are hidden. Its history is kept, so switching it back on restores everything immediately. At least one CLI always stays on. -```jsonc -{ - "version": "2.0.0", - "tasks": [ - { - "label": "API server", - "type": "process", - "command": "${workspaceFolder}/.venv/bin/python", - "args": ["-m", "uvicorn", "app.main:app", "--reload"], - "options": { "cwd": "${workspaceFolder}" }, - "isBackground": true - }, - { - "label": "Frontend", - "type": "npm", - "script": "dev", - "isBackground": true - }, - { - "label": "Full stack", - "dependsOn": ["API server", "Frontend"], - "dependsOrder": "parallel" - } - ] -} -``` +## Features at a glance + +- **Session browser** — Every session from every supported CLI, organized by folder, searchable by content. +- **Fork and resume** — Branch off from any point in a session's history. +- **Built-in terminal** — Connect to running sessions or launch new ones without leaving the app. +- **Status notifications** — In-app alerts when a session is waiting for permission approval or user input. +- **Session grid** — Live terminals for all open sessions on one screen. +- **Per-CLI launch options** — Permission mode, worktree, sandbox, approval policy, and model, set per session, per project, or globally. +- **IDE emulation** (Claude only) — Review and edit Claude's proposed diffs in a side panel before they land. +- **Projects** — Group work by what it is, not where it lives: a folder on disk, a brief the agent reads, a plan, a todo list, and the sessions filed under it. +- **Project tasks and server logs** — Run `.vscode/tasks.json` commands and keep their logs beside your sessions. +- **Plans and memory** (Claude only) — Browse and edit plan files and `CLAUDE.md` in one place. +- **Activity stats** — Heatmap of your coding activity across all projects. + +## Sessions + +The **Sessions** tab lists every session grouped by the folder it runs in. Full-text search finds a session by what was discussed, not just when it happened. Any session can be resumed, forked from any point in its history, or read in the message viewer. + +### Launch options + +Each CLI exposes its own options when you start or resume a session: + +| CLI | Options | +|---|---| +| Claude Code | Permission mode, worktree, Chrome | +| Codex | Sandbox policy, approval policy, model | + +Set them per session, per project, or globally in Settings. + +### Status notifications + +Switchboard watches every session in the background, Claude and Codex alike, and shows status in the sidebar so you can tell at a glance which sessions need attention while you work in a different one. + +![Status Notifications](build/screenshot-notifications.png) + +- **Waiting for input** — A session that needs your response is highlighted. +- **Permission approval** — A session blocked on a permission grant or approval gets a badge immediately. Switchboard reads each CLI's own wording, so Claude's permission prompts and Codex's approval requests both register. +- **Activity indicators** — See which sessions are running, idle, or finished. + +### Session grid + +Toggle the grid from the sidebar for a bird's-eye view of all open sessions, grouped by project. -Switchboard reads JSON with comments and trailing commas and currently supports -`shell`, `process`, and `npm` tasks, `dependsOn`/`dependsOrder`, task working -directories and environment variables, platform overrides, and an optional -`options.envFile`. Common workspace, home, path-separator, and `${env:NAME}` -variables are expanded. Debug adapters, breakpoints, VS Code command/input -variables, and problem-matcher diagnostics are outside the current scope; the -feature is intentionally focused on running commands and seeing their logs. +![Session Grid Overview](build/screenshot-grid.png) + +- **Live terminals** — Every open session renders its full terminal in a card, whichever CLI it runs. +- **Status at a glance** — Each card shows a running/stopped/busy dot and last-activity time. +- **Click to focus, double-click to expand** — Click a card header to focus it. Double-click to return to the single-terminal view for that session. +- **Persistent** — The grid preference survives restarts. + +### IDE emulation and file preview (Claude only) + +Switchboard can act as an IDE for Claude Code. It speaks Claude CLI's own IDE protocol, so Codex sessions launch without it. When enabled, Claude's file opens and proposed edits appear in a side panel next to the terminal instead of going to an external editor. + +![IDE Emulation](build/screenshot-ide.png) + +- **Diff review** — A proposed change shows up as a diff. Accept or reject it in place. +- **Inline and side-by-side** — Toggle between unified and side-by-side views. The choice is remembered. +- **Partial acceptance** — In inline mode, accept or reject individual chunks, then submit the result. +- **File viewer** — Clickable file links in terminal output (OSC 8 hyperlinks) open in the side panel with syntax highlighting. + +To let Claude use VS Code, Cursor, or another editor instead, uncheck **IDE Emulation** in **Global Settings**. Switchboard then stops registering as an IDE and Claude CLI discovers your real editor. The change applies to new sessions only. ## Projects -The Sessions tab groups sessions by the folder they run in. The Projects tab -groups them by the work they belong to. +The Sessions tab groups sessions by the folder they run in. The **Projects** tab groups them by the work they belong to. | | What it is | |---|---| | **Project** | A piece of work with a folder on disk. Everything else about it is optional. | | **Track** | A line of effort inside a project, with its own sessions. Optional. | -| **Session** | Exactly what it is today, plus the project it belongs to. | +| **Session** | A session as it is today, plus the project it belongs to. | | **Folder** | What the Sessions tab shows: a path on disk. A project can attach any number. | -Every project gets a real folder under `~/Switchboard/` (change it under -**Projects Folder** in Global Settings). Switchboard writes a starter brief into -`CLAUDE.md` and `AGENTS.md` there. The brief tells the agent where the -project's files go, and the agent creates them when it first needs them: a -plan the user asks for goes to `plan.md` in whatever form fits, -`plan-tracker.md` is kept beside it as phases with checkboxes so Switchboard -can show progress, follow-ups go to `todos.md`, and anything worth remembering -across sessions goes to `memory.md`. None of them is read at the start of a -session, only when the plan or the todos come up. The brief also lists the -attached folders and tells the agent to read each folder's own instructions -before changing files there. Attaching or detaching a folder updates that -list; the rest of the brief is yours to edit. - -Sessions start in the project folder by default. A project or a track can -choose an attached folder instead. Wherever a project session starts, -Switchboard passes the project folder and every attached folder to the CLI as -extra directories, so the agent can read and edit all of them. Claude also -loads the `CLAUDE.md` from each of those directories. Codex only reads -`AGENTS.md` from the folder it starts in, so a Codex session that starts in -an attached folder does not see the project brief. Codex also refuses to -start with extra directories unless its sandbox is `workspace-write` or -`danger-full-access`, so Switchboard's global default for Codex is -`workspace-write`. Choose "Default" in Settings to hand the choice back to -codex's own config; under read-only the extra directories are left off, since -read-only can read every path anyway. - -The Projects tab lists projects only. Selecting one opens its **Overview** -page: the brief, the plan's progress, open todos, attached folders, and one -card per track with its latest sessions. Opening a session from there switches -to working mode: a slim project strip on top, a session list beside the -terminal, and the plan and todo counts at the foot of the list. The list can -be grouped by **Time** (today, yesterday, this week...), **Track**, or -**State** (needs input, running, idle). Each row shows the title, the track, -the CLI, its age and message count. Right-click a project, track, or session -for its actions. Projects and sessions are ordered by their last event: a -session started, finished a turn, asked for something, or was opened. A -session that is still working does not move while its transcript grows, so -two working sessions hold their places instead of leapfrogging. Archived -sessions sit in a closed "Archived" line at the foot of the list and under a -track card's foot; one click opens them, dimmed, in place. The -**Settings** tab is a list of rows: the name, the start -folder, folders, the worktree branch, and the tracks. Changes save as you -make them. Folder rows show the branch each folder is on, read from git when -the page opens, and "modified" when it has uncommitted changes. - ![Project View overview](build/project-view-overview.png) +### The project folder and brief + +Every project gets a real folder under `~/Switchboard/` (change it under **Projects Folder** in Global Settings). Switchboard writes a starter brief into `CLAUDE.md` and `AGENTS.md` there. The brief tells the agent where the project's files go, and the agent creates them when it first needs them: + +| File | Purpose | +|---|---| +| `plan.md` | The plan, in whatever form fits the work | +| `plan-tracker.md` | Phases with checkboxes, so Switchboard can show progress | +| `todos.md` | Follow-ups and ideas | +| `memory.md` | Anything worth remembering across sessions | + +None of these is read at the start of a session, only when the plan or the todos come up. The brief also lists the attached folders and tells the agent to read each folder's own instructions before changing files there. Attaching or detaching a folder updates that list. The rest of the brief is yours to edit. + +### Where sessions start + +Sessions start in the project folder by default. A project or a track can choose an attached folder instead. Wherever a project session starts, Switchboard passes the project folder and every attached folder to the CLI as extra directories, so the agent can read and edit all of them. + +The two CLIs handle this differently: + +- **Claude** loads the `CLAUDE.md` from each of those directories. +- **Codex** only reads `AGENTS.md` from the folder it starts in, so a Codex session that starts in an attached folder does not see the project brief. Codex also refuses extra directories unless its sandbox is `workspace-write` or `danger-full-access`, so Switchboard's global default for Codex is `workspace-write`. Choose "Default" in Settings to hand the choice back to Codex's own config. Under `read-only` the extra directories are left off, since read-only can read every path anyway. + +### Overview and working mode + +The Projects tab lists projects only. Selecting one opens its **Overview**: the brief, the plan's progress, open todos, attached folders, and one card per track with its latest sessions. + +Opening a session from there switches to working mode: a slim project strip on top, a session list beside the terminal, and the plan and todo counts at the foot of the list. + ![Project View session workspace](build/project-view-session.png) +- **Grouping** — Group the list by **Time** (today, yesterday, this week...), **Track**, or **State** (needs input, running, idle). +- **Rows** — Each row shows the title, the track, the CLI, its age, and message count. Right-click a project, track, or session for its actions. +- **Ordering** — Projects and sessions are ordered by their last event: started, finished a turn, asked for something, or was opened. A session that is still working does not move while its transcript grows, so two working sessions hold their places instead of leapfrogging. +- **Archived** — Archived sessions sit in a closed "Archived" line at the foot of the list and under each track card. One click opens them, dimmed, in place. + +The project's **Settings** tab is a list of rows: the name, the start folder, folders, the worktree branch, and the tracks. Changes save as you make them. Folder rows show the branch each folder is on, read from git when the page opens, and "modified" when there are uncommitted changes. + +### Creating and organizing projects + ![Project View new session menu](build/project-view-new-session-menu.png) +- **New project** — Name it, pick a template, and attach the folders it works in. The dialog shows what Create will make: the project folder and its files, each worktree on its branch, and the tracks the template adds. Sessions started from the overview are filed under the project and still show under their folder in the Sessions tab. +- **Templates** — New project offers **Feature**, **Research**, and **Customer**. A template is a folder under the app's data directory with a `template.json` (name, description, tracks) and the files a new project starts with. Its `CLAUDE.md` becomes the top of the brief. Edit them or add your own from Global Settings. +- **Tracks** — Optional lines of work inside a project, each with its own sessions, start folder, and CLI. A track card's "Resume latest" reopens its most recent session. "New" starts one there. +- **Move to project** — Any session row has a move action. Nothing is filed until you launch it from a project or move it there. +- **Mark as done** — Done projects drop to the bottom, collapsed. Removing a project only forgets it. The folder and the sessions stay on disk. + +### Plan tab + ![Project View plan and todos](build/project-view-plan.png) -- **New project** — Name it, pick a template, and attach the folders it works - in. The dialog shows what Create will make: the project folder and its - files, each worktree on its branch, and the tracks the template adds. Start - sessions from the overview; they are filed under the project and still show - under their folder in the Sessions tab. -- **Tracks** — Optional lines of work inside a project, each with its own - sessions, start folder and CLI. A track card's "Resume latest" reopens its - most recent session; "New" starts one there. -- **Plan tab** — The phases in `plan-tracker.md` with their items, the todos, - and which sessions started or finished each one. Tick items in place, or - start a session on a phase or a todo: it opens with that item as its first - prompt, filed under the project. A plan written in Claude Code's plan mode - can be adopted into a project from the Plans list. -- **Templates** — New project offers Feature, Research and Customer. A - template is a folder under the app's data directory with a - `template.json` (name, description, tracks) and the files a new project - starts with; its `CLAUDE.md` becomes the top of the brief. Edit them or add - your own from Global Settings. -- **Worktrees** — Attaching a plain folder uses it where it is. Attaching a - git repository asks how the project should work in it: as it is, on - whatever branch is checked out, or with its own checkout under the project - folder (`repos/`), on one branch shared - by every worktree in the project, or one you name per repository. The - worktree inherits the repository's `.vscode/tasks.json`. For Codex, - Switchboard writes an `AGENTS.override.md` into the worktree carrying the - project brief (and the repository's own `AGENTS.md`), excluded from git. - Marking a project done offers to remove its worktrees; branches stay. -- **Move to project** — Any session row has a move action. Nothing is filed - until you launch it from a project or move it there. -- **Play button** — A project's task menu combines the `.vscode/tasks.json` - tasks from every attached folder. -- **Mark as done** — Done projects drop to the bottom, collapsed. Removing a - project only forgets it; the folder and the sessions stay on disk. - -## Session Grid Overview - -Toggle the grid overview from the sidebar for a bird's-eye view of all your open sessions at once, grouped by project. +The Plan tab shows the phases in `plan-tracker.md` with their items, the todos, and which sessions started or finished each one. Tick items in place, or start a session on a phase or a todo: it opens with that item as its first prompt, filed under the project. A plan written in Claude Code's plan mode can be adopted into a project from the Plans list. -![Session Grid Overview](build/screenshot-grid.png) +### Worktrees -- **Live terminals** — Every open session renders its full terminal in a card, so you can monitor multiple agents simultaneously, whichever CLI each one is running. -- **Status at a glance** — Each card shows a running/stopped/busy indicator dot and last-activity timestamp. -- **Click to focus, double-click to expand** — Click a card header to focus it; double-click to switch back to single-terminal view for that session. -- **Persistent** — Grid preference is saved across restarts. +Attaching a plain folder uses it where it is. Attaching a git repository asks how the project should work in it: -## File Preview Side Panel & Claude IDE MCP Emulator +- **As it is**, on whatever branch is checked out. +- **With its own checkout** under the project folder at `repos/`, on one branch shared by every worktree in the project, or one you name per repository. -Switchboard can act as an IDE for your Claude Code sessions. This one is Claude-only — it speaks Claude CLI's own IDE protocol, and Codex sessions are launched without it. When enabled, Claude's file opens and proposed edits appear in a side panel next to the terminal instead of being sent to an external editor. +A worktree inherits the repository's `.vscode/tasks.json`. For Codex, Switchboard writes an `AGENTS.override.md` into the worktree carrying the project brief and the repository's own `AGENTS.md`, excluded from git. Marking a project done offers to remove its worktrees. Branches stay. -![IDE Emulation](build/screenshot-ide.png) +## Project Tasks and Server Logs -- **Diff review** — When Claude proposes a file change, it shows up as a diff in the side panel. You can review the changes and accept or reject them directly. -- **Inline & side-by-side** — Toggle between inline (unified) and side-by-side diff views. Your preference is remembered across sessions. -- **Partial acceptance** — In inline mode, you can accept or reject individual chunks within a diff, then submit the final result. -- **File viewer** — Clickable file links in terminal output (OSC 8 hyperlinks) open in the side panel with syntax highlighting. +Switchboard runs the commands in a project's `.vscode/tasks.json` without needing a debugger. Use it for dev servers, workers, asset watchers, test suites, or any long-running command whose output you want next to your coding sessions. -To disable IDE emulation entirely (e.g. if you want Claude to use VS Code or Cursor instead), uncheck **IDE Emulation** in **Global Settings**. This stops Switchboard from registering as an IDE, so Claude CLI will discover and connect to your real editor. Changes take effect on new sessions — running sessions are not affected. +- **Task launcher** — A play button appears in each project header. In worktree headers it sits between the hide and new-session buttons on hover, and stays visible while a task runs. A project's menu combines the tasks from every attached folder. +- **Live, retained logs** — Running a task keeps the menu open and shows its state on the row. Clicking a task that has run opens its terminal in the main pane, or beside the session list inside a project. Output is kept when you switch away, and the selected task view is restored after a renderer reload. +- **Independent lifecycle** — Tasks run separately from Claude and Codex sessions. Stop or restart them from the terminal header without touching an agent transcript. +- **Project and worktree scope** — Commands run with the selected project or worktree as `${workspaceFolder}`. A worktree inherits its parent project's tasks when it has no `.vscode/tasks.json` of its own. A worktree-local file overrides the inherited one. +- **Compound stacks** — `dependsOn` tasks start several services in parallel, so one click can bring up API + worker + frontend. Stopping the compound stops its children. -## Status Notifications +Example: -Switchboard monitors all your sessions in the background — Claude and Codex alike — and shows status indicators in the sidebar so you can tell at a glance which sessions need attention, even when you're working in a different one. +```jsonc +{ + "version": "2.0.0", + "tasks": [ + { + "label": "API server", + "type": "process", + "command": "${workspaceFolder}/.venv/bin/python", + "args": ["-m", "uvicorn", "app.main:app", "--reload"], + "options": { "cwd": "${workspaceFolder}" }, + "isBackground": true + }, + { + "label": "Frontend", + "type": "npm", + "script": "dev", + "isBackground": true + }, + { + "label": "Full stack", + "dependsOn": ["API server", "Frontend"], + "dependsOrder": "parallel" + } + ] +} +``` -![Status Notifications](build/screenshot-notifications.png) +**Supported:** JSON with comments and trailing commas; `shell`, `process`, and `npm` tasks; `dependsOn` and `dependsOrder`; task working directories and environment variables; platform overrides; an optional `options.envFile`; and the common workspace, home, path-separator, and `${env:NAME}` variables. -- **Waiting for input** — A session that needs your response is highlighted so you don't miss it. -- **Permission approval** — When a session is blocked waiting for a permission grant or an approval, its badge lets you know immediately. Switchboard reads each CLI's own wording, so Claude's permission prompts and Codex's approval requests both register. -- **Activity indicators** — See which sessions are actively running, idle, or finished. +**Out of scope:** debug adapters, breakpoints, VS Code command and input variables, and problem-matcher diagnostics. The feature is deliberately focused on running commands and seeing their logs. -## Editor +## Keyboard shortcuts | Shortcut | Action | -|----------|--------| -| `Cmd+F` / `Ctrl+F` | Find in file (also works in terminal) | +|---|---| +| `Cmd+F` / `Ctrl+F` | Find in file (also works in the terminal) | | `Cmd+G` / `Ctrl+G` | Go to line | -## Download - -Grab the latest release for your platform: +## Development -**[Download Switchboard](https://github.com/doctly/switchboard/releases/latest)** - -- **macOS**: `.dmg` (Apple Silicon & Intel) -- **Windows**: `.exe` installer -- **Linux**: `.AppImage`, `.deb`, or `.pacman` (Arch/Manjaro) - -## Prerequisites +### Prerequisites - **Node.js** 20+ - **npm** 10+ -- Platform build tools for native modules: +- Build tools for native modules (node-pty, better-sqlite3): - **macOS**: Xcode Command Line Tools (`xcode-select --install`) - - **Linux**: `build-essential`, `python3` (`sudo apt install build-essential python3`) + - **Linux**: `build-essential` and `python3` (`sudo apt install build-essential python3`) - **Windows**: Visual Studio Build Tools or `npm install -g windows-build-tools` -## Development Setup +### Running from source ```bash -# Install dependencies (runs postinstall automatically) -npm install - -# Start the app -npm start +npm install # installs dependencies and runs postinstall +npm start # bundles CodeMirror, then launches Electron ``` -`npm start` bundles CodeMirror and launches Electron. For faster iteration after the first run: +After the first run, skip the bundle step for faster iteration: ```bash npm run electron ``` -## Building - -All build commands bundle CodeMirror first, then invoke electron-builder. +To keep a development instance's data separate from the installed app, set `SWITCHBOARD_DATA_DIR`: ```bash -# Current platform -npm run build - -# Platform-specific -npm run build:mac # DMG + zip (arm64 + x64) -npm run build:win # NSIS installer (x64 + arm64) -npm run build:linux # AppImage + deb + pacman (x64 + arm64) +npm run electron-dev # uses ~/.switchboard-dev ``` -Output goes to `dist/`. - -### Building on Arch / Manjaro - -The `deb` and `pacman` targets are built via the `fpm` binary bundled by -electron-builder, which links against `libcrypt.so.1`. Arch ships `libxcrypt` -without that legacy ABI, so install the compat shim once: +Run the test suite with: ```bash -sudo pacman -S libxcrypt-compat +npm test ``` -`AppImage` builds without it. +### Building -The pacman package is published as **`switchboard-doctly`** rather than -`switchboard` because the Arch `extra` repo already ships a package named -`switchboard` (elementary OS's Pantheon Control Center). Renaming avoids the -file-conflict that would block installation alongside it. The app itself is -still called Switchboard everywhere users see it — only the package identity -changes. Uninstall later with `sudo pacman -R switchboard-doctly`. - -## Releasing - -Releases are driven by git tags: +Every build command bundles CodeMirror first, then runs electron-builder. Output goes to `dist/`. ```bash -git tag v0.1.0 -git push origin v0.1.0 +npm run build # current platform +npm run build:mac # DMG + zip (arm64 + x64) +npm run build:win # NSIS installer (x64 + arm64) +npm run build:linux # AppImage + deb + pacman (x64 + arm64) ``` -The GitHub Actions workflow builds for all platforms and publishes to GitHub Releases. You can also release locally: +**Arch / Manjaro:** the `deb` and `pacman` targets use the `fpm` binary bundled with electron-builder, which links against `libcrypt.so.1`. Arch ships `libxcrypt` without that legacy ABI, so install the compat shim once. `AppImage` builds without it. ```bash -npm run release # builds + publishes to GitHub Releases +sudo pacman -S libxcrypt-compat ``` -Set `GH_TOKEN` in your environment (a GitHub personal access token with `repo` scope). +### Code signing -## Auto-Updates +Set these environment variables for signed distribution builds: -The app uses `electron-updater` to check for updates from GitHub Releases on launch and every 4 hours. Updates are only checked in packaged builds (not during development). The flow: +| Platform | Variables | +|---|---| +| macOS | `CSC_LINK` (p12 certificate) and `CSC_KEY_PASSWORD`, or sign via Keychain | +| Windows | `CSC_LINK` and `CSC_KEY_PASSWORD` for EV/OV code signing | +| Any | `CSC_IDENTITY_AUTO_DISCOVERY=false` to skip signing (CI artifact builds) | -1. App auto-downloads updates in the background -2. A toast notification appears when the update is ready -3. User can restart immediately or dismiss (installs on next quit) +The macOS build uses custom entitlements in `build/entitlements.mac.plist` to allow JIT and unsigned memory execution, which the native modules require. -## Code Signing +### Releasing -For distribution, set these environment variables: +Releases are driven by git tags. The GitHub Actions workflow builds for all platforms and publishes to GitHub Releases: -- **macOS**: `CSC_LINK` (p12 certificate) and `CSC_KEY_PASSWORD`, or sign via Keychain -- **Windows**: `CSC_LINK` and `CSC_KEY_PASSWORD` for EV/OV code signing -- Set `CSC_IDENTITY_AUTO_DISCOVERY=false` to skip signing (CI artifact builds) +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +To release locally instead, set `GH_TOKEN` to a GitHub personal access token with `repo` scope and run: -The macOS build uses custom entitlements (`build/entitlements.mac.plist`) to allow JIT and unsigned memory execution, required by native modules (node-pty, better-sqlite3). +```bash +npm run release +``` -## Project Structure +### Project structure ``` main.js Electron main process preload.js Context bridge (IPC bindings) -db.js SQLite session cache & metadata -harnesses/ Per-CLI modules (claude, codex) + registry +db.js SQLite session cache and metadata +harnesses/ Per-CLI modules (claude, codex) and registry public/ Renderer (HTML/CSS/JS) -scripts/ Build & postinstall scripts -build/ Icons, entitlements, builder resources +templates/ Built-in project templates (feature, research, customer) +workers/ Background workers +scripts/ Build and postinstall scripts +test/ Node test suite (npm test) +build/ Icons, entitlements, screenshots, builder resources .github/workflows/ CI/CD ``` + +## Further reading + +- [`docs/customizing-colors.md`](docs/customizing-colors.md) — changing the app's colors and applying a light theme (in French). + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/docs/codex-support-design.md b/docs/codex-support-design.md deleted file mode 100644 index 17f3f836..00000000 --- a/docs/codex-support-design.md +++ /dev/null @@ -1,421 +0,0 @@ -# Multi-harness sessions: adding OpenAI Codex - -Design for letting a session be **Claude** or **Codex**, remembering which is -which, and resuming each with the right CLI. - -Everything in "Verified facts" below was tested against `codex-cli 0.149.1` -installed locally, not read from docs. - ---- - -## 1. Verified facts about Codex - -| Thing | Claude Code | Codex CLI | -|---|---|---| -| Transcript location | `~/.claude/projects//.jsonl` | `$CODEX_HOME/sessions/YYYY/MM/DD/rollout--.jsonl` | -| Grouping on disk | one dir per **project** | one dir per **date** (mixed projects) | -| Project path source | `cwd` on any line | `session_meta.payload.cwd` on line 1 | -| Session id | the file name | the file name's uuid suffix | -| Pre-assign session id | `claude --session-id ` | **not possible** | -| Resume | `claude --resume ` | `codex resume ` | -| Fork | `claude --resume --fork-session` | `codex fork ` | -| Sub-agent transcripts | in a `subagents/` subdirectory | mixed in with the rest | -| Transcript written at launch | yes | **no — only on the first turn** | -| Resume writes | same file | same file (appends, id preserved) | - -`CODEX_HOME` defaults to `~/.codex` and must be honoured if set. - -### The rollout format - -Line 1 is always `session_meta`: - -```json -{"timestamp":"...","type":"session_meta","payload":{ - "session_id":"01a03f7c-...","cwd":"/Users/home/dev/foo", - "originator":"codex_cli_rs","cli_version":"0.149.1","source":"cli"}} -``` - -**The session id is the file name's uuid, not `session_meta.session_id`.** -That field is the *lineage root* and is repeated by every resume and fork of a -conversation — on this machine six rollout files share one value, which would -collide on `session_cache.sessionId`. The file name's uuid is unique per -rollout, is what `codex resume ` accepts (verified), and is what codex's own -`session_index.jsonl` keys on. - -Subsequent lines carry a `type` and a `payload.type`: - -- `response_item` / `message` with a `role` → **the authoritative text** -- `event_msg` / `user_message`, `agent_message` → a duplicate copy of the same - turns, emitted by some versions only -- `event_msg` / `token_count`, `task_started`, `task_complete` → bookkeeping -- `response_item` / `message`, `reasoning`, `custom_tool_call`, `custom_tool_call_output` -- `turn_context`, `world_state` → per-turn config - -Count **only** `response_item` messages: it is present in all 53 rollouts on -this machine while `event_msg` is missing from 6, and counting both would double -every turn. Skip `role: 'developer'` (CLI scaffolding) and skip user messages -opening with a simple XML tag — codex injects ``, -``, ``, `` as user turns, and -across every rollout on disk no genuine prompt starts with one. - -Timestamps are ISO-8601 UTC, so the same first/last scan Claude's parser already -does works unchanged. - ---- - -## 2. The one hard constraint - -**Codex has no `--session-id`.** Switchboard's whole new-session flow depends on -knowing the id *before* spawning: - -```js -const sessionId = crypto.randomUUID(); // app.js launchNewSession -claudeArgs.push('--session-id', sessionId) // main.js open-terminal -``` - -Worse, the rollout file does not exist until the user sends their first -message — a launched-but-unused Codex session leaves no trace on disk at all. - -So a new Codex session must be **launched under a temporary id and promoted to -its real id later**. - -### Sub-agent rollouts are not sessions - -Codex records sub-agent threads as ordinary rollout files, but refuses to resume -one: - -``` -cannot resume an unloaded multi-agent v2 sub-agent through its parent; -resume the parent first, or use thread/read to inspect it -``` - -They are marked in `session_meta` by any of `thread_source: 'subagent'`, -`parent_thread_id`, `agent_path`, `agent_nickname` — not all together, and older -rollouts carry none. `forked_from_id` is **not** one of them: a fork is a real -session. - -The indexer skips them. Claude's subagent transcripts are already excluded (they -live in a `subagents/` subdirectory the indexer never scans), so this keeps the -two consistent, and it avoids sidebar rows whose only action always fails. Of 54 -rollouts here, 23 are dropped this way or for having no user turn, leaving 31. - -### The handshake - -`CODEX_INTERNAL_ORIGINATOR_OVERRIDE` is copied verbatim into -`session_meta.payload.originator`. Verified: - -``` -env CODEX_INTERNAL_ORIGINATOR_OVERRIDE=switchboard_probe_1 codex -→ {"session_id":"01a03f7c-...","originator":"switchboard_probe_1"} -``` - -That gives an exact, race-free marker: - -1. Renderer makes a temp uuid, same as today. -2. Main spawns codex with `CODEX_INTERNAL_ORIGINATOR_OVERRIDE=switchboard-`. -3. The codex watcher sees a new rollout, reads line 1, matches `originator`. -4. Main sends `session-detected(tempId, realId)`. - -Step 4 needs no new renderer code — `app.js:208 onSessionDetected` already -re-keys `openSessions`, `activeSessionId`, and the header. It is currently dead -code (nothing in main emits it). This revives it. - -The tag is `switchboard_`. The charset matters: the -value is forwarded as an HTTP header, and the binary carries an "ignoring -invalid thread originator header value" path, so it is kept to `[a-z0-9_]`. - -**Fallback, required.** The env var is internal and may be removed. If no -rollout matches the originator, fall back to: a rollout whose `cwd` equals the -session's `projectPath`, started after the spawn (with a few seconds of clock -skew allowed). It explicitly refuses a rollout tagged for a *different* -switchboard launch, which is the one case two concurrent new sessions could -steal each other's transcript. - -**Backstops.** The watcher is the fast path, but a dropped `fs.watch` event -would strand a session under its temporary id forever, so `get-projects` also -sweeps — only while something is actually waiting, and only over transcripts -touched since the launch. - ---- - -## 3. Structure: a harness adapter - -Today the transcript format, the on-disk layout, and the launch command are -hardcoded across `main.js`, `session-cache.js`, `read-session-file.js`, and -`workers/scan-projects.js`. Add `harnesses/` with one module per CLI and a -registry: - -``` -harnesses/index.js → { claude, codex }, getHarness(id), availableHarnesses() -harnesses/claude.js → today's behaviour, moved not rewritten -harnesses/codex.js → new -``` - -Each adapter exports: - -```js -{ - id: 'codex', - label: 'Codex', - available(), // does its home dir / binary exist - sessionsRoot(), // dir to scan and fs.watch - listFolders(), // folder keys under that root - folderPath(folder), // key → absolute dir - readSessionFile(filePath, folder), // → the same session shape as today - transcriptPath(session), // → absolute .jsonl path - buildLaunch({ sessionId, isNew, projectPath, options }), - // → { cmd, args, env } -} -``` - -`readSessionFile` must keep returning the exact object -`upsertCachedSessions` already writes, so `session-cache.js` needs no shape -changes — only `readSessionFile(...)` → `getHarness(row.runtime).readSessionFile(...)`. - -### Folder keys - -`session_cache.folder` becomes a namespaced key: - -- Claude: `` — **unchanged**, no migration -- Codex: `codex/2026/08/26` - -`encodeProjectPath` output is `[a-zA-Z0-9-]+` only, so a key containing `/` -can never collide with an existing Claude row. One helper resolves both: - -```js -function folderPath(folder) { - return folder.startsWith('codex/') - ? path.join(CODEX_SESSIONS_DIR, folder.slice(6)) - : path.join(PROJECTS_DIR, folder); -} -``` - -Date-keyed folders work with the existing incremental machinery for free: -`getFolderIndexMtimeMs` gates on newest `.jsonl` mtime, so only today's and -yesterday's dirs are ever rescanned. Old dirs cost one `stat`. - -`cache_meta.projectPath` is meaningless for a date folder (many projects per -day) — store `NULL`. It is only read by the empty-project backfill in -`buildProjectsFromCache`, which already skips nulls. - ---- - -## 4. Data model and migration - -### Naming: `harnesses/` in code, `runtime` in the schema - -The module namespace is `harnesses/`, not `agents/`, because **`agent` is already -taken**: `feat/subagent-support` adds `parentSessionId`, `agentId`, `subagentType` -to `session_cache`, where `agentId` means a Claude *subagent*. "Harness" is also -the accurate word — what varies is the CLI program wrapping the model. - -The column is `runtime`, not `harness`, for a different reason: **it already -exists.** A build from a parallel branch left `runtime TEXT DEFAULT 'claude'` and -`sessionFile TEXT` in real databases (confirmed against a live db_version-5 DB: -859 rows, `runtime = 'claude'` throughout, `sessionFile` null throughout). Those -are exactly the two columns this design needs, with exactly the right semantics, -so they are adopted rather than shadowed by a second pair that would drift. - -The names differ between layer and schema, which is a small price for not -carrying two columns that mean the same thing. - -Two columns on `session_cache`: - -```sql -ALTER TABLE session_cache ADD COLUMN runtime TEXT DEFAULT 'claude'; -ALTER TABLE session_cache ADD COLUMN sessionFile TEXT; -``` - -- `runtime` — which CLI owns this session. The default means every existing row - is correct with no backfill. `NOT NULL` is deliberately omitted: the parallel - branch's column is nullable, and requiring it would force a table rebuild on - DBs that already have data. Reads go through `getHarness()`, which treats null - as Claude. -- `sessionFile` — absolute transcript path. Needed because a Codex filename is - `rollout--.jsonl`, not `.jsonl`. `NULL` on old rows, and - `transcriptPath()` falls back to `PROJECTS_DIR/folder/sessionId.jsonl`. - -Both go in the **schema reconciliation block** (`db.js`, the `PRAGMA -table_info` section), not the numbered `migrations` array. That block already -exists precisely because db_version can't be trusted across branches, and its -comment says so. - -`session_meta` (name, starred, archived) is keyed by sessionId alone and needs -no change — a renamed or pinned Codex session works on day one. - -### What an existing user sees on upgrade - -- **No cache wipe. No re-index of Claude sessions.** Both columns are - defaulted or nullable, so nothing is invalidated. -- Every existing session keeps its row and gets the Claude icon. -- On first reconcile, `~/.codex/sessions` is scanned and past Codex sessions - appear in the sidebar, grouped into the right projects by their `cwd`. -- If Codex isn't installed, `available()` is false: no scan, no watcher, and - the "+" menu doesn't offer Codex. Nothing errors. - -The one visible change for a Codex user is that their existing Codex history -shows up. That is the feature, not a regression. - ---- - -## 5. Launch, resume, fork - -`harnesses/codex.js buildLaunch`: - -| Case | Args | -|---|---| -| new | `codex` (+ `CODEX_INTERNAL_ORIGINATOR_OVERRIDE`) | -| resume | `codex resume ` | -| fork | `codex fork ` | - -Option mapping — Codex has different vocabulary, so it needs its own -dialog fields rather than reusing Claude's permission modes: - -| Switchboard | Claude | Codex | -|---|---|---| -| permission | `--permission-mode ` | `--ask-for-approval on-request\|never` | -| skip safety | `--dangerously-skip-permissions` | `--dangerously-bypass-approvals-and-sandbox` | -| model | — | `--model ` (`codexModel`) | -| sandbox | — | `--sandbox read-only\|workspace-write\|danger-full-access` | -| extra dirs | `--add-dir` | `--add-dir` (same) | -| pre-launch cmd | prepended | prepended (same) | -| worktree | `--worktree` | not supported — hide the field | - -`cwd` is set by the PTY spawn, as today; `-C/--cd` is not needed. - -New settings keys (`SETTING_DEFAULTS`, global + per project): -`defaultAgent`, `codexSandbox`, `codexApproval`, `codexModel`. Adding keys is -backward-compatible — `get-effective-settings` starts from `SETTING_DEFAULTS` -and only overrides defined values. - ---- - -## 6. UI - -**Sidebar icon.** `buildSessionItem` already prepends a `.terminal-badge` for -`type === 'terminal'`. Same slot, driven by `session.runtime`: Claude glyph, -Codex glyph, or the terminal glyph. Add `is-codex` next to `is-terminal`. - -**New-session popover.** Rows are generated from a `get-harnesses` IPC rather -than hardcoded, so a machine without codex installed simply never sees it. It -renders Claude immediately and re-renders when main answers, so it never appears -empty. - -**Config dialog.** Takes a runtime. Codex gets sandbox / approval / model; -Claude keeps permission mode / worktree / Chrome. They are not translations of -each other — showing Claude's controls for a codex session would offer settings -that are silently dropped at launch. - -**Resume.** No UI change. `openSession` reads `session.runtime` from the row and -main dispatches on it. - ---- - -## 7. What stays Claude-only - -Gate these on `runtime === 'claude'` rather than trying to generalise now: - -- **MCP / IDE emulation** — `--ide`, `CLAUDE_CODE_SSE_PORT`, `mcp-bridge.js`. - Codex has its own MCP model; skip it entirely for Codex sessions. -- **Fork / plan-accept detection** — `session-transitions.js` matches on - `forkedFrom`, `planContent`, and `slug`, none of which exist in a rollout. - Codex `fork` is an explicit command, so no detection is needed. -(Busy-state was on this list until the TUI was probed — see below.) -- **Scheduled tasks** — `schedule-runner.js`, `createScheduleSession`. -- **Stats view** — reads `~/.claude/stats-cache.json`. -- **Slug grouping** — no slug in a rollout, so Codex sessions render ungrouped. - -### Activity: working, waiting, blocked - -Probed from a live session rather than guessed. Codex uses **the same terminal -title protocol as Claude**: a braille spinner frame (U+2800–U+28FF) prefixes the -title while a turn runs. The difference is idle — Claude marks it with `✳`, -codex simply drops the prefix, so for codex "not a spinner" *is* idle. Reading -it as "no information" would leave sessions spinning in the sidebar forever, and -treating a plain Claude title the same way would end a busy state early. Hence -`parseTitleState` per harness. - -The other two states arrive as OSC 9, but only if notifications are on, so the -launch forces them: - -``` --c tui.notifications=true --c tui.notification_method="osc9" --c tui.notification_condition="always" -``` - -Same reasoning as `TERM_PROGRAM=iTerm.app` for Claude — the signal is what drives -the sidebar. `unfocused` would suppress exactly the case that matters. Passed as -`-c` overrides, so the user's own `config.toml` is untouched. - -Codex sends two kinds, with wording nothing like Claude's: - -| Payload | Means | -|---|---| -| `Approval requested: ` | blocked on the user | -| the agent's final message | turn finished, response waiting | - -So classification moves into the harness too, and main hands the renderer a -*kind* rather than the renderer re-deriving one from text. Verified end to end: -busy at 7.5s, idle + `kind=idle` at 13.2s carrying the answer, `kind=attention` -at 30.3s carrying the approval request. - -**JSONL viewer** has a real Codex path rather than a gate — see step 7. The -mapping is exact enough that all 96 tool calls in a real rollout pair with their -results, and the existing renderer needed no change. - ---- - -## 8. Implementation order - -Each step leaves the app working. - -1. **`harnesses/` + move Claude into it.** Pure refactor, no behaviour change. - Existing tests must still pass. -2. **DB columns + `folderPath()` helper.** Still Claude-only; proves the - migration is a no-op on a real DB. -3. **Codex indexer.** `harnesses/codex.js readSessionFile`, folder listing, - watcher on `$CODEX_HOME/sessions`. Past Codex sessions appear, read-only. -4. **Resume.** `codex resume ` from a sidebar click. This is the first - point the feature is useful. `codex resume` accepts the same flags as a - fresh launch, so one option mapping covers both. Enumerated values are - validated against what the CLI accepts rather than interpolated — they come - from stored settings, which outlive the codex version that understood them. - The sidebar badge moves here from step 6: without it there is no way to tell - which row is which, so resume cannot be tested by hand. -5. **New session + originator handshake.** Temp id, `session-detected`, - the cwd/mtime fallback. `onSessionDetected` in the renderer already existed - but was dead code (nothing emitted it) and did not re-key `pendingSessions`, - so the temp id kept being re-injected as a phantom row — fixed alongside. -6. **UI.** Agent picker in the popover, icon in the sidebar, Codex fields in - the config dialog. -7. **Fork + JSONL viewer.** A fork runs on the CLI that wrote the session it - forks, not the default — `codex fork` cannot continue a Claude transcript. - The viewer is fed by a per-harness `toViewerEntries`, so it never learns a - second transcript format: a codex tool call becomes a `tool_use` block, its - output a `tool_result` keyed by the same call id, and a reasoning summary a - `thinking` block. Claude's is identity. Normalising in main rather than the - renderer keeps the viewer ignorant of which CLI wrote the file. - -### Tests worth writing - -- `harnesses/codex.js readSessionFile` against a fixture rollout (session_meta, - user_message, agent_message, tool calls) → asserts summary, counts, cwd, times. -- `folderPath()` round-trip for both namespaces, including a project path that - hits the 200-char hash branch of `encodeProjectPath`. -- Originator matching: right file among several created in the same second, - and the cwd/mtime fallback when originator is absent. -- A migration test in the style of `test/db-schema-reconcile.test.js`: open a - pre-upgrade DB, run reconciliation, assert existing rows read back with - `runtime = 'claude'` and that nothing was deleted. - ---- - -## 9. Risks - -| Risk | Mitigation | -|---|---| -| `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` is internal and may vanish | cwd + birthtime fallback, always present | -| `codex migrate-rollouts` suggests sessions may move to sqlite thread history | rollout dir is still written by 0.149.1; adapter isolates the change to one file | -| Codex prompts "do you trust this directory?" on first launch in a new dir | user answers in the terminal, same as Claude's trust prompt | -| A launched-but-unused Codex session leaves no file | it stays a pending row and is cleaned up by the existing pending reconciliation | From b531f3a5a5fd4f6c6f73ae86f4b3b2b1fbdc22be Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sun, 6 Sep 2026 15:59:39 -0700 Subject: [PATCH 13/24] Hold the session list still, and polish tasks and project creation Opening a session no longer reorders the project session list. The row you click stays under the pointer; only a new session starting, a turn finishing, or the CLI asking for something moves it. Resuming a session no longer counts either, so a resumed session joins the Now group at its own time rather than jumping to the front. Project rows now show the last message's time, the same value the Sessions tab shows, instead of a renderer-side clock that also counted opens. Sorting uses the later of that time and the last event, and a busy session keeps the place it had when its turn began. The event clock is no longer persisted: every event coincides with a transcript write, so the transcript's own time says the same thing after a restart. Also: - Keep the scroll offset when the session pane and the task popover rebuild, so neither jumps to the top. - Give a running task a restart button beside stop, matching the pair in the task log header. - Show repos/ in the project Files tab instead of skipping it. - Drop the template picker from New project while that work is in progress. The templates themselves are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JSRojpqL8kVjSmKFiw4pVz --- README.md | 3 +-- public/app.js | 45 +++++++++++++++++++++++++----------- public/projects-view.js | 51 +++++++++-------------------------------- public/style.css | 14 +++++++++++ public/task-runner.js | 20 +++++++++++++++- 5 files changed, 76 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 1755f960..5f24c4b9 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ Opening a session from there switches to working mode: a slim project strip on t - **Grouping** — Group the list by **Time** (today, yesterday, this week...), **Track**, or **State** (needs input, running, idle). - **Rows** — Each row shows the title, the track, the CLI, its age, and message count. Right-click a project, track, or session for its actions. -- **Ordering** — Projects and sessions are ordered by their last event: started, finished a turn, asked for something, or was opened. A session that is still working does not move while its transcript grows, so two working sessions hold their places instead of leapfrogging. +- **Ordering** — Projects and sessions are ordered by their last event: a new session started, a turn finished, or the CLI asked for something. Opening or resuming a session does not move it, so the list holds still under a click. A session that is still working does not move while its transcript grows, so two working sessions hold their places instead of leapfrogging. - **Archived** — Archived sessions sit in a closed "Archived" line at the foot of the list and under each track card. One click opens them, dimmed, in place. The project's **Settings** tab is a list of rows: the name, the start folder, folders, the worktree branch, and the tracks. Changes save as you make them. Folder rows show the branch each folder is on, read from git when the page opens, and "modified" when there are uncommitted changes. @@ -170,7 +170,6 @@ The project's **Settings** tab is a list of rows: the name, the start folder, fo ![Project View new session menu](build/project-view-new-session-menu.png) - **New project** — Name it, pick a template, and attach the folders it works in. The dialog shows what Create will make: the project folder and its files, each worktree on its branch, and the tracks the template adds. Sessions started from the overview are filed under the project and still show under their folder in the Sessions tab. -- **Templates** — New project offers **Feature**, **Research**, and **Customer**. A template is a folder under the app's data directory with a `template.json` (name, description, tracks) and the files a new project starts with. Its `CLAUDE.md` becomes the top of the brief. Edit them or add your own from Global Settings. - **Tracks** — Optional lines of work inside a project, each with its own sessions, start folder, and CLI. A track card's "Resume latest" reopens its most recent session. "New" starts one there. - **Move to project** — Any session row has a move action. Nothing is filed until you launch it from a project or move it there. - **Mark as done** — Done projects drop to the bottom, collapsed. Removing a project only forgets it. The folder and the sessions stay on disk. diff --git a/public/app.js b/public/app.js index 1103de9b..ca16c1b6 100644 --- a/public/app.js +++ b/public/app.js @@ -141,10 +141,11 @@ const sessionBusyState = new Map(); // sessionId → boolean (currently active) // it (or Mark as read) clears it. Busy is not saved: nothing is running yet. const SESSION_NOTICES_KEY = 'sessionNotices'; -// When something last happened to a session that is worth moving it for: it -// started, finished a turn, asked for something, or was opened. A working -// session rewrites its transcript constantly, so the Projects tab sorts on -// this clock instead of the file's modified time and holds still mid-turn. +// When something last happened to a session that is worth moving it for: a +// new session started, a turn finished, or the CLI asked for something. +// Opening or resuming a session does not count, so the list holds still under +// a click. Not persisted: every event coincides with a transcript write, so +// after a restart the transcript's own last-message time says the same thing. const sessionEventTimes = new Map(); // sessionId → ms since epoch function bumpSessionEvent(sessionId) { @@ -154,27 +155,32 @@ function bumpSessionEvent(sessionId) { if (typeof refreshProjectViews === 'function') refreshProjectViews({ reason: 'sessions' }); } -/** The time to sort a session by: its last event, else the transcript's modified time. */ +/** + * The time to sort a session by: the later of its last event and the + * transcript's last message. A working session rewrites its transcript + * constantly, so while the CLI is busy the session keeps the time it had when + * the turn began (frozen in setActivity); the turn ending moves it. + */ function sessionEventTime(session) { - const known = sessionEventTimes.get(session.sessionId); - if (known) return known; + const id = session.sessionId; + const known = sessionEventTimes.get(id) || 0; const t = new Date(session.modified).getTime(); - return Number.isFinite(t) ? t : 0; + const modified = Number.isFinite(t) ? t : 0; + if (known && sessionBusyState.get(id) === true) return known; + return Math.max(known, modified); } function saveSessionNotices() { try { // Ids of sessions that no longer exist cost nothing but should not pile up. const cap = (set) => [...set].slice(-200); - const events = Object.fromEntries([...sessionEventTimes].sort((a, b) => b[1] - a[1]).slice(0, 500)); - localStorage.setItem(SESSION_NOTICES_KEY, JSON.stringify({ ready: cap(responseReadySessions), attention: cap(attentionSessions), events })); + localStorage.setItem(SESSION_NOTICES_KEY, JSON.stringify({ ready: cap(responseReadySessions), attention: cap(attentionSessions) })); } catch {} } try { const saved = JSON.parse(localStorage.getItem(SESSION_NOTICES_KEY) || 'null'); for (const id of saved?.ready || []) responseReadySessions.add(id); for (const id of saved?.attention || []) attentionSessions.add(id); - for (const [id, t] of Object.entries(saved?.events || {})) if (Number.isFinite(t)) sessionEventTimes.set(id, t); } catch {} // Some CLIs (notably Codex) start under a temporary ID and are re-keyed once @@ -219,6 +225,11 @@ function setActivity(sessionId, active) { const wasActive = sessionBusyState.get(sessionId) || false; sessionBusyState.set(sessionId, active); if (active && typeof hideSessionHoverPreview === 'function') hideSessionHoverPreview(sessionId); + // A turn is starting: pin the row where it is until the turn ends. + if (active && !wasActive) { + const session = sessionMap.get(sessionId); + if (session) sessionEventTimes.set(sessionId, sessionEventTime(session)); + } if (wasActive && !active) { bumpSessionEvent(sessionId); @@ -264,7 +275,8 @@ function markUnread(sessionId) { } function clearNotifications(sessionId) { - bumpSessionEvent(sessionId); + // Opening a session is not an event: the row stays where it is so the + // list does not reshuffle under the pointer. clearUnread(sessionId); attentionSessions.delete(sessionId); forEachSessionItem(sessionId, item => item.classList.remove('needs-attention')); @@ -765,8 +777,13 @@ function scheduleActiveSessionsPoll() { async function pollActiveSessions() { try { const ids = await window.api.getActiveSessions(); - // A session that just came alive, whoever started it, is news. - for (const id of ids) if (!activePtyIds.has(id)) { sessionEventTimes.set(id, Date.now()); } + // A new session that just came alive is news. A resumed one keeps its + // place until the agent does something. + for (const id of ids) { + if (activePtyIds.has(id)) continue; + const pending = pendingSessions.get(id); + if (pending && !pending.restored) sessionEventTimes.set(id, Date.now()); + } activePtyIds = new Set(ids); updateRunningIndicators(); updateTerminalHeader(); diff --git a/public/projects-view.js b/public/projects-view.js index 9b432d76..0df51e3a 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -538,7 +538,9 @@ function buildSessionRow(project, session, { showTrack = true, className = 'pane } else if (showTrack) parts.push(trackTagHtml(project, track)); // The CLI mark, same as the status bar: the logo says which CLI, no word needed. parts.push(`${cliIcon(session, 12)}`); - parts.push(`${escapeHtml(formatDate(new Date(sessionEventTime(session))))}`); + // The last message's time, the same as the Sessions tab. A session with no + // transcript yet shows when it started. + parts.push(`${escapeHtml(formatDate(new Date(session.modified || sessionEventTime(session))))}`); if (session.messageCount) parts.push(`·${session.messageCount} msgs`); row.innerHTML = `${escapeHtml(sessionTitle(session))}${parts.join('')}`; // A floating button, shown on hover: dismiss a session that never started @@ -1937,9 +1939,6 @@ async function renderFileTree(project, list, state) { async function walk(rel, depth) { const entries = await listProjectDir(project, state, rel); for (const entry of entries) { - // Worktrees live under repos/ and are browsed from their own sessions; - // listing a whole checkout here would swamp the project's own files. - if (depth === 0 && entry.type === 'directory' && entry.name === 'repos') continue; if (entry.type === 'other') continue; rows.push({ entry, depth }); if (entry.type === 'directory' && state.expanded.has(entry.relativePath)) await walk(entry.relativePath, depth + 1); @@ -2603,6 +2602,11 @@ function renderPanes(project) { const oldInput = projectPanes.querySelector('#pane-search-input'); const selection = oldInput === document.activeElement && oldInput?.dataset.projectId === project.id ? [oldInput.selectionStart, oldInput.selectionEnd] : null; + // Rebuilding the pane resets its scroll. Keep the offset when it is the + // same project's list, so a click or a status change does not shift the + // rows under the pointer. + const keepScroll = oldInput?.dataset.projectId === project.id + ? projectPanes.querySelector('.pane-scroll')?.scrollTop || 0 : 0; const mode = paneGroupMode(project); const plan = parsePlan(fileContent(project, 'plan-tracker.md')); const todos = parseTodos(fileContent(project, 'todos.md')); @@ -2654,6 +2658,7 @@ function renderPanes(project) { pane.appendChild(foot); projectPanes.replaceChildren(pane); + if (keepScroll) pane.querySelector('.pane-scroll').scrollTop = keepScroll; if (selection) { input.focus({ preventScroll: true }); input.setSelectionRange(...selection); @@ -3205,8 +3210,8 @@ function slugifyClient(name) { /** * New project: a form on the left, and on the right what Create will make: - * the project folder and its files, each worktree on its branch, folders - * used in place, and the tracks the template adds. Pass `folders` to open + * the project folder and its files, each worktree on its branch, and folders + * used in place. Pass `folders` to open * with folders already attached (a "new project from this folder" flow). */ async function showNewProjectDialog({ name: initialName = '', folders: initialFolders = [] } = {}) { @@ -3222,9 +3227,6 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo
- -
-
@@ -3252,8 +3254,6 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo const q = (sel) => dialog.querySelector(sel); const nameInput = q('#np-name'); - const templatesEl = q('#np-templates'); - const templateHint = q('#np-template-hint'); const foldersEl = q('#np-folders'); const branchSection = q('#np-branch'); const branchLine = q('#np-branch-line'); @@ -3267,35 +3267,15 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo // mode is null until the folder is known: a plain folder becomes in-place on // its own, a repository asks. A caller may decide up front with f.mode. const folders = initialFolders.map(f => ({ path: f.path, mode: f.mode || null, branch: '', git: null, envFiles: [], copyEnv: new Set() })); - let templateKind = ''; let root = '~/Switchboard'; try { root = (await window.api.getProjectsRoot()) || root; } catch {} - let templates = []; - try { templates = (await window.api.listTemplates())?.templates || []; } catch {} const shortPath = (p) => (typeof shortProjectPath === 'function' ? shortProjectPath(p) : p); const slug = () => slugifyClient(nameInput.value); const branchName = () => (sharedBox.checked && branchInput.value.trim()) || slug(); // With one shared name every worktree uses it; otherwise each repo names its own, defaulting to the slug. const branchFor = (folder) => (sharedBox.checked ? branchName() : (folder.branch.trim() || slug())); - const template = () => templates.find(t => t.kind === templateKind) || null; const hasWorktree = () => folders.some(f => f.mode === 'worktree'); - function renderTemplates() { - templatesEl.replaceChildren(); - const chips = [{ kind: '', name: 'Blank' }, ...templates]; - for (const t of chips) { - const chip = document.createElement('button'); - chip.type = 'button'; - chip.className = 'np-chip' + (t.kind === templateKind ? ' on' : ''); - chip.dataset.kind = t.kind; - chip.textContent = t.name; - chip.onclick = () => { templateKind = t.kind; renderTemplates(); renderPreview(); }; - templatesEl.appendChild(chip); - } - const t = template(); - templateHint.textContent = t ? t.description : 'The brief, and nothing else until a session needs it.'; - } - function renderBranchLine() { const any = hasWorktree(); branchLine.style.display = any ? '' : 'none'; @@ -3448,7 +3428,6 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo function renderPreview() { const s = slug(); - const t = template(); const worktrees = folders.filter(f => f.mode === 'worktree'); // A repository still being asked about is left out until it is decided. const inPlace = folders.filter(f => f.mode === 'in-place' || (f.mode === null && f.git?.git !== true)); @@ -3466,16 +3445,10 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo if (env.length) tree.push(`copied in: ${escapeHtml(env.join(', '))}`); } } - const hue = projectHue(s); - const tracks = (t?.tracks || []).map((track, i) => { - const h = Math.round((hue + 40 + i * 137.5) % 360); - return `${escapeHtml(track.name)}`; - }); previewEl.innerHTML = `
What you get
${tree.join('')}
${inPlace.length ? `
Also works in place${inPlace.map(f => `${PICONS.folder(11)}${escapeHtml(shortPath(f.path))}`).join('')}
` : ''} - ${tracks.length ? `
Tracks
${tracks.join('')}
` : ''}
Sessions start in the project folder and can read every folder listed here.
`; } @@ -3526,7 +3499,6 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo })), sharedBranch: sharedBox.checked, branchName: branchInput.value.trim(), - template: templateKind || undefined, }); } catch (err) { result = { error: err.message }; @@ -3560,7 +3532,6 @@ async function showNewProjectDialog({ name: initialName = '', folders: initialFo } document.addEventListener('keydown', onKey); - renderTemplates(); renderFolders(); renderPreview(); for (const folder of folders) { diff --git a/public/style.css b/public/style.css index 7a84cd52..1c6f1965 100644 --- a/public/style.css +++ b/public/style.css @@ -3332,6 +3332,20 @@ body { display: flex; flex-direction: column; } color: #d45a70; } +.task-row-action.restart { + color: #9aa0b4; +} + +.task-row-action.restart:hover { + color: #c9cde0; + background: rgba(255,255,255,0.08); +} + +.task-row-action:disabled { + opacity: 0.4; + cursor: default; +} + .task-row-action.stop:hover { background: rgba(212,90,112,0.14); } diff --git a/public/task-runner.js b/public/task-runner.js index 0389226f..bda4ab24 100644 --- a/public/task-runner.js +++ b/public/task-runner.js @@ -107,6 +107,9 @@ function taskStateText(run) { } function renderTaskPopover(project, popover) { + // The popover scrolls, and every task state change rebuilds it. Keep the + // offset so starting a task near the bottom does not jump to the top. + const scrollTop = popover.scrollTop; popover.replaceChildren(); const header = document.createElement('div'); header.className = 'task-popover-header'; @@ -198,7 +201,21 @@ function renderTaskPopover(project, popover) { await runProjectTask(taskPath, task.label, { showLog: false }); } }); - row.append(copy, state, action); + row.append(copy, state); + if (task.run?.running) { + // A running server gets restart beside stop, the same pair as the log header. + const restart = document.createElement('button'); + restart.className = 'task-row-action restart'; + restart.title = `Restart ${task.label}`; + restart.innerHTML = ''; + restart.addEventListener('click', async event => { + event.stopPropagation(); + restart.disabled = true; + await restartProjectTask(taskPath, task.label, { showLog: false }); + }); + row.appendChild(restart); + } + row.appendChild(action); row.addEventListener('click', async () => { if (task.supported === false) return; @@ -211,6 +228,7 @@ function renderTaskPopover(project, popover) { }); popover.appendChild(row); } + popover.scrollTop = scrollTop; } function showTaskPopover(project, anchor) { From fd65bf460c2ee4dbf220879bea1af2af75c18986 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sun, 6 Sep 2026 18:18:05 -0700 Subject: [PATCH 14/24] Point the Slack invite at the doctly-ai workspace The status bar link and the README both used the old switchboard-jg96485 workspace. The sidebar's "Join Slack" menu item reads its URL from the status bar anchor, so it follows along. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JSRojpqL8kVjSmKFiw4pVz --- README.md | 2 +- public/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5f24c4b9..24c7b318 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Switchboard is a desktop app that puts every Claude Code and Codex session, acro ![Switchboard](build/screenshot.png) -**[Download the latest release](https://github.com/doctly/switchboard/releases/latest)** · **[Join the Slack community](https://join.slack.com/t/switchboard-jg96485/shared_invite/zt-490s7hh3p-lktCuVxj2MIjRKUUj2hYWQ)** +**[Download the latest release](https://github.com/doctly/switchboard/releases/latest)** · **[Join the Slack community](https://join.slack.com/t/doctly-ai/shared_invite/zt-4948uf70i-_WeI229ehttWfvjWvGxl3Q)** ## Contents diff --git a/public/index.html b/public/index.html index 59409a86..fae2e196 100644 --- a/public/index.html +++ b/public/index.html @@ -105,7 +105,7 @@
- + From 40315ec9475a4c5e02f436e518241d6e89f3aceb Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Tue, 8 Sep 2026 17:50:53 -0700 Subject: [PATCH 15/24] Improve project file management and conversation search Add file and folder actions while preserving editor buffers and keeping project and session file views current after renames or trash operations. Synchronize managed project briefs, refine project task and session controls, expose session IDs, and clarify quota reset dates. Index complete user and assistant message text for new or changed transcripts, excluding tools and reasoning. Skip search-index writes when the stored content is unchanged, without forcing an index rebuild. --- claude-auth.js | 10 +- codex-auth.js | 9 +- db.js | 18 +++- file-management.js | 60 +++++++++++ harnesses/claude.js | 19 ++-- harnesses/codex.js | 12 ++- main.js | 26 +++++ preload.js | 1 + projects.js | 101 +++++++++++------ public/file-actions.js | 86 +++++++++++++++ public/file-panel.js | 57 +++++++++- public/index.html | 1 + public/projects-view.js | 180 +++++++++++++++++++++++++++---- public/settings-panel.js | 2 +- public/sidebar.js | 1 + public/style.css | 28 ++++- public/task-runner.js | 1 + public/viewer-panel.js | 22 ++++ session-cache.js | 5 +- templates/customer/CLAUDE.md | 6 -- templates/feature/CLAUDE.md | 6 -- templates/research/CLAUDE.md | 6 -- test/conversation-search.test.js | 90 ++++++++++++++++ test/db-schema-reconcile.test.js | 133 +++++++++++++++++++++++ test/file-actions.test.js | 88 +++++++++++++++ test/file-management.test.js | 111 +++++++++++++++++++ test/projects.test.js | 25 +++-- test/reconcile-cache.test.js | 61 +++++++++++ test/viewer-panel.test.js | 44 ++++++++ 29 files changed, 1102 insertions(+), 107 deletions(-) create mode 100644 file-management.js create mode 100644 public/file-actions.js create mode 100644 test/conversation-search.test.js create mode 100644 test/file-actions.test.js create mode 100644 test/file-management.test.js diff --git a/claude-auth.js b/claude-auth.js index fa6d9e3f..dbe9c693 100644 --- a/claude-auth.js +++ b/claude-auth.js @@ -66,7 +66,6 @@ function formatResetTime(value) { } if (isNaN(resetDate.getTime())) return null; const now = new Date(); - const diffMs = resetDate - now; const hours = resetDate.getHours(); const minutes = resetDate.getMinutes(); @@ -77,8 +76,13 @@ function formatResetTime(value) { const tz = Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(resetDate) .find(p => p.type === 'timeZoneName')?.value || ''; - if (diffMs < 0) return `${timeStr} (${tz})`; - if (diffMs < 24 * 60 * 60 * 1000) return `${timeStr} (${tz})`; + // Which calendar day it lands on, not how far away it is: a reset 8 hours + // from 11pm is tomorrow, and a bare "7am" would read as one that has passed. + // Rounded, so a DST day of 23 or 25 hours still counts as one day. + const startOfDay = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x.getTime(); }; + const days = Math.round((startOfDay(resetDate) - startOfDay(now)) / 86400000); + if (days === 0) return `${timeStr} (${tz})`; + if (days === 1) return `tomorrow at ${timeStr} (${tz})`; const month = resetDate.toLocaleString('en', { month: 'short' }); const day = resetDate.getDate(); diff --git a/codex-auth.js b/codex-auth.js index 16bc030d..b217b629 100644 --- a/codex-auth.js +++ b/codex-auth.js @@ -46,7 +46,6 @@ function formatResetTime(value) { } if (isNaN(resetDate.getTime())) return null; const now = new Date(); - const diffMs = resetDate - now; const hours = resetDate.getHours(); const minutes = resetDate.getMinutes(); @@ -57,7 +56,13 @@ function formatResetTime(value) { const tz = Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(resetDate) .find(p => p.type === 'timeZoneName')?.value || ''; - if (diffMs < 24 * 60 * 60 * 1000) return `${timeStr} (${tz})`; + // Which calendar day it lands on, not how far away it is: a reset 8 hours + // from 11pm is tomorrow, and a bare "7am" would read as one that has passed. + // Rounded, so a DST day of 23 or 25 hours still counts as one day. + const startOfDay = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x.getTime(); }; + const days = Math.round((startOfDay(resetDate) - startOfDay(now)) / 86400000); + if (days === 0) return `${timeStr} (${tz})`; + if (days === 1) return `tomorrow at ${timeStr} (${tz})`; const month = resetDate.toLocaleString('en', { month: 'short' }); const day = resetDate.getDate(); diff --git a/db.js b/db.js index 75b53ce5..ea601201 100644 --- a/db.js +++ b/db.js @@ -317,7 +317,8 @@ const stmts = { searchMapDeleteByType: db.prepare('DELETE FROM search_map WHERE type = ?'), searchInsertFts: db.prepare('INSERT OR REPLACE INTO search_fts(rowid, title, body) VALUES (?, ?, ?)'), searchInsertMap: db.prepare('INSERT OR REPLACE INTO search_map(id, type, folder) VALUES (?, ?, ?)'), - searchMapLookup: db.prepare('SELECT rowid FROM search_map WHERE id = ? AND type = ?'), + searchMapLookup: db.prepare('SELECT rowid, folder FROM search_map WHERE id = ? AND type = ?'), + searchContentMatches: db.prepare('SELECT 1 FROM search_fts WHERE rowid = ? AND title = ? AND body = ?'), searchUpdateTitle: db.prepare('UPDATE search_fts SET title = ? WHERE rowid = (SELECT rowid FROM search_map WHERE id = ? AND type = ?)'), searchDeleteByRowid: db.prepare('DELETE FROM search_fts WHERE rowid = ?'), searchMapDeleteByRowid: db.prepare('DELETE FROM search_map WHERE rowid = ?'), @@ -479,18 +480,27 @@ function setFolderMeta(folder, projectPath, indexMtimeMs) { const upsertSearchEntriesBatch = db.transaction((entries) => { for (const e of entries) { + const folder = e.folder || null; + const title = e.title || ''; + const body = e.body || ''; + const existing = stmts.searchMapLookup.get(e.id, e.type); + // Transcript mtime also changes for tool output and CLI bookkeeping. Keep + // the FTS row when its searchable content is identical, including across + // app restarts. Comparing the stored text needs no migration or rebuild. + if (existing && existing.folder === folder && + stmts.searchContentMatches.get(existing.rowid, title, body)) continue; + // Delete any existing FTS row for this (id, type) pair before inserting. // search_map uses INSERT OR REPLACE which deletes the old row and creates // a new one with a new rowid, but the orphaned FTS5 row keyed to the old // rowid would never be cleaned up — causing duplicate search results and // unbounded FTS table growth. - const existing = stmts.searchMapLookup.get(e.id, e.type); if (existing) { stmts.searchDeleteByRowid.run(existing.rowid); stmts.searchMapDeleteByRowid.run(existing.rowid); } - const result = stmts.searchInsertMap.run(e.id, e.type, e.folder || null); - stmts.searchInsertFts.run(result.lastInsertRowid, e.title || '', e.body || ''); + const result = stmts.searchInsertMap.run(e.id, e.type, folder); + stmts.searchInsertFts.run(result.lastInsertRowid, title, body); } }); diff --git a/file-management.js b/file-management.js new file mode 100644 index 00000000..c7b6b7d3 --- /dev/null +++ b/file-management.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const path = require('path'); +const { resolveProjectEntry } = require('./project-files'); + +// Resolve the parent, not the leaf: renaming/trashing a symlink must act on +// the link itself. Parents still have to resolve inside the project. +function resolveManagedEntry(projectPath, relativePath) { + if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath)) { + throw new Error('Invalid project-relative path'); + } + const { root } = resolveProjectEntry(projectPath); + const candidate = path.resolve(root, relativePath); + if (candidate === root) throw new Error('The project folder itself cannot be changed'); + const { resolved: parent } = resolveProjectEntry(root, path.relative(root, path.dirname(candidate))); + const filePath = path.join(parent, path.basename(candidate)); + const stat = fs.lstatSync(filePath); + return { root, filePath, stat }; +} + +async function manageProjectEntry(projectPath, relativePath, action, newName, { shell, confirmTrash }) { + const { filePath, stat } = resolveManagedEntry(projectPath, relativePath); + const result = { action, projectPath, relativePath, filePath }; + if (action === 'reveal') { + shell.showItemInFolder(filePath); + } else if (action === 'open-folder') { + if (!stat.isDirectory()) throw new Error('Not a folder'); + const error = await shell.openPath(filePath); + if (error) throw new Error(error); + } else if (action === 'rename') { + if (typeof newName !== 'string' || !newName.trim() || newName === '.' || newName === '..' || /[/\\\0]/.test(newName)) { + throw new Error('Enter a file or folder name without path separators'); + } + const newFilePath = path.join(path.dirname(filePath), newName); + if (newFilePath === filePath) return { ...result, cancelled: true }; + // lstat also catches dangling symlinks, which existsSync would miss. + try { + fs.lstatSync(newFilePath); + throw new Error('A file or folder with that name already exists'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + fs.renameSync(filePath, newFilePath); + result.newFilePath = newFilePath; + result.newRelativePath = path.join(path.dirname(relativePath), newName); + } else if (action === 'trash') { + if (!await confirmTrash(filePath, stat.isDirectory())) return { ...result, cancelled: true }; + // A native confirmation is asynchronous. Recheck before acting in case + // the item or one of its parents was replaced while the dialog was open. + const current = resolveManagedEntry(projectPath, relativePath); + if (current.filePath !== filePath || current.stat.ino !== stat.ino || current.stat.dev !== stat.dev) { + throw new Error('The item changed while confirming. Refresh the files and try again.'); + } + await shell.trashItem(filePath); + } else { + throw new Error('Unknown file action'); + } + return result; +} + +module.exports = { manageProjectEntry, resolveManagedEntry }; diff --git a/harnesses/claude.js b/harnesses/claude.js index c053b854..d24e9a41 100644 --- a/harnesses/claude.js +++ b/harnesses/claude.js @@ -233,7 +233,7 @@ function readSessionFile(filePath, folder, projectPath) { const lines = content.split('\n').filter(Boolean); let summary = ''; let messageCount = 0; - let textContent = ''; + const textParts = []; let slug = null; let customTitle = null; let aiTitle = null; @@ -256,14 +256,17 @@ function readSessionFile(filePath, folder, projectPath) { if (entry.type === 'ai-title' && entry.aiTitle) { aiTitle = entry.aiTitle; } - if (entry.type === 'user' || entry.type === 'assistant' || - (entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant'))) { + const isConversationMessage = entry.type === 'user' || entry.type === 'assistant' || + (entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant')); + if (isConversationMessage) { messageCount++; } const msg = entry.message; const text = typeof msg === 'string' ? msg : (typeof msg?.content === 'string' ? msg.content : - (msg?.content?.[0]?.text || '')); + (Array.isArray(msg?.content) ? msg.content + .filter(block => block?.type === 'text' && typeof block.text === 'string') + .map(block => block.text).join('\n') : '')); if (!summary && (entry.type === 'user' || (entry.type === 'message' && entry.role === 'user'))) { // Skip local command messages (! prefix) — use the next real user message if (text && !/||/.test(text)) { @@ -272,9 +275,9 @@ function readSessionFile(filePath, folder, projectPath) { summary = taskMatch ? 'Scheduled: ' + taskMatch[1] : text.slice(0, 120); } } - if (text && textContent.length < 8000) { - textContent += text.slice(0, 500) + '\n'; - } + // Search the entire conversation, including every text block. Tool + // inputs/results, thinking and non-message records are not conversation. + if (isConversationMessage && text) textParts.push(text); } if (!summary || messageCount < 1) return null; return { @@ -288,7 +291,7 @@ function readSessionFile(filePath, folder, projectPath) { created: firstTimestamp || stat.birthtime.toISOString(), modified: lastTimestamp || stat.mtime.toISOString(), fileMtime: stat.mtime.toISOString(), - messageCount, textContent, slug, customTitle, aiTitle, + messageCount, textContent: textParts.join('\n'), slug, customTitle, aiTitle, }; } catch { return null; diff --git a/harnesses/codex.js b/harnesses/codex.js index f48c3ad5..dcd222a0 100644 --- a/harnesses/codex.js +++ b/harnesses/codex.js @@ -213,7 +213,9 @@ function messageText(payload) { const content = payload?.content; if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; - return content.map(c => (c && typeof c.text === 'string' ? c.text : '')).join(''); + return content + .filter(c => c && ['text', 'input_text', 'output_text'].includes(c.type) && typeof c.text === 'string') + .map(c => c.text).join('\n'); } /** @@ -235,7 +237,7 @@ function readSessionFile(filePath, folder) { let projectPath = null; let summary = ''; let messageCount = 0; - let textContent = ''; + const textParts = []; let firstTimestamp = null; let lastTimestamp = null; @@ -276,7 +278,9 @@ function readSessionFile(filePath, folder) { messageCount++; if (!summary && role === 'user' && text) summary = text.slice(0, 120); - if (text && textContent.length < 8000) textContent += text.slice(0, 500) + '\n'; + // Keep full conversation text; tool calls/results and reasoning never + // reach this branch because they are not user/assistant messages. + if (text) textParts.push(text); } // No cwd means no project to file it under; no user turn means the session @@ -292,7 +296,7 @@ function readSessionFile(filePath, folder) { created: firstTimestamp || stat.birthtime.toISOString(), modified: lastTimestamp || stat.mtime.toISOString(), fileMtime: stat.mtime.toISOString(), - messageCount, textContent, + messageCount, textContent: textParts.join('\n'), slug: null, customTitle: null, aiTitle: null, }; } catch { diff --git a/main.js b/main.js index f9e2acfd..ef0ecfe2 100644 --- a/main.js +++ b/main.js @@ -36,6 +36,7 @@ const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslS const { startScheduler } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); const { listProjectDirectory, readProjectFile, readPreviewFile } = require('./project-files'); +const { manageProjectEntry } = require('./file-management'); const { PREVIEW_SCHEME, PREVIEW_SCHEMES, handlePreviewAssetRequest } = require('./preview-assets'); protocol.registerSchemesAsPrivileged(PREVIEW_SCHEMES); const { createTaskManager } = require('./task-manager'); @@ -299,6 +300,9 @@ projects.init({ isHarnessId: (id) => allHarnesses().some(h => h.id === id), plansDir: PLANS_DIR, }); +// An upgrade can change the working rules in the brief. Bring every project's +// managed blocks up to date once at startup; unchanged files are not written. +projects.syncAllProjectBriefs().catch(err => log.error('[projects] brief sync failed:', err?.message || String(err))); // Watch every project's plan-tracker.md and todos.md so a tick made by a // session is credited to it and the page refreshes. projects.initPlanWatch({ @@ -520,6 +524,28 @@ ipcMain.handle('list-project-directory', async (_event, projectPath, relativePat } }); +ipcMain.handle('manage-project-entry', async (_event, projectPath, relativePath, action, newName) => { + try { + const result = await manageProjectEntry(projectPath, relativePath, action, newName, { + shell, + confirmTrash: async (filePath, isDirectory) => { + const destination = process.platform === 'win32' ? 'Recycle Bin' : 'Trash'; + const choice = await dialog.showMessageBox(mainWindow, { + type: 'question', + message: `Move "${path.basename(filePath)}" to the ${destination}?`, + detail: `${isDirectory ? 'The folder and its contents' : 'The file'} can be restored from the ${destination}. Open previews of this item will close; unsaved edits will be discarded.`, + buttons: ['Cancel', `Move to ${destination}`], defaultId: 0, cancelId: 0, + noLink: true, + }); + return choice.response === 1; + }, + }); + return { ok: true, ...result }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + ipcMain.handle('read-project-file', async (_event, projectPath, relativePath) => { try { return { ok: true, ...readProjectFile(projectPath, relativePath) }; diff --git a/preload.js b/preload.js index b1cdd33a..ed2cd0b2 100644 --- a/preload.js +++ b/preload.js @@ -166,6 +166,7 @@ contextBridge.exposeInMainWorld('api', { readFileForPanel: (filePath) => ipcRenderer.invoke('read-file-for-panel', filePath), listProjectDirectory: (projectPath, relativePath) => ipcRenderer.invoke('list-project-directory', projectPath, relativePath), readProjectFile: (projectPath, relativePath) => ipcRenderer.invoke('read-project-file', projectPath, relativePath), + manageProjectEntry: (projectPath, relativePath, action, newName) => ipcRenderer.invoke('manage-project-entry', projectPath, relativePath, action, newName), saveFileForPanel: (filePath, content) => ipcRenderer.invoke('save-file-for-panel', filePath, content), watchFile: (filePath) => ipcRenderer.invoke('watch-file', filePath), unwatchFile: (filePath) => ipcRenderer.invoke('unwatch-file', filePath), diff --git a/projects.js b/projects.js index af4a21cc..1c9c037b 100644 --- a/projects.js +++ b/projects.js @@ -93,22 +93,27 @@ function uniqueSlug(name) { // // The brief uses absolute paths throughout, because a session may start in an // attached folder rather than the project folder and "./plan.md" would then -// point at the wrong place. The attached-folder list sits between markers so -// it can be rewritten on attach/detach without touching the user's own text. - -const FOLDERS_START = ''; -const FOLDERS_END = ''; +// point at the wrong place. +// +// Everything Switchboard owns — the title, the project folder, the working +// rules and the attached-folder list — sits in one block between markers. It +// is rewritten whole on every sync, so a change to any of it reaches projects +// that already exist. Anything outside the markers is the user's and is never +// touched. + +const MANAGED_START = ''; +const MANAGED_END = ''; +const MANAGED_NOTE = ''; const BRIEF_FILES = ['CLAUDE.md', 'AGENTS.md']; -function foldersBlock(folderPaths) { - const lines = [FOLDERS_START, '## Attached folders']; +function foldersSection(folderPaths) { + const lines = ['## Attached folders']; if (!folderPaths.length) { lines.push('No folders are attached yet. Attach the repos this project works in from the project settings in Switchboard.'); } else { - lines.push("These attached folders are part of the project's working context. Unless the user specifies otherwise, interpret their requests—including questions, explanations, investigations, reviews, planning, and changes—in the context of these folders. Inspect the relevant attached folders to understand the request and ground your response in their contents. The project folder holds shared plans, notes, drafts, and context; it is not the full scope of the work. Before working with an attached folder, read its own instructions (CLAUDE.md or AGENTS.md at its root) and follow them there."); + lines.push("These attached folders are part of the project's working context. Unless the user specifies otherwise, interpret their requests—including questions, explanations, investigations, reviews, planning, and changes—in the context of these folders. Inspect the relevant attached folders to understand the request and ground your response in their contents. The project folder holds shared plans, notes, drafts, and context; it is not the full scope of the work. The first time you work in an attached folder in a session, read its own instructions (CLAUDE.md or AGENTS.md at its root) and follow them there. Once read, they hold for the rest of the session; do not re-read them before each task."); for (const p of folderPaths) lines.push(`- ${p}`); } - lines.push(FOLDERS_END); return lines.join('\n') + '\n'; } @@ -119,19 +124,25 @@ function foldersBlock(folderPaths) { // files is created up front: the agent makes them when it first needs them. const PROJECT_FILES = ['plan.md', 'plan-tracker.md', 'todos.md', 'memory.md']; -function briefHeader(name, root) { - return `# ${name} - - - +/** The one block Switchboard owns: title, folder, rules, attached folders. */ +function managedBlock(name, root, folderPaths = []) { + return `${MANAGED_START} +${MANAGED_NOTE} +# ${name} Project folder: ${root} + +${briefRules(root)} +${foldersSection(folderPaths)}${MANAGED_END} `; } +/** + * A new brief: the managed block, then whatever the template adds below it as + * the user's own text. Without a template the managed block is the whole file. + */ function defaultBrief(name, root, folderPaths = [], header = null) { - return `${header || briefHeader(name, root)} -${briefRules(root)} -${foldersBlock(folderPaths)}`; + const block = managedBlock(name, root, folderPaths); + return header ? `${block}\n${header}` : block; } function briefRules(root) { @@ -150,8 +161,9 @@ function briefRules(root) { add a todo, append it there. Tick a todo when it is done. Work that is already a phase or an item in the tracker does not belong in the todos as well. - Anything you want to remember across sessions goes in ${memory}. -- Read ${plan}, ${tracker} or ${todos} only when the user refers to the plan or - the todos, not at the start of every session. +- Read ${plan}, ${tracker} or ${todos} only when the user brings up the plan or + the todos. Not at the start of a session, and not again before each task. + What you have already read stays current until you change it. - These files may not exist yet. Create them when you first need them. `; } @@ -378,32 +390,34 @@ async function addProjectFiles(projectId, sourcePaths) { return { ok: true, ...addedFilesAtRoot(project.root), added, errors }; } -/** Replace the managed block in one brief file, or append it when missing. */ +/** + * Replace the managed block in one brief file. When the markers are missing — + * the user deleted them — the block goes back at the top, where a new brief + * puts it, and the user's own text stays below. + */ function syncBriefFile(filePath, block) { if (!fs.existsSync(filePath)) return false; const text = fs.readFileSync(filePath, 'utf8'); - const start = text.indexOf(FOLDERS_START); - const end = text.indexOf(FOLDERS_END); - let next; - if (start !== -1 && end !== -1 && end > start) { - next = text.slice(0, start) + block.trimEnd() + text.slice(end + FOLDERS_END.length); - } else { - next = text.trimEnd() + '\n\n' + block; - } + const start = text.indexOf(MANAGED_START); + const end = text.indexOf(MANAGED_END); + const next = start !== -1 && end !== -1 && end > start + ? text.slice(0, start) + block.trimEnd() + '\n' + text.slice(end + MANAGED_END.length).replace(/^\n+/, '') + : block.trimEnd() + '\n\n' + text.trimStart(); if (next !== text) fs.writeFileSync(filePath, next, 'utf8'); return true; } /** - * Keep the attached-folder list in CLAUDE.md and AGENTS.md current, and - * refresh the Codex bridge file in every worktree folder, since it carries a - * copy of AGENTS.md. + * Keep the managed block in CLAUDE.md and AGENTS.md current — the title, the + * project folder, the working rules and the attached-folder list — and refresh + * the Codex bridge file in every worktree folder, since it carries a copy of + * AGENTS.md. */ async function syncProjectBrief(projectId) { const project = db.getProject(projectId); if (!project) return; const folders = db.listProjectFolders(projectId); - const block = foldersBlock(folders.map(f => f.path)); + const block = managedBlock(project.name, project.root, folders.map(f => f.path)); for (const file of BRIEF_FILES) { try { syncBriefFile(path.join(project.root, file), block); } catch (err) { log.error?.(`[projects] could not update ${file} in ${project.root}`, err); @@ -417,6 +431,25 @@ async function syncProjectBrief(projectId) { } } +/** + * Bring every project's brief up to date at startup, so a Switchboard upgrade + * that changes the working rules reaches projects that already exist. Each + * file is only written when its text actually changed, so this is normally a + * no-op. + */ +async function syncAllProjectBriefs() { + let projectList = []; + try { projectList = db.listProjects() || []; } catch (err) { + log.error?.('[projects] could not list projects to sync briefs', err); + return; + } + for (const project of projectList) { + try { await syncProjectBrief(project.id); } catch (err) { + log.error?.(`[projects] could not sync the brief for ${project.name}`, err); + } + } +} + // --- Codex bridge file --- /** Add a pattern to the repository's shared info/exclude, once. */ @@ -655,6 +688,8 @@ function updateProject(id, patch) { if (!Object.keys(clean).length) return { ok: true, project: loadProjectNode(id) }; clean.modified = new Date().toISOString(); db.updateProject(id, clean); + // The brief's title comes from the project name, so a rename rewrites it. + if (clean.name) syncProjectBrief(id).catch(err => log.error?.('[projects] could not retitle the brief', err)); notifyRendererProjectsChanged(); const result = { ok: true, project: loadProjectNode(id) }; // Finishing a project is the moment to offer removing its worktrees; the @@ -1392,7 +1427,7 @@ module.exports = { projectsRoot, slugify, uniqueSlug, defaultBrief, createProject, updateProject, deleteProject, attachFolder, detachFolder, folderGitStatus, folderGitInfo, projectGitInfo, projectGitDiff, - syncProjectBrief, saveBrief, createProjectFile, addProjectFiles, listAddedFiles, + syncProjectBrief, syncAllProjectBriefs, saveBrief, createProjectFile, addProjectFiles, listAddedFiles, launchContext, mergeAddDirs, worktreeParentFor, CODEX_BRIDGE_FILE, PROJECT_FILES, ADDED_FILES_DIR, readProjectPlan, setPlanItem, appendPlanItem, recordPlanLink, adoptPlan, diff --git a/public/file-actions.js b/public/file-actions.js new file mode 100644 index 00000000..a7757530 --- /dev/null +++ b/public/file-actions.js @@ -0,0 +1,86 @@ +// Shared context menu for the project Files tab and the session file browser. +// Uses the app's context menu and prompt components from projects-view.js. +function projectEntryPath(root, relativePath = '') { + const separator = window.api.platform === 'win32' ? '\\' : '/'; + return root.replace(/[\\/]$/, '') + (relativePath ? separator + relativePath : ''); +} + +function remapFileActionPath(value, change) { + if (!value) return value; + const normalize = path => { + const normalized = path.replace(/\\/g, '/'); + return window.api.platform === 'win32' ? normalized.toLowerCase() : normalized; + }; + const sources = [ + [projectEntryPath(change.projectPath, change.relativePath), change.newRelativePath && projectEntryPath(change.projectPath, change.newRelativePath)], + [change.filePath, change.newFilePath], + ]; + for (const [from, to] of sources) { + const key = normalize(from); + if (normalize(value) === key || normalize(value).startsWith(key + '/')) { + return to ? to + value.slice(from.length) : null; + } + } + return value; +} + +function remapFileActionRelative(root, relativePath, change) { + if (!relativePath) return relativePath; + const original = projectEntryPath(root, relativePath); + const mapped = remapFileActionPath(original, change); + return mapped === original ? relativePath : mapped === null ? null : mapped.slice(projectEntryPath(root).length + 1); +} + +function fileEntryMenuItems(root, entry, open) { + const manager = window.api.platform === 'darwin' ? 'Finder' : window.api.platform === 'win32' ? 'Explorer' : 'File Manager'; + const trash = window.api.platform === 'win32' ? 'Recycle Bin' : 'Trash'; + const run = action => async () => { + try { + let name; + if (action === 'rename' || action === 'trash') { + if (typeof canManageBrowserEntry === 'function' && !canManageBrowserEntry(root, entry.relativePath)) { + alert('Resolve or close the open diff for this item before changing it.'); + return; + } + } + if (action === 'rename') { + name = await showPromptDialog({ title: 'Rename ' + entry.name, label: 'Name', value: entry.name, confirm: 'Rename' }); + if (!name || name === entry.name) return; + } + const result = await window.api.manageProjectEntry(root, entry.relativePath, action, name); + if (!result?.ok) throw new Error(result?.error || 'Could not complete the file action.'); + if (result.cancelled || (action !== 'rename' && action !== 'trash')) return; + if (typeof applyProjectFileAction === 'function') await applyProjectFileAction(result); + if (typeof applyBrowserFileAction === 'function') await applyBrowserFileAction(result); + } catch (err) { + alert(err.message || 'Could not complete the file action.'); + } + }; + return [ + { head: entry.name }, + entry.type === 'directory' + ? { label: 'Open folder', icon: PICONS.folder(14), onClick: run('open-folder') } + : { label: 'Open in editor / preview', icon: PICONS.file(14), disabled: !entry.viewable, onClick: open }, + { label: 'Reveal in ' + manager, icon: PICONS.open(14), onClick: run('reveal') }, + { sep: true }, + { label: 'Copy path', onClick: () => window.api.writeClipboard(projectEntryPath(root, entry.relativePath)) }, + { label: 'Copy relative path', onClick: () => window.api.writeClipboard(entry.relativePath) }, + { sep: true }, + { label: 'Rename…', icon: PICONS.pencil(14), onClick: run('rename') }, + { label: 'Move to ' + trash + '…', icon: PICONS.trash(14), danger: true, onClick: run('trash') }, + ]; +} + +function bindFileEntryMenu(row, root, entry, open) { + row.addEventListener('contextmenu', event => { + event.preventDefault(); + event.stopPropagation(); + showContextMenu(fileEntryMenuItems(root, entry, open), { x: event.clientX, y: event.clientY }); + }); + row.addEventListener('keydown', event => { + if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) return; + event.preventDefault(); + event.stopPropagation(); + showContextMenu(fileEntryMenuItems(root, entry, open), { anchor: row }); + }); +} diff --git a/public/file-panel.js b/public/file-panel.js index 73683160..bdd0ca15 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -779,12 +779,67 @@ function loadProjectDirectory(sessionId, state, relativePath) { }); } +function canManageBrowserEntry(root, relativePath) { + const change = { projectPath: root, relativePath, filePath: projectEntryPath(root, relativePath) }; + return ![...filePanelState.values()].some(state => state.currentTab?.type === 'diff' && + !state.currentTab.resolved && remapFileActionPath(state.currentTab.filePath, change) === null); +} + +async function applyBrowserFileAction(change) { + for (const [sessionId, state] of filePanelState) { + state.directoryCache.clear(); + state.loadingDirectories.clear(); + state.browserError = ''; + state.browserGeneration++; + state.selectedPath = remapFileActionRelative(state.projectPath, state.selectedPath, change) || ''; + state.expandedPaths = new Set([...state.expandedPaths].map(rel => remapFileActionRelative(state.projectPath, rel, change)).filter(Boolean)); + const tab = state.currentTab; + if (!tab) continue; + const next = remapFileActionPath(tab.filePath, change); + if (next === tab.filePath) continue; + if (!next) { + if (sessionId === currentPanelSessionId || tab.type === 'diff') destroyCurrentTab(state); + state.currentTab = null; + state.panelVisible = state.explorerVisible; + } else { + tab.filePath = next; + tab.label = basename(next); + if (tab.type === 'file') { + tab.preview = { ...tab.preview, filePath: next }; + if (sessionId === currentPanelSessionId) { + const result = await fpViewerPanel.relocate(tab.label, next); + if (state.currentTab === tab && fpViewerPanel.filePath === next) { + tab.content = fpViewerPanel.getContent(); + if (result?.ok) tab.preview = { ...result, previewType: fpViewerPanel.previewType }; + } + } else { + // Refresh asset URLs for a background HTML/media preview too. + let result; + try { result = await window.api.readFileForPanel(next); } catch {} + if (state.currentTab === tab && tab.filePath === next && result?.ok) tab.preview = result; + } + } else if (sessionId === currentPanelSessionId) { + renderDiffContent(sessionId, tab); + } + } + } + if (currentPanelSessionId) { + renderProjectBrowser(currentPanelSessionId); + const state = filePanelState.get(currentPanelSessionId); + if (!state?.currentTab) { + renderTabContent(currentPanelSessionId, null); + if (!state?.panelVisible) hidePanel(); + } + } +} + function appendTreeEntries(sessionId, state, container, entries, depth) { for (const entry of entries) { const row = document.createElement('button'); row.className = 'project-file-tree-row'; row.style.paddingLeft = `${8 + depth * 14}px`; row.dataset.path = entry.relativePath; + bindFileEntryMenu(row, state.projectPath, entry, () => openProjectFile(sessionId, entry.relativePath)); if (entry.type === 'directory') { const expanded = state.expandedPaths.has(entry.relativePath); @@ -819,7 +874,7 @@ function appendTreeEntries(sessionId, state, container, entries, depth) { row.classList.add('file'); row.classList.toggle('selected', state.selectedPath === entry.relativePath); - row.disabled = !entry.viewable; + row.classList.toggle('preview-unavailable', !entry.viewable); row.title = entry.viewable ? entry.relativePath : 'This file type cannot be previewed'; row.innerHTML = '📄'; row.querySelector('.project-file-tree-name').textContent = entry.name; diff --git a/public/index.html b/public/index.html index fae2e196..867a8fef 100644 --- a/public/index.html +++ b/public/index.html @@ -117,6 +117,7 @@ + diff --git a/public/projects-view.js b/public/projects-view.js index 0df51e3a..dd8fca99 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -14,7 +14,7 @@ // cachedProjectTreeAll, showArchived, searchMatchIds, searchInput, // activeSessionId, activePtyIds, attentionSessions, responseReadySessions, // sessionBusyState, sessionMap, pendingSessions, openSessions, activeTab, -// gridViewActive, loadProjects, refreshSidebar, launchNewSession, +// gridViewActive, visibleSessionCount, loadProjects, refreshSidebar, launchNewSession, // openSession, pollActiveSessions, placeholder, terminalArea, memoryViewer, // memoryPanel, hideAllViewers, showSession, fitAndScroll, // resolveDefaultSessionOptions @@ -674,6 +674,26 @@ function projectSubline(project) { return parts.join(' · '); } +/** A separate badge for task processes, alongside the session status. */ +function updateProjectTaskIndicator(row, project) { + const badge = row.querySelector('.proj-task-indicator'); + if (!badge) return; + const count = runningTasksFor(project); + badge.style.display = count ? '' : 'none'; + badge.title = `${count} task${count === 1 ? '' : 's'} running`; + badge.setAttribute('aria-label', badge.title); + badge.querySelector('.proj-task-running-count').textContent = count || ''; +} + +/** Task events can affect several projects when they share an attached folder. */ +function updateProjectTaskIndicators(projectPath) { + document.querySelectorAll('.proj-row[data-project-id]').forEach(row => { + const project = findTreeProject(row.dataset.projectId); + if (!project || (project.root !== projectPath && !(project.folders || []).some(f => f.path === projectPath))) return; + updateProjectTaskIndicator(row, project); + }); +} + function buildProjectRow(project) { const row = document.createElement('div'); row.className = 'proj-row' + (project.id === projectsUi.selectedProjectId ? ' selected' : '') + (project.status === 'done' ? ' done' : ''); @@ -685,7 +705,8 @@ function buildProjectRow(project) { const runningCount = sessions.filter(s => isSessionRunning(s.sessionId)).length; row.innerHTML = markHtml(project, 28) + `${escapeHtml(project.name)}${escapeHtml(projectSubline(project))}` + - `${stateDot(state)}${runningCount ? `${runningCount}` : ''}`; + `${PICONS.play(10)}${stateDot(state)}${runningCount ? `${runningCount}` : ''}`; + updateProjectTaskIndicator(row, project); return row; } @@ -1280,7 +1301,7 @@ function buildTrackCard(project, track) { (track ? `` : ''); card.appendChild(head); - const shown = sessions.slice(0, 3); + const shown = sessions.slice(0, visibleSessionCount); for (const s of shown) card.appendChild(buildCardSessionRow(project, s)); if (!sessions.length) { const none = document.createElement('div'); @@ -1292,7 +1313,7 @@ function buildTrackCard(project, track) { const foot = document.createElement('div'); foot.className = 'tcard-f'; foot.innerHTML = - (sessions.length > 3 ? `` : '') + + (sessions.length > shown.length ? `` : '') + ``; card.appendChild(foot); // Archived sessions of this track sit under the foot, closed until asked for. @@ -1357,23 +1378,24 @@ async function loadProjectFiles(project, { force = false } = {}) { })); } +/** + * The brief's own words: whatever the user wrote outside Switchboard's managed + * block. The block holds the title, the project folder, the working rules and + * the attached folders, none of which is worth showing back to them. + */ function briefSummary(content) { if (!content) return { text: '', placeholder: true }; - const lines = content.split(/\r?\n/); - let i = 0; - while (i < lines.length && !/^#\s/.test(lines[i])) i++; - i++; + const outside = content.replace(/[\s\S]*?/g, ''); const para = []; - while (i < lines.length) { - const line = lines[i].trim(); - if (!line) { if (para.length) break; i++; continue; } - if (/^#/.test(line) || /^Project folder:/.test(line)) break; + for (const raw of outside.split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith('\n/, 'the managed block leads'); + assert.match(brief, /\n# Website upkeep\n/, 'title inside the block'); + assert.ok(!brief.includes('Edit me.'), 'no placeholder line'); assert.match(brief, /plan\.md/); assert.match(brief, /todos\.md/); assert.equal(fs.readFileSync(path.join(p.root, 'AGENTS.md'), 'utf8'), brief); @@ -358,7 +360,8 @@ test('the brief uses absolute paths and keeps its attached-folder list current', assert.ok(brief.includes(path.join(project.root, 'plan-tracker.md')), 'tracker path is absolute'); assert.ok(brief.includes(path.join(project.root, 'todos.md')), 'todos path is absolute'); assert.ok(!brief.includes('./plan.md'), 'no relative paths'); - assert.match(brief, /only when the user refers to the plan/, 'not read every session'); + assert.match(brief, /only when the user brings up the plan/, 'not read unprompted'); + assert.match(brief, /not again before each task/, 'not re-read every turn'); assert.match(brief, /"## Phase N: title"/, 'tracker format spelled out'); assert.ok(brief.includes(path.join(project.root, 'memory.md')), 'memory file named'); assert.match(brief, /does not belong in the todos as well/, 'plan items are not duplicated as todos'); @@ -373,19 +376,21 @@ test('the brief uses absolute paths and keeps its attached-folder list current', brief = fs.readFileSync(claudeMd, 'utf8'); assert.ok(brief.startsWith('# My own heading\nMy notes.'), 'user text kept'); assert.ok(brief.includes(`- ${path.resolve(a)}`) && brief.includes(`- ${path.resolve(b)}`), 'both folders listed'); - assert.equal((brief.match(//g) || []).length, 1, 'one managed block'); + assert.equal((brief.match(//g) || []).length, 1, 'one managed block'); await projects.detachFolder(project.id, path.resolve(a)); brief = fs.readFileSync(claudeMd, 'utf8'); assert.ok(!brief.includes(`- ${path.resolve(a)}`)); assert.ok(brief.includes(`- ${path.resolve(b)}`)); - // A brief whose block was deleted gets it appended again. + // A brief whose block was deleted gets it back at the top, the user's + // own text kept below it. fs.writeFileSync(agentsMd, '# Rewritten by hand\n', 'utf8'); await projects.detachFolder(project.id, path.resolve(b)); const agents = fs.readFileSync(agentsMd, 'utf8'); - assert.ok(agents.startsWith('# Rewritten by hand')); + assert.ok(agents.startsWith(''), 'block restored at the top'); assert.ok(agents.includes('No folders are attached yet')); + assert.ok(agents.includes('# Rewritten by hand'), 'hand-written text kept'); } finally { rm(a); rm(b); } } finally { t.cleanup(); } }); @@ -542,11 +547,12 @@ test('saveBrief writes CLAUDE.md and AGENTS.md alike and restores the folder blo const saved = await projects.saveBrief(project.id, '# Brief\n\nShip the thing.'); assert.equal(saved.ok, true); - assert.ok(saved.content.startsWith('# Brief\n\nShip the thing.\n'), 'user text first, newline added'); + assert.ok(saved.content.startsWith(''), 'managed block leads'); + assert.ok(saved.content.includes('# Brief\n\nShip the thing.\n'), 'user text kept, newline added'); assert.ok(saved.content.includes(`- ${path.resolve(repo)}`), 'folder block put back'); assert.equal(fs.readFileSync(path.join(project.root, 'CLAUDE.md'), 'utf8'), saved.content); assert.equal(fs.readFileSync(path.join(project.root, 'AGENTS.md'), 'utf8'), saved.content); - assert.equal((saved.content.match(//g) || []).length, 1); + assert.equal((saved.content.match(//g) || []).length, 1); } finally { rm(repo); } } finally { t.cleanup(); } }); @@ -699,12 +705,13 @@ test('templates: listed from the bundled folder, applied with tokens, tracks cre assert.deepEqual(project.tracks.map(x => x.name), ['Discovery', 'Proposal', 'Build']); assert.deepEqual(project.tracks.map(x => x.cwd), [null, null, null], 'template tracks start in the project folder'); const brief = fs.readFileSync(path.join(project.root, 'CLAUDE.md'), 'utf8'); - assert.ok(brief.startsWith('# Onboarding flow\n'), 'template heading with the name filled in'); + assert.ok(brief.startsWith(''), 'the managed block leads'); + assert.ok(brief.includes('\n# Onboarding flow\n'), 'title from the project name'); assert.ok(brief.includes('## How this project works'), 'template text kept'); assert.ok(brief.includes(`Project folder: ${project.root}`), 'root token filled in'); assert.ok(!brief.includes('{{'), 'no tokens left'); assert.ok(brief.includes('## Working rules'), 'rules still appended'); - assert.ok(brief.includes(''), 'folder block still appended'); + assert.ok(brief.includes('## Attached folders'), 'folder list still there'); assert.equal(fs.readFileSync(path.join(project.root, 'AGENTS.md'), 'utf8'), brief); assert.ok(fs.readFileSync(path.join(project.root, 'contacts.md'), 'utf8').startsWith('# Contacts for Onboarding flow')); assert.ok(fs.existsSync(path.join(project.root, 'proposals', 'README.md'))); diff --git a/test/reconcile-cache.test.js b/test/reconcile-cache.test.js index 5ed1ee50..1654701c 100644 --- a/test/reconcile-cache.test.js +++ b/test/reconcile-cache.test.js @@ -65,6 +65,67 @@ function makeFakeDb(metaMap, globalSettings = {}) { }; } +test('refreshFolder upgrades only new or changed transcripts to full conversation search', () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-conversation-search-')); + try { + const folder = 'project'; + const folderPath = path.join(projectsDir, folder); + fs.mkdirSync(folderPath); + const oldText = 'original conversation '.repeat(500) + ' move_fna_lines'; + const write = (id, text) => fs.writeFileSync(path.join(folderPath, `${id}.jsonl`), + JSON.stringify({ type: 'user', cwd: '/tmp/project', message: { content: text } }) + '\n'); + write('changed', oldText); + write('unchanged', oldText); + + const fake = makeFakeDb(new Map()); + const searchEntries = new Map(); + const writes = []; + for (const id of ['changed', 'unchanged']) { + fake.cachedRows.push({ sessionId: id, folder, + fileMtime: fs.statSync(path.join(folderPath, `${id}.jsonl`)).mtime.toISOString() }); + searchEntries.set(id, 'legacy excerpt'); + } + fake.db.getCachedByFolder = key => fake.cachedRows.filter(row => row.folder === key); + fake.db.upsertCachedSessions = sessions => { + for (const session of sessions) { + const old = fake.cachedRows.find(row => row.sessionId === session.sessionId); + if (old) Object.assign(old, session); + else fake.cachedRows.push(session); + } + }; + fake.db.deleteSearchSession = id => searchEntries.delete(id); + fake.db.upsertSearchEntries = entries => { + for (const entry of entries) { + writes.push(entry.id); + searchEntries.set(entry.id, entry.body); + } + }; + sessionCache.init({ PROJECTS_DIR: projectsDir, activeSessions: new Map(), + getMainWindow: () => null, log: console, db: fake.db }); + + sessionCache.refreshFolder(folder); + assert.deepEqual(writes, [], 'deploying the parser must not reindex unchanged files'); + + const changedFile = path.join(folderPath, 'changed.jsonl'); + const previousMtime = fs.statSync(changedFile).mtimeMs; + fs.appendFileSync(changedFile, JSON.stringify({ type: 'assistant', message: { content: 'new reply' } }) + '\n'); + fs.utimesSync(changedFile, new Date(), new Date(previousMtime + 5000)); + write('new', oldText); + sessionCache.refreshFolder(folder); + + assert.deepEqual(writes.sort(), ['changed', 'new']); + assert.equal(searchEntries.get('changed'), oldText + '\nnew reply', 'a changed file contributes its entire history'); + assert.equal(searchEntries.get('new'), oldText); + assert.equal(searchEntries.get('unchanged'), 'legacy excerpt'); + + writes.length = 0; + sessionCache.refreshFolder(folder); + assert.deepEqual(writes, [], 'the updated file mtime must prevent repeated indexing'); + } finally { + fs.rmSync(projectsDir, { recursive: true, force: true }); + } +}); + test('reconcileCacheFromFilesystem indexes new and stale folders but skips up-to-date ones', () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-reconcile-')); try { diff --git a/test/viewer-panel.test.js b/test/viewer-panel.test.js index 9f7c3c5a..842cadc7 100644 --- a/test/viewer-panel.test.js +++ b/test/viewer-panel.test.js @@ -84,3 +84,47 @@ test('a delayed reload cannot replace a newly selected file or revive a destroye assert.equal(panel.editorView, null); assert.equal(panel.filePath, ''); }); + + +test('renaming an open file preserves edits, updates saves and ignores a stale reload', async () => { + const { panel, api, saved } = setup(); + panel.open('Draft', '/notes.md', 'unsaved edits'); + const requests = []; + api.readFileForPanel = () => new Promise(resolve => requests.push(resolve)); + const stale = panel._reloadFromDisk(); + const rename = panel.relocate('Renamed', '/renamed.md'); + requests[0]({ ok: true, content: 'stale disk content' }); + await stale; + requests[1]({ ok: true, content: 'disk content' }); + await rename; + assert.equal(panel.filePath, '/renamed.md'); + assert.equal(panel.getContent(), 'unsaved edits'); + assert.equal(panel._watchedPath, '/renamed.md'); + panel.toolbar.flashSave = () => {}; + await panel._save(); + assert.equal(saved(), 1); +}); + + +test('renaming to a media extension cannot discard an editable buffer', async () => { + const { panel, api, media } = setup(); + panel.open('Text', '/notes.txt', 'unsaved text'); + api.readFileForPanel = async () => ({ ok: true, ...media }); + await panel.relocate('Image', '/notes.png'); + assert.equal(panel.filePath, '/notes.png'); + assert.equal(panel.getContent(), 'unsaved text'); + assert.equal(panel.previewType, 'text'); +}); + +test('a rename reply cannot revive a closed editor', async () => { + const { panel, api } = setup(); + panel.open('Text', '/notes.txt', 'text'); + let resolve; + api.readFileForPanel = () => new Promise(done => { resolve = done; }); + const rename = panel.relocate('Renamed', '/renamed.txt'); + panel.destroy(); + resolve({ ok: true, content: 'late reply' }); + await rename; + assert.equal(panel.filePath, ''); + assert.equal(panel.editorView, null); +}); From d5522843e483b7ccc62aff858bc1217d6396aad5 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Tue, 8 Sep 2026 23:14:51 -0700 Subject: [PATCH 16/24] Snooze a project until later, and fix submenu clicks A project can be snoozed from its right-click menu for an hour, three hours, this evening, tomorrow, next week, or a picked time. It moves to a collapsed Snoozed shelf between Active and Done, its page closes, and its sessions keep running. Snooze is two columns on the project row (snoozedUntil, snoozedAt); the list decides from the clock at render, so nothing runs at wake time and there is no timer at all while nothing is snoozed. One setTimeout, armed at the earliest wake and clamped to setTimeout's limit, repaints the list; the session poll and window focus repaint too, so a late timer only delays the repaint. A session that needs input brings its project back early, and a project that needs input cannot be snoozed. A woken project shows a yellow dot ahead of every other state, plus "woke 2h ago" in its subline, until it is opened. The marker is derived from the stored wake time, so it survives a restart. Also: a pointerdown on a submenu closed the menu before the click fired, because submenus hang off document.body and the outside-click check only looked at the root menu. That broke every submenu item, including "New session > Claude" from the right-click menu. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XNY9bj19PRXuqTY9RS9pWF --- db.js | 8 +- projects.js | 28 +++++++ public/index.html | 1 + public/projects-view.js | 180 +++++++++++++++++++++++++++++++++++++--- public/snooze.js | 132 +++++++++++++++++++++++++++++ public/style.css | 18 +++- test/projects.test.js | 33 +++++++- test/snooze.test.js | 86 +++++++++++++++++++ 8 files changed, 470 insertions(+), 16 deletions(-) create mode 100644 public/snooze.js create mode 100644 test/snooze.test.js diff --git a/db.js b/db.js index ea601201..84abe5bd 100644 --- a/db.js +++ b/db.js @@ -229,6 +229,12 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_tracks_project ON tracks(projectId)'); // Tracks inherit it unless they set their own cwd. const cols = new Set(db.prepare('PRAGMA table_info(projects)').all().map(c => c.name)); if (!cols.has('defaultCwd')) db.exec('ALTER TABLE projects ADD COLUMN defaultCwd TEXT'); + // Snooze is an overlay on an active project: the row keeps status 'active' + // and is hidden from the list while snoozedUntil is in the future. Nothing + // clears the columns at wake time; a past snoozedUntil simply no longer + // counts, so the renderer decides from the timestamp alone. + if (!cols.has('snoozedUntil')) db.exec('ALTER TABLE projects ADD COLUMN snoozedUntil TEXT'); + if (!cols.has('snoozedAt')) db.exec('ALTER TABLE projects ADD COLUMN snoozedAt TEXT'); } // Which session started or finished a plan phase or a todo. Items are matched // by their text, not their line, so editing the file above an item does not @@ -572,7 +578,7 @@ function deleteSetting(key) { // --- Project functions --- -const PROJECT_PATCH_KEYS = ['name', 'status', 'sharedBranch', 'branchName', 'defaultCwd', 'modified']; +const PROJECT_PATCH_KEYS = ['name', 'status', 'sharedBranch', 'branchName', 'defaultCwd', 'snoozedUntil', 'snoozedAt', 'modified']; const TRACK_PATCH_KEYS = ['name', 'cwd', 'cli', 'status', 'sortOrder']; function listProjects() { diff --git a/projects.js b/projects.js index 1c9c037b..ee7a7e4d 100644 --- a/projects.js +++ b/projects.js @@ -573,6 +573,8 @@ function projectNode(row, folderRows = [], trackRows = []) { sharedBranch: row.sharedBranch === undefined ? true : !!row.sharedBranch, branchName: row.branchName || null, defaultCwd: row.defaultCwd || null, + snoozedUntil: row.snoozedUntil || null, + snoozedAt: row.snoozedAt || null, created: row.created, modified: row.modified, lastActivity: null, sessionCount: 0, addedFilesPath: added.dirPath, @@ -685,6 +687,15 @@ function updateProject(id, patch) { if (cwd.error) return { error: cwd.error }; clean.defaultCwd = cwd.cwd; } + if (patch?.snoozedUntil !== undefined) { + const snooze = normalizeSnooze(row, patch.snoozedUntil, clean.status || row.status); + if (snooze.error) return { error: snooze.error }; + Object.assign(clean, snooze); + } else if (clean.status === 'done' && row.snoozedUntil) { + // Finishing a project ends its snooze; Done is not a place to wake up into. + clean.snoozedUntil = null; + clean.snoozedAt = null; + } if (!Object.keys(clean).length) return { ok: true, project: loadProjectNode(id) }; clean.modified = new Date().toISOString(); db.updateProject(id, clean); @@ -700,6 +711,23 @@ function updateProject(id, patch) { return result; } +/** + * A snooze is a wake time in the future, or null to wake now. Snoozing again + * to the same instant keeps the original snoozedAt, so a repeated click does + * not churn the row. Only visibility changes: sessions keep running. + */ +function normalizeSnooze(row, value, status) { + if (value === null || value === '') return { snoozedUntil: null, snoozedAt: null }; + if (typeof value !== 'string') return { error: 'Wake time must be an ISO date' }; + const wake = Date.parse(value); + if (!Number.isFinite(wake)) return { error: 'Wake time must be an ISO date' }; + if (wake <= Date.now()) return { error: 'Wake time must be in the future' }; + if (status === 'done') return { error: 'A finished project cannot be snoozed' }; + const iso = new Date(wake).toISOString(); + const sameWake = row.snoozedUntil && Date.parse(row.snoozedUntil) === wake && row.snoozedAt; + return { snoozedUntil: iso, snoozedAt: sameWake ? row.snoozedAt : new Date().toISOString() }; +} + /** Rows only. The folder on disk and every session stay. */ function deleteProject(id) { const row = db.getProject(id); diff --git a/public/index.html b/public/index.html index 867a8fef..7866955b 100644 --- a/public/index.html +++ b/public/index.html @@ -130,6 +130,7 @@ + diff --git a/public/projects-view.js b/public/projects-view.js index dd8fca99..b303bd52 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -40,6 +40,10 @@ const projectsUi = { lastStateKey: '', working: false, doneOpen: false, + snoozedOpen: false, + // Ids of the projects the list last showed as snoozed. The session poll + // compares against it to notice a snoozed project raising its hand. + snoozedKey: '', // 'pane:' or 'card::' → true while its archived list is open archivedOpen: (() => { try { return JSON.parse(sessionStorage.getItem('projects.archivedOpen') || '{}'); } catch { return {}; } })(), files: new Map(), // `${projectId}:${name}` → { content, at } @@ -592,7 +596,20 @@ function sessionActivity(session) { } const GROUP_STATE_ORDER = ['attention', 'ready', 'running', 'idle']; -const GROUP_STATE_LABEL = { running: 'Working', ready: 'Finished, not read yet', attention: 'Needs you', idle: 'Open' }; +const GROUP_STATE_LABEL = { running: 'Working', ready: 'Finished, not read yet', attention: 'Needs you', idle: 'Open', woke: 'Back from snooze' }; +// Every class a project dot can carry: the session states plus the woke marker. +const PROJECT_DOT_STATES = [...GROUP_STATE_ORDER, 'woke']; + +/** + * The dot on a project row. A project back from snooze shows yellow ahead of + * everything else: opening it clears the marker at once, and the session + * state shows from then on. The marker comes from the stored wake time, so + * it survives a restart. + */ +function projectDotState(project) { + if (projectWokeAt(project, Date.now())) return 'woke'; + return groupState(projectSessionsAll(project)); +} /** The strongest state among these sessions: needs-you beats finished beats working. */ function groupState(sessions) { @@ -612,16 +629,16 @@ function stateDot(state, extraClass = '') { /** Roll running / attention up onto every project row, card and session row. */ function updateProjectStatusDots() { - const apply = (el, sessions) => { + const applyState = (el, state) => { const dot = el.querySelector(':scope > .proj-dot, :scope > .proj-status > .proj-dot, :scope > .tcard-h > .proj-dot'); if (!dot) return; - const state = groupState(sessions); - for (const name of GROUP_STATE_ORDER) dot.classList.toggle(name, state === name); + for (const name of PROJECT_DOT_STATES) dot.classList.toggle(name, state === name); if (GROUP_STATE_LABEL[state]) dot.title = GROUP_STATE_LABEL[state]; else dot.removeAttribute('title'); }; + const apply = (el, sessions) => applyState(el, groupState(sessions)); document.querySelectorAll('.proj-row[data-project-id]').forEach(row => { const project = findTreeProject(row.dataset.projectId); - if (project) apply(row, projectSessionsAll(project)); + if (project) applyState(row, projectDotState(project)); }); document.querySelectorAll('.tcard[data-track-key]').forEach(el => { const project = findTreeProject(el.dataset.projectId); @@ -650,6 +667,10 @@ function updateProjectStatusDots() { } } } + // A session that starts needing input wakes its snoozed project, and a + // snoozed project has no row while the shelf is closed, so the dots cannot + // carry that change. Compare the snoozed set instead. + if (projectSnoozedKey(currentProjectTree()?.projects || []) !== projectsUi.snoozedKey) renderProjectList(); } // --- Sidebar: the project list --- @@ -670,6 +691,8 @@ function projectSubline(project) { const parts = [trackCount ? `${trackCount} track${trackCount === 1 ? '' : 's'}` : 'no tracks']; parts.push(`${sessions.length} session${sessions.length === 1 ? '' : 's'}`); if (state === 'attention') parts.push('needs input'); + else if (projectSnoozed(project, Date.now())) parts.push(`wakes ${snoozeWakeDescription(project.snoozedUntil)}`); + else if (projectWokeAt(project, Date.now())) parts.push(`woke ${formatDate(new Date(project.snoozedUntil))}`); else if (projectSortTime(project)) parts.push(formatDate(new Date(projectSortTime(project)))); return parts.join(' · '); } @@ -696,12 +719,12 @@ function updateProjectTaskIndicators(projectPath) { function buildProjectRow(project) { const row = document.createElement('div'); - row.className = 'proj-row' + (project.id === projectsUi.selectedProjectId ? ' selected' : '') + (project.status === 'done' ? ' done' : ''); + row.className = 'proj-row' + (project.id === projectsUi.selectedProjectId ? ' selected' : '') + (project.status === 'done' ? ' done' : '') + (isProjectSnoozed(project) ? ' snoozed' : ''); row.id = 'proj-' + project.id; row.dataset.projectId = project.id; row.title = project.root; const sessions = projectSessionsAll(project); - const state = groupState(sessions); + const state = projectDotState(project); const runningCount = sessions.filter(s => isSessionRunning(s.sessionId)).length; row.innerHTML = markHtml(project, 28) + `${escapeHtml(project.name)}${escapeHtml(projectSubline(project))}` + @@ -731,8 +754,12 @@ function renderProjectList() { list.appendChild(toolbar); const byEvent = (a, b) => projectSortTime(b) - projectSortTime(a); - const active = projects.filter(p => p.status !== 'done').sort(byEvent); - const done = projects.filter(p => p.status === 'done').sort(byEvent); + const active = [], snoozed = [], done = []; + for (const p of projects) (p.status === 'done' ? done : isProjectSnoozed(p) ? snoozed : active).push(p); + active.sort(byEvent); + done.sort(byEvent); + // The shelf reads as "what comes back first". + snoozed.sort((a, b) => Date.parse(a.snoozedUntil) - Date.parse(b.snoozedUntil)); if (!projects.length) { const empty = document.createElement('div'); @@ -751,6 +778,15 @@ function renderProjectList() { for (const project of active) list.appendChild(buildProjectRow(project)); } + if (snoozed.length) { + const label = document.createElement('div'); + label.className = 'proj-section proj-section--toggle' + (projectsUi.snoozedOpen ? ' open' : ''); + label.id = 'proj-sec-snoozed'; + label.innerHTML = `${PICONS.chevronRight(9)}Snoozed · ${snoozed.length}`; + list.appendChild(label); + if (projectsUi.snoozedOpen) for (const project of snoozed) list.appendChild(buildProjectRow(project)); + } + if (done.length) { const label = document.createElement('div'); label.className = 'proj-section proj-section--toggle' + (projectsUi.doneOpen ? ' open' : ''); @@ -765,6 +801,10 @@ function renderProjectList() { getNodeKey(node) { return node.id || undefined; }, }); bindProjectList(); + // Every project, not the search's subset: a wake outside the filter still changes the shelf count. + const all = tree?.projects || []; + projectsUi.snoozedKey = projectSnoozedKey(all); + armProjectWakeTimer(all); } function bindProjectList() { @@ -772,6 +812,8 @@ function bindProjectList() { if (newBtn) newBtn.onclick = () => showNewProjectDialog(); const doneToggle = projectsContent.querySelector('#proj-sec-done'); if (doneToggle) doneToggle.onclick = () => { projectsUi.doneOpen = !projectsUi.doneOpen; renderProjectList(); }; + const snoozedToggle = projectsContent.querySelector('#proj-sec-snoozed'); + if (snoozedToggle) snoozedToggle.onclick = () => { projectsUi.snoozedOpen = !projectsUi.snoozedOpen; renderProjectList(); }; projectsContent.querySelectorAll('.proj-row').forEach(row => { const project = findTreeProject(row.dataset.projectId); if (!project) return; @@ -791,6 +833,15 @@ function selectProject(id, { tab } = {}) { if (typeof terminalHeader !== 'undefined') terminalHeader.style.display = 'none'; } projectsUi.selectedProjectId = id; + // Opening a project that woke ends the snooze for good: the "woke" note + // goes and the columns clear. Until then the note marks what came back. + const opened = findTreeProject(id); + if (opened && projectWokeAt(opened, Date.now())) { + opened.snoozedUntil = null; + opened.snoozedAt = null; + window.api.updateProject(id, { snoozedUntil: null }).catch(() => {}); + updateProjectStatusDots(); // the yellow dot goes as soon as the project is opened + } if (tab) { setProjectTab(id, tab); rememberProjectOverview(id); @@ -1191,7 +1242,7 @@ function renderOverview() {
${escapeHtml(project.name)} - ${project.status === 'done' ? 'Done' : 'Active'} + ${projectStatusChip(project)} ${state ? stateDot(state, 'proj-dot--lg') : ''}
@@ -2271,6 +2322,7 @@ function renderSettings(project, body) {
+ ${isProjectSnoozed(project) ? `` : ''} Changes save as you make them. @@ -2351,6 +2403,8 @@ function renderSettings(project, body) { q('#ws-set-new-track').onclick = () => promptNewTrack(project); q('#ws-set-status').onclick = () => toggleProjectDone(project); + const wakeBtn = q('#ws-set-wake'); + if (wakeBtn) wakeBtn.onclick = () => wakeProject(project); q('#ws-set-remove').onclick = () => removeProjectFlow(project); const flash = projectsUi.savedFlash; @@ -2763,7 +2817,10 @@ function closeContextMenu() { } function onCtxPointerDown(e) { - if (openCtxMenu && !openCtxMenu.contains(e.target)) closeContextMenu(); + if (!openCtxMenu) return; + // Submenus hang off document.body, not the root menu, so they count as inside too. + const inside = openCtxMenu.contains(e.target) || openCtxMenu._subs.some(s => s.contains(e.target)); + if (!inside) closeContextMenu(); } function onCtxKey(e) { @@ -2946,6 +3003,7 @@ function projectMenuItems(project, { fromPage = false } = {}) { { label: 'Settings', icon: ICONS.gear(14), onClick: () => selectProject(project.id, { tab: 'settings' }) }, { label: 'Open project folder', icon: PICONS.open(14), onClick: () => window.api.openPath(project.root) }, { sep: true }, + ...snoozeMenuItems(project), { label: isDone ? 'Reopen project' : 'Mark as done', icon: PICONS.check(14), onClick: () => toggleProjectDone(project) }, { label: 'Remove project…', icon: PICONS.trash(14), danger: true, onClick: () => removeProjectFlow(project) }, ].filter(Boolean); @@ -3071,7 +3129,7 @@ function sessionMenuItems(session) { // a stray click used to throw it away. Escape and Cancel are the ways out. /** A small modal with one text field. Resolves the trimmed value, or null on cancel. */ -function showPromptDialog({ title, label, value = '', placeholder = '', confirm = 'Save', help = '' }) { +function showPromptDialog({ title, label, value = '', placeholder = '', confirm = 'Save', help = '', type = 'text', min = '' }) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'add-project-overlay'; @@ -3080,7 +3138,7 @@ function showPromptDialog({ title, label, value = '', placeholder = '', confirm dialog.innerHTML = `

${escapeHtml(title)}

${label ? `` : ''} -
+
${help ? `
${escapeHtml(help)}
` : ''}
@@ -3283,6 +3341,102 @@ async function toggleProjectDone(project) { loadProjects(); } +// --- Snooze --- +// +// Hidden until a wake time; the sessions keep running. The decision is made +// from the clock at render (see snooze.js), so waking costs nothing and the +// only timer is the one below, armed at the earliest wake. + +let projectWakeTimer = null; + +/** Snoozed as the list sees it: the wake time is ahead and no session needs input. */ +function isProjectSnoozed(project) { + return projectSnoozed(project, Date.now(), groupState(projectSessionsAll(project)) === 'attention'); +} + +function projectSnoozedKey(projects) { + return projects.filter(isProjectSnoozed).map(p => p.id).sort().join('|'); +} + +/** + * One timer for the whole list, and none while nothing is snoozed. A late + * timer (a throttled background window, a laptop asleep past the hour) only + * delays the repaint: focus and the session poll compare against the clock + * too, so the list catches up on its own. + */ +function armProjectWakeTimer(projects) { + if (projectWakeTimer) { clearTimeout(projectWakeTimer); projectWakeTimer = null; } + const delay = nextWakeDelayMs(projects, Date.now()); + if (delay === null) return; + projectWakeTimer = setTimeout(() => { projectWakeTimer = null; renderProjectList(); }, delay); +} + +async function snoozeProject(project, snoozedUntil) { + const result = await window.api.updateProject(project.id, { snoozedUntil }); + if (result?.error) { alert(result.error); return; } + // Out of sight means the page too: snoozing the open project closes it. + // The shelf itself is left as the user set it; it starts collapsed. + if (snoozedUntil && projectsUi.selectedProjectId === project.id) { + projectsUi.selectedProjectId = null; + saveProjectsUi(); + leaveProjectViews(); + placeholder.style.display = ''; + } + loadProjects(); +} + +function wakeProject(project) { + return snoozeProject(project, null); +} + +async function pickSnoozeTime(project) { + const now = new Date(); + const suggested = new Date(now.getTime() + 60 * 60 * 1000); + suggested.setMinutes(0, 0, 0); + const value = await showPromptDialog({ + title: `Snooze ${project.name}`, label: 'Wake up at', confirm: 'Snooze', + type: 'datetime-local', value: toLocalInputValue(suggested), min: toLocalInputValue(now), + help: 'The project moves to the Snoozed shelf until then. Its sessions keep running.', + }); + if (!value) return; + const wake = new Date(value); + if (!Number.isFinite(wake.getTime()) || wake.getTime() <= Date.now()) { alert('Pick a time in the future.'); return; } + snoozeProject(project, wake.toISOString()); +} + +function snoozeSubmenu(project) { + const items = resolveSnoozePresets(new Date()).map(p => ({ label: p.label, hint: p.whenLabel, onClick: () => snoozeProject(project, p.snoozedUntil) })); + items.push({ sep: true }, { label: 'Pick a time…', onClick: () => pickSnoozeTime(project) }); + return items; +} + +/** The snooze rows of a project menu. A finished project has none; one that needs input cannot hide. */ +function snoozeMenuItems(project) { + if (project.status === 'done') return []; + if (groupState(projectSessionsAll(project)) === 'attention') { + return [{ label: 'Snooze', icon: PICONS.clock(14), disabled: true, hint: 'needs input' }]; + } + if (isProjectSnoozed(project)) { + return [ + { label: 'Wake now', icon: PICONS.clock(14), hint: `wakes ${snoozeWakeDescription(project.snoozedUntil)}`, onClick: () => wakeProject(project) }, + { label: 'Snooze again', icon: PICONS.clock(14), submenu: snoozeSubmenu(project) }, + ]; + } + return [{ label: 'Snooze', icon: PICONS.clock(14), submenu: snoozeSubmenu(project) }]; +} + +function projectStatusChip(project) { + if (project.status === 'done') return 'Done'; + if (isProjectSnoozed(project)) return `Snoozed`; + return 'Active'; +} + +// The app can come back from a sleep or a throttled background window after +// a wake time passed. Focus is the moment the user looks, so repaint then. +window.addEventListener('focus', () => { + if ((currentProjectTree()?.projects || []).some(p => p.snoozedUntil)) renderProjectList(); +}); + async function removeProjectFlow(project) { if (!confirm(`Remove ${project.name} from Switchboard?\n\nThe folder ${project.root} and all sessions stay on disk.`)) return; const result = await window.api.deleteProject(project.id); diff --git a/public/snooze.js b/public/snooze.js new file mode 100644 index 00000000..cd5552ff --- /dev/null +++ b/public/snooze.js @@ -0,0 +1,132 @@ +// Snooze: hide a project from the list until a wake time, without touching its +// sessions. Nothing runs at wake time. The row keeps two timestamps +// (snoozedUntil, snoozedAt) and the list decides from the clock at render: +// a past snoozedUntil no longer counts. This is what keeps it cheap. There is +// no schedule to maintain, and with nothing snoozed there is no timer at all. +(function (root) { + const MINUTE_MS = 60 * 1000; + const HOUR_MS = 60 * MINUTE_MS; + const DAY_MS = 24 * HOUR_MS; + const EVENING_HOUR = 18; + const MORNING_HOUR = 9; + // setTimeout treats a delay above this as 0 and fires at once, which would + // re-render in a loop for any snooze longer than ~24.8 days. + const MAX_TIMEOUT_MS = 2 ** 31 - 1; + // Fire a moment after the wake time so the comparison is not on the edge. + const WAKE_SLACK_MS = 250; + + function wakeTime(project) { + if (!project || !project.snoozedUntil) return null; + const t = Date.parse(project.snoozedUntil); + return Number.isFinite(t) ? t : null; // malformed data never hides a row + } + + /** + * Hidden while the wake time is ahead and nothing in the project needs the + * user. `attention` is the caller's answer to "does a session need input": + * a snoozed project raises its hand for that, and only that. Finished turns + * are ordinary background activity and stay quiet. + */ + function projectSnoozed(project, nowMs, attention = false) { + if (!project || project.status === 'done') return false; + const wake = wakeTime(project); + if (wake === null || wake <= nowMs) return false; + return !attention; + } + + /** The wake time of a project whose snooze has run out and not been cleared yet, else null. */ + function projectWokeAt(project, nowMs) { + if (!project || project.status === 'done') return null; + const wake = wakeTime(project); + return wake !== null && wake <= nowMs ? project.snoozedUntil : null; + } + + /** + * How long until the earliest wake among these projects, clamped so the + * timer is valid. Null when nothing is due: no timer needs to exist. + */ + function nextWakeDelayMs(projects, nowMs) { + let earliest = null; + for (const p of projects || []) { + if (p.status === 'done') continue; + const wake = wakeTime(p); + if (wake === null || wake <= nowMs) continue; + if (earliest === null || wake < earliest) earliest = wake; + } + if (earliest === null) return null; + return Math.min(earliest - nowMs + WAKE_SLACK_MS, MAX_TIMEOUT_MS); + } + + function timeOfDay(date) { + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + } + + function atHour(base, hour) { + const d = new Date(base); + d.setHours(hour, 0, 0, 0); + return d; + } + + // Calendar days, not DAY_MS: a spring-forward day is 23 hours, so 23:30 plus + // 24h lands two days out. + function addDays(base, days) { + const d = new Date(base); + d.setDate(d.getDate() + days); + return d; + } + + function weekday(date) { + return date.toLocaleDateString(undefined, { weekday: 'short' }); + } + + /** + * The menu's choices. "This evening" appears only while it is more than an + * hour away. On a Sunday "Tomorrow" and "Next week" are the same Monday + * morning, so only "Tomorrow" is offered. + */ + function resolveSnoozePresets(now = new Date()) { + const inAnHour = new Date(now.getTime() + HOUR_MS); + const inThreeHours = new Date(now.getTime() + 3 * HOUR_MS); + const presets = [ + { id: 'hour', label: 'In 1 hour', whenLabel: timeOfDay(inAnHour), snoozedUntil: inAnHour.toISOString() }, + { id: 'three-hours', label: 'In 3 hours', whenLabel: timeOfDay(inThreeHours), snoozedUntil: inThreeHours.toISOString() }, + ]; + const evening = atHour(now, EVENING_HOUR); + if (evening.getTime() - now.getTime() > HOUR_MS) { + presets.push({ id: 'evening', label: 'This evening', whenLabel: timeOfDay(evening), snoozedUntil: evening.toISOString() }); + } + const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); + presets.push({ id: 'tomorrow', label: 'Tomorrow', whenLabel: timeOfDay(tomorrow), snoozedUntil: tomorrow.toISOString() }); + const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; + const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); + if (nextWeek.getTime() !== tomorrow.getTime()) { + presets.push({ id: 'next-week', label: 'Next week', whenLabel: `${weekday(nextWeek)} ${timeOfDay(nextWeek)}`, snoozedUntil: nextWeek.toISOString() }); + } + return presets; + } + + /** "6:00 PM" today, "tomorrow 9:00 AM", "Mon 9:00 AM" this week, else "Sep 20, 9:00 AM". */ + function snoozeWakeDescription(snoozedUntil, now = new Date()) { + const wakeMs = Date.parse(snoozedUntil); + if (!Number.isFinite(wakeMs)) return ''; + const wake = new Date(wakeMs); + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + const dayDelta = Math.floor((wakeMs - startOfToday.getTime()) / DAY_MS); + const time = timeOfDay(wake); + if (dayDelta === 0) return time; + if (dayDelta === 1) return `tomorrow ${time}`; + if (dayDelta > 1 && dayDelta < 7) return `${weekday(wake)} ${time}`; + return `${wake.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}, ${time}`; + } + + /** Value for an : local time, minute precision. */ + function toLocalInputValue(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; + } + + const api = { MAX_TIMEOUT_MS, projectSnoozed, projectWokeAt, nextWakeDelayMs, resolveSnoozePresets, snoozeWakeDescription, toLocalInputValue }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else Object.assign(root, api); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/public/style.css b/public/style.css index 45af0ead..e45ae764 100644 --- a/public/style.css +++ b/public/style.css @@ -4557,6 +4557,15 @@ body { display: flex; flex-direction: column; } opacity: 1; } +.proj-row.snoozed { + opacity: 0.6; +} + +.proj-row.snoozed:hover, +.proj-row.snoozed.selected { + opacity: 1; +} + .proj-mark { display: inline-flex; align-items: center; @@ -4604,7 +4613,7 @@ body { display: flex; flex-direction: column; } } /* Keep an inactive session dot from reserving space after the task count. */ -.proj-status > .proj-dot:not(.running, .ready, .attention, .idle) { +.proj-status > .proj-dot:not(.running, .ready, .attention, .idle, .woke) { display: none; } @@ -4667,6 +4676,12 @@ body { display: flex; flex-direction: column; } box-shadow: 0 0 6px rgba(79,195,247,0.6); } +/* Back from a snooze and not opened yet: yellow, until the project is opened. */ +.proj-dot.woke { + background: #f5d442; + box-shadow: 0 0 6px rgba(245,212,66,0.55); +} + /* A live session doing none of those: green, but quiet. */ .proj-dot.idle { background: rgba(62,207,90,0.4); @@ -4752,6 +4767,7 @@ body { display: flex; flex-direction: column; } .ws-chip--status { height: 20px; font-weight: 600; } .ws-chip--status.active { color: #5ec46a; border-color: rgba(62,207,90,0.3); background: rgba(62,207,90,0.08); } .ws-chip--status.done { color: #9090a8; } +.ws-chip--status.snoozed { color: #8fa3ff; border-color: rgba(120,130,255,0.3); background: rgba(120,130,255,0.08); } .ws-chip--add { color: #6a6a80; border-style: dashed; cursor: pointer; } .ws-chip--add:hover { color: #b0b0c4; border-color: rgba(255,255,255,0.2); } .ws-chip--cli { height: 18px; padding: 0 6px; font-size: 10px; font-weight: 600; } diff --git a/test/projects.test.js b/test/projects.test.js index 96bc2d8b..85e39cc0 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -23,7 +23,7 @@ function makeFakeDb({ global = {} } = {}) { updateProject: (id, patch) => { const row = rows.projects.find(r => r.id === id); if (!row) return 0; - for (const key of ['name', 'status', 'sharedBranch', 'branchName', 'defaultCwd', 'modified']) { + for (const key of ['name', 'status', 'sharedBranch', 'branchName', 'defaultCwd', 'snoozedUntil', 'snoozedAt', 'modified']) { if (key in patch) row[key] = key === 'sharedBranch' ? (patch[key] ? 1 : 0) : patch[key]; } return 1; @@ -170,6 +170,37 @@ test('createProject refuses a blank name, a missing folder, an existing root fol } finally { t.cleanup(); } }); +test('snooze: a wake time in the future, kept as an overlay on an active project', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'Later' }); + assert.equal(project.snoozedUntil, null); + assert.equal(projects.updateProject(project.id, { snoozedUntil: 'soon' }).error, 'Wake time must be an ISO date'); + assert.equal(projects.updateProject(project.id, { snoozedUntil: new Date(Date.now() - 1000).toISOString() }).error, 'Wake time must be in the future'); + + const wake = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const snoozed = projects.updateProject(project.id, { snoozedUntil: wake }).project; + assert.equal(snoozed.snoozedUntil, wake); + assert.ok(snoozed.snoozedAt, 'snoozedAt is stamped'); + assert.equal(snoozed.status, 'active', 'snooze does not change the status'); + + const again = projects.updateProject(project.id, { snoozedUntil: wake }).project; + assert.equal(again.snoozedAt, snoozed.snoozedAt, 'the same wake time keeps the original snoozedAt'); + const later = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + assert.equal(projects.updateProject(project.id, { snoozedUntil: later }).project.snoozedUntil, later); + + const done = projects.updateProject(project.id, { status: 'done' }).project; + assert.equal(done.snoozedUntil, null, 'finishing a project ends its snooze'); + assert.equal(done.snoozedAt, null); + assert.equal(projects.updateProject(project.id, { snoozedUntil: wake }).error, 'A finished project cannot be snoozed'); + + projects.updateProject(project.id, { status: 'active', snoozedUntil: wake }); + const woken = projects.updateProject(project.id, { snoozedUntil: null }).project; + assert.equal(woken.snoozedUntil, null); + assert.equal(woken.snoozedAt, null); + } finally { t.cleanup(); } +}); + test('updateProject, attach/detach, deleteProject keep the folder on disk', async () => { const t = setup(); try { diff --git a/test/snooze.test.js b/test/snooze.test.js new file mode 100644 index 00000000..21e33530 --- /dev/null +++ b/test/snooze.test.js @@ -0,0 +1,86 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + MAX_TIMEOUT_MS, projectSnoozed, projectWokeAt, nextWakeDelayMs, resolveSnoozePresets, snoozeWakeDescription, toLocalInputValue, +} = require('../public/snooze'); + +const NOW = Date.parse('2026-09-08T12:00:00.000Z'); +const FUTURE = '2026-09-09T09:00:00.000Z'; +const PAST = '2026-09-08T10:00:00.000Z'; + +test('projectSnoozed is a comparison against the clock, nothing else', () => { + assert.equal(projectSnoozed({ snoozedUntil: FUTURE }, NOW), true); + assert.equal(projectSnoozed({ snoozedUntil: PAST }, NOW), false, 'a past wake time no longer counts, with no writer'); + assert.equal(projectSnoozed({ snoozedUntil: null }, NOW), false); + assert.equal(projectSnoozed({}, NOW), false); + assert.equal(projectSnoozed({ snoozedUntil: 'not a date' }, NOW), false, 'bad data never hides a row'); + assert.equal(projectSnoozed({ snoozedUntil: FUTURE, status: 'done' }, NOW), false, 'done is not snoozed'); +}); + +test('a project that needs input raises its hand and is not hidden', () => { + assert.equal(projectSnoozed({ snoozedUntil: FUTURE }, NOW, true), false); + assert.equal(projectSnoozed({ snoozedUntil: FUTURE }, NOW, false), true); +}); + +test('projectWokeAt reports a run-out snooze until it is cleared', () => { + assert.equal(projectWokeAt({ snoozedUntil: PAST }, NOW), PAST); + assert.equal(projectWokeAt({ snoozedUntil: FUTURE }, NOW), null); + assert.equal(projectWokeAt({ snoozedUntil: null }, NOW), null); + assert.equal(projectWokeAt({ snoozedUntil: PAST, status: 'done' }, NOW), null); +}); + +test('nextWakeDelayMs picks the earliest future wake and clamps to a valid timeout', () => { + assert.equal(nextWakeDelayMs([], NOW), null, 'nothing snoozed, no timer'); + assert.equal(nextWakeDelayMs([{ snoozedUntil: PAST }], NOW), null, 'already woke, no timer'); + assert.equal(nextWakeDelayMs([{ snoozedUntil: FUTURE, status: 'done' }], NOW), null); + const soon = new Date(NOW + 5 * 60 * 1000).toISOString(); + const delay = nextWakeDelayMs([{ snoozedUntil: FUTURE }, { snoozedUntil: soon }, { snoozedUntil: PAST }], NOW); + assert.ok(delay > 5 * 60 * 1000 && delay < 5 * 60 * 1000 + 1000, `fires just after the earliest wake, got ${delay}`); + const farOff = new Date(NOW + 60 * 24 * 60 * 60 * 1000).toISOString(); + assert.equal(nextWakeDelayMs([{ snoozedUntil: farOff }], NOW), MAX_TIMEOUT_MS, 'a two-month snooze must not overflow setTimeout'); +}); + +test('presets: evening only while it is more than an hour away', () => { + const morning = new Date(2026, 8, 9, 9, 0, 0); // Wed + const ids = resolveSnoozePresets(morning).map(p => p.id); + assert.deepEqual(ids, ['hour', 'three-hours', 'evening', 'tomorrow', 'next-week']); + const lateAfternoon = new Date(2026, 8, 9, 17, 30, 0); + assert.deepEqual(resolveSnoozePresets(lateAfternoon).map(p => p.id), ['hour', 'three-hours', 'tomorrow', 'next-week']); +}); + +test('presets: tomorrow and next week land at 9:00 on the right calendar days', () => { + const wed = new Date(2026, 8, 9, 9, 0, 0); + const byId = Object.fromEntries(resolveSnoozePresets(wed).map(p => [p.id, new Date(p.snoozedUntil)])); + assert.deepEqual([byId.tomorrow.getDate(), byId.tomorrow.getHours(), byId.tomorrow.getMinutes()], [10, 9, 0]); + assert.deepEqual([byId['next-week'].getDay(), byId['next-week'].getDate(), byId['next-week'].getHours()], [1, 14, 9]); +}); + +test('presets: on a Sunday "Tomorrow" and "Next week" are the same Monday, so only one is offered', () => { + const sunday = new Date(2026, 8, 13, 9, 0, 0); + assert.equal(sunday.getDay(), 0); + const ids = resolveSnoozePresets(sunday).map(p => p.id); + assert.ok(ids.includes('tomorrow')); + assert.ok(!ids.includes('next-week')); +}); + +test('presets: every wake time is ahead of now', () => { + for (const hour of [0, 8, 12, 17, 18, 23]) { + const now = new Date(2026, 8, 12, hour, 45, 0); + for (const p of resolveSnoozePresets(now)) assert.ok(Date.parse(p.snoozedUntil) > now.getTime(), `${p.id} at ${hour}:45`); + } +}); + +test('snoozeWakeDescription reads as a person would say it', () => { + const now = new Date(2026, 8, 9, 12, 0, 0); // Wed noon + const at = (d, h) => new Date(2026, 8, d, h, 0, 0).toISOString(); + assert.match(snoozeWakeDescription(at(9, 18), now), /^6:00/); + assert.match(snoozeWakeDescription(at(10, 9), now), /^tomorrow 9:00/); + assert.match(snoozeWakeDescription(at(14, 9), now), /^Mon 9:00/); + assert.match(snoozeWakeDescription(at(20, 9), now), /^Sep 20, 9:00/); + assert.equal(snoozeWakeDescription('junk', now), ''); +}); + +test('toLocalInputValue formats for a datetime-local input', () => { + assert.equal(toLocalInputValue(new Date(2026, 0, 5, 7, 3, 0)), '2026-01-05T07:03'); +}); From 1a515113230a7b5751e14c4134fb5055b5444e31 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Sat, 12 Sep 2026 13:03:55 -0700 Subject: [PATCH 17/24] Add scheduled tasks, PowerPoint previews, and terminal file links Replace the file-based scheduling flow with saved folder and project schedules, timing presets, background launches, run history, and legacy schedule import. Preview PowerPoint decks in an isolated viewer and bundle the renderer for development and release builds. Open unsupported files in their default app. Make terminal file references clickable with destination tooltips and line navigation. Improve snooze wake-up ordering and indicate clipped message previews. Include regression coverage for scheduling, previews, external file opening, and terminal links. --- .github/workflows/build.yml | 4 +- .gitignore | 2 + db.js | 121 ++++++++++ main.js | 127 ++++++---- package-lock.json | 129 +++++++++- package.json | 19 +- preload.js | 13 +- project-files.js | 31 ++- projects.js | 299 ++++++++++++++++++++++- public/app.js | 14 +- public/dialogs.js | 48 ---- public/file-actions.js | 15 +- public/file-panel.js | 32 ++- public/index.html | 3 + public/plans-memory-view.js | 31 --- public/pptx-preview.css | 19 ++ public/pptx-preview.html | 27 +++ public/pptx-preview.js | 116 +++++++++ public/projects-view.js | 100 ++++++-- public/schedule-time.js | 189 +++++++++++++++ public/schedules.js | 394 +++++++++++++++++++++++++++++++ public/sidebar.js | 13 +- public/style.css | 117 +++++++++ public/terminal-links.js | 191 +++++++++++++++ public/terminal-manager.js | 53 +++-- public/viewer-panel.js | 41 +++- schedule-ipc.js | 220 ----------------- schedule-runner.js | 133 +---------- session-cache.js | 4 + session-preview.js | 16 +- terminal-file-links.js | 78 ++++++ test/file-actions.test.js | 16 +- test/project-files.test.js | 64 ++++- test/projects.test.js | 181 +++++++++++++- test/schedule-injection.test.js | 119 ---------- test/schedule-time.test.js | 115 +++++++++ test/session-preview.test.js | 10 +- test/terminal-file-links.test.js | 81 +++++++ test/terminal-links.test.js | 160 +++++++++++++ test/viewer-panel.test.js | 57 ++++- 40 files changed, 2724 insertions(+), 678 deletions(-) create mode 100644 public/pptx-preview.css create mode 100644 public/pptx-preview.html create mode 100644 public/pptx-preview.js create mode 100644 public/schedule-time.js create mode 100644 public/schedules.js create mode 100644 public/terminal-links.js delete mode 100644 schedule-ipc.js create mode 100644 terminal-file-links.js delete mode 100644 test/schedule-injection.test.js create mode 100644 test/schedule-time.test.js create mode 100644 test/terminal-file-links.test.js create mode 100644 test/terminal-links.test.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80804be6..ca6599dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,8 +62,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Bundle CodeMirror - run: npm run bundle:codemirror + - name: Bundle editor and file previews + run: npm run bundle - name: Build run: npx electron-builder --${{ matrix.platform }} --publish never diff --git a/.gitignore b/.gitignore index f3f7f916..e9a741a1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ dist/ *.db-wal *.db-shm public/codemirror-bundle.js +public/pptx-preview-bundle.js +public/pptx-preview-bundle.js.LEGAL.txt .DS_Store .idea/ *.pem diff --git a/db.js b/db.js index 84abe5bd..c8da8337 100644 --- a/db.js +++ b/db.js @@ -218,11 +218,44 @@ db.exec(` ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_tracks_project ON tracks(projectId)'); +// A scheduled task: a saved prompt plus a time. It lives in exactly one place, +// decided by projectId — set, it is listed in the project view under trackId +// (null = General) and runs in the track's cwd; null, it is a folder schedule +// listed on the Sessions tab under `cwd`. Timing is stored as fields +// (`every`, atHour, atMinute, weekday); `every = 'cron'` keeps a raw cron +// string only for schedules imported from the old schedule-*.md files. +db.exec(` + CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT, + trackId TEXT, + cwd TEXT, + prompt TEXT NOT NULL, + every TEXT NOT NULL, + atHour INTEGER, + atMinute INTEGER, + weekday INTEGER, + cron TEXT, + cli TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + catchUp INTEGER NOT NULL DEFAULT 0, + sourceFile TEXT, + lastRunAt TEXT, + lastSessionId TEXT, + created TEXT NOT NULL + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(projectId)'); { const cols = new Set(db.prepare('PRAGMA table_info(session_meta)').all().map(c => c.name)); if (!cols.has('projectId')) db.exec('ALTER TABLE session_meta ADD COLUMN projectId TEXT'); if (!cols.has('trackId')) db.exec('ALTER TABLE session_meta ADD COLUMN trackId TEXT'); if (!cols.has('formerTrackName')) db.exec('ALTER TABLE session_meta ADD COLUMN formerTrackName TEXT'); + // Which schedule started the session, and when it fired. Read by the + // session row's clock chip; nothing else depends on it. + if (!cols.has('scheduleId')) db.exec('ALTER TABLE session_meta ADD COLUMN scheduleId TEXT'); + if (!cols.has('scheduledAt')) db.exec('ALTER TABLE session_meta ADD COLUMN scheduledAt TEXT'); } { // Where a project's sessions start by default (null = the project folder). @@ -381,6 +414,23 @@ const stmts = { `), trackDelete: db.prepare('DELETE FROM tracks WHERE id = ?'), tracksDeleteByProject: db.prepare('DELETE FROM tracks WHERE projectId = ?'), + schedulesListAll: db.prepare('SELECT * FROM schedules ORDER BY created'), + schedulesListByProject: db.prepare('SELECT * FROM schedules WHERE projectId = ? ORDER BY created'), + scheduleGet: db.prepare('SELECT * FROM schedules WHERE id = ?'), + scheduleInsert: db.prepare(` + INSERT INTO schedules (id, name, projectId, trackId, cwd, prompt, every, atHour, atMinute, weekday, cron, cli, enabled, catchUp, sourceFile, lastRunAt, lastSessionId, created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + scheduleDelete: db.prepare('DELETE FROM schedules WHERE id = ?'), + schedulesDeleteByProject: db.prepare('DELETE FROM schedules WHERE projectId = ?'), + schedulesClearTrack: db.prepare('UPDATE schedules SET trackId = NULL WHERE trackId = ?'), + scheduleRekeySession: db.prepare('UPDATE schedules SET lastSessionId = ? WHERE lastSessionId = ?'), + scheduleLinkSet: db.prepare(` + INSERT INTO session_meta (sessionId, scheduleId, scheduledAt) VALUES (?, ?, ?) + ON CONFLICT(sessionId) DO UPDATE SET scheduleId = excluded.scheduleId, scheduledAt = excluded.scheduledAt + `), + scheduleLinkRekey: db.prepare('UPDATE session_meta SET scheduleId = ?, scheduledAt = ? WHERE sessionId = ?'), + scheduleLinkClear: db.prepare('UPDATE session_meta SET scheduleId = NULL, scheduledAt = NULL WHERE scheduleId = ?'), // Session ↔ project assignment lives on session_meta so it survives cache // rebuilds. name/starred/archived are left untouched by these statements. assignmentSet: db.prepare(` @@ -580,6 +630,7 @@ function deleteSetting(key) { const PROJECT_PATCH_KEYS = ['name', 'status', 'sharedBranch', 'branchName', 'defaultCwd', 'snoozedUntil', 'snoozedAt', 'modified']; const TRACK_PATCH_KEYS = ['name', 'cwd', 'cli', 'status', 'sortOrder']; +const SCHEDULE_PATCH_KEYS = ['name', 'trackId', 'cwd', 'prompt', 'every', 'atHour', 'atMinute', 'weekday', 'cron', 'cli', 'enabled', 'catchUp', 'lastRunAt', 'lastSessionId']; function listProjects() { return stmts.projectList.all(); @@ -625,6 +676,7 @@ function updateProject(id, patch) { const deleteProjectTx = db.transaction((id) => { stmts.assignmentClearProject.run(id); stmts.tracksDeleteByProject.run(id); + stmts.schedulesDeleteByProject.run(id); stmts.projectFoldersDeleteByProject.run(id); stmts.planLinksDeleteByProject.run(id); stmts.projectDelete.run(id); @@ -698,6 +750,8 @@ const deleteTrackTx = db.transaction((id, archiveSessions) => { db.prepare(`UPDATE session_meta SET formerTrackName = ?, trackId = NULL, archived = CASE WHEN ? THEN 1 ELSE archived END WHERE trackId = ?`).run(track.name, archiveSessions ? 1 : 0, id); stmts.trackDelete.run(id); + // Its schedules stay in the project, under General, like its sessions. + stmts.schedulesClearTrack.run(id); return sessionIds; }); @@ -705,6 +759,71 @@ function deleteTrack(id, { archiveSessions = false } = {}) { return deleteTrackTx(id, archiveSessions); } +// --- Schedules --- + +function listSchedules() { + return stmts.schedulesListAll.all(); +} + +function listSchedulesByProject(projectId) { + return stmts.schedulesListByProject.all(projectId); +} + +function getSchedule(id) { + return stmts.scheduleGet.get(id) || null; +} + +function insertSchedule(row) { + stmts.scheduleInsert.run( + row.id, row.name, row.projectId || null, row.trackId || null, row.cwd || null, + row.prompt, row.every, + row.atHour ?? null, row.atMinute ?? null, row.weekday ?? null, row.cron || null, + row.cli || null, row.enabled === false ? 0 : 1, row.catchUp ? 1 : 0, + row.sourceFile || null, row.lastRunAt || null, row.lastSessionId || null, row.created + ); +} + +function updateSchedule(id, patch) { + const clean = { ...patch }; + if ('enabled' in clean) clean.enabled = clean.enabled ? 1 : 0; + if ('catchUp' in clean) clean.catchUp = clean.catchUp ? 1 : 0; + return updatePatch('schedules', SCHEDULE_PATCH_KEYS, id, clean); +} + +const deleteScheduleTx = db.transaction((id) => { + stmts.scheduleLinkClear.run(id); + stmts.scheduleDelete.run(id); +}); + +function deleteSchedule(id) { + deleteScheduleTx(id); +} + +/** A session started by a schedule: the row remembers it, and the schedule remembers the run. */ +const recordScheduleRunTx = db.transaction((scheduleId, sessionId, at) => { + stmts.scheduleLinkSet.run(sessionId, scheduleId, at); + db.prepare('UPDATE schedules SET lastRunAt = ?, lastSessionId = ? WHERE id = ?').run(at, sessionId, scheduleId); +}); + +function recordScheduleRun(scheduleId, sessionId, at) { + recordScheduleRunTx(scheduleId, sessionId, at); +} + +// A session that started under a temporary id keeps its schedule link once +// the real id is known (codex). +const rekeyScheduleSessionTx = db.transaction((fromId, toId) => { + const row = stmts.get.get(fromId); + if (row?.scheduleId) { + stmts.scheduleLinkSet.run(toId, row.scheduleId, row.scheduledAt); + stmts.scheduleLinkRekey.run(null, null, fromId); + } + stmts.scheduleRekeySession.run(toId, fromId); +}); + +function rekeyScheduleSession(fromId, toId) { + rekeyScheduleSessionTx(fromId, toId); +} + function setSessionAssignment(sessionId, projectId, trackId) { stmts.assignmentSet.run(sessionId, projectId || null, trackId || null); } @@ -741,6 +860,8 @@ module.exports = { listProjects, getProject, getProjectBySlug, insertProject, updateProject, deleteProject, listProjectFolders, listAllProjectFolders, upsertProjectFolder, deleteProjectFolder, listTracks, listAllTracks, getTrack, insertTrack, updateTrack, deleteTrack, + listSchedules, listSchedulesByProject, getSchedule, insertSchedule, updateSchedule, deleteSchedule, + recordScheduleRun, rekeyScheduleSession, setSessionAssignment, copySessionAssignment, moveSessionAssignment, insertPlanLink, listPlanLinks, rekeyPlanLinks, closeDb, diff --git a/main.js b/main.js index ef0ecfe2..d2598a09 100644 --- a/main.js +++ b/main.js @@ -33,10 +33,11 @@ const { createTerminalActivity } = require('./terminal-activity'); // Shell profiles → shell-profiles.js const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles'); -const { startScheduler } = require('./schedule-runner'); +const { scanSchedules } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); -const { listProjectDirectory, readProjectFile, readPreviewFile } = require('./project-files'); +const { listProjectDirectory, readProjectFile, readPreviewFile, openFileExternally } = require('./project-files'); const { manageProjectEntry } = require('./file-management'); +const { resolveTerminalFiles } = require('./terminal-file-links'); const { PREVIEW_SCHEME, PREVIEW_SCHEMES, handlePreviewAssetRequest } = require('./preview-assets'); protocol.registerSchemesAsPrivileged(PREVIEW_SCHEMES); const { createTaskManager } = require('./task-manager'); @@ -430,6 +431,12 @@ ipcMain.handle('set-session-assignment', guarded((sessionId, projectId, trackId) } return result; })); +// Scheduled tasks. The tick that fires them is startScheduleTicker below. +ipcMain.handle('list-schedules', guarded(() => projects.listSchedules())); +ipcMain.handle('create-schedule', guarded((spec) => projects.createSchedule(spec || {}))); +ipcMain.handle('update-schedule', guarded((id, patch) => projects.updateSchedule(id, patch || {}))); +ipcMain.handle('delete-schedule', guarded((id) => projects.deleteSchedule(id))); +ipcMain.handle('resolve-schedule-launch', guarded((id) => projects.resolveScheduleLaunch(id))); ipcMain.handle('create-track', guarded((projectId, spec) => projects.createTrack(projectId, spec || {}))); ipcMain.handle('update-track', guarded((id, patch) => projects.updateTrack(id, patch || {}))); ipcMain.handle('delete-track', guarded((id, options = {}) => { @@ -508,11 +515,21 @@ ipcMain.on('mcp-diff-response', (_event, sessionId, diffId, action, editedConten resolvePendingDiff(sessionId, diffId, action, editedContent); }); +ipcMain.handle('resolve-terminal-files', (_event, references) => resolveTerminalFiles(references)); + +ipcMain.handle('open-file-externally', async (_event, filePath, projectRoot) => { + try { + return { ok: true, ...await openFileExternally(filePath, projectRoot, shell) }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + ipcMain.handle('read-file-for-panel', async (_event, filePath) => { try { return { ok: true, ...readPreviewFile(filePath) }; } catch (err) { - return { ok: false, error: err.message }; + return { ok: false, error: err.message, code: err.code }; } }); @@ -550,7 +567,7 @@ ipcMain.handle('read-project-file', async (_event, projectPath, relativePath) => try { return { ok: true, ...readProjectFile(projectPath, relativePath) }; } catch (err) { - return { ok: false, error: err.message }; + return { ok: false, error: err.message, code: err.code }; } }); @@ -1074,7 +1091,6 @@ ipcMain.handle('delete-setting', (_event, key) => { }); // --- Scheduled tasks --- -const scheduleIpc = require('./schedule-ipc'); const SETTING_DEFAULTS = { permissionMode: null, @@ -1245,7 +1261,8 @@ ipcMain.handle('get-session-last-message', (_event, sessionId) => { const row = getCachedSession(sessionId); if (!row) return { error: 'Session not found in cache' }; try { - return { text: lastAssistantMessage(readSessionViewerEntries(row)) }; + const { text, truncated } = lastAssistantMessage(readSessionViewerEntries(row)); + return { text, truncated }; } catch (err) { return { error: err.message }; } @@ -1552,6 +1569,14 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se log.error('[projects] could not record launch assignment', err); } } + // Started by a schedule: the session remembers which, the schedule + // remembers the run. Folder schedules have no project, so this is separate + // from the assignment above. + if (startFresh && !isPlainTerminal && sessionOptions?.scheduleId) { + try { projects.recordScheduleRun(sessionOptions.scheduleId, sessionId); } catch (err) { + log.error('[schedule] could not record the run', err); + } + } // A resumed project session is a project session too, for the plan watcher. if (!isPlainTerminal && !session.projectId) { const meta = getMeta(session.realSessionId || sessionId); @@ -1937,6 +1962,7 @@ function resolvePendingLaunches(candidatePaths) { rekeyPlanLinks(tempId, realId); } catch (err) { log.error('[projects] assignment move failed', err); } } + try { dbModule.rekeyScheduleSession(tempId, realId); } catch (err) { log.error('[schedule] rekey failed', err); } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('session-detected', tempId, realId); } @@ -2118,6 +2144,51 @@ if (!gotSingleInstanceLock) { } }); + +// --- Scheduled task ticker --- +// Main owns the clock: a renderer timer is throttled while the window is +// hidden, and a schedule has to fire at the minute it names. Each fire is one +// 'schedule-due' event; the renderer launches the session, because the +// terminal lives there. A schedule whose last session is still working is +// skipped by projects.dueSchedules. +let scheduleTickerStop = null; +// "Still working" is the CLI being busy, not the PTY being open: an +// interactive session sits at its prompt until someone closes it, and that +// must not stop the next run. +function isSessionBusy(sessionId) { + const session = activeSessions.get(sessionId); + return !!session && !session.exited && !!session._cliBusy; +} +function fireSchedules(ids, reason) { + if (!ids.length || !mainWindow || mainWindow.isDestroyed()) return; + for (const id of ids) { + const launch = projects.resolveScheduleLaunch(id); + if (launch.error) { log.warn(`[schedule] ${id}: ${launch.error}`); continue; } + log.info(`[schedule] ${reason}: ${launch.schedule.name}`); + mainWindow.webContents.send('schedule-due', launch); + } +} +function startScheduleTicker() { + if (scheduleTickerStop) return; + let interval = null; + const tick = () => { + try { fireSchedules(projects.dueSchedules(new Date(), isSessionBusy), 'due'); } catch (err) { + log.error('[schedule] tick failed', err); + } + }; + // Align to the minute so a 9:00 schedule fires at 9:00:00, not 9:00:37. + const first = setTimeout(() => { tick(); interval = setInterval(tick, 60 * 1000); }, (60 - new Date().getSeconds()) * 1000); + // Catch-up runs, for schedules that asked for it, once the renderer has had + // time to load. Missed while the app was closed means missed since the last + // run (projects.missedSchedules). + const catchUp = setTimeout(() => { + try { fireSchedules(projects.missedSchedules(new Date(), isSessionBusy), 'catch-up'); } catch (err) { + log.error('[schedule] catch-up failed', err); + } + }, 20 * 1000); + scheduleTickerStop = () => { clearTimeout(first); clearTimeout(catchUp); if (interval) clearInterval(interval); scheduleTickerStop = null; }; +} + app.whenReady().then(() => { protocol.handle(PREVIEW_SCHEME, handlePreviewAssetRequest); buildMenu(); @@ -2125,42 +2196,14 @@ if (!gotSingleInstanceLock) { createWindow(); startProjectsWatcher(); startHarnessWatchers(); - scheduleIpc.ensureScheduleCreatorCommand(); - - // Shared runCommand for cron scheduler and "run now" — takes argv, not a shell string - const { spawn: cpSpawn } = require('child_process'); - function runScheduleCommand(claudeArgv, cwd, name, onDone) { - const globalSettings = getSetting('global') || {}; - const profileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile; - const profile = resolveShell(profileId); - const shell = profile.path; - const cmd = 'claude ' + quoteArgvForShell(shell, claudeArgv); - const args = shellArgs(shell, cmd, profile.args || []); - - log.info(`[schedule] Running: ${shell} ${args.join(' ')}`); - const child = cpSpawn(shell, args, { - cwd, - stdio: ['ignore', 'ignore', 'pipe'], - env: { ...cleanPtyEnv, FORCE_COLOR: '0' }, - }); - - let stderr = ''; - child.stderr.on('data', (data) => { stderr += data.toString(); }); - - child.on('exit', (code) => { - if (stderr.trim()) log.error(`[schedule] ${name} stderr:\n${stderr.trim()}`); - log.info(`[schedule] ${name} finished (exit ${code})`); - if (onDone) onDone(); - }); - - child.on('error', (err) => { - log.error(`[schedule] ${name} error:`, err.message); - if (onDone) onDone(); - }); - } - - scheduleIpc.init(log, runScheduleCommand); - startScheduler(log, runScheduleCommand); + // Scheduled tasks: once the old file-based ones are imported, tick once a + // minute and tell the renderer which schedules are due. The renderer + // starts them as ordinary sessions (launchScheduledSession). + try { + const imported = projects.importLegacySchedules(scanSchedules(log)); + if (imported) log.info(`[schedule] Imported ${imported} schedule file(s) as folder schedules`); + } catch (err) { log.error('[schedule] legacy import failed', err); } + startScheduleTicker(); // Re-index search if FTS table was recreated (e.g. tokenizer config change) if (searchFtsRecreated) populateCacheViaWorker(); diff --git a/package-lock.json b/package-lock.json index f60d9816..a9189d86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.34", "hasInstallScript": true, "dependencies": { + "@aiden0z/pptx-renderer": "1.2.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode-graphemes": "^0.4.0", @@ -43,6 +44,24 @@ "png2icons": "^2.0.1" } }, + "node_modules/@aiden0z/pptx-renderer": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.2.4.tgz", + "integrity": "sha512-/4X/BApiknc/JM4nAWQRp5zrnA2GKSMv5r1u3XLsMvySw9DTQjyXIKRtltRsmIYfoN32tHGFnrSlGok7nFS60w==", + "license": "Apache-2.0", + "dependencies": { + "echarts": "^6.0.0", + "jszip": "^3.10.1" + }, + "peerDependencies": { + "pdfjs-dist": ">=5 <7" + }, + "peerDependenciesMeta": { + "pdfjs-dist": { + "optional": true + } + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.1", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", @@ -3262,9 +3281,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/crc": { "version": "3.8.0", @@ -3673,6 +3690,16 @@ "dev": true, "license": "MIT" }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4838,6 +4865,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4961,6 +4994,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -5091,6 +5130,48 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5107,6 +5188,15 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5716,6 +5806,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5904,6 +6000,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -6217,6 +6319,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6779,6 +6887,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -7063,6 +7177,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/package.json b/package.json index da5df424..6a397a4a 100644 --- a/package.json +++ b/package.json @@ -6,21 +6,24 @@ "author": "Doctly ", "main": "main.js", "scripts": { - "start": "npm run bundle:codemirror && electron .", + "start": "npm run bundle && electron .", "test": "node --test", "electron": "electron .", "electron-dev": "SWITCHBOARD_DATA_DIR=\"$HOME/.switchboard-dev\" electron .", "bundle:codemirror": "esbuild public/codemirror-setup.js --bundle --outfile=public/codemirror-bundle.js --format=iife --platform=browser --minify", "generate-icons": "node scripts/generate-icons.js && node scripts/generate-dmg-background.js", - "build": "npm run bundle:codemirror && electron-builder", - "build:mac": "npm run bundle:codemirror && electron-builder --mac && node scripts/postinstall.js", - "build:mac:arm64": "npm run bundle:codemirror && electron-builder --mac --arm64 && node scripts/postinstall.js", - "build:win": "npm run bundle:codemirror && electron-builder --win", - "build:linux": "npm run bundle:codemirror && electron-builder --linux", - "release": "npm run bundle:codemirror && electron-builder --publish always", - "postinstall": "node scripts/postinstall.js" + "build": "npm run bundle && electron-builder", + "build:mac": "npm run bundle && electron-builder --mac && node scripts/postinstall.js", + "build:mac:arm64": "npm run bundle && electron-builder --mac --arm64 && node scripts/postinstall.js", + "build:win": "npm run bundle && electron-builder --win", + "build:linux": "npm run bundle && electron-builder --linux", + "release": "npm run bundle && electron-builder --publish always", + "postinstall": "node scripts/postinstall.js", + "bundle": "npm run bundle:codemirror && npm run bundle:pptx", + "bundle:pptx": "esbuild public/pptx-preview.js --bundle --outfile=public/pptx-preview-bundle.js --format=iife --platform=browser --minify" }, "dependencies": { + "@aiden0z/pptx-renderer": "1.2.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode-graphemes": "^0.4.0", diff --git a/preload.js b/preload.js index ed2cd0b2..6e68ec50 100644 --- a/preload.js +++ b/preload.js @@ -31,9 +31,6 @@ contextBridge.exposeInMainWorld('api', { setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value), deleteSetting: (key) => ipcRenderer.invoke('delete-setting', key), getEffectiveSettings: (projectPath) => ipcRenderer.invoke('get-effective-settings', projectPath), - getScheduleCreatorCommand: () => ipcRenderer.invoke('get-schedule-creator-command'), - createScheduleSession: (projectPath) => ipcRenderer.invoke('create-schedule-session', projectPath), - runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath), getShellProfiles: () => ipcRenderer.invoke('get-shell-profiles'), // Project/worktree tasks @@ -75,6 +72,14 @@ contextBridge.exposeInMainWorld('api', { onProjectPlanChanged: (callback) => { ipcRenderer.on('project-plan-changed', (_event, projectId) => callback(projectId)); }, + listSchedules: () => ipcRenderer.invoke('list-schedules'), + createSchedule: (spec) => ipcRenderer.invoke('create-schedule', spec), + updateSchedule: (id, patch) => ipcRenderer.invoke('update-schedule', id, patch), + deleteSchedule: (id) => ipcRenderer.invoke('delete-schedule', id), + resolveScheduleLaunch: (id) => ipcRenderer.invoke('resolve-schedule-launch', id), + onScheduleDue: (callback) => { + ipcRenderer.on('schedule-due', (_event, launch) => callback(launch)); + }, createTrack: (projectId, spec) => ipcRenderer.invoke('create-track', projectId, spec), updateTrack: (id, patch) => ipcRenderer.invoke('update-track', id, patch), deleteTrack: (id, options) => ipcRenderer.invoke('delete-track', id, options), @@ -164,6 +169,8 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.send('mcp-diff-response', sessionId, diffId, action, editedContent); }, readFileForPanel: (filePath) => ipcRenderer.invoke('read-file-for-panel', filePath), + openFileExternally: (filePath, projectRoot) => ipcRenderer.invoke('open-file-externally', filePath, projectRoot), + resolveTerminalFiles: (references) => ipcRenderer.invoke('resolve-terminal-files', references), listProjectDirectory: (projectPath, relativePath) => ipcRenderer.invoke('list-project-directory', projectPath, relativePath), readProjectFile: (projectPath, relativePath) => ipcRenderer.invoke('read-project-file', projectPath, relativePath), manageProjectEntry: (projectPath, relativePath, action, newName) => ipcRenderer.invoke('manage-project-entry', projectPath, relativePath, action, newName), diff --git a/project-files.js b/project-files.js index 22210efc..c7d754cd 100644 --- a/project-files.js +++ b/project-files.js @@ -15,12 +15,13 @@ function previewType(filePath) { const ext = path.extname(filePath).toLowerCase(); if (IMAGE_MIME_TYPES[ext]) return 'image'; if (ext === '.pdf') return 'pdf'; + if (ext === '.pptx') return 'pptx'; if (ext === '.html' || ext === '.htm') return 'html'; return 'text'; } function previewLimit(filePath) { - return ['image', 'pdf'].includes(previewType(filePath)) ? MAX_MEDIA_PREVIEW_BYTES : MAX_PREVIEW_BYTES; + return ['image', 'pdf', 'pptx'].includes(previewType(filePath)) ? MAX_MEDIA_PREVIEW_BYTES : MAX_PREVIEW_BYTES; } const BINARY_EXTENSIONS = new Set([ '.7z', '.a', '.avi', '.bin', '.bmp', '.class', '.db', '.dmg', '.dll', '.doc', @@ -61,7 +62,7 @@ function resolveProjectEntry(projectPath, relativePath = '') { function isViewableFile(filePath, stat) { if (!stat.isFile() || stat.size > previewLimit(filePath)) return false; - if (['image', 'pdf'].includes(previewType(filePath))) return true; + if (['image', 'pdf', 'pptx'].includes(previewType(filePath))) return true; if (BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())) return false; if (!stat.size) return true; @@ -113,20 +114,39 @@ function readProjectFile(projectPath, relativePath) { function readPreviewFile(filePath, projectRoot) { const stat = fs.statSync(filePath); if (!isViewableFile(filePath, stat)) { - throw new Error(stat.size > previewLimit(filePath) + const error = new Error(stat.size > previewLimit(filePath) ? 'File is too large to preview' : 'File type cannot be previewed'); + if (stat.isFile()) error.code = 'PREVIEW_UNAVAILABLE'; + throw error; } const type = previewType(filePath); const result = { filePath, previewType: type, fileUrl: pathToFileURL(filePath).href }; if (type === 'html') result.previewUrl = createPreviewAssetUrl(filePath, projectRoot); - if (type === 'image' || type === 'pdf') { - const mimeType = type === 'pdf' ? 'application/pdf' : IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()]; + if (type === 'pptx') result.previewUrl = createPreviewAssetUrl(path.join(__dirname, 'public', 'pptx-preview.html')); + if (type === 'image' || type === 'pdf' || type === 'pptx') { + const mimeType = type === 'pdf' ? 'application/pdf' + : type === 'pptx' ? 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + : IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()]; return { ...result, mimeType, base64: fs.readFileSync(filePath).toString('base64') }; } return { ...result, content: fs.readFileSync(filePath, 'utf8') }; } +// Only called for a user opening a file, never for background preview reads. +async function openFileExternally(filePath, projectRoot, shell) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) throw new Error('File path must be absolute'); + let resolved = fs.realpathSync(filePath); + if (projectRoot != null) { + const { root } = resolveProjectEntry(projectRoot); + resolved = resolveProjectEntry(root, path.relative(root, resolved)).resolved; + } + if (!fs.statSync(resolved).isFile()) throw new Error('Not a file'); + const error = await shell.openPath(resolved); + if (error) throw new Error(error); + return { filePath: resolved }; +} + module.exports = { MAX_PREVIEW_BYTES, MAX_MEDIA_PREVIEW_BYTES, @@ -134,5 +154,6 @@ module.exports = { listProjectDirectory, readProjectFile, readPreviewFile, + openFileExternally, resolveProjectEntry, }; diff --git a/projects.js b/projects.js index ee7a7e4d..d1b06450 100644 --- a/projects.js +++ b/projects.js @@ -38,6 +38,8 @@ const PROJECT_STATUSES = new Set(['active', 'done']); const ENV_FILE_MAX_BYTES = 1024 * 1024; const ENV_SKIP_DIRS = new Set(['.git', 'node_modules', 'vendor', '.venv', 'venv']); +const scheduleTime = require('./public/schedule-time'); + let db, log, buildProjectsFromCache, notifyRendererProjectsChanged, isHarnessId, plansDir; function init(ctx) { @@ -565,7 +567,22 @@ function trackNode(row) { }; } -function projectNode(row, folderRows = [], trackRows = []) { +/** A schedule row as the renderer sees it: booleans, nulls, and nothing it cannot use. */ +function scheduleNode(row) { + return { + id: row.id, name: row.name, + projectId: row.projectId || null, trackId: row.trackId || null, cwd: row.cwd || null, + prompt: row.prompt, + every: row.every, atHour: row.atHour ?? null, atMinute: row.atMinute ?? null, + weekday: row.weekday ?? null, cron: row.cron || null, + cli: row.cli || null, enabled: !!row.enabled, catchUp: !!row.catchUp, + sourceFile: row.sourceFile || null, + lastRunAt: row.lastRunAt || null, lastSessionId: row.lastSessionId || null, + created: row.created, + }; +} + +function projectNode(row, folderRows = [], trackRows = [], scheduleRows = []) { const added = addedFilesAtRoot(row.root); return { id: row.id, name: row.name, slug: row.slug, root: row.root, @@ -584,6 +601,7 @@ function projectNode(row, folderRows = [], trackRows = []) { branch: f.branch || null, sortOrder: f.sortOrder || 0, })), tracks: trackRows.map(trackNode), + schedules: scheduleRows.map(scheduleNode), sessions: [], }; } @@ -591,7 +609,7 @@ function projectNode(row, folderRows = [], trackRows = []) { function loadProjectNode(id) { const row = db.getProject(id); if (!row) return null; - return projectNode(row, db.listProjectFolders(id), db.listTracks(id)); + return projectNode(row, db.listProjectFolders(id), db.listTracks(id), db.listSchedulesByProject(id)); } // --- Create / update / delete --- @@ -1066,6 +1084,277 @@ function deleteTrack(id, { archiveSessions = false } = {}) { return { ok: true, projectId: track.projectId, formerTrackName: track.name, sessionIds }; } +// --- Schedules --- +// A saved prompt plus a time. When it fires, the renderer starts an ordinary +// session with the prompt as its first message (initialPrompt) and the +// schedule's id on the launch options, so the session row can show where it +// came from. A row lives in exactly one place: with a projectId it belongs to +// the project view (under trackId, null = General) and runs in the track's +// cwd; without one it is a folder schedule listed on the Sessions tab under +// `cwd`. Timing is fields, not cron, except for rows imported from the old +// schedule-*.md files (every = 'cron'). + +const SCHEDULE_TIMING_KEYS = ['every', 'atHour', 'atMinute', 'weekday', 'cron']; + +/** Validate the timing fields of a spec. Returns { timing } or { error }. */ +function normalizeScheduleTiming(spec, existing = null) { + const every = spec.every === undefined ? existing?.every : spec.every; + if (!scheduleTime.EVERY_VALUES.has(every)) return { error: 'Pick when the task runs' }; + const pick = (key, lo, hi, fallback) => { + const raw = spec[key] === undefined ? existing?.[key] : spec[key]; + if (raw === null || raw === undefined || raw === '') return fallback; + const n = Number(raw); + if (!Number.isInteger(n) || n < lo || n > hi) return { error: `${key} must be between ${lo} and ${hi}` }; + return n; + }; + const timing = { every, atHour: null, atMinute: null, weekday: null, cron: null }; + if (every === 'cron') { + // Only an imported file gets here, and the dialog never edits the cron: + // picking a preset replaces it. Keep whatever the row already has. + const cron = spec.cron === undefined ? existing?.cron : spec.cron; + if (typeof cron !== 'string' || cron.trim().split(/\s+/).length !== 5) return { error: 'A cron schedule needs five fields' }; + timing.cron = cron.trim(); + return { timing }; + } + if (every === 'hour' || every === 'day' || every === 'weekdays' || every === 'week') { + const m = pick('atMinute', 0, 59, 0); + if (m?.error) return m; + timing.atMinute = m; + } + if (every === 'day' || every === 'weekdays' || every === 'week') { + const h = pick('atHour', 0, 23, 9); + if (h?.error) return h; + timing.atHour = h; + } + if (every === 'week') { + const d = pick('weekday', 0, 6, 1); + if (d?.error) return d; + timing.weekday = d; + } + return { timing }; +} + +/** Where a schedule belongs: { projectId, trackId, cwd } or { error }. */ +function normalizeSchedulePlace(spec) { + const projectId = spec.projectId || null; + if (projectId) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + let trackId = spec.trackId || null; + if (trackId) { + const track = db.getTrack(trackId); + if (!track || track.projectId !== projectId) return { error: 'Track not found in that project' }; + } + return { projectId, trackId, cwd: null }; + } + const cwd = typeof spec.cwd === 'string' ? spec.cwd.trim() : ''; + if (!cwd || !path.isAbsolute(cwd)) return { error: 'A folder schedule needs a folder' }; + if (!isDirectory(cwd)) return { error: `Not a directory: ${cwd}` }; + return { projectId: null, trackId: null, cwd: path.resolve(cwd) }; +} + +function listSchedules() { + return db.listSchedules().map(scheduleNode); +} + +function createSchedule(spec) { + const name = typeof spec?.name === 'string' ? spec.name.trim() : ''; + if (!name) return { error: 'Give the task a name' }; + const prompt = typeof spec?.prompt === 'string' ? spec.prompt.trim() : ''; + if (!prompt) return { error: 'The task needs a prompt' }; + const place = normalizeSchedulePlace(spec || {}); + if (place.error) return { error: place.error }; + const timing = normalizeScheduleTiming(spec || {}); + if (timing.error) return { error: timing.error }; + const cli = normalizeTrackCli(spec.cli); + if (cli.error) return { error: cli.error }; + const row = { + id: crypto.randomUUID(), name, ...place, prompt, ...timing.timing, cli: cli.cli, + enabled: spec.enabled !== false, catchUp: !!spec.catchUp, + sourceFile: typeof spec.sourceFile === 'string' ? spec.sourceFile : null, + created: new Date().toISOString(), + }; + db.insertSchedule(row); + if (row.projectId) db.updateProject(row.projectId, { modified: row.created }); + notifyRendererProjectsChanged(); + return { ok: true, schedule: scheduleNode(db.getSchedule(row.id)) }; +} + +function updateSchedule(id, patch) { + const row = db.getSchedule(id); + if (!row) return { error: 'Schedule not found' }; + const clean = {}; + if (typeof patch?.name === 'string') { + const name = patch.name.trim(); + if (!name) return { error: 'Give the task a name' }; + clean.name = name; + } + if (typeof patch?.prompt === 'string') { + const prompt = patch.prompt.trim(); + if (!prompt) return { error: 'The task needs a prompt' }; + clean.prompt = prompt; + } + // A schedule can move between tracks of its project, but never between a + // project and a folder: that is a different row in a different place. + if (patch?.trackId !== undefined) { + if (!row.projectId) return { error: 'A folder schedule has no track' }; + const trackId = patch.trackId || null; + if (trackId) { + const track = db.getTrack(trackId); + if (!track || track.projectId !== row.projectId) return { error: 'Track not found in that project' }; + } + clean.trackId = trackId; + } + if (SCHEDULE_TIMING_KEYS.some(k => patch?.[k] !== undefined)) { + const timing = normalizeScheduleTiming(patch, row); + if (timing.error) return { error: timing.error }; + Object.assign(clean, timing.timing); + } + if (patch?.cli !== undefined) { + const cli = normalizeTrackCli(patch.cli); + if (cli.error) return { error: cli.error }; + clean.cli = cli.cli; + } + if (patch?.enabled !== undefined) clean.enabled = !!patch.enabled; + if (patch?.catchUp !== undefined) clean.catchUp = !!patch.catchUp; + if (!Object.keys(clean).length) return { ok: true, schedule: scheduleNode(row) }; + db.updateSchedule(id, clean); + if (row.projectId) db.updateProject(row.projectId, { modified: new Date().toISOString() }); + notifyRendererProjectsChanged(); + return { ok: true, schedule: scheduleNode(db.getSchedule(id)) }; +} + +function deleteSchedule(id) { + const row = db.getSchedule(id); + if (!row) return { error: 'Schedule not found' }; + db.deleteSchedule(id); + if (row.projectId) db.updateProject(row.projectId, { modified: new Date().toISOString() }); + notifyRendererProjectsChanged(); + return { ok: true }; +} + +/** + * Why a schedule is not firing right now, in words, or null when it would. + * Pausing is derived: a done project or track pauses its schedules and + * reopening resumes them, with nothing stored. + */ +function schedulePausedReason(row) { + if (!row.enabled) return 'off'; + if (!row.projectId) return null; + const project = db.getProject(row.projectId); + if (!project) return 'project missing'; + if (project.status === 'done') return 'project done'; + if (row.trackId) { + const track = db.getTrack(row.trackId); + if (track && track.status === 'done') return 'track done'; + } + return null; +} + +/** + * Everything the renderer needs to start the session for a schedule, resolved + * now rather than stored: a project schedule runs where its track's sessions + * start, so moving the track moves it. { schedule, target, runtime } or + * { error }. + */ +function resolveScheduleLaunch(id) { + const row = db.getSchedule(id); + if (!row) return { error: 'Schedule not found' }; + let target; + let runtime = row.cli || null; + if (row.projectId) { + const project = db.getProject(row.projectId); + if (!project) return { error: 'Project not found' }; + const track = row.trackId ? db.getTrack(row.trackId) : null; + target = { + projectPath: track?.cwd || project.defaultCwd || project.root, + projectId: project.id, + trackId: track ? track.id : null, + projectName: project.name, + }; + if (!runtime && track?.cli) runtime = track.cli; + } else { + target = { projectPath: row.cwd, projectId: null, trackId: null, projectName: null }; + } + if (!isDirectory(target.projectPath)) return { error: `Not a directory: ${target.projectPath}` }; + return { schedule: scheduleNode(row), target, runtime }; +} + +/** + * The schedules that should fire in the minute containing `now`. `isBusy` + * says whether a session id is still working (the CLI busy, not merely the + * terminal open): a schedule whose last run has not finished is skipped + * rather than doubled up. + */ +function dueSchedules(now, isBusy = () => false) { + const out = []; + for (const row of db.listSchedules()) { + if (schedulePausedReason(row)) continue; + if (!scheduleTime.dueThisMinute(row, now)) continue; + if (row.lastSessionId && isBusy(row.lastSessionId)) { + log.info?.(`[schedule] Skipping ${row.name} — previous run still working`); + continue; + } + out.push(row.id); + } + return out; +} + +/** Schedules that asked to catch up and were due while the app was closed. */ +function missedSchedules(now, isBusy = () => false) { + const out = []; + for (const row of db.listSchedules()) { + if (schedulePausedReason(row)) continue; + if (!scheduleTime.missedRun(row, now.getTime())) continue; + if (row.lastSessionId && isBusy(row.lastSessionId)) continue; + out.push(row.id); + } + return out; +} + +/** Called from open-terminal when the launch options name a schedule. */ +function recordScheduleRun(scheduleId, sessionId) { + if (!scheduleId || !db.getSchedule(scheduleId)) return false; + db.recordScheduleRun(scheduleId, sessionId, new Date().toISOString()); + notifyRendererProjectsChanged(); + return true; +} + +/** + * One-time import of the old file-based schedules. Each schedule-*.md becomes + * a folder schedule on the folder it sits in. The file stays the source of + * truth: the prompt tells the CLI to read it. Returns how many were imported. + */ +const LEGACY_IMPORT_KEY = 'schedules_imported_from_files'; + +function importLegacySchedules(scanned) { + if (db.getSetting(LEGACY_IMPORT_KEY)) return 0; + const existing = new Set(db.listSchedules().map(r => r.sourceFile).filter(Boolean)); + let count = 0; + for (const s of scanned || []) { + if (!s.filePath || existing.has(s.filePath)) continue; + if (!isDirectory(s.projectPath)) continue; + const preset = scheduleTime.presetFromCron(s.cron); + const timing = preset + ? { every: preset.every, atHour: preset.atHour ?? null, atMinute: preset.atMinute ?? null, weekday: preset.weekday ?? null, cron: null } + : { every: 'cron', atHour: null, atMinute: null, weekday: null, cron: String(s.cron).trim() }; + db.insertSchedule({ + id: crypto.randomUUID(), + name: s.name || path.basename(s.filePath, '.md').replace(/^schedule-/, ''), + projectId: null, trackId: null, cwd: s.projectPath, + prompt: `Run the scheduled task defined in ${s.filePath}. Read that file and follow its instructions.`, + ...timing, + cli: null, enabled: s.enabled !== false, catchUp: false, + sourceFile: s.filePath, + created: new Date().toISOString(), + }); + count++; + } + db.setSetting(LEGACY_IMPORT_KEY, { at: new Date().toISOString(), count }); + if (count) notifyRendererProjectsChanged(); + return count; +} + // --- Session assignment --- /** Explicitly file a session. projectId null clears both ids. */ @@ -1128,12 +1417,14 @@ function buildProjectTree(showArchived) { const folderRows = db.listAllProjectFolders(); const trackRows = db.listAllTracks(); + const scheduleRows = db.listSchedules(); const byId = new Map(); for (const row of projectRows) { byId.set(row.id, projectNode( row, folderRows.filter(f => f.projectId === row.id), - trackRows.filter(t => t.projectId === row.id) + trackRows.filter(t => t.projectId === row.id), + scheduleRows.filter(t => t.projectId === row.id) )); } const rootsById = new Map(projectRows.map(r => [r.id, realOrResolved(r.root)])); @@ -1463,6 +1754,8 @@ module.exports = { listTemplates, templatesRoot, renderTemplateText, editPlanItem, listEnvFiles, defaultEnvSelection, copyEnvFiles, createTrack, updateTrack, deleteTrack, + listSchedules, createSchedule, updateSchedule, deleteSchedule, resolveScheduleLaunch, + dueSchedules, missedSchedules, recordScheduleRun, importLegacySchedules, schedulePausedReason, assignSession, recordLaunchAssignment, projectForCwd, buildProjectTree, }; diff --git a/public/app.js b/public/app.js index ca16c1b6..ec1217fd 100644 --- a/public/app.js +++ b/public/app.js @@ -932,6 +932,8 @@ async function loadProjects({ resort = false, reason = 'project' } = {}) { window.api.getProjects(true), window.api.getProjectTree(false).catch(() => ({ projects: [] })), window.api.getProjectTree(true).catch(() => ({ projects: [] })), + // Scheduled tasks ride along: the folder clocks and session chips read them. + typeof loadSchedules === 'function' ? loadSchedules() : null, ]); cachedProjects = defaultProjects; cachedAllProjects = allProjects; @@ -1010,7 +1012,7 @@ async function loadProjects({ resort = false, reason = 'project' } = {}) { // rebindSidebarEvents, buildSessionItem, startRename) → sidebar.js -async function launchNewSession(project, sessionOptions) { +async function launchNewSession(project, sessionOptions, { focus = true } = {}) { // A temporary id. Claude is told to use it (--session-id); codex cannot be, // so main watches for its transcript and sends session-detected with the real // one, which re-keys everything below. @@ -1040,6 +1042,12 @@ async function launchNewSession(project, sessionOptions) { session.projectId = project.projectId; session.trackId = project.trackId || null; } + // Started by a scheduled task: the row shows the chip from the first + // moment, not only once the DB has the link (main records it on spawn). + if (options.scheduleId) { + session.scheduleId = options.scheduleId; + session.scheduledAt = new Date().toISOString(); + } // Track as pending (no .jsonl yet) const folder = encodeProjectPath(projectPath); @@ -1069,7 +1077,9 @@ async function launchNewSession(project, sessionOptions) { } if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive); - showSession(sessionId); + // A scheduled launch runs in the background: it shows up in the lists like + // any session, but does not take over whatever the user is looking at. + if (focus) showSession(sessionId); pollActiveSessions(); } diff --git a/public/dialogs.js b/public/dialogs.js index 002812c7..4db189c7 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -39,54 +39,6 @@ async function forkSession(session, project) { launchNewSession(project, options); } -async function launchScheduleCreator(project) { - const options = await resolveDefaultSessionOptions(project); - // Pre-create a JSONL session with the schedule creation prompt, then resume into it - const result = await window.api.createScheduleSession(project.projectPath); - if (!result || !result.sessionId) return; - - const session = { - sessionId: result.sessionId, - summary: 'Create scheduled task', - firstPrompt: '', - projectPath: project.projectPath, - name: null, - starred: 0, - archived: 0, - messageCount: 1, - modified: new Date().toISOString(), - created: new Date().toISOString(), - }; - - // Inject into sidebar - const folder = encodeProjectPath(project.projectPath); - pendingSessions.set(result.sessionId, { session, projectPath: project.projectPath, folder }); - sessionMap.set(result.sessionId, session); - for (const projList of [cachedProjects, cachedAllProjects]) { - let proj = projList.find(p => p.projectPath === project.projectPath); - if (!proj) { - proj = { folder, projectPath: project.projectPath, sessions: [] }; - projList.unshift(proj); - } - proj.sessions.unshift(session); - } - refreshSidebar(); - - const entry = createTerminalEntry(session); - // Resume the pre-seeded session - options.appendSystemPrompt = result.systemPrompt; - options.resumeExisting = true; - const openResult = await window.api.openTerminal(result.sessionId, project.projectPath, false, options); - if (!openResult.ok) { - entry.terminal.write(`\r\nError: ${openResult.error}\r\n`); - entry.closed = true; - return; - } - if (typeof setSessionMcpActive === 'function') setSessionMcpActive(result.sessionId, !!openResult.mcpActive); - showSession(result.sessionId); - pollActiveSessions(); -} - function showNewSessionPopover(project, anchorEl) { // Remove any existing popover document.querySelectorAll('.new-session-popover').forEach(el => el.remove()); diff --git a/public/file-actions.js b/public/file-actions.js index a7757530..b356daea 100644 --- a/public/file-actions.js +++ b/public/file-actions.js @@ -31,6 +31,19 @@ function remapFileActionRelative(root, relativePath, change) { return mapped === original ? relativePath : mapped === null ? null : mapped.slice(projectEntryPath(root).length + 1); } +// A preview read stays side-effect free; only user-initiated open flows call +// this fallback after their stale-request checks. Preserve the current editor. +async function openUnsupportedFile(result, filePath, projectRoot) { + if (result?.code !== 'PREVIEW_UNAVAILABLE') return false; + try { + const opened = await window.api.openFileExternally(filePath, projectRoot); + if (!opened?.ok) throw new Error(opened?.error || 'Could not open the file in its default application.'); + } catch (error) { + alert(error.message || 'Could not open the file in its default application.'); + } + return true; +} + function fileEntryMenuItems(root, entry, open) { const manager = window.api.platform === 'darwin' ? 'Finder' : window.api.platform === 'win32' ? 'Explorer' : 'File Manager'; const trash = window.api.platform === 'win32' ? 'Recycle Bin' : 'Trash'; @@ -60,7 +73,7 @@ function fileEntryMenuItems(root, entry, open) { { head: entry.name }, entry.type === 'directory' ? { label: 'Open folder', icon: PICONS.folder(14), onClick: run('open-folder') } - : { label: 'Open in editor / preview', icon: PICONS.file(14), disabled: !entry.viewable, onClick: open }, + : { label: entry.viewable ? 'Open in editor / preview' : 'Open in default application', icon: PICONS.file(14), disabled: entry.type !== 'file', onClick: open }, { label: 'Reveal in ' + manager, icon: PICONS.open(14), onClick: run('reveal') }, { sep: true }, { label: 'Copy path', onClick: () => window.api.writeClipboard(projectEntryPath(root, entry.relativePath)) }, diff --git a/public/file-panel.js b/public/file-panel.js index bdd0ca15..69051118 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -360,10 +360,27 @@ function destroyCurrentTab(state) { } } -async function openFileInPanel(sessionId, filePath) { - const result = await window.api.readFileForPanel(filePath); - if (!result.ok) return; - openFileTab(sessionId, { ...result, filePath }); +async function openFileInPanel(sessionId, filePath, location = {}) { + const state = getSessionState(sessionId); + if (state.currentTab?.type === 'diff' && !state.currentTab.resolved) { + alert('Resolve or close the current diff before opening a file.'); + return; + } + const request = state.fileOpenRequest = (state.fileOpenRequest || 0) + 1; + // Clicking another reference to the same file keeps its unsaved edits. + if (currentPanelSessionId === sessionId && state.currentTab?.type === 'file' && fpViewerPanel.filePath === filePath) { + if (location.line) fpViewerPanel.goToLocation(location.line, location.column); + showPanel(state); + return; + } + const previousTab = state.currentTab; + let result; + try { result = await window.api.readFileForPanel(filePath); } catch (err) { result = { ok: false, error: err.message }; } + const effectiveSessionId = sessionIdForState(state, sessionId); + if (!effectiveSessionId || currentPanelSessionId !== effectiveSessionId || state.fileOpenRequest !== request || state.currentTab !== previousTab) return; + if (await openUnsupportedFile(result, filePath)) return; + if (!result?.ok) { alert(result?.error || 'Unable to open the file.'); return; } + openFileTab(effectiveSessionId, { ...result, filePath, line: location.line, column: location.column }); } async function openProjectFile(sessionId, relativePath) { @@ -384,6 +401,7 @@ async function openProjectFile(sessionId, relativePath) { if (state.projectPath !== requestedRoot || state.selectedPath !== relativePath) return; const effectiveSessionId = sessionIdForState(state, sessionId); if (!effectiveSessionId) return; + if (await openUnsupportedFile(result, projectEntryPath(requestedRoot, relativePath), requestedRoot)) return; if (!result.ok) { state.selectedPath = ''; state.browserError = result.error || 'Unable to preview file'; @@ -874,11 +892,11 @@ function appendTreeEntries(sessionId, state, container, entries, depth) { row.classList.add('file'); row.classList.toggle('selected', state.selectedPath === entry.relativePath); - row.classList.toggle('preview-unavailable', !entry.viewable); - row.title = entry.viewable ? entry.relativePath : 'This file type cannot be previewed'; + row.classList.toggle('preview-unavailable', entry.type !== 'file'); + row.title = entry.viewable ? entry.relativePath : entry.type === 'file' ? entry.relativePath + '\nOpen in default application' : 'This item cannot be opened'; row.innerHTML = '📄'; row.querySelector('.project-file-tree-name').textContent = entry.name; - if (entry.viewable) row.addEventListener('click', () => openProjectFile(sessionId, entry.relativePath)); + if (entry.type === 'file') row.addEventListener('click', () => openProjectFile(sessionId, entry.relativePath)); container.appendChild(row); } } diff --git a/public/index.html b/public/index.html index 7866955b..450f5941 100644 --- a/public/index.html +++ b/public/index.html @@ -122,6 +122,7 @@ + @@ -131,6 +132,8 @@ + + diff --git a/public/plans-memory-view.js b/public/plans-memory-view.js index 33886d95..81edd628 100644 --- a/public/plans-memory-view.js +++ b/public/plans-memory-view.js @@ -261,37 +261,6 @@ function buildMemoryItem(file) { info.appendChild(metaEl); row.appendChild(info); - // Play button for schedule files - if (isSchedule) { - const playBtn = document.createElement('button'); - playBtn.className = 'schedule-play-btn'; - playBtn.title = 'Run now'; - playBtn.innerHTML = ''; - const playIcon = ''; - const spinnerIcon = ''; - const checkIcon = ''; - playBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - playBtn.classList.add('running'); - playBtn.innerHTML = spinnerIcon; - playBtn.title = 'Running...'; - const result = await window.api.runScheduleNow(file.filePath); - playBtn.classList.remove('running'); - playBtn.classList.add('done'); - playBtn.innerHTML = checkIcon; - playBtn.title = 'Launched!'; - setTimeout(() => { - playBtn.classList.remove('done'); - playBtn.innerHTML = playIcon; - playBtn.title = 'Run now'; - }, 2000); - if (result && !result.ok) { - console.error('Schedule run failed:', result.error); - } - }); - row.appendChild(playBtn); - } - item.appendChild(row); item.addEventListener('click', () => openMemory(file)); diff --git a/public/pptx-preview.css b/public/pptx-preview.css new file mode 100644 index 00000000..f5358d60 --- /dev/null +++ b/public/pptx-preview.css @@ -0,0 +1,19 @@ +:root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 12px; color: #d8d8e5; background: #1b1b24; } +* { box-sizing: border-box; } +body { margin: 0; height: 100vh; display: flex; flex-direction: column; overflow: hidden; } +[hidden] { display: none !important; } +nav { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; flex: 0 0 auto; padding: 8px; background: #22222e; border-bottom: 1px solid #363642; } +nav label { display: flex; align-items: center; gap: 5px; } +button, select, input { font: inherit; color: inherit; background: #30303e; border: 1px solid #494957; border-radius: 4px; min-height: 27px; } +button { padding: 3px 8px; cursor: pointer; } +button:hover:not(:disabled), select:hover { background: #404052; } +button:disabled { opacity: .4; cursor: default; } +input { width: 4.5em; padding: 3px 5px; } +select { margin-left: auto; padding: 3px 5px; } +:focus-visible { outline: 2px solid #a99aff; outline-offset: 2px; } +#status { margin: 0; padding: 18px; line-height: 1.5; color: #bdbdcc; text-align: center; } +#status.error { color: #f1b1b1; } +#scroll { flex: 1; min-height: 0; overflow: auto; padding: 12px; } +#slides { width: 100%; } +#slides [data-slide-index] { color-scheme: light; color: black; } +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } diff --git a/public/pptx-preview.html b/public/pptx-preview.html new file mode 100644 index 00000000..c778028e --- /dev/null +++ b/public/pptx-preview.html @@ -0,0 +1,27 @@ + + + + + + + PowerPoint preview + + + + + +

Loading presentation…

+
+ + diff --git a/public/pptx-preview.js b/public/pptx-preview.js new file mode 100644 index 00000000..379e3cdb --- /dev/null +++ b/public/pptx-preview.js @@ -0,0 +1,116 @@ +import { PptxViewer, RECOMMENDED_ZIP_LIMITS } from '@aiden0z/pptx-renderer/browser'; + +// This page runs in an opaque sandbox, without the app's preload or file access. +// Only the selected deck's bytes are transferred in by ViewerPanel. +const controls = document.querySelector('nav'); +const status = document.getElementById('status'); +const scroll = document.getElementById('scroll'); +const slides = document.getElementById('slides'); +const slideInput = document.getElementById('slide'); +const count = document.getElementById('count'); +const previous = document.getElementById('previous'); +const next = document.getElementById('next'); +const zoom = document.getElementById('zoom'); +const abort = new AbortController(); +let viewer; +let loaded = false; +let busy = false; + +function updateControls() { + const index = viewer?.currentSlideIndex || 0; + const total = viewer?.slideCount || 0; + slideInput.value = String(index + 1); + slideInput.max = String(total); + count.textContent = `of ${total}`; + previous.disabled = busy || index === 0; + next.disabled = busy || index >= total - 1; + slideInput.disabled = zoom.disabled = busy; +} + +function showError(message) { + status.textContent = message; + status.className = 'error'; + status.hidden = false; +} + +async function navigate(index) { + if (!loaded || busy || !Number.isInteger(index)) return updateControls(); + busy = true; + updateControls(); + try { + await viewer.goToSlide(Math.max(0, Math.min(index, viewer.slideCount - 1)), { behavior: 'instant', block: 'start' }); + } catch { + showError('This slide could not be displayed.'); + } finally { + busy = false; + updateControls(); + } +} + +previous.addEventListener('click', () => navigate(viewer.currentSlideIndex - 1)); +next.addEventListener('click', () => navigate(viewer.currentSlideIndex + 1)); +slideInput.addEventListener('change', () => navigate(slideInput.valueAsNumber - 1)); +zoom.addEventListener('change', async () => { + if (!loaded || busy) return; + busy = true; + updateControls(); + try { + const index = viewer.currentSlideIndex; + await viewer.setZoom(Number(zoom.value)); + await viewer.goToSlide(index, { behavior: 'instant', block: 'start' }); + } catch { + showError('The zoom could not be changed.'); + } finally { + busy = false; + updateControls(); + } +}); +scroll.addEventListener('keydown', event => { + if (!loaded || event.target !== scroll || event.altKey || event.ctrlKey || event.metaKey) return; + const index = event.key === 'ArrowRight' ? viewer.currentSlideIndex + 1 + : event.key === 'ArrowLeft' ? viewer.currentSlideIndex - 1 + : event.key === 'Home' ? 0 : event.key === 'End' ? viewer.slideCount - 1 : null; + if (index !== null) { event.preventDefault(); navigate(index); } +}); + +async function openPresentation(event) { + if (event.source !== parent || event.data?.type !== 'switchboard-pptx') return; + window.removeEventListener('message', openPresentation); + try { + const buffer = event.data.buffer; + if (!(buffer instanceof ArrayBuffer) || !buffer.byteLength || buffer.byteLength > 32 * 1024 * 1024) { + throw new Error('Invalid presentation data'); + } + viewer = new PptxViewer(slides, { + scrollContainer: scroll, + zipLimits: RECOMMENDED_ZIP_LIMITS, + lazySlides: true, + lazyMedia: true, + pdfjs: false, + onSlideChange: updateControls, + onSlideError: index => showError(`Slide ${index + 1} could not be displayed. Other slides are still available.`), + }); + await viewer.open(buffer, { + signal: abort.signal, + listOptions: { windowed: true, initialSlides: 2, batchSize: 2, showSlideLabels: true }, + }); + if (!viewer.slideCount) throw new Error('The presentation contains no slides'); + loaded = true; + // Keep partial-slide errors visible after opening. + if (status.className !== 'error') status.hidden = true; + controls.hidden = false; + updateControls(); + } catch { + if (abort.signal.aborted) return; + viewer?.destroy(); + controls.hidden = true; + showError('This presentation could not be previewed. It may be damaged, password protected, or exceed preview limits.'); + } +} + +// Deck hyperlinks must not navigate this frame away from the preview. +document.addEventListener('click', event => { + if (event.target.closest?.('a')) event.preventDefault(); +}, true); +window.addEventListener('message', openPresentation); +window.addEventListener('pagehide', () => { abort.abort(); viewer?.destroy(); }, { once: true }); diff --git a/public/projects-view.js b/public/projects-view.js index b303bd52..3f1f4fe2 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -21,8 +21,9 @@ // Depends on sidebar.js: isSessionRunning // Depends on task-runner.js: showTaskPopover, tasksByPath, updateTaskButton, // activeTaskView -// Depends on dialogs.js: showNewSessionDialog, launchTerminalSession, -// launchScheduleCreator, forkSession +// Depends on dialogs.js: showNewSessionDialog, launchTerminalSession, forkSession +// Depends on schedules.js: schedulesForProject, showProjectScheduleMenu, +// showScheduleDialog, scheduleChipHtml // Depends on utils.js / icons.js: escapeHtml, formatDate, cleanDisplayName, ICONS let openProjectPopover = null; @@ -70,7 +71,9 @@ function ensureSessionHoverPreview() { el.className = 'session-turn-preview'; el.setAttribute('role', 'tooltip'); el.setAttribute('aria-hidden', 'true'); - el.innerHTML = '
Last AI message
'; + el.innerHTML = '
Last AI message
' + + '
' + + ''; document.body.appendChild(el); sessionHoverPreviewEl = el; return el; @@ -120,7 +123,7 @@ async function showSessionHoverPreview(row, session) { try { result = await window.api.getSessionLastMessage(id); } catch { return; } if (request !== sessionHoverPreviewRequest) return; if (result?.error) return; - cached = { eventTime, text: result?.text || '' }; + cached = { eventTime, text: result?.text || '', truncated: !!result?.truncated }; sessionHoverPreviewCache.set(id, cached); } @@ -133,6 +136,13 @@ async function showSessionHoverPreview(row, session) { sessionHoverPreviewRow = row; el.classList.add('visible'); el.setAttribute('aria-hidden', 'false'); + // Two ways the preview can be short of the real message: the reader capped + // the text, or it is taller than the box and CSS clipped it. Both used to + // end mid-sentence with nothing to show for it. Measuring needs the box + // laid out, so it happens after .visible. + const clipped = textEl.scrollHeight > textEl.clientHeight + 1; + el.querySelector('.session-turn-preview-body').classList.toggle('clipped', clipped); + el.querySelector('.session-turn-preview-more').hidden = !(clipped || cached.truncated); positionSessionHoverPreview(row); } @@ -449,6 +459,14 @@ function runningTasksFor(project) { return taskPseudoProject(project).tasks.filter(t => t.run?.running).length; } +/** The header's Schedules button: a count of the project's scheduled tasks, tinted while any is on. */ +function scheduleButtonHtml(project, id, small = false) { + const schedules = typeof schedulesForProject === 'function' ? schedulesForProject(project) : []; + const on = schedules.filter(s => s.enabled).length; + const cls = schedules.length ? (on ? ' has-schedules' : ' has-schedules all-off') : ''; + return ``; +} + /** A track's +: straight to its CLI when it has one, else the shared Project View menu. */ async function launchFromTrack(project, track, anchor) { const target = launchTargetFor(project, track); @@ -540,6 +558,8 @@ function buildSessionRow(project, session, { showTrack = true, className = 'pane if (!track && session.formerTrackName) { parts.push(`Formerly: ${escapeHtml(session.formerTrackName)}`); } else if (showTrack) parts.push(trackTagHtml(project, track)); + // Started by a scheduled task: which one, and when it fired. + if (session.scheduleId && typeof scheduleChipHtml === 'function') parts.push(scheduleChipHtml(session)); // The CLI mark, same as the status bar: the logo says which CLI, no word needed. parts.push(`${cliIcon(session, 12)}`); // The last message's time, the same as the Sessions tab. A session with no @@ -679,6 +699,13 @@ function updateProjectStatusDots() { function projectSortTime(project) { let best = 0; for (const s of projectSessionsAll(project)) best = Math.max(best, sessionEventTime(s)); + // Coming back from a snooze is an event. Without this a woken project + // returns to a spot buried under everything that moved while it was away, + // leaving the yellow dot to carry the whole signal. The wake time is a real + // timestamp, so it decays like any other event, and opening the project + // clears it back to its natural position along with the dot. + const woke = projectWokeAt(project, Date.now()); + if (woke) best = Math.max(best, Date.parse(woke)); if (best) return best; const t = new Date(project.lastActivity || project.modified || 0).getTime(); return Number.isFinite(t) ? t : 0; @@ -1252,6 +1279,7 @@ function renderOverview() {
+ ${scheduleButtonHtml(project, 'ws-schedules')}
@@ -1279,6 +1307,7 @@ function renderOverview() { projectViewer.querySelector('#ws-new').onclick = (e) => showNewSessionMenu(project, null, e.currentTarget); projectViewer.querySelector('#ws-more').onclick = (e) => showContextMenu(projectMenuItems(project, { fromPage: true }), { anchor: e.currentTarget }); projectViewer.querySelector('#ws-tasks').onclick = (e) => showTaskPopover(taskPseudoProject(project), e.currentTarget); + projectViewer.querySelector('#ws-schedules').onclick = (e) => showProjectScheduleMenu(project, e.currentTarget); projectViewer.querySelector('#ws-add-folder').onclick = () => attachFolderAsk(project); loadProjectFiles(project).then(() => { if (selectedProject()?.id === project.id) renderPlanMeta(project); }); loadProjectGit(project).then(changed => { if (changed && selectedProject()?.id === project.id) applyGitStatus(project); }); @@ -1961,7 +1990,7 @@ async function saveProjectEditorFile(filePath, content) { return { ok: true }; } -async function openFileInProjectEditor(project, rel) { +async function openFileInProjectEditor(project, rel, { allowExternal = false } = {}) { const state = filesState(project); state.selected = rel; const request = state.openRequest = (state.openRequest || 0) + 1; @@ -1987,6 +2016,13 @@ async function openFileInProjectEditor(project, rel) { } } if (state.openRequest !== request || !host.isConnected || selectedProject()?.id !== project.id) return; + if (result?.code === 'PREVIEW_UNAVAILABLE') { + // Restoring the Files tab must never launch another application. + state.selected = ed.projectId === project.id ? ed.rel : null; + projectViewer.querySelectorAll('.ws-file-row').forEach(row => row.classList.toggle('selected', row.dataset.rel === state.selected)); + if (allowExternal) await openUnsupportedFile(result, filePath, project.root); + return; + } if (!result?.ok) { host.innerHTML = `
${escapeHtml(result?.error || 'Could not open the file.')}
`; return; @@ -2068,13 +2104,13 @@ async function renderFileTree(project, list, state) { const isDir = entry.type === 'directory'; const open = isDir && state.expanded.has(entry.relativePath); const row = document.createElement('div'); - row.className = 'ws-file-row' + (isDir ? ' is-dir' : '') + (!isDir && entry.viewable === false ? ' is-binary' : '') + + row.className = 'ws-file-row' + (isDir ? ' is-dir' : '') + (!isDir && entry.type !== 'file' ? ' is-binary' : '') + (state.selected === entry.relativePath ? ' selected' : ''); row.dataset.rel = entry.relativePath; row.tabIndex = 0; - bindFileEntryMenu(row, project.root, entry, () => openFileInProjectEditor(project, entry.relativePath)); + bindFileEntryMenu(row, project.root, entry, () => openFileInProjectEditor(project, entry.relativePath, { allowExternal: true })); row.style.paddingLeft = `${10 + depth * 14}px`; - row.title = !isDir && entry.viewable === false ? `${entry.relativePath} (preview unavailable: unsupported type or too large)` : entry.relativePath; + row.title = entry.type === 'file' && entry.viewable === false ? entry.relativePath + '\nOpen in default application' : entry.relativePath; row.innerHTML = `${isDir ? (open ? '▾' : '▸') : ''}${escapeHtml(entry.name)}`; row.onclick = async () => { if (isDir) { @@ -2082,8 +2118,8 @@ async function renderFileTree(project, list, state) { await renderFileTree(project, list, state); return; } - if (entry.viewable === false) return; - openFileInProjectEditor(project, entry.relativePath); + if (entry.type !== 'file') return; + openFileInProjectEditor(project, entry.relativePath, { allowExternal: true }); }; list.appendChild(row); } @@ -2475,7 +2511,7 @@ function buildTrackSettingsRow(project, track) { window.api.getHarnesses().then(list => { if (row.isConnected) renderCli((list || []).filter(h => h.enabled)); }).catch(() => {}); cli.onchange = () => patch({ cli: cli.value || null }); - row.querySelector('.ws-trow-status').onclick = () => patch({ status: track.status === 'done' ? 'active' : 'done' }); + row.querySelector('.ws-trow-status').onclick = () => toggleTrackDone(project, track, patch); row.querySelector('.ws-trow-delete').onclick = () => deleteTrackFlow(project, track); return row; } @@ -2492,10 +2528,12 @@ function renderStrip(project) { ${branch ? `${PICONS.branch(11)}${escapeHtml(branch)}` : ''} + ${scheduleButtonHtml(project, 'strip-schedules', true)} `; projectStrip.querySelector('#strip-back').onclick = () => { setProjectTab(project, 'overview'); showProjectOverview(); }; projectStrip.querySelector('#strip-tasks').onclick = (e) => showTaskPopover(taskPseudoProject(project), e.currentTarget); + projectStrip.querySelector('#strip-schedules').onclick = (e) => showProjectScheduleMenu(project, e.currentTarget); projectStrip.querySelector('#strip-new').onclick = (e) => showNewSessionMenu(project, null, e.currentTarget); projectStrip.querySelector('#strip-more').onclick = (e) => showContextMenu(projectMenuItems(project, { fromPage: true }), { anchor: e.currentTarget }); } @@ -2999,7 +3037,7 @@ function projectMenuItems(project, { fromPage = false } = {}) { { sep: true }, { label: 'Rename…', icon: PICONS.pencil(14), onClick: () => renameProjectFlow(project) }, { label: 'Attach folder…', icon: PICONS.folder(14), onClick: () => attachFolderAsk(project) }, - { label: 'Create scheduled task', icon: PICONS.clock(14), onClick: () => launchScheduleCreator({ projectPath: project.root }) }, + { label: 'New scheduled task…', icon: PICONS.clock(14), onClick: () => showScheduleDialog({ projectId: project.id, trackId: null, project }) }, { label: 'Settings', icon: ICONS.gear(14), onClick: () => selectProject(project.id, { tab: 'settings' }) }, { label: 'Open project folder', icon: PICONS.open(14), onClick: () => window.api.openPath(project.root) }, { sep: true }, @@ -3019,6 +3057,7 @@ function trackMenuItems(project, track) { return [ sessions.length ? { label: 'Resume latest', icon: PICONS.play(13), onClick: () => openSession(sessions[0]) } : null, { label: 'New session', icon: PICONS.plus(14), submenu: newSessionItems(project, track) }, + { label: 'New scheduled task…', icon: PICONS.clock(14), onClick: () => showScheduleDialog({ projectId: project.id, trackId: track.id, project }) }, { label: 'Show all sessions', icon: PICONS.list(14), onClick: () => openTrackInPanes(project, track.id) }, { sep: true }, { label: 'Rename…', icon: PICONS.pencil(14), onClick: () => renameTrackFlow(project, track) }, @@ -3031,7 +3070,7 @@ function trackMenuItems(project, track) { { label: 'Codex', icon: ICONS.codex(14, 'codex-icon'), muted: track.cli === 'codex', onClick: () => patch({ cli: 'codex' }) }, ] }, { sep: true }, - { label: track.status === 'done' ? 'Reopen track' : 'Mark as done', icon: PICONS.check(14), onClick: () => patch({ status: track.status === 'done' ? 'active' : 'done' }) }, + { label: track.status === 'done' ? 'Reopen track' : 'Mark as done', icon: PICONS.check(14), onClick: () => toggleTrackDone(project, track, patch) }, { label: 'Delete track…', icon: PICONS.trash(14), danger: true, onClick: () => deleteTrackFlow(project, track) }, ].filter(Boolean); } @@ -3103,13 +3142,13 @@ function sessionMenuItems(session) { ? { ...launchTargetFor(info.project, (info.project.tracks || []).find(t => t.id === session.trackId) || null), projectPath: session.projectPath } : folder; const sessionActions = [ - { label: 'Copy session ID', onClick: () => window.api.writeClipboard(session.sessionId) }, session.type !== 'terminal' ? { label: 'Fork', icon: PICONS.fork(14), onClick: () => forkSession(session, forkTarget) } : null, session.type !== 'terminal' ? { label: unread ? 'Mark as read' : 'Mark as unread', icon: unread ? ICONS.markRead(14) : ICONS.markUnread(14), onClick: () => { if (unread) clearUnread(session.sessionId); else markUnread(session.sessionId); refreshSidebar(); } } : null, session.type !== 'terminal' ? { label: 'View messages', icon: PICONS.messages(14), onClick: () => showJsonlViewer(session) } : null, session.type !== 'terminal' && !running ? { label: 'Resume with config…', icon: ICONS.launchConfig(14), onClick: () => showResumeSessionDialog(session) } : null, ].filter(Boolean); const stateActions = [ + { label: 'Copy session ID', onClick: () => window.api.writeClipboard(session.sessionId) }, running ? { label: 'Stop', icon: '', onClick: () => confirmAndStopSession(session.sessionId) } : null, isDismissibleSession(session.sessionId) ? { label: 'Dismiss', icon: PICONS.x(14), hint: 'never started', onClick: () => dismissSession(session.sessionId) } : null, session.type !== 'terminal' ? { label: session.archived ? 'Unarchive' : 'Archive', icon: PICONS.archive(14), onClick: () => toggleArchiveSession(session) } : null, @@ -3282,14 +3321,19 @@ function runningWorkInProject(project) { * Warn before marking a project done while work is still running: going ahead * stops all of it. Resolves true to proceed, false to leave the project active. */ -function confirmProjectDone(project, running) { +function confirmProjectDone(project, running, schedules = []) { const parts = []; if (running.sessions.length) parts.push(`${running.sessions.length} running session${running.sessions.length === 1 ? '' : 's'}`); if (running.tasks.length) parts.push(`${running.tasks.length} running task${running.tasks.length === 1 ? '' : 's'}`); + const stopping = parts.length > 0; const all = [ ...running.sessions.map(s => sessionTitle(s)), ...running.tasks.map(t => t.label), + ...schedules.map(s => `${s.name} (scheduled, will pause)`), ]; + const scheduleNote = schedules.length + ? ` ${schedules.length} scheduled task${schedules.length === 1 ? '' : 's'} will pause until the project is reopened.` + : ''; const names = all.slice(0, 8); const more = all.length - names.length; return new Promise(resolve => { @@ -3301,12 +3345,12 @@ function confirmProjectDone(project, running) { dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'done-running-title'); dialog.innerHTML = ` -

Stop ${escapeHtml(parts.join(' and '))} in ${escapeHtml(project.name)}?

-
Marking the project done stops all of it.
+

${stopping ? `Stop ${escapeHtml(parts.join(' and '))} in ${escapeHtml(project.name)}?` : `Mark ${escapeHtml(project.name)} as done?`}

+
${stopping ? 'Marking the project done stops all of it.' : ''}${escapeHtml(scheduleNote)}
${names.map(n => `${escapeHtml(n)}`).join('')}${more > 0 ? `+ ${more} more` : ''}
- +
`; const finish = value => { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(value); }; const onKey = e => { if (e.key === 'Escape') { e.stopPropagation(); finish(false); } }; @@ -3319,12 +3363,28 @@ function confirmProjectDone(project, running) { }); } +/** + * Marking a track done pauses its scheduled tasks until it is reopened; say + * so before doing it. `patch` is the caller's updateTrack wrapper. + */ +async function toggleTrackDone(project, track, patch) { + const toDone = track.status !== 'done'; + if (toDone) { + const paused = schedulesForProject(project).filter(s => s.trackId === track.id && s.enabled); + if (paused.length && !confirm(`Mark “${track.name}” as done?\n\n${paused.length} scheduled task${paused.length === 1 ? '' : 's'} in it will pause until the track is reopened.`)) return; + } + await patch({ status: toDone ? 'done' : 'active' }); +} + async function toggleProjectDone(project) { const isDone = project.status === 'done'; if (!isDone) { const running = runningWorkInProject(project); - if (running.sessions.length || running.tasks.length) { - if (!await confirmProjectDone(project, running)) return; + // Schedules do not need stopping — a done project simply stops firing + // them — but the user should hear that before it happens. + const schedules = schedulesForProject(project).filter(s => s.enabled); + if (running.sessions.length || running.tasks.length || schedules.length) { + if (!await confirmProjectDone(project, running, schedules)) return; for (const session of running.sessions) { try { await window.api.stopSession(session.sessionId); } catch {} activePtyIds.delete(session.sessionId); diff --git a/public/schedule-time.js b/public/schedule-time.js new file mode 100644 index 00000000..546aa54f --- /dev/null +++ b/public/schedule-time.js @@ -0,0 +1,189 @@ +// Schedule timing: when a scheduled task is due, in words and in minutes. +// Pure functions over a schedule row's timing fields (every, atHour, atMinute, +// weekday, cron). No timers, no I/O. Main ticks once a minute and asks +// `dueThisMinute`; the renderer asks `nextDueAt` and `describeTiming` to show +// "next run" and the preset in words. Shared by main and the renderer the same +// way snooze.js is. +(function (root) { + const MINUTE_MS = 60 * 1000; + // How far `nextDueAt` looks before giving up: a weekly schedule is at most + // seven days out, and a cron with an impossible date never fires. + const LOOKAHEAD_MINUTES = 8 * 24 * 60; + + /** The choices the dialog offers. `every = 'cron'` is never offered: it only comes from an imported file. */ + const PRESETS = [ + { every: '15m', label: 'Every 15 minutes' }, + { every: '30m', label: 'Every 30 minutes' }, + { every: 'hour', label: 'Every hour' }, + { every: 'day', label: 'Every day' }, + { every: 'weekdays', label: 'Weekdays' }, + { every: 'week', label: 'Every week' }, + ]; + const EVERY_VALUES = new Set([...PRESETS.map(p => p.every), 'cron']); + const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + // Check if a cron field matches a value. Supports *, ranges (1-5), lists + // (1,3,5) and steps (*/5). Kept from the old schedule-runner so imported + // crons keep firing exactly as they did. + function cronFieldMatches(field, value) { + if (field === '*') return true; + if (field.startsWith('*/')) { + const step = parseInt(field.slice(2), 10); + return step > 0 && value % step === 0; + } + if (field.includes(',')) return field.split(',').some(f => cronFieldMatches(f.trim(), value)); + if (field.includes('-')) { + const [lo, hi] = field.split('-').map(Number); + return value >= lo && value <= hi; + } + return parseInt(field, 10) === value; + } + + /** Whether a 5-field cron expression matches the given local time. */ + function cronMatches(cronExpr, date) { + const parts = String(cronExpr || '').trim().split(/\s+/); + if (parts.length !== 5) return false; + const [minute, hour, dom, month, dow] = parts; + return cronFieldMatches(minute, date.getMinutes()) && + cronFieldMatches(hour, date.getHours()) && + cronFieldMatches(dom, date.getDate()) && + cronFieldMatches(month, date.getMonth() + 1) && + cronFieldMatches(dow, date.getDay()); + } + + function num(v, fallback) { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; + } + + /** Does this schedule fire in the minute containing `date` (local time)? */ + function dueThisMinute(schedule, date) { + if (!schedule) return false; + const minute = date.getMinutes(); + const hour = date.getHours(); + const day = date.getDay(); + const atMinute = num(schedule.atMinute, 0); + const atHour = num(schedule.atHour, 9); + switch (schedule.every) { + case '15m': return minute % 15 === 0; + case '30m': return minute % 30 === 0; + case 'hour': return minute === atMinute; + case 'day': return hour === atHour && minute === atMinute; + case 'weekdays': return day >= 1 && day <= 5 && hour === atHour && minute === atMinute; + case 'week': return day === num(schedule.weekday, 1) && hour === atHour && minute === atMinute; + case 'cron': return cronMatches(schedule.cron, date); + default: return false; + } + } + + /** Start of the minute after `ms`. */ + function nextMinuteStart(ms) { + return Math.floor(ms / MINUTE_MS) * MINUTE_MS + MINUTE_MS; + } + + /** + * The first due minute strictly after `fromMs`, as ms, or null when none is + * found within the lookahead. Walks minute by minute so it can never + * disagree with `dueThisMinute`. + */ + function nextDueAt(schedule, fromMs) { + if (!schedule || !EVERY_VALUES.has(schedule.every)) return null; + let t = nextMinuteStart(fromMs); + for (let i = 0; i < LOOKAHEAD_MINUTES; i++, t += MINUTE_MS) { + if (dueThisMinute(schedule, new Date(t))) return t; + } + return null; + } + + /** + * A run that should have happened while the app was closed: there is a due + * minute after the last run (or the schedule's creation) and before now. + */ + function missedRun(schedule, nowMs) { + if (!schedule || !schedule.catchUp) return false; + const since = Date.parse(schedule.lastRunAt || schedule.created || ''); + if (!Number.isFinite(since)) return false; + const due = nextDueAt(schedule, since); + return due !== null && due <= nowMs; + } + + function timeOfDay(hour, minute) { + const d = new Date(2000, 0, 1, hour, minute, 0, 0); + return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + } + + /** The timing in words: "every 15 minutes", "every day at 9:00 AM", "Mondays at 9:00 AM". */ + function describeTiming(schedule) { + if (!schedule) return ''; + const atMinute = num(schedule.atMinute, 0); + const atHour = num(schedule.atHour, 9); + switch (schedule.every) { + case '15m': return 'every 15 minutes'; + case '30m': return 'every 30 minutes'; + case 'hour': return atMinute ? `every hour at :${String(atMinute).padStart(2, '0')}` : 'every hour'; + case 'day': return `every day at ${timeOfDay(atHour, atMinute)}`; + case 'weekdays': return `weekdays at ${timeOfDay(atHour, atMinute)}`; + case 'week': return `${WEEKDAYS[num(schedule.weekday, 1)] || 'Monday'}s at ${timeOfDay(atHour, atMinute)}`; + case 'cron': return describeCron(schedule.cron); + default: return ''; + } + } + + /** Plain words for the crons an imported file is likely to have; the raw string otherwise. */ + function describeCron(cron) { + const preset = presetFromCron(cron); + if (preset) return describeTiming(preset) + ' (from file)'; + const parts = String(cron || '').trim().split(/\s+/); + if (parts.length === 5 && /^\*\/\d+$/.test(parts[0]) && parts.slice(1).every(p => p === '*')) { + return `every ${parts[0].slice(2)} minutes (from file)`; + } + return `cron ${String(cron || '').trim()} (from file)`; + } + + /** + * The preset a cron expression is exactly equivalent to, as timing fields, + * or null when no preset fits and the cron has to be kept as is. + */ + function presetFromCron(cron) { + const parts = String(cron || '').trim().split(/\s+/); + if (parts.length !== 5) return null; + const [minute, hour, dom, month, dow] = parts; + if (dom !== '*' || month !== '*') return null; + const m = /^\d+$/.test(minute) ? Number(minute) : null; + const h = /^\d+$/.test(hour) ? Number(hour) : null; + if (m !== null && (m < 0 || m > 59)) return null; + if (h !== null && (h < 0 || h > 23)) return null; + if (hour === '*' && dow === '*') { + if (minute === '*/15') return { every: '15m' }; + if (minute === '*/30') return { every: '30m' }; + if (m !== null) return { every: 'hour', atMinute: m }; + return null; + } + if (m === null || h === null) return null; + if (dow === '*') return { every: 'day', atHour: h, atMinute: m }; + if (dow === '1-5') return { every: 'weekdays', atHour: h, atMinute: m }; + if (/^[0-6]$/.test(dow)) return { every: 'week', atHour: h, atMinute: m, weekday: Number(dow) }; + return null; + } + + /** "in 12 min", "in 3 h", "tomorrow 9:00 AM", "Mon 9:00 AM", else "Sep 20, 9:00 AM". */ + function describeNextRun(dueMs, nowMs) { + if (!Number.isFinite(dueMs)) return ''; + const delta = dueMs - nowMs; + if (delta < 90 * 1000) return 'in a minute'; + if (delta < 60 * MINUTE_MS) return `in ${Math.round(delta / MINUTE_MS)} min`; + const due = new Date(dueMs); + const time = due.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + const startOfToday = new Date(nowMs); + startOfToday.setHours(0, 0, 0, 0); + const dayDelta = Math.floor((dueMs - startOfToday.getTime()) / (24 * 60 * MINUTE_MS)); + if (dayDelta === 0) return `today ${time}`; + if (dayDelta === 1) return `tomorrow ${time}`; + if (dayDelta > 1 && dayDelta < 7) return `${due.toLocaleDateString(undefined, { weekday: 'short' })} ${time}`; + return `${due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}, ${time}`; + } + + const api = { PRESETS, EVERY_VALUES, WEEKDAYS, cronMatches, dueThisMinute, nextDueAt, missedRun, describeTiming, presetFromCron, describeNextRun, timeOfDay }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else Object.assign(root, api); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/public/schedules.js b/public/schedules.js new file mode 100644 index 00000000..e11867de --- /dev/null +++ b/public/schedules.js @@ -0,0 +1,394 @@ +// Scheduled tasks in the renderer: the list, the dialog that edits one, the +// dropdown that lists them, and the launch when main says one is due. +// +// A schedule is a saved prompt plus a time (public/schedule-time.js). Main +// ticks once a minute and sends 'schedule-due' with everything resolved; the +// session it starts is an ordinary one (launchNewSession) that carries the +// schedule's id so its row can show a clock chip. A schedule lives in one +// place: with a projectId it is listed in the project view under its track, +// without one it is a folder schedule on the Sessions tab, under `cwd`. +// +// Depends on globals: launchNewSession, resolveDefaultSessionOptions, +// showContextMenu, loadProjects, escapeHtml, ICONS, PICONS, shortProjectPath. + +let cachedSchedules = []; + +async function loadSchedules() { + try { + const list = await window.api.listSchedules(); + cachedSchedules = Array.isArray(list) ? list : []; + } catch { cachedSchedules = []; } + return cachedSchedules; +} + +function scheduleById(id) { + return cachedSchedules.find(s => s.id === id) || null; +} + +/** Folder schedules of a Sessions-tab folder. */ +function schedulesForFolder(projectPath) { + return cachedSchedules.filter(s => !s.projectId && s.cwd === projectPath); +} + +/** A project's schedules, from the tree node when it has them, else the cache. */ +function schedulesForProject(project) { + if (Array.isArray(project?.schedules)) return project.schedules; + return cachedSchedules.filter(s => s.projectId === project?.id); +} + +/** + * Why a schedule is not firing, in words, or '' when it is. Mirrors + * projects.js schedulePausedReason so the dropdown and main agree. + */ +function schedulePauseLabel(schedule, project = null) { + if (!schedule.enabled) return 'off'; + if (project?.status === 'done') return 'paused: project done'; + if (schedule.trackId && project) { + const track = (project.tracks || []).find(t => t.id === schedule.trackId); + if (track?.status === 'done') return 'paused: track done'; + } + return ''; +} + +function scheduleNextRunLabel(schedule, project = null) { + const paused = schedulePauseLabel(schedule, project); + if (paused) return paused; + const now = Date.now(); + const due = nextDueAt(schedule, now); + return due === null ? 'never' : describeNextRun(due, now); +} + +function scheduleLastRunLabel(schedule) { + if (!schedule.lastRunAt) return 'never'; + const at = Date.parse(schedule.lastRunAt); + return Number.isFinite(at) ? formatDate(new Date(at)) : 'never'; +} + +// --- Launching --- + +/** Start the session for a schedule main says is due. Never steals focus. */ +async function launchScheduledSession(launch) { + if (!launch || launch.error || !launch.schedule || !launch.target) return; + const { schedule, target, runtime } = launch; + const options = await resolveDefaultSessionOptions(target); + options.runtime = runtime || 'claude'; + options.initialPrompt = schedule.prompt; + options.scheduleId = schedule.id; + await launchNewSession(target, options, { focus: false }); +} + +async function runScheduleNow(schedule) { + const launch = await window.api.resolveScheduleLaunch(schedule.id); + if (launch?.error) { alert(launch.error); return; } + await launchScheduledSession(launch); +} + +async function toggleScheduleEnabled(schedule) { + const result = await window.api.updateSchedule(schedule.id, { enabled: !schedule.enabled }); + if (result?.error) alert(result.error); + else await refreshSchedules(); +} + +async function deleteScheduleFlow(schedule) { + if (!confirm(`Delete the scheduled task “${schedule.name}”?\n\nSessions it already started are kept.`)) return; + const result = await window.api.deleteSchedule(schedule.id); + if (result?.error) alert(result.error); + else await refreshSchedules(); +} + +/** Reload the list and whatever is showing it. */ +async function refreshSchedules() { + await loadSchedules(); + loadProjects(); +} + +// --- The dropdown --- + +/** + * Menu rows for a list of schedules, each with its own submenu, then a row to + * add one. `place` says where a new one goes: { projectId, trackId } or { cwd }. + */ +function scheduleMenuItems(schedules, place, project = null) { + const items = []; + if (schedules.length) { + items.push({ head: 'Scheduled tasks' }); + for (const s of schedules) { + const paused = schedulePauseLabel(s, project); + items.push({ + label: s.name, + icon: PICONS.clock(13), + muted: !!paused, + hint: paused || scheduleNextRunLabel(s, project), + submenu: [ + { head: describeTiming(s) }, + { label: 'Edit…', icon: PICONS.pencil(13), onClick: () => showScheduleDialog({ schedule: s, project }) }, + { label: 'Run now', icon: PICONS.play(13), onClick: () => runScheduleNow(s) }, + { label: s.enabled ? 'Turn off' : 'Turn on', icon: PICONS.check(13), onClick: () => toggleScheduleEnabled(s) }, + { sep: true }, + { label: 'Delete…', icon: PICONS.trash(13), danger: true, onClick: () => deleteScheduleFlow(s) }, + ], + }); + } + items.push({ sep: true }); + } + items.push({ label: 'New scheduled task…', icon: PICONS.plus(13), onClick: () => showScheduleDialog({ ...place, project }) }); + return items; +} + +/** The Sessions-tab clock: straight to the dialog when the folder has none, else the list. */ +function showFolderScheduleMenu(projectPath, anchor) { + const schedules = schedulesForFolder(projectPath); + if (!schedules.length) { showScheduleDialog({ cwd: projectPath }); return; } + showContextMenu(scheduleMenuItems(schedules, { cwd: projectPath }), { anchor }); +} + +/** The project view's Schedules button: rows grouped by track. */ +function showProjectScheduleMenu(project, anchor) { + const schedules = schedulesForProject(project); + if (!schedules.length) { showScheduleDialog({ projectId: project.id, trackId: null, project }); return; } + const items = []; + const groups = [{ track: null, list: schedules.filter(s => !s.trackId) }]; + for (const t of project.tracks || []) groups.push({ track: t, list: schedules.filter(s => s.trackId === t.id) }); + for (const g of groups) { + if (!g.list.length) continue; + const rows = scheduleMenuItems(g.list, { projectId: project.id, trackId: g.track?.id || null }, project); + // One heading per track, then its rows without the generic one. + items.push({ head: g.track ? g.track.name : 'General' }); + items.push(...rows.filter(r => !r.head && !(r.label && r.label.startsWith('New scheduled'))).filter(r => !r.sep)); + } + items.push({ sep: true }); + items.push({ label: 'New scheduled task…', icon: PICONS.plus(13), onClick: () => showScheduleDialog({ projectId: project.id, trackId: null, project }) }); + showContextMenu(items, { anchor }); +} + +/** Tint and badge for a clock button, from how many schedules it has. */ +function decorateScheduleButton(btn, schedules) { + const live = schedules.filter(s => s.enabled).length; + btn.classList.toggle('has-schedules', schedules.length > 0); + btn.classList.toggle('all-off', schedules.length > 0 && live === 0); + let badge = btn.querySelector('.schedule-count'); + if (schedules.length) { + if (!badge) { badge = document.createElement('span'); badge.className = 'schedule-count'; btn.appendChild(badge); } + badge.textContent = String(schedules.length); + btn.title = schedules.length === 1 ? '1 scheduled task' : `${schedules.length} scheduled tasks`; + } else { + if (badge) badge.remove(); + btn.title = 'New scheduled task'; + } +} + +// --- The chip on a session row --- + +/** "clock Name · 9:00 AM" for a session a schedule started, else ''. */ +/** `compact` shows the clock alone; the task's name and time stay in the tooltip. */ +function scheduleChipHtml(session, { compact = false } = {}) { + if (!session?.scheduleId) return ''; + const schedule = scheduleById(session.scheduleId); + const name = schedule ? schedule.name : 'Scheduled'; + const at = Date.parse(session.scheduledAt || ''); + const when = Number.isFinite(at) ? new Date(at).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : ''; + const title = `Started by the scheduled task “${name}”` + (Number.isFinite(at) ? ` at ${new Date(at).toLocaleString()}` : ''); + if (compact) return `${PICONS.clock(11)}`; + return `${PICONS.clock(9)}${escapeHtml(name)}${when ? ` · ${escapeHtml(when)}` : ''}`; +} + +// --- The dialog --- + +function pad2(n) { return String(n).padStart(2, '0'); } + +/** + * Create or edit a schedule. `schedule` set means edit; otherwise `place` + * says where the new one goes: { projectId, trackId } or { cwd }. `project` + * is the tree node, for the track picker and the pause label. + */ +function showScheduleDialog({ schedule = null, projectId = null, trackId = null, cwd = null, project = null } = {}) { + const editing = !!schedule; + const isProject = editing ? !!schedule.projectId : !!projectId; + const tracks = (project?.tracks || []).filter(t => t.status !== 'done' || t.id === schedule?.trackId); + const fromFile = !!schedule?.sourceFile; + const state = { + every: schedule?.every || 'day', + atHour: schedule?.atHour ?? 9, + atMinute: schedule?.atMinute ?? 0, + weekday: schedule?.weekday ?? 1, + cron: schedule?.cron || null, + }; + + const overlay = document.createElement('div'); + overlay.className = 'new-session-overlay'; + const dialog = document.createElement('div'); + dialog.className = 'new-session-dialog schedule-dialog'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + + const where = isProject + ? (project ? project.name : 'this project') + : shortProjectPath(editing ? schedule.cwd : cwd); + + dialog.innerHTML = ` +

${editing ? 'Edit scheduled task' : 'New scheduled task'} — ${escapeHtml(where)}

+
+
Name
+
+
+ ${isProject ? ` +
+
Track
Sessions it starts land here and run where the track's sessions start
+
+ +
+
` : ''} +
+
When
+
+
+
+ + + +
+
+
+
+
Prompt
${fromFile ? 'Imported from a schedule file. The file stays the source of truth; this prompt only points at it.' : 'Sent as the first message of each session it starts. It runs without any history, so say everything.'}
+
+
+
+
CLI
+
+ +
+
+
+
On
Off keeps it here without firing
+
+
+
+
Catch up on missed runs
If it was due while Switchboard was closed, run once at the next launch
+
+
+ ${editing ? `
Last run: ${escapeHtml(scheduleLastRunLabel(schedule))} · Next run:
` : ''} + +
+ ${editing ? '' : ''} + + + +
`; + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + const presetsEl = dialog.querySelector('#sd-presets'); + const whenDesc = dialog.querySelector('#sd-when-desc'); + const timeInput = dialog.querySelector('#sd-time'); + const minuteInput = dialog.querySelector('#sd-minute'); + const weekdaySel = dialog.querySelector('#sd-weekday'); + const errorEl = dialog.querySelector('#sd-error'); + + // The CLI list comes from the harness registry, like the track row's does, + // so a CLI added later appears here and one switched off in settings does + // not. A schedule already naming a disabled one keeps it, visibly. + const cliSel = dialog.querySelector('#sd-cli'); + const renderCli = (harnesses) => { + const options = [{ id: '', label: isProject ? 'Track default' : `Default (${harnesses[0]?.label || 'Claude'})` }, ...harnesses.map(h => ({ id: h.id, label: h.label }))]; + if (schedule?.cli && !options.some(o => o.id === schedule.cli)) options.push({ id: schedule.cli, label: `${schedule.cli} (off in settings)` }); + cliSel.replaceChildren(); + for (const opt of options) { + const o = document.createElement('option'); + o.value = opt.id; o.textContent = opt.label; o.selected = (schedule?.cli || '') === opt.id; + cliSel.appendChild(o); + } + }; + renderCli([{ id: 'claude', label: 'Claude' }]); + window.api.getHarnesses().then(list => { if (dialog.isConnected) renderCli((list || []).filter(h => h.enabled)); }).catch(() => {}); + + function currentTiming() { + const t = { every: state.every, atHour: null, atMinute: null, weekday: null, cron: null }; + if (state.every === 'cron') { t.cron = state.cron; return t; } + if (state.every === 'hour') t.atMinute = Number(minuteInput.value) || 0; + if (['day', 'weekdays', 'week'].includes(state.every)) { + const [h, m] = String(timeInput.value || '09:00').split(':').map(Number); + t.atHour = Number.isFinite(h) ? h : 9; + t.atMinute = Number.isFinite(m) ? m : 0; + } + if (state.every === 'week') t.weekday = Number(weekdaySel.value) || 0; + return t; + } + + function renderPresets() { + presetsEl.innerHTML = PRESETS.map(p => + `` + ).join('') + (state.every === 'cron' + ? `
Keeps the file's timing: ${escapeHtml(describeTiming({ every: 'cron', cron: state.cron }))}. Pick a preset to replace it — there is no way back.
` + : ''); + presetsEl.querySelectorAll('.sd-preset').forEach(btn => { + btn.onclick = () => { state.every = btn.dataset.every; renderPresets(); }; + }); + dialog.querySelector('#sd-time-row').hidden = state.every === 'cron' || state.every === '15m' || state.every === '30m'; + dialog.querySelector('#sd-weekday-wrap').hidden = state.every !== 'week'; + dialog.querySelector('#sd-time-wrap').hidden = !['day', 'weekdays', 'week'].includes(state.every); + dialog.querySelector('#sd-minute-wrap').hidden = state.every !== 'hour'; + updateWhenDesc(); + } + + function updateWhenDesc() { + const timing = currentTiming(); + whenDesc.textContent = describeTiming(timing); + const next = dialog.querySelector('#sd-next'); + if (next) { + const now = Date.now(); + const due = nextDueAt(timing, now); + next.textContent = due === null ? 'never' : describeNextRun(due, now); + } + } + timeInput.oninput = updateWhenDesc; + minuteInput.oninput = updateWhenDesc; + weekdaySel.onchange = updateWhenDesc; + renderPresets(); + + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } + function showError(msg) { errorEl.textContent = msg; errorEl.hidden = false; } + + async function save() { + const timing = currentTiming(); + const patch = { + name: dialog.querySelector('#sd-name').value, + prompt: dialog.querySelector('#sd-prompt').value, + ...timing, + cli: dialog.querySelector('#sd-cli').value || null, + enabled: dialog.querySelector('#sd-enabled').checked, + catchUp: dialog.querySelector('#sd-catchup').checked, + }; + if (isProject) patch.trackId = dialog.querySelector('#sd-track').value || null; + let result; + if (editing) result = await window.api.updateSchedule(schedule.id, patch); + else result = await window.api.createSchedule(isProject ? { ...patch, projectId } : { ...patch, cwd }); + if (result?.error) { showError(result.error); return; } + close(); + await refreshSchedules(); + } + + dialog.querySelector('.new-session-cancel-btn').onclick = close; + dialog.querySelector('.new-session-start-btn').onclick = save; + const del = dialog.querySelector('.sd-delete-btn'); + if (del) del.onclick = async () => { close(); await deleteScheduleFlow(schedule); }; + + function onKey(e) { + if (e.key === 'Escape') { e.stopPropagation(); close(); } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) save(); + } + document.addEventListener('keydown', onKey); + setTimeout(() => dialog.querySelector('#sd-name').focus(), 0); +} + +// --- Wiring --- + +if (typeof window !== 'undefined' && window.api?.onScheduleDue) { + window.api.onScheduleDue((launch) => { + launchScheduledSession(launch).catch(err => console.error('[schedule] launch failed', err)); + }); +} diff --git a/public/sidebar.js b/public/sidebar.js index 8f6e8b48..c2f64acb 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -366,10 +366,12 @@ function renderProjects(projects, resort) { const taskBtn = createProjectTaskButton(project); if (taskBtn) header.appendChild(taskBtn); + // The folder's scheduled tasks: a plain clock opens the dialog for a new + // one; with schedules it is tinted, counts them, and lists them. const scheduleBtn = document.createElement('button'); scheduleBtn.className = 'project-schedule-btn'; - scheduleBtn.title = 'Create scheduled task'; scheduleBtn.innerHTML = ICONS.schedule(16); + decorateScheduleButton(scheduleBtn, schedulesForFolder(project.projectPath)); header.appendChild(scheduleBtn); const settingsBtn = document.createElement('button'); @@ -501,7 +503,7 @@ function rebindSidebarEvents(projects) { } const scheduleBtn = header.querySelector('.project-schedule-btn'); if (scheduleBtn) { - scheduleBtn.onclick = (e) => { e.stopPropagation(); launchScheduleCreator(project); }; + scheduleBtn.onclick = (e) => { e.stopPropagation(); showFolderScheduleMenu(project.projectPath, scheduleBtn); }; } const settingsBtn = header.querySelector('.project-settings-btn'); if (settingsBtn) { @@ -822,6 +824,13 @@ function buildSessionItem(session) { timeEl.title = session.sessionId; timeEl.textContent = timeStr + (session.messageCount ? ' \u00b7 ' + session.messageCount + ' msgs' : ''); metaEl.append(mark, timeEl); + // Started by a scheduled task: a clock, with which one and when in its tooltip. + if (session.scheduleId && typeof scheduleChipHtml === 'function') { + const chip = document.createElement('span'); + chip.className = 'session-schedule-chip'; + chip.innerHTML = scheduleChipHtml(session, { compact: true }); + metaEl.appendChild(chip); + } // Action buttons container const actions = document.createElement('div'); diff --git a/public/style.css b/public/style.css index e45ae764..ae66ce25 100644 --- a/public/style.css +++ b/public/style.css @@ -5989,6 +5989,25 @@ body { display: flex; flex-direction: column; } letter-spacing: 0.08em; text-transform: uppercase; } +.session-turn-preview-body { position: relative; } +/* The message runs past the box: fade the last line so the cut reads as a cut. */ +.session-turn-preview-body.clipped::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 44px; + pointer-events: none; + background: linear-gradient(to bottom, rgba(25,25,35,0), #191923); +} +.session-turn-preview-more { + margin-top: 8px; + color: #77778e; + font-size: 10.5px; + font-style: italic; +} +.session-turn-preview-more[hidden] { display: none; } .session-turn-preview-text { max-height: 342px; overflow: hidden; @@ -6778,3 +6797,101 @@ body { display: flex; flex-direction: column; } "the next phase" and "all phases done", and neither of them spins. */ .proj-dot--next { background: #f0a050; box-shadow: 0 0 6px rgba(240,160,80,0.5); } .proj-dot--done { background: #3ecf5a; box-shadow: 0 0 6px rgba(62,207,90,0.5); } + +/* --- Scheduled tasks --- */ +/* The Sessions-tab clock: plain until the folder has schedules, then tinted + with a count. All-off keeps the count but drops the tint. */ +.project-schedule-btn { position: relative; } +.project-schedule-btn.has-schedules { + opacity: 1; + color: #e0a030; + width: auto; + padding: 0 4px 0 3px; + gap: 3px; + background: rgba(255,180,50,0.1); +} +.project-schedule-btn.has-schedules.all-off { color: #8a8a9a; background: rgba(255,255,255,0.05); } +.project-schedule-btn .schedule-count { + font-size: 9px; + font-weight: 700; + line-height: 1; + min-width: 8px; +} +/* The project header's button: a plain ws-btn, tinted while any schedule is on. */ +.ws-btn.ws-schedule-btn.has-schedules { color: #e8b04a; border-color: rgba(224,160,48,0.4); background: rgba(224,160,48,0.1); } +.ws-btn.ws-schedule-btn.has-schedules.all-off { color: #8a8a9a; border-color: rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); } +.ws-btn.ws-schedule-btn:hover { background: rgba(224,160,48,0.18); color: #f0bc5c; } + +/* The chip on a session row: clock, schedule name, time it fired. */ +.pane-tag--schedule { + display: inline-flex; + align-items: center; + gap: 3px; + text-transform: none; + letter-spacing: 0; + font-weight: 600; + color: #e8b04a; + background: rgba(224,160,48,0.12); + max-width: 180px; +} +.pane-tag--schedule svg { flex-shrink: 0; } +/* Icon only: the session list has no room for the task's name. */ +.pane-tag--icon { padding: 1px 3px; gap: 0; } +.session-schedule-chip { display: inline-flex; margin-left: 6px; min-width: 0; } +.session-schedule-chip .pane-tag { font-size: 9px; } + +/* The dialog. */ +.schedule-dialog { width: 600px; } +.schedule-dialog .sd-when { flex-direction: column; align-items: stretch; gap: 8px; width: 100%; } +.schedule-dialog .sd-presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } +.schedule-dialog .sd-preset { padding: 7px 10px; } +.schedule-dialog .sd-preset .perm-name { font-size: 12px; } +.schedule-dialog .sd-cron-note { + grid-column: 1 / -1; + font-size: 12px; + color: #a0a0b8; + padding: 6px 8px; + border: 1px dashed rgba(224,160,48,0.35); + border-radius: 6px; +} +.schedule-dialog .sd-cron-note strong { color: #e8b04a; font-weight: 600; } +.schedule-dialog .sd-time-row { display: flex; gap: 14px; align-items: center; font-size: 12px; color: #a0a0b8; } +.schedule-dialog .sd-time-row label { display: inline-flex; align-items: center; gap: 6px; } +.schedule-dialog .sd-time-row [hidden] { display: none !important; } +.schedule-dialog .sd-time { width: auto; min-width: 128px; padding-right: 8px; } +.schedule-dialog .sd-time::-webkit-datetime-edit { padding: 0; } +.schedule-dialog .sd-time::-webkit-calendar-picker-indicator { margin-left: 4px; opacity: 0.6; } +.schedule-dialog .sd-prompt { resize: vertical; min-height: 90px; font-family: inherit; line-height: 1.4; } +.schedule-dialog .sd-runs { font-size: 11.5px; color: #8a8aa0; padding: 10px 18px 0; } +.schedule-dialog .sd-error { color: #e36a78; font-size: 12.5px; padding: 10px 18px 0; } +.schedule-dialog .new-session-actions { display: flex; align-items: center; gap: 8px; } +.schedule-dialog .sd-delete-btn { + background: transparent; + border: 1px solid rgba(227,106,120,0.35); + color: #e36a78; + border-radius: 8px; + padding: 7px 14px; + font-size: 13px; + cursor: pointer; +} +.schedule-dialog .sd-delete-btn:hover { background: rgba(227,106,120,0.1); } + + +/* Full destinations for terminal file links and OSC 8 hyperlinks. */ +.terminal-link-tooltip { + position: fixed; + z-index: 10000; + max-width: min(640px, calc(100vw - 16px)); + max-height: calc(100vh - 16px); + overflow: hidden; + padding: 7px 10px; + border: 1px solid #494957; + border-radius: 5px; + background: #252530; + color: #e4e4ed; + box-shadow: 0 4px 16px #0006; + font: 12px/1.45 'SF Mono', 'Cascadia Code', Consolas, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + pointer-events: none; +} diff --git a/public/terminal-links.js b/public/terminal-links.js new file mode 100644 index 00000000..cbc74770 --- /dev/null +++ b/public/terminal-links.js @@ -0,0 +1,191 @@ +// Terminal links that do not arrive as OSC 8 hyperlinks (notably Codex file +// references). Work on xterm's buffer cells; never rewrite the PTY stream. +(function (root) { + function looksLikeFile(value) { + if (typeof value !== 'string' || !value || value.length > 4096) return false; + // No session-directory inference: only absolute paths, home paths and + // explicit local/editor URIs. The main process validates the target. + return /^(?:\/(?![/\\])|~[/\\]|[a-z]:[/\\]|(?:file|vscode|vscode-insiders|cursor|windsurf):\/\/)/i.test(value); + } + + function findFileReferences(text) { + const references = []; + const occupied = []; + const add = (value, start, quoted = false) => { + if (!quoted) { + const leading = value.match(/^[([{*]+/)?.[0].length || 0; + value = value.slice(leading); start += leading; + value = value.replace(/[.,;:!?*]+$/, ''); + for (const [open, close] of [['(', ')'], ['[', ']'], ['{', '}']]) { + while (value.endsWith(close) && value.split(close).length > value.split(open).length) value = value.slice(0, -1); + } + } + if (looksLikeFile(value)) references.push({ reference: value, start, end: start + value.length }); + }; + // Preserve a Markdown target containing spaces and avoid treating its + // label as a second path. Codex usually renders just the target's path. + const markdown = /\[[^\]\r\n]*\]\((<[^>\r\n]+>|[^)\r\n]+)\)/g; + for (const match of text.matchAll(markdown)) { + const target = match[1]; + const start = match.index + match[0].indexOf('](') + 2; + occupied.push([match.index, match.index + match[0].length]); + add(target.startsWith('<') ? target.slice(1, -1) : target, start + (target.startsWith('<') ? 1 : 0), true); + } + const tokens = /(?\r\n]+)>)|[^\s"'`<>]+/gu; + for (const match of text.matchAll(tokens)) { + if (occupied.some(([start, end]) => match.index < end && match.index + match[0].length > start)) continue; + const quoted = match.slice(1).find(value => value !== undefined); + add(quoted ?? match[0], match.index + (quoted !== undefined ? 1 : 0), quoted !== undefined); + } + return references.sort((a, b) => a.start - b.start).slice(0, 32); + } + + function logicalLine(terminal, y) { + const buffer = terminal.buffer.active; + let first = y - 1; + if (!buffer.getLine(first)) return null; + while (first > 0 && y - first <= 16 && buffer.getLine(first)?.isWrapped) first--; + const positions = []; + let text = ''; + const cell = buffer.getNullCell(); + for (let row = first; row < buffer.length && row < first + 32 && text.length < 8192; row++) { + const line = buffer.getLine(row); + if (!line || (row > first && !line.isWrapped)) break; + const next = buffer.getLine(row + 1); + for (let x = 0; x < line.length; x++) { + line.getCell(x, cell); + const width = cell.getWidth(); + if (!width) continue; + const chars = cell.getChars(); + // A wide character can wrap early, leaving a padding cell behind. + if (!chars && x === line.length - 1 && next?.isWrapped && next.getCell(0)?.getWidth() === 2) continue; + const value = chars || ' '; + const position = { start: { x: x + 1, y: row + 1 }, end: { x: x + width, y: row + 1 } }; + for (let i = 0; i < value.length; i++) positions.push(position); + text += value; + } + } + return { text, positions, first, cols: terminal.cols, buffer }; + } + + function createFileLinkProvider(terminal, { getSession, resolve, openFile, showTooltip = () => {}, hideTooltip = () => {} }) { + const cache = new Map(); + let disposed = false; + return { + dispose() { disposed = true; cache.clear(); hideTooltip(); }, + async provideLinks(y, callback) { + if (disposed) { callback([]); return; } + const context = logicalLine(terminal, y); + if (!context || disposed) { callback([]); return; } + const matches = findFileReferences(context.text).filter(match => + context.positions[match.start]?.start.y <= y && context.positions[match.end - 1]?.end.y >= y); + const key = JSON.stringify(matches.map(match => match.reference)); + let cached = cache.get(key); + if (!cached || cached.until < Date.now()) { + const promise = matches.length ? Promise.resolve().then(() => resolve(matches.map(match => match.reference))).catch(() => []) : Promise.resolve([]); + cached = { promise, until: Date.now() + 1500 }; + cache.set(key, cached); + if (cache.size > 128) cache.delete(cache.keys().next().value); + } + const targets = await cached.promise; + if (disposed) { callback([]); return; } + const current = logicalLine(terminal, y); + if (disposed || !current || current.text !== context.text || + current.first !== context.first || current.cols !== context.cols || current.buffer !== context.buffer) { callback([]); return; } + callback(matches.flatMap((match, index) => { + const target = targets?.[index]; + if (!target?.filePath) return []; + return [{ text: match.reference, + range: { start: context.positions[match.start].start, end: context.positions[match.end - 1].end }, + activate: () => { hideTooltip(); return openFile(getSession().sessionId, target.filePath, target); }, + hover: event => showTooltip(event, formatTarget(target)), + leave: hideTooltip, + dispose: hideTooltip, + }]; + })); + }, + }; + } + + function formatTarget(target) { + return target.filePath + (target.line ? `:${target.line}${target.column ? ':' + target.column : ''}` : ''); + } + + function createLinkHandler({ getSession, resolve, openFile, openExternal, showTooltip, hideTooltip }) { + let generation = 0; + let disposed = false; + let hovered = null; + const cache = new Map(); + const targetFor = uri => { + if (/^https?:\/\//i.test(uri)) return Promise.resolve({ url: uri }); + if (!looksLikeFile(uri)) return Promise.resolve(null); + let cached = cache.get(uri); + if (!cached || cached.until < Date.now()) { + cached = { until: Date.now() + 1500, + promise: Promise.resolve().then(() => resolve([uri])).then(targets => targets?.[0] || null).catch(() => null) }; + cache.set(uri, cached); + if (cache.size > 64) cache.delete(cache.keys().next().value); + } + return cached.promise; + }; + const leave = () => { generation++; hovered = null; hideTooltip(); }; + return { + async activate(_event, uri) { + if (disposed) return; + // Use the same target that was shown on hover, including its location. + const targetPromise = hovered?.uri === uri ? hovered.promise : targetFor(uri); + leave(); + const target = await targetPromise; + if (disposed || !target) return; + if (target.url) await openExternal(target.url); + else if (target.filePath) await openFile(getSession().sessionId, target.filePath, target); + }, + async hover(event, uri) { + leave(); + if (disposed) return; + const version = generation; + const promise = targetFor(uri); + hovered = { uri, promise }; + const target = await promise; + if (disposed || version !== generation) return; + if (target) showTooltip(event, target.url || formatTarget(target)); + else if (looksLikeFile(uri)) showTooltip(event, `File unavailable: ${uri}`); + }, + leave, + dispose() { disposed = true; leave(); cache.clear(); }, + }; + } + + function createTooltip(container) { + const doc = container.ownerDocument; + const view = doc.defaultView; + let element = null; + let disposed = false; + const hide = () => { element?.remove(); element = null; }; + return { + show(event, text) { + hide(); + if (disposed || !container.isConnected) return; + element = doc.createElement('div'); + element.className = 'xterm-hover terminal-link-tooltip'; + element.setAttribute('role', 'tooltip'); + element.textContent = text; + element.style.left = '0px'; + element.style.top = '0px'; + (container.querySelector('.xterm') || container).appendChild(element); + const rect = element.getBoundingClientRect(); + const x = Math.max(8, Math.min(event.clientX + 12, view.innerWidth - rect.width - 8)); + let y = event.clientY + 20; + if (y + rect.height > view.innerHeight - 8) y = event.clientY - rect.height - 12; + element.style.left = `${x}px`; + element.style.top = `${Math.max(8, y)}px`; + }, + hide, + dispose() { disposed = true; hide(); }, + }; + } + + const api = { findFileReferences, logicalLine, createFileLinkProvider, createLinkHandler, createTooltip, formatTarget }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.TerminalFileLinks = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/public/terminal-manager.js b/public/terminal-manager.js index 05c8c53c..f7ba8a95 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -399,6 +399,20 @@ function createTerminalEntry(session) { container.className = 'terminal-container'; terminalsEl.appendChild(container); + const linkTooltip = TerminalFileLinks.createTooltip(container); + const linkActions = { + resolve: references => window.api.resolveTerminalFiles(references), + showTooltip: linkTooltip.show, + hideTooltip: linkTooltip.hide, + openFile: (...args) => openFileInPanel(...args), + openExternal: uri => window.api.openExternal(uri), + }; + // Read the entry at click time: Codex replaces a provisional session ID + // after launch, and a fork can re-key an existing terminal too. + const linkHandler = TerminalFileLinks.createLinkHandler({ getSession: () => entry.session, ...linkActions }); + const activateLink = (event, uri) => linkHandler.activate(event, uri) + .catch(err => console.warn('[terminal] Could not open link:', err)); + const terminal = new Terminal({ fontSize: 12, fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace", @@ -415,13 +429,9 @@ function createTerminalEntry(session) { // in place. Windows/Linux get the same escape hatch via Shift, which needs no flag. macOptionClickForcesSelection: true, linkHandler: { - activate: (_event, uri) => { - if (uri.startsWith('file://') && typeof openFileInPanel === 'function') { - try { openFileInPanel(sessionId, decodeURIComponent(new URL(uri).pathname)); } catch {} - } else { - window.api.openExternal(uri); - } - }, + activate: activateLink, + hover: linkHandler.hover, + leave: linkHandler.leave, allowNonHttpProtocols: true, }, }); @@ -446,13 +456,7 @@ function createTerminalEntry(session) { const fitAddon = new FitAddon.FitAddon(); terminal.loadAddon(fitAddon); - terminal.loadAddon(new WebLinksAddon.WebLinksAddon((_event, url) => { - if (url.startsWith('file://') && typeof openFileInPanel === 'function') { - try { openFileInPanel(sessionId, decodeURIComponent(new URL(url).pathname)); } catch {} - } else { - window.api.openExternal(url); - } - })); + terminal.loadAddon(new WebLinksAddon.WebLinksAddon(activateLink, { hover: linkHandler.hover, leave: linkHandler.leave })); const searchAddon = new SearchAddon.SearchAddon(); terminal.loadAddon(searchAddon); terminal.loadAddon(new UnicodeGraphemesAddon.UnicodeGraphemesAddon()); @@ -515,6 +519,27 @@ function createTerminalEntry(session) { const entry = { terminal, element: container, fitAddon, searchAddon, openSearchBar, closeSearchBar, session, closed: false }; openSessions.set(sessionId, entry); + // OSC 8 and web links retain precedence over detected filesystem paths. + const fileLinks = TerminalFileLinks.createFileLinkProvider(terminal, { + getSession: () => entry.session, ...linkActions, + }); + const fileLinkRegistration = terminal.registerLinkProvider(fileLinks); + const linkScroll = terminal.onScroll(linkHandler.leave); + const linkResize = terminal.onResize(linkHandler.leave); + container.addEventListener('mouseleave', linkHandler.leave); + container.addEventListener('wheel', linkHandler.leave, { passive: true }); + window.addEventListener('blur', linkHandler.leave); + terminal.loadAddon({ + activate() {}, + dispose() { + fileLinks.dispose(); fileLinkRegistration.dispose(); + linkScroll.dispose(); linkResize.dispose(); + container.removeEventListener('mouseleave', linkHandler.leave); + container.removeEventListener('wheel', linkHandler.leave); + window.removeEventListener('blur', linkHandler.leave); + linkHandler.dispose(); linkTooltip.dispose(); + }, + }); restoreTerminalHistory(entry); // Wire up IPC (use entry.session.sessionId so fork re-keying works) diff --git a/public/viewer-panel.js b/public/viewer-panel.js index c3322bc9..44abda22 100644 --- a/public/viewer-panel.js +++ b/public/viewer-panel.js @@ -7,7 +7,7 @@ * * Toolbar buttons are shown/hidden automatically based on file type: * - Preview: shown for markdown and HTML files - * - Images and PDFs: read-only previews with text editing controls hidden + * - Images, PDFs and PowerPoint: read-only previews with text editing controls hidden * - Wrap: shown for text (defaults on for markdown, off for others) * - Save: shown if onSave is provided * - Close: shown if onClose is provided @@ -176,7 +176,8 @@ class ViewerPanel { this.wrapMode = isMd; if (isMedia) { const bytes = Uint8Array.from(atob(preview.base64 || ''), char => char.charCodeAt(0)); - this._objectUrl = URL.createObjectURL(new Blob([bytes], { type: preview.mimeType })); + if (this.previewType === 'pptx') this._presentationBytes = bytes; + else this._objectUrl = URL.createObjectURL(new Blob([bytes], { type: preview.mimeType })); this.previewMode = true; this._renderPreview(); this._watchFile(filePath); @@ -210,6 +211,16 @@ class ViewerPanel { // Watch for external changes this._watchFile(filePath); + if (preview.line) this.goToLocation(preview.line, preview.column); + } + + goToLocation(line, column = 1) { + if (!this.editorView || !Number.isSafeInteger(line) || line < 1) return; + const doc = this.editorView.state.doc; + const target = doc.line(Math.min(line, doc.lines)); + const offset = Number.isSafeInteger(column) ? Math.max(0, Math.min(column - 1, target.length)) : 0; + if (this.previewMode) this._setPreview(false); + this.editorView.dispatch({ selection: { anchor: target.from + offset }, scrollIntoView: true }); } _createEditor(content, filePath) { @@ -244,12 +255,31 @@ class ViewerPanel { } _isMedia() { - return this.previewType === 'image' || this.previewType === 'pdf'; + return ['image', 'pdf', 'pptx'].includes(this.previewType); } _renderPreview() { this.previewEl.replaceChildren(); - if (this.previewType === 'image') { + if (this.previewType === 'pptx') { + this.previewEl.className = 'viewer-media-preview'; + this.previewEl.style.display = 'block'; + const frame = document.createElement('iframe'); + frame.className = 'viewer-preview-frame'; + frame.title = `PowerPoint preview: ${this.filePath.split(/[\\/]/).pop()}`; + frame.setAttribute('sandbox', 'allow-scripts'); + frame.referrerPolicy = 'no-referrer'; + const bytes = this._presentationBytes; + const version = this._openVersion; + this._presentationBytes = null; + frame.addEventListener('load', () => { + if (version !== this._openVersion || !frame.contentWindow) return; + // The sandbox has an opaque origin, so postMessage needs '*'. The + // destination is this exact frame, and only deck bytes are shared. + frame.contentWindow.postMessage({ type: 'switchboard-pptx', buffer: bytes.buffer }, '*', [bytes.buffer]); + }, { once: true }); + frame.src = this.previewUrl; + this.previewEl.appendChild(frame); + } else if (this.previewType === 'image') { this.previewEl.className = 'viewer-media-preview viewer-media-preview--image'; this.previewEl.style.display = 'flex'; const img = document.createElement('img'); @@ -304,6 +334,7 @@ class ViewerPanel { this.previewEl.style.display = 'none'; if (this._objectUrl) URL.revokeObjectURL(this._objectUrl); this._objectUrl = null; + this._presentationBytes = null; } _setPreview(show) { @@ -349,7 +380,7 @@ class ViewerPanel { if (result?.ok) { // A new extension must not discard an editable buffer by switching it // to a binary preview. Reopening later uses the new file type normally. - const preview = this.editorView && ['image', 'pdf'].includes(result.previewType) + const preview = this.editorView && ['image', 'pdf', 'pptx'].includes(result.previewType) ? { ...result, previewType: 'text' } : result; this.open(title, filePath, this.editorView ? this.getContent() : result.content, preview); } else { diff --git a/schedule-ipc.js b/schedule-ipc.js deleted file mode 100644 index 66a4232d..00000000 --- a/schedule-ipc.js +++ /dev/null @@ -1,220 +0,0 @@ -// schedule-ipc.js — IPC handlers and helpers for scheduled task creation -const { ipcMain } = require('electron'); -const path = require('path'); -const fs = require('fs'); -const os = require('os'); -const crypto = require('crypto'); -const { encodeProjectPath } = require('./encode-project-path'); - -const CLAUDE_DIR = path.join(os.homedir(), '.claude'); -const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); -const SCHEDULE_COMMANDS_DIR = path.join(CLAUDE_DIR, 'commands'); - -const SCHEDULE_CREATOR_TEMPLATE = `--- -name: create-switchboard-schedule -description: Create a new Switchboard scheduled task for this project ---- - -You are helping the user create a scheduled task in Switchboard. This task will run automatically on a cron schedule using Claude Code CLI in headless mode (-p flag). - -## Instructions for the user - -Welcome! I'll help you set up a scheduled task for this project. Tell me: -- **What** the task should do -- **When** it should run (e.g. "every weekday at 9am", "hourly", "every Sunday night") - -I'll generate the schedule file and save it. You can always edit it later from the brain tab. - -## How to create the task - -Ask the user what the task should do and when it should run. Keep it conversational — one or two questions at a time, not all at once. - -Once you have enough information, generate a cron expression from their description and confirm it in plain english (e.g. "That's every weekday at 9:00 AM"). - -## File format - -Save to \`/.claude/commands/schedule-.md\`: - -\`\`\`markdown ---- -name: -cron: <5-field cron expression> -enabled: true -slug: -cli: - permission-mode: acceptEdits - allowed-tools: - -
-
-
-
- Chrome -
Enable Chrome browser automation
-
-
- -
-
`; - - const codexFields = ` -
-
- Sandbox -
What Codex is allowed to touch
-
-
- -
-
-
-
- Approval -
When Codex stops to ask before running a command
-
-
- -
-
-
-
- Model -
Blank uses Codex's default
-
-
- -
-
`; - + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); dialog.innerHTML = ` -

New ${isCodex ? 'Codex' : 'Claude'} Session — ${escapeHtml(shortProjectPath(project.projectPath))}

- ${isCodex ? codexFields : claudeFields} -
-
- Pre-launch Command -
Prepended to the ${isCodex ? 'codex' : 'claude'} command
-
-
- -
-
-
-
- Additional Directories -
Extra directories to include (comma-separated)
-
-
- -
-
+

New ${escapeHtml(runtime === 'codex' ? 'Codex' : runtime === 'claude' ? 'Claude' : runtime)} Session — ${escapeHtml(shortProjectPath(project.projectPath))}

+
+
- - -
- `; - + + +
`; + const form = SessionConfigForm.mount(dialog.querySelector('.session-config-fields'), { runtime, defaults: effective }); overlay.appendChild(dialog); document.body.appendChild(overlay); - - // Bind mode grid clicks (Claude only — codex has no permission modes) - const modeGrid = dialog.querySelector('#nsd-mode-grid'); - if (modeGrid) modeGrid.addEventListener('click', (e) => { - const btn = e.target.closest('.permission-option'); - if (!btn) return; - const mode = btn.dataset.mode; - if (mode === 'dangerous-skip') { - dangerousSkip = !dangerousSkip; - if (dangerousSkip) selectedMode = null; - } else { - dangerousSkip = false; - selectedMode = mode === 'null' ? null : mode; - } - modeGrid.innerHTML = renderModeGrid(); - }); - - function close() { - overlay.remove(); - document.removeEventListener('keydown', onKey); - } - + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } function start() { - const options = { runtime }; - if (isCodex) { - options.codexSandbox = dialog.querySelector('#nsd-codex-sandbox').value; - options.codexApproval = dialog.querySelector('#nsd-codex-approval').value; - options.codexModel = dialog.querySelector('#nsd-codex-model').value.trim(); - } else { - if (dangerousSkip) { - options.dangerouslySkipPermissions = true; - } else if (selectedMode) { - options.permissionMode = selectedMode; - } - if (dialog.querySelector('#nsd-worktree').checked) { - options.worktree = true; - options.worktreeName = dialog.querySelector('#nsd-worktree-name').value.trim(); - } - if (dialog.querySelector('#nsd-chrome').checked) { - options.chrome = true; - } + let options; + try { options = form.getOptions(); } catch (err) { + const error = dialog.querySelector('.session-config-error'); + error.textContent = err.message; error.hidden = false; + return; } - const preLaunch = dialog.querySelector('#nsd-pre-launch').value.trim(); - if (preLaunch) options.preLaunchCmd = preLaunch; - options.addDirs = dialog.querySelector('#nsd-add-dirs').value.trim(); - if (effective.mcpEmulation === false) options.mcpEmulation = false; close(); - launchNewSession(project, options); + launchNewSession(project, { ...options, runtime }); } - dialog.querySelector('.new-session-cancel-btn').onclick = close; dialog.querySelector('.new-session-start-btn').onclick = start; - - // Keyboard support function onKey(e) { if (e.key === 'Escape') close(); - if (e.key === 'Enter' && !e.target.matches('input')) start(); + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) start(); } document.addEventListener('keydown', onKey); } diff --git a/public/index.html b/public/index.html index 450f5941..e9bf3810 100644 --- a/public/index.html +++ b/public/index.html @@ -120,6 +120,8 @@ + + diff --git a/public/schedules.js b/public/schedules.js index e11867de..3b31d3ff 100644 --- a/public/schedules.js +++ b/public/schedules.js @@ -70,8 +70,10 @@ function scheduleLastRunLabel(schedule) { async function launchScheduledSession(launch) { if (!launch || launch.error || !launch.schedule || !launch.target) return; const { schedule, target, runtime } = launch; - const options = await resolveDefaultSessionOptions(target); - options.runtime = runtime || 'claude'; + const runtimeId = runtime || 'claude'; + const defaults = await window.api.getEffectiveSettings(target.projectPath); + const options = SessionConfig.resolveOptions(runtimeId, defaults, schedule.sessionConfig?.[runtimeId] || {}); + options.runtime = runtimeId; options.initialPrompt = schedule.prompt; options.scheduleId = schedule.id; await launchNewSession(target, options, { focus: false }); @@ -206,6 +208,7 @@ function showScheduleDialog({ schedule = null, projectId = null, trackId = null, const isProject = editing ? !!schedule.projectId : !!projectId; const tracks = (project?.tracks || []).filter(t => t.status !== 'done' || t.id === schedule?.trackId); const fromFile = !!schedule?.sourceFile; + const configs = SessionConfig.normalizeByCli(schedule?.sessionConfig || {}); const state = { every: schedule?.every || 'day', atHour: schedule?.atHour ?? 9, @@ -262,6 +265,13 @@ function showScheduleDialog({ schedule = null, projectId = null, trackId = null, +
+
Session Settings
Loading folder defaults…
+
+
+
On
Off keeps it here without firing
@@ -276,7 +286,7 @@ function showScheduleDialog({ schedule = null, projectId = null, trackId = null, ${editing ? '' : ''} - +
`; overlay.appendChild(dialog); @@ -288,24 +298,96 @@ function showScheduleDialog({ schedule = null, projectId = null, trackId = null, const minuteInput = dialog.querySelector('#sd-minute'); const weekdaySel = dialog.querySelector('#sd-weekday'); const errorEl = dialog.querySelector('#sd-error'); + const configMode = dialog.querySelector('#sd-config-mode'); + const configFields = dialog.querySelector('#sd-config-fields'); + const defaultsFolder = dialog.querySelector('#sd-defaults-folder'); + const saveButton = dialog.querySelector('.new-session-start-btn'); + let configForm = null; + let configRuntime = null; + let effectiveDefaults = {}; + let configVersion = 0; + let configRequest = null; + let configReady = false; // The CLI list comes from the harness registry, like the track row's does, // so a CLI added later appears here and one switched off in settings does // not. A schedule already naming a disabled one keeps it, visibly. const cliSel = dialog.querySelector('#sd-cli'); const renderCli = (harnesses) => { - const options = [{ id: '', label: isProject ? 'Track default' : `Default (${harnesses[0]?.label || 'Claude'})` }, ...harnesses.map(h => ({ id: h.id, label: h.label }))]; + const selected = cliSel.options.length ? cliSel.value : (schedule?.cli || ''); + const options = [{ id: '', label: isProject ? 'Track default' : 'Default (Claude)' }, ...harnesses.map(h => ({ id: h.id, label: h.label }))]; if (schedule?.cli && !options.some(o => o.id === schedule.cli)) options.push({ id: schedule.cli, label: `${schedule.cli} (off in settings)` }); cliSel.replaceChildren(); for (const opt of options) { const o = document.createElement('option'); - o.value = opt.id; o.textContent = opt.label; o.selected = (schedule?.cli || '') === opt.id; + o.value = opt.id; o.textContent = opt.label; o.selected = selected === opt.id; cliSel.appendChild(o); } }; renderCli([{ id: 'claude', label: 'Claude' }]); window.api.getHarnesses().then(list => { if (dialog.isConnected) renderCli((list || []).filter(h => h.enabled)); }).catch(() => {}); + function keepConfiguration() { + if (configForm && configMode.value === 'custom') configs[configRuntime] = configForm.getOverrides(); + } + + function renderConfiguration() { + configFields.hidden = configMode.value !== 'custom'; + configForm = null; + configFields.replaceChildren(); + if (configFields.hidden) return; + const runtime = configRuntime; + if (!SessionConfig.own(configs, runtime)) configs[runtime] = {}; + configForm = SessionConfigForm.mount(configFields, { + runtime, defaults: effectiveDefaults, overrides: configs[runtime], inherit: true, + onChange: values => { configs[runtime] = values; }, + }); + } + + async function refreshConfiguration() { + keepConfiguration(); + const version = ++configVersion; + configReady = false; + configForm = null; + configFields.hidden = true; + configMode.disabled = true; + saveButton.disabled = true; + defaultsFolder.textContent = 'Loading folder defaults…'; + try { + const place = isProject + ? { projectId: editing ? schedule.projectId : projectId, trackId: dialog.querySelector('#sd-track').value || null } + : { cwd: editing ? schedule.cwd : cwd }; + const context = await window.api.getScheduleContext({ ...place, cli: cliSel.value || null }); + if (version !== configVersion || !dialog.isConnected) return; + if (context?.error) throw new Error(context.error); + const defaults = await window.api.getEffectiveSettings(context.target.projectPath); + if (version !== configVersion || !dialog.isConnected) return; + configRuntime = context.runtime || 'claude'; + effectiveDefaults = defaults; + const label = configRuntime === 'claude' ? 'Claude' : configRuntime === 'codex' ? 'Codex' : configRuntime; + defaultsFolder.textContent = `${label} defaults from ${context.target.projectPath}`; + defaultsFolder.title = context.target.projectPath; + configMode.value = SessionConfig.own(configs, configRuntime) ? 'custom' : 'defaults'; + configMode.disabled = false; + renderConfiguration(); + configReady = true; + saveButton.disabled = false; + } catch (err) { + if (version !== configVersion || !dialog.isConnected) return; + defaultsFolder.textContent = 'Could not load session settings'; + showError(err.message); + } + } + const reloadConfiguration = () => { configRequest = refreshConfiguration(); }; + cliSel.onchange = reloadConfiguration; + const trackSelect = dialog.querySelector('#sd-track'); + if (trackSelect) trackSelect.onchange = reloadConfiguration; + configMode.onchange = () => { + if (configMode.value === 'defaults') delete configs[configRuntime]; + renderConfiguration(); + }; + reloadConfiguration(); + function currentTiming() { const t = { every: state.every, atHour: null, atMinute: null, weekday: null, cron: null }; if (state.every === 'cron') { t.cron = state.cron; return t; } @@ -350,16 +432,20 @@ function showScheduleDialog({ schedule = null, projectId = null, trackId = null, weekdaySel.onchange = updateWhenDesc; renderPresets(); - function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } + function close() { configVersion++; overlay.remove(); document.removeEventListener('keydown', onKey); } function showError(msg) { errorEl.textContent = msg; errorEl.hidden = false; } async function save() { + await configRequest; + if (!dialog.isConnected || !configReady) return; + try { keepConfiguration(); } catch (err) { showError(err.message); return; } const timing = currentTiming(); const patch = { name: dialog.querySelector('#sd-name').value, prompt: dialog.querySelector('#sd-prompt').value, ...timing, cli: dialog.querySelector('#sd-cli').value || null, + sessionConfig: configs, enabled: dialog.querySelector('#sd-enabled').checked, catchUp: dialog.querySelector('#sd-catchup').checked, }; diff --git a/public/session-config-form.js b/public/session-config-form.js new file mode 100644 index 00000000..c7bad5f8 --- /dev/null +++ b/public/session-config-form.js @@ -0,0 +1,67 @@ +// The same settings form is mounted by New Session and Create/Edit Schedule. +(function (root) { + function mount(container, { runtime, defaults = {}, overrides = {}, inherit = false, onChange = () => {} }) { + let saved = SessionConfig.normalizeOverrides(runtime, overrides); + let values = SessionConfig.resolveOptions(runtime, defaults, saved); + const fields = SessionConfig.fieldsFor(runtime).filter(f => !f.hidden); + const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const keys = field => field.type === 'permission' ? [field.key, 'dangerouslySkipPermissions'] : [field.key]; + function permissionButtons(field) { + return field.choices.map(c => ``).join('') + + ``; + } + function control(field) { + const value = values[field.key]; + if (field.type === 'permission') return `
${permissionButtons(field)}
`; + if (field.type === 'boolean') return ``; + if (field.type === 'select') return ``; + if (field.type === 'textarea') return ``; + return ``; + } + container.innerHTML = fields.map(field => `
+
${esc(field.label)}${field.description ? `
${esc(field.description)}
` : ''}${inherit ? '
' : ''}
+
${control(field)}
`).join(''); + function inheritance(row, field) { + if (!inherit) return; + const custom = keys(field).some(key => SessionConfig.own(saved, key)); + row.querySelector('.session-config-inheritance').innerHTML = custom + ? '' : 'Folder default'; + } + for (const field of fields) { + const row = container.querySelector(`[data-config-field="${field.key}"]`); + inheritance(row, field); + const changed = () => { inheritance(row, field); onChange({ ...saved }); }; + row.addEventListener('click', e => { + if (e.target.closest('.session-config-reset')) { + for (const key of keys(field)) delete saved[key]; + values = SessionConfig.resolveOptions(runtime, defaults, saved); + const input = row.querySelector('[data-config-input]'); + if (field.type === 'permission') row.querySelector('.permission-grid').innerHTML = permissionButtons(field); + else if (field.type === 'boolean') input.checked = values[field.key]; + else input.value = values[field.key]; + changed(); + return; + } + const button = e.target.closest('[data-permission]'); + if (!button) return; + const mode = button.dataset.permission; + saved.dangerouslySkipPermissions = mode === 'dangerous-skip' && !values.dangerouslySkipPermissions; + saved.permissionMode = mode === 'dangerous-skip' ? null : mode || null; + values = SessionConfig.resolveOptions(runtime, defaults, saved); + row.querySelector('.permission-grid').innerHTML = permissionButtons(field); + changed(); + }); + const input = row.querySelector('[data-config-input]'); + if (input) input.addEventListener('input', () => { + saved[field.key] = field.type === 'boolean' ? input.checked : input.value; + values[field.key] = saved[field.key]; + changed(); + }); + } + return { + getOverrides: () => SessionConfig.normalizeOverrides(runtime, saved), + getOptions: () => SessionConfig.resolveOptions(runtime, defaults, saved), + }; + } + root.SessionConfigForm = { mount }; +})(window); diff --git a/public/session-config.js b/public/session-config.js new file mode 100644 index 00000000..236a882d --- /dev/null +++ b/public/session-config.js @@ -0,0 +1,99 @@ +// Shared definitions, validation and inheritance for session and schedule settings. +// Add supported fields here once; both configuration forms use this registry. +(function (root) { + const PERMISSION_MODES = [ + { value: null, label: 'Default', desc: 'Prompt for all actions' }, + { value: 'auto', label: 'Auto', desc: 'Classifier allows routine work, stops for risky actions' }, + { value: 'acceptEdits', label: 'Accept Edits', desc: 'Auto-accept file edits, prompt for others' }, + { value: 'plan', label: 'Plan Mode', desc: 'Read-only exploration, no writes' }, + { value: 'dontAsk', label: "Don't Ask", desc: 'Auto-deny tools not explicitly allowed' }, + { value: 'bypassPermissions', label: 'Bypass', desc: 'Auto-accept all tool calls' }, + ]; + const CODEX_SANDBOX_MODES = [ + { value: '', label: 'Default', desc: "Use codex's own config" }, + { value: 'read-only', label: 'Read Only', desc: 'No writes, no commands that change things' }, + { value: 'workspace-write', label: 'Workspace Write', desc: 'Write inside the project directory' }, + { value: 'danger-full-access', label: 'Full Access', desc: 'No sandbox at all' }, + ]; + const CODEX_APPROVAL_POLICIES = [ + { value: '', label: 'Default', desc: "Use codex's own config" }, + { value: 'on-request', label: 'On Request', desc: 'Codex decides when to ask' }, + { value: 'never', label: 'Never', desc: 'Never ask; failures go back to the model' }, + ]; + const FIELDS = { + claude: [ + { key: 'permissionMode', label: 'Permission Mode', type: 'permission', default: null, choices: PERMISSION_MODES }, + { key: 'dangerouslySkipPermissions', type: 'boolean', default: false, hidden: true }, + { key: 'allowedTools', label: 'Allowed Tools', type: 'text', default: '', wide: true, description: 'Tools allowed without a permission prompt (comma-separated)' }, + { key: 'appendSystemPrompt', label: 'Additional System Prompt', type: 'textarea', default: '', wide: true, description: 'Instructions appended to Claude’s system prompt' }, + { key: 'worktree', label: 'Worktree', type: 'boolean', default: false, description: 'Run each new session in an isolated git worktree' }, + { key: 'worktreeName', label: 'Worktree Name', type: 'text', default: '', placeholder: 'name (optional)' }, + { key: 'chrome', label: 'Chrome', type: 'boolean', default: false, description: 'Enable Chrome browser automation' }, + { key: 'mcpEmulation', type: 'boolean', default: true, hidden: true }, + ], + codex: [ + { key: 'codexSandbox', label: 'Sandbox', type: 'select', default: '', choices: CODEX_SANDBOX_MODES, description: 'What Codex is allowed to touch' }, + { key: 'codexApproval', label: 'Approval', type: 'select', default: '', choices: CODEX_APPROVAL_POLICIES, description: 'When Codex asks before running a command' }, + { key: 'codexModel', label: 'Model', type: 'text', default: '', placeholder: 'default', description: "Blank uses Codex's default" }, + { key: 'dangerouslySkipPermissions', label: 'Bypass Approvals and Sandbox', type: 'boolean', default: false, description: 'Disables permission prompts and sandbox restrictions' }, + ], + }; + const COMMON_FIELDS = [ + { key: 'preLaunchCmd', label: 'Pre-launch Command', type: 'text', default: '', wide: true, placeholder: 'e.g. aws-vault exec profile --', description: 'Prepended to the CLI command' }, + { key: 'addDirs', label: 'Additional Directories', type: 'text', default: '', wide: true, placeholder: '/path/to/dir1, /path/to/dir2', description: 'Extra directories (comma-separated). Project attachments are included automatically.' }, + ]; + const own = (o, key) => Object.prototype.hasOwnProperty.call(o || {}, key); + const isObject = value => !!value && typeof value === 'object' && !Array.isArray(value); + function fieldsFor(runtime) { return [...(FIELDS[runtime] || []), ...COMMON_FIELDS]; } + + function normalizeOverrides(runtime, input = {}) { + if (!isObject(input)) throw new Error('Session settings must be an object'); + const fields = new Map(fieldsFor(runtime).map(f => [f.key, f])); + const out = {}; + for (const [key, value] of Object.entries(input)) { + const field = fields.get(key); + if (!field) throw new Error(`Unsupported ${runtime} setting: ${key}`); + if (field.type === 'boolean') { + if (typeof value !== 'boolean') throw new Error(`${key} must be on or off`); + out[key] = value; + } else if (field.choices) { + if (!field.choices.some(c => c.value === value)) throw new Error(`Invalid ${field.label}`); + out[key] = value; + } else { + if (typeof value !== 'string') throw new Error(`${field.label} must be text`); + if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) throw new Error(`${field.label} contains unsafe characters`); + if (field.type !== 'textarea' && /[\r\n]/.test(value)) throw new Error(`${field.label} must be a single line`); + out[key] = field.type === 'textarea' ? value : value.trim(); + } + } + return out; + } + + function normalizeByCli(input = {}) { + if (!isObject(input)) throw new Error('Schedule settings must be an object'); + const out = {}; + for (const [runtime, values] of Object.entries(input)) { + if (!/^[a-z][a-z0-9_-]*$/.test(runtime) || ['constructor', 'prototype', '__proto__'].includes(runtime)) throw new Error('Invalid CLI'); + out[runtime] = normalizeOverrides(runtime, values); + } + return out; + } + + function resolveOptions(runtime, defaults = {}, overrides = {}) { + const clean = normalizeOverrides(runtime, overrides); + const out = {}; + for (const field of fieldsFor(runtime)) { + let value = own(clean, field.key) ? clean[field.key] : defaults?.[field.key]; + // Null means a CLI's own default for selects/text, and off for toggles. + if (value === undefined) value = field.default; + if (value === null && field.type !== 'permission') value = field.type === 'boolean' ? false : ''; + out[field.key] = value; + } + return normalizeOverrides(runtime, out); + } + + const api = { PERMISSION_MODES, CODEX_SANDBOX_MODES, CODEX_APPROVAL_POLICIES, FIELDS, COMMON_FIELDS, + fieldsFor, normalizeOverrides, normalizeByCli, resolveOptions, own }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.SessionConfig = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/public/style.css b/public/style.css index 82917f69..0e828241 100644 --- a/public/style.css +++ b/public/style.css @@ -3752,14 +3752,27 @@ body { display: flex; flex-direction: column; } .new-session-dialog { background: #1e1e2a; + color-scheme: dark; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 24px; width: 540px; max-width: 90vw; + max-height: 90vh; + overflow-y: auto; box-shadow: 0 16px 48px rgba(0,0,0,0.4); } +.session-config-fields[hidden] { display: none !important; } +.session-config-fields .settings-field-control { min-width: 0; } +.session-config-fields .settings-field-wide .settings-field-control { width: 100%; } +.session-config-fields .permission-grid { width: 100%; } +.session-config-fields .settings-input { max-width: 100%; } +.session-config-inheritance { margin-top: 4px; color: #8a8aa0; font-size: 11px; } +.session-config-reset { padding: 0; border: 0; background: none; color: #a8b9f0; font: inherit; cursor: pointer; } +.session-config-reset:hover { text-decoration: underline; } +.session-config-error { color: #e36a78; font-size: 12px; margin-top: 12px; } + .new-session-dialog h3 { font-size: 15px; color: #d0d0e8; diff --git a/public/utils.js b/public/utils.js index 9691f764..1a754f3d 100644 --- a/public/utils.js +++ b/public/utils.js @@ -12,43 +12,8 @@ function shortProjectPath(projectPath) { return projectPath.split(/[\\/]/).filter(Boolean).slice(-2).join('/'); } -/** - * Permission modes offered for a session, in the order they're shown. - * - * `value` is passed verbatim to `claude --permission-mode`, so each one must - * stay a member of that flag's choice list (as of CLI 2.1.220: acceptEdits, - * auto, bypassPermissions, manual, dontAsk, plan). A `null` value means we omit - * the flag entirely and let the CLI apply the user's own configured default. - * - * Shared by both session dialogs and the settings panel — the list used to be - * copy-pasted in all three, so a new mode reached only whichever copy someone - * remembered to edit. - */ -const PERMISSION_MODES = [ - { value: null, label: 'Default', desc: 'Prompt for all actions' }, - { value: 'auto', label: 'Auto', desc: 'Classifier allows routine work, stops for risky actions' }, - { value: 'acceptEdits', label: 'Accept Edits', desc: 'Auto-accept file edits, prompt for others' }, - { value: 'plan', label: 'Plan Mode', desc: 'Read-only exploration, no writes' }, - { value: 'dontAsk', label: "Don't Ask", desc: 'Auto-deny tools not explicitly allowed' }, - { value: 'bypassPermissions', label: 'Bypass', desc: 'Auto-accept all tool calls' }, -]; - -// Codex's equivalents. Deliberately a separate list rather than a translation -// of PERMISSION_MODES: codex has a sandbox policy and an approval policy where -// Claude has one permission mode, and the values are passed to the CLI verbatim. -// Must stay in sync with the sets buildLaunchArgs validates against. -const CODEX_SANDBOX_MODES = [ - { value: '', label: 'Default', desc: "Use codex's own config" }, - { value: 'read-only', label: 'Read Only', desc: 'No writes, no commands that change things' }, - { value: 'workspace-write', label: 'Workspace Write', desc: 'Write inside the project directory' }, - { value: 'danger-full-access', label: 'Full Access', desc: 'No sandbox at all' }, -]; - -const CODEX_APPROVAL_POLICIES = [ - { value: '', label: 'Default', desc: "Use codex's own config" }, - { value: 'on-request', label: 'On Request', desc: 'Codex decides when to ask' }, - { value: 'never', label: 'Never', desc: 'Never ask; failures go back to the model' }, -]; +// Session dialogs, schedules and global settings share the same CLI choices. +const { PERMISSION_MODES, CODEX_SANDBOX_MODES, CODEX_APPROVAL_POLICIES } = SessionConfig; // Mirror Claude CLI's project-folder naming. Must stay in sync with // encode-project-path.js (main process). Reverse-engineered from claude CLI 2.1.126. diff --git a/test/db-schema-reconcile.test.js b/test/db-schema-reconcile.test.js index 15bed0aa..5a039a6d 100644 --- a/test/db-schema-reconcile.test.js +++ b/test/db-schema-reconcile.test.js @@ -45,6 +45,38 @@ function inspectDb(dataDir) { const PROJECT_TABLES = ['projects', 'project_folders', 'tracks']; +test('schedule settings preserve explicit defaults and separate CLI choices across restarts', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-schedule-config-')); + try { + const r = runInElectronNode(` + const assert = require('node:assert/strict'); + let db = require('./db'); + const sessionConfig = { claude: { permissionMode: null, chrome: false, addDirs: '', + allowedTools: 'Read,Write', appendSystemPrompt: 'Follow the task instructions.' }, + codex: { codexSandbox: 'read-only', codexModel: 'chosen-model' } }; + db.insertSchedule({ id: 'configured', name: 'Configured', cwd: '/project', prompt: 'Do it', + every: 'hour', created: '2026-09-01T00:00:00Z', sessionConfig }); + db.insertSchedule({ id: 'inherited', name: 'Inherited', cwd: '/project', prompt: 'Do it', + every: 'hour', created: '2026-09-01T00:00:00Z' }); + db.closeDb(); + delete require.cache[require.resolve('./db')]; + db = require('./db'); + assert.deepEqual(db.getSchedule('configured').sessionConfig, sessionConfig); + assert.deepEqual(db.getSchedule('inherited').sessionConfig, {}); + assert.deepEqual(db.listSchedules().find(s => s.id === 'configured').sessionConfig, sessionConfig); + db.updateSchedule('configured', { name: 'Renamed' }); + assert.deepEqual(db.getSchedule('configured').sessionConfig, sessionConfig); + db.updateSchedule('configured', { sessionConfig: {} }); + db.closeDb(); + delete require.cache[require.resolve('./db')]; + db = require('./db'); + assert.deepEqual(db.getSchedule('configured').sessionConfig, {}); + db.closeDb(); + `, dir); + assert.equal(r.status, 0, r.stderr); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + test('legacy schedule imports survive restarts and deletion, and failed inserts remain retryable', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-schedule-imports-')); try { diff --git a/test/projects.test.js b/test/projects.test.js index fe4d54f7..a6f18901 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -31,7 +31,7 @@ function makeFakeDb({ global = {} } = {}) { updateSchedule: (id, patch) => { const r = rows.schedules.find(x => x.id === id); if (!r) return 0; - for (const key of ['name', 'trackId', 'cwd', 'prompt', 'every', 'atHour', 'atMinute', 'weekday', 'cron', 'cli', 'enabled', 'catchUp', 'lastRunAt', 'lastSessionId']) { + for (const key of ['name', 'trackId', 'cwd', 'prompt', 'every', 'atHour', 'atMinute', 'weekday', 'cron', 'cli', 'enabled', 'catchUp', 'lastRunAt', 'lastSessionId', 'sessionConfig']) { if (key in patch) r[key] = (key === 'enabled' || key === 'catchUp') ? (patch[key] ? 1 : 0) : patch[key]; } return 1; @@ -1133,6 +1133,39 @@ test('resolveScheduleLaunch runs where the track starts, and a folder schedule i } finally { t.cleanup(); } }); +test('schedule configuration stays per CLI while inherited project folders and track CLIs move', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'Configured schedules' }); + const sub = path.join(project.root, 'working'); + fs.mkdirSync(sub); + const { track } = projects.createTrack(project.id, { name: 'Run', cli: 'claude' }); + const sessionConfig = { claude: { permissionMode: null, chrome: false }, codex: { codexModel: 'saved-model' } }; + const { schedule } = projects.createSchedule({ name: 'Configured', prompt: 'Do it', every: 'hour', projectId: project.id, trackId: track.id, sessionConfig }); + assert.deepEqual(schedule.sessionConfig, sessionConfig); + let launch = projects.resolveScheduleLaunch(schedule.id); + assert.equal(launch.target.projectPath, project.root); + assert.equal(launch.runtime, 'claude'); + projects.updateProject(project.id, { defaultCwd: sub }); + launch = projects.resolveScheduleLaunch(schedule.id); + assert.equal(launch.target.projectPath, sub, 'track without a cwd follows the project default'); + projects.updateTrack(track.id, { cwd: project.root, cli: 'codex' }); + launch = projects.resolveScheduleLaunch(schedule.id); + assert.equal(launch.target.projectPath, project.root); + assert.equal(launch.runtime, 'codex'); + assert.deepEqual(launch.schedule.sessionConfig, sessionConfig, 'a CLI change preserves both sets of custom settings'); + const preview = projects.resolveScheduleContext({ projectId: project.id, trackId: track.id }); + assert.deepEqual(preview.target, launch.target); + assert.equal(preview.runtime, launch.runtime); + const updated = projects.updateSchedule(schedule.id, { sessionConfig: { codex: sessionConfig.codex } }); + assert.deepEqual(updated.schedule.sessionConfig, { codex: sessionConfig.codex }, 'resetting Claude keeps Codex choices'); + assert.match(projects.updateSchedule(schedule.id, { sessionConfig: { codex: { permissionMode: 'plan' } } }).error, /Unsupported codex setting/); + assert.match(projects.updateSchedule(schedule.id, { sessionConfig: { claude: { chrome: 'false' } } }).error, /on or off/); + assert.match(projects.updateSchedule(schedule.id, { sessionConfig: { claude: { runtime: 'codex' } } }).error, /Unsupported/); + assert.deepEqual(projects.updateSchedule(schedule.id, { name: 'Renamed' }).schedule.sessionConfig, { codex: sessionConfig.codex }); + } finally { t.cleanup(); } +}); + test('deleting a track moves its schedules to General; deleting the project removes them', async () => { const t = setup(); try { @@ -1202,3 +1235,75 @@ test('legacy schedules retry after empty and partial scans without repeating suc assert.equal(projects.importLegacySchedules(scanned), 0); } finally { t.cleanup(); } }); + + +test('legacy CLI settings survive import, editing and launch without inheriting folder overrides', () => { + const t = setup(); + try { + const config = require('../public/session-config'); + const claude = require('../harnesses/claude'); + const { parseFrontmatter } = require('../schedule-runner'); + const { meta } = parseFrontmatter(`--- +cron: 0 * * * * +cli: + permission-mode: dontAsk + allowed-tools: Read,Bash(git status:*) + append-system-prompt: Follow the task instructions exactly. + add-dirs: /one, /two + model: old-model + max-budget-usd: 2.50 +--- +Do the task.`); + const source = { filePath: path.join(t.root, 'schedule-custom.md'), projectPath: t.root, ...meta }; + assert.equal(projects.importLegacySchedules([source]), 1); + const row = projects.listSchedules()[0]; + assert.equal(row.cli, 'claude'); + const saved = row.sessionConfig.claude; + assert.equal(saved.permissionMode, 'dontAsk'); + assert.equal(saved.allowedTools, 'Read,Bash(git status:*)'); + assert.equal(saved.appendSystemPrompt, meta.cli['append-system-prompt']); + assert.equal(saved.addDirs, '/one, /two'); + assert.equal(saved.model, undefined, 'model is deliberately omitted'); + assert.equal(saved.maxBudgetUsd, undefined, 'budget is deliberately omitted'); + const options = config.resolveOptions('claude', { + permissionMode: 'bypassPermissions', dangerouslySkipPermissions: true, + worktree: true, worktreeName: 'new-default', chrome: true, mcpEmulation: true, + preLaunchCmd: 'other-prefix', addDirs: '/other', + allowedTools: 'Bash', appendSystemPrompt: 'other instructions', + }, saved); + assert.deepEqual(options, saved, 'all previous launch defaults are explicitly pinned'); + const args = claude.buildLaunchArgs({ sessionId: 'imported', isNew: true, options: { ...options, initialPrompt: row.prompt, scheduleId: row.id } }); + assert.ok(!args.includes('--model')); + assert.ok(!args.includes('--print')); + assert.ok(!args.includes('--max-budget-usd')); + assert.ok(!args.includes('--worktree')); + assert.ok(!args.includes('--chrome')); + assert.ok(!args.includes('--dangerously-skip-permissions')); + assert.equal(args[args.indexOf('--permission-mode') + 1], 'dontAsk'); + assert.equal(args[args.indexOf('--allowedTools') + 1], saved.allowedTools); + assert.equal(args[args.indexOf('--append-system-prompt') + 1], saved.appendSystemPrompt); + assert.deepEqual(args.slice(args.indexOf('--add-dir'), args.indexOf('--add-dir') + 4), ['--add-dir', '/one', '--add-dir', '/two']); + assert.equal(args.at(-1), row.prompt); + assert.deepEqual(projects.updateSchedule(row.id, { sessionConfig: row.sessionConfig }).schedule.sessionConfig, row.sessionConfig); + projects.updateSchedule(row.id, { sessionConfig: { claude: { permissionMode: 'plan' } } }); + assert.equal(projects.importLegacySchedules([source]), 0); + assert.deepEqual(projects.listSchedules()[0].sessionConfig, { claude: { permissionMode: 'plan' } }); + } finally { t.cleanup(); } +}); + +test('legacy omitted options preserve old defaults and invalid settings retry independently', () => { + const t = setup(); + try { + const defaults = { filePath: path.join(t.root, 'schedule-defaults.md'), projectPath: t.root, cron: '0 * * * *' }; + const bad = { ...defaults, filePath: path.join(t.root, 'schedule-bad.md'), cli: { 'permission-mode': 'invalid' } }; + const explicitDefault = { ...defaults, filePath: path.join(t.root, 'schedule-explicit.md'), cli: { 'permission-mode': 'default' } }; + assert.equal(projects.importLegacySchedules([bad, defaults, explicitDefault]), 2); + const row = projects.listSchedules().find(s => s.sourceFile === defaults.filePath); + assert.equal(row.sessionConfig.claude.permissionMode, 'acceptEdits'); + assert.equal(row.sessionConfig.claude.allowedTools, 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch'); + assert.equal(projects.listSchedules().find(s => s.sourceFile === explicitDefault.filePath).sessionConfig.claude.permissionMode, null); + assert.ok(!t.db.getImportedScheduleFiles().includes(bad.filePath)); + bad.cli['permission-mode'] = 'plan'; + assert.equal(projects.importLegacySchedules([bad, defaults]), 1, 'fixed files can be retried'); + } finally { t.cleanup(); } +}); diff --git a/test/session-config.test.js b/test/session-config.test.js new file mode 100644 index 00000000..b2a022bc --- /dev/null +++ b/test/session-config.test.js @@ -0,0 +1,86 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const SessionConfig = require('../public/session-config'); + +test('quick-launch defaults pick up newly defined settings without another option-copying list', async () => { + const field = { key: 'futureOption', label: 'Future option', type: 'text', default: '' }; + SessionConfig.FIELDS.claude.push(field); + try { + const context = vm.createContext({ SessionConfig, window: { api: { + getEffectiveSettings: async () => ({ permissionMode: null, chrome: false, addDirs: '', + codexModel: 'model', futureOption: 'future value', hiddenProjects: ['/not-a-launch-option'] }), + } } }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/dialogs.js'), 'utf8'), context); + const options = await context.resolveDefaultSessionOptions({ projectPath: '/project' }); + assert.equal(options.futureOption, 'future value'); + assert.equal(options.permissionMode, null); + assert.equal(options.chrome, false); + assert.equal(options.addDirs, ''); + assert.equal(options.codexModel, 'model'); + assert.equal(options.hiddenProjects, undefined); + } finally { SessionConfig.FIELDS.claude.pop(); } +}); + +test('custom false, null and empty values beat folder defaults; untouched fields follow changes', () => { + const overrides = { permissionMode: null, dangerouslySkipPermissions: false, chrome: false, addDirs: '' }; + const defaults = { permissionMode: 'bypassPermissions', dangerouslySkipPermissions: true, chrome: true, + addDirs: '/inherited', preLaunchCmd: 'old-prefix', codexModel: 'unrelated' }; + const options = SessionConfig.resolveOptions('claude', defaults, overrides); + for (const [key, value] of Object.entries(overrides)) assert.equal(options[key], value); + assert.equal(options.preLaunchCmd, 'old-prefix'); + assert.equal(options.codexModel, undefined); + assert.equal(SessionConfig.resolveOptions('claude', { ...defaults, preLaunchCmd: 'new-prefix' }, overrides).preLaunchCmd, 'new-prefix'); + assert.equal(SessionConfig.resolveOptions('claude', defaults, {}).chrome, true, 'reset to inheritance'); +}); + +test('configuration rejects cross-CLI fields, internal launch flags and invalid values', () => { + for (const input of [{ permissionMode: 'plan' }, { worktree: true }, { forkFrom: 'id' }, { scheduleId: 'id' }]) { + assert.throws(() => SessionConfig.normalizeOverrides('codex', input), /Unsupported/); + } + assert.throws(() => SessionConfig.normalizeOverrides('claude', { chrome: 'false' }), /on or off/); + assert.throws(() => SessionConfig.normalizeOverrides('codex', { codexSandbox: 'unknown' }), /Invalid Sandbox/); + assert.throws(() => SessionConfig.normalizeOverrides('claude', { preLaunchCmd: 'one\ntwo' }), /single line/); + assert.throws(() => SessionConfig.normalizeByCli(JSON.parse('{"__proto__":{}}')), /Invalid CLI/); + assert.deepEqual(SessionConfig.normalizeByCli({ claude: {}, codex: { codexModel: ' model ' } }), { claude: {}, codex: { codexModel: 'model' } }); +}); + +test('scheduled launches use current folder defaults with only the selected CLI overrides', async () => { + let defaults = { permissionMode: 'plan', chrome: true, codexSandbox: 'workspace-write', codexModel: 'folder-model' }; + const launches = []; + const folders = []; + const context = vm.createContext({ SessionConfig, window: { api: { + getEffectiveSettings: async folder => { folders.push(folder); return defaults; }, + } }, launchNewSession: async (target, options, behavior) => { launches.push({ target, options, behavior }); } }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/schedules.js'), 'utf8'), context); + const schedule = { id: 'schedule', prompt: 'Do it', sessionConfig: { + claude: { permissionMode: null, chrome: false }, codex: { codexModel: 'custom-model' }, + } }; + await context.launchScheduledSession({ schedule, target: { projectPath: '/first', projectId: 'project', trackId: 'track' }, runtime: 'claude' }); + assert.equal(launches[0].options.permissionMode, null); + assert.equal(launches[0].options.chrome, false); + assert.equal(launches[0].options.codexModel, undefined); + assert.equal(launches[0].options.initialPrompt, 'Do it'); + assert.equal(launches[0].options.scheduleId, 'schedule'); + assert.equal(launches[0].behavior.focus, false); + defaults = { ...defaults, codexSandbox: 'read-only' }; + await context.launchScheduledSession({ schedule, target: { projectPath: '/moved' }, runtime: 'codex' }); + assert.equal(launches[1].options.codexSandbox, 'read-only'); + assert.equal(launches[1].options.codexModel, 'custom-model'); + assert.equal(launches[1].options.permissionMode, undefined); + assert.deepEqual(folders, ['/first', '/moved']); +}); + + +test('additional Claude instructions preserve multiline text through settings and launch', () => { + const appendSystemPrompt = 'First line.\n Indented instructions.\n'; + const options = SessionConfig.resolveOptions('claude', {}, { appendSystemPrompt, allowedTools: 'Read,Write' }); + assert.equal(options.appendSystemPrompt, appendSystemPrompt); + const args = require('../harnesses/claude').buildLaunchArgs({ sessionId: 'test', isNew: true, options }); + assert.equal(args[args.indexOf('--append-system-prompt') + 1], appendSystemPrompt); + assert.equal(args[args.indexOf('--allowedTools') + 1], 'Read,Write'); + assert.throws(() => SessionConfig.normalizeOverrides('claude', { appendSystemPrompt: 'bad\x00prompt' }), /unsafe characters/); + assert.throws(() => SessionConfig.normalizeOverrides('codex', { allowedTools: 'Read' }), /Unsupported/); +}); From 4c2fce0f90beb677041fd4bd558beef2a94e6858 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Mon, 14 Sep 2026 11:55:13 -0700 Subject: [PATCH 20/24] Add model and effort settings, a JSON reader, and recent project files Model and effort: sessions and schedules can pick a model and effort for each CLI. Claude gets --model and --effort; Codex gets --model and model_reasoning_effort as a -c override, since it has no effort flag. Codex efforts depend on the model, so the form reads Codex's own model cache, disables levels the chosen model lacks, and resets an unsupported choice to Default with a note. Blank or Default passes no flag. The legacy schedule import now carries the old runner's model and pins effort unset. JSON reader: Preview on a .json or .jsonc file opens a collapsible tree. It parses with jsonc-parser, bundled for the renderer by bundle:jsonc, and keeps key order, duplicate keys and exact numbers. A broken file says where it breaks and jumps there in the editor. .jsonc allows comments and trailing commas; .json stays strict. Children are built only when a node opens. Recent Files: the Overview's right column lists the five newest files in the project folder by creation time, without the root briefs, repos/, hidden entries or dependency folders. A row opens the file in the Files tab with its folders expanded. Also: on Sundays, snooze's "Next week" is the Monday after tomorrow instead of being hidden, and the project list's task badge matches the session count's size and spacing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XNY9bj19PRXuqTY9RS9pWF --- .gitignore | 1 + harnesses/claude.js | 12 ++ harnesses/codex.js | 38 ++++++ main.js | 9 ++ package.json | 5 +- preload.js | 2 + projects.js | 50 +++++++- public/index.html | 2 + public/json-reader.js | 226 ++++++++++++++++++++++++++++++++++ public/projects-view.js | 63 +++++++++- public/session-config-form.js | 55 ++++++++- public/session-config.js | 15 ++- public/snooze.js | 14 ++- public/style.css | 53 +++++++- public/viewer-panel.js | 72 ++++++++++- test/harness-claude.test.js | 16 +++ test/harness-codex.test.js | 34 +++++ test/json-reader.test.js | 69 +++++++++++ test/projects.test.js | 40 +++++- test/session-config.test.js | 35 ++++++ test/snooze.test.js | 11 +- test/viewer-panel.test.js | 54 +++++++- 22 files changed, 843 insertions(+), 33 deletions(-) create mode 100644 public/json-reader.js create mode 100644 test/json-reader.test.js diff --git a/.gitignore b/.gitignore index e9a741a1..8ab697c3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ public/codemirror-bundle.js public/pptx-preview-bundle.js public/pptx-preview-bundle.js.LEGAL.txt +public/jsonc-parser-bundle.js .DS_Store .idea/ *.pem diff --git a/harnesses/claude.js b/harnesses/claude.js index be602e6b..74bed717 100644 --- a/harnesses/claude.js +++ b/harnesses/claude.js @@ -401,6 +401,11 @@ function classifyNotification(message) { // --- Launch --- +// The levels `claude --effort` accepts. Checked rather than passed blind: a +// stored setting can outlive the claude version that understood it, and an +// unknown level fails the launch. +const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); + /** * Argv for the claude binary. Returned as an array so the caller can quote it * for the target shell rather than building a command string here. @@ -424,6 +429,13 @@ function buildLaunchArgs({ sessionId, isNew, options }) { } else if (options.permissionMode) { args.push('--permission-mode', String(options.permissionMode)); } + // Empty leaves the choice to claude's own default. + if (options.model) { + args.push('--model', String(options.model)); + } + if (EFFORT_LEVELS.has(options.effort)) { + args.push('--effort', options.effort); + } // --worktree only applies when STARTING a session — it creates a fresh // isolated git worktree. Resuming (isNew === false) must reuse the // session's existing directory, so ignore the worktree option on resume diff --git a/harnesses/codex.js b/harnesses/codex.js index dcd222a0..19700e52 100644 --- a/harnesses/codex.js +++ b/harnesses/codex.js @@ -538,6 +538,40 @@ function classifyNotification(message) { const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']); const APPROVAL_POLICIES = new Set(['on-request', 'never']); +// Reasoning efforts across codex's model catalog. Codex has no --effort flag: +// it is the model_reasoning_effort config key, passed as a -c override. The +// value is parsed as TOML, so only these exact words are ever written into it. +const REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']); + +/** + * Codex's model list, from the cache its own model picker reads + * ($CODEX_HOME/models_cache.json). Each model names the reasoning efforts it + * supports, which the settings form uses to keep an effort the model rejects + * out of a launch. Read rather than running `codex debug models`: the main + * process does not have the login shell's PATH (see available()). Empty when + * codex has never fetched it or the file is not the shape expected. + */ +function readModelCatalog() { + let data; + try { + data = JSON.parse(fs.readFileSync(path.join(codexHome(), 'models_cache.json'), 'utf8')); + } catch { + return []; + } + const out = []; + for (const m of Array.isArray(data?.models) ? data.models : []) { + if (!m || typeof m.slug !== 'string' || !m.slug) continue; + const levels = Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : []; + out.push({ + slug: m.slug, + label: typeof m.display_name === 'string' && m.display_name ? m.display_name : m.slug, + efforts: levels.map(l => (typeof l === 'string' ? l : l?.effort)).filter(e => typeof e === 'string' && e), + defaultEffort: typeof m.default_reasoning_level === 'string' ? m.default_reasoning_level : null, + visible: m.visibility !== 'hide', + }); + } + return out; +} /** * Argv for the codex binary. @@ -589,6 +623,9 @@ function buildLaunchArgs({ sessionId, isNew, options }) { if (options.codexModel) { args.push('--model', String(options.codexModel)); } + if (REASONING_EFFORTS.has(options.codexEffort)) { + args.push('-c', `model_reasoning_effort="${options.codexEffort}"`); + } // --add-dir names extra writable roots, and codex refuses to start when // the sandbox cannot grant them ("effective permissions do not allow // additional writable roots"). Read-only, the default, can already read @@ -618,6 +655,7 @@ module.exports = { titleIndexPath, readSessionTitles, parseTitleState, classifyNotification, buildLaunchArgs, launchEnv, originatorTag, readLaunchSignals, matchesLaunch, needsIdDetection, + readModelCatalog, available, codexHome, sessionsRoot, listFolders, folderPath, folderForProject, listTranscripts, sessionIdFromPath, transcriptPath, isSubagentMeta, deriveProjectPath, diff --git a/main.js b/main.js index 00274344..20b7f954 100644 --- a/main.js +++ b/main.js @@ -480,6 +480,7 @@ ipcMain.handle('list-env-files', guarded((folderPath) => ({ ipcMain.handle('save-project-brief', guarded((id, content) => projects.saveBrief(id, content))); ipcMain.handle('create-project-file', guarded((id, name, content) => projects.createProjectFile(id, name, content))); ipcMain.handle('add-project-files', guarded((id, sourcePaths) => projects.addProjectFiles(id, sourcePaths))); +ipcMain.handle('list-recent-project-files', guarded((id) => projects.listRecentProjectFiles(id))); ipcMain.handle('get-project-plan', guarded((id) => projects.readProjectPlan(id))); ipcMain.handle('set-plan-item', guarded((id, kind, line, done) => projects.setPlanItem(id, kind, line, done))); ipcMain.handle('append-plan-item', guarded((id, kind, text) => projects.appendPlanItem(id, kind, text))); @@ -1023,6 +1024,9 @@ const SETTING_DEFAULTS = { chrome: false, preLaunchCmd: '', addDirs: '', + // Claude's model and effort. Empty leaves the choice to claude. + model: '', + effort: '', visibleSessionCount: 5, sidebarWidth: 340, terminalTheme: 'switchboard', @@ -1036,6 +1040,7 @@ const SETTING_DEFAULTS = { codexSandbox: 'workspace-write', codexApproval: '', codexModel: '', + codexEffort: '', }; // --- Harness enablement --- @@ -1061,6 +1066,10 @@ ipcMain.handle('get-harnesses', () => { .map(h => ({ id: h.id, label: h.label, enabled: !disabled.has(h.id) })); }); +// Codex's model catalog, for model suggestions and the per-model reasoning +// efforts in session and schedule settings. +ipcMain.handle('get-codex-models', () => getHarness('codex')?.readModelCatalog?.() || []); + ipcMain.handle('get-shell-profiles', () => { _shellProfiles = null; // refresh on each request return getShellProfiles(); diff --git a/package.json b/package.json index ac53cf8f..9fdb8ae1 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,9 @@ "build:linux": "npm run bundle && electron-builder --linux", "release": "npm run bundle && electron-builder --publish always", "postinstall": "node scripts/postinstall.js", - "bundle": "npm run bundle:codemirror && npm run bundle:pptx", - "bundle:pptx": "esbuild public/pptx-preview.js --bundle --outfile=public/pptx-preview-bundle.js --format=iife --platform=browser --minify" + "bundle": "npm run bundle:codemirror && npm run bundle:pptx && npm run bundle:jsonc", + "bundle:pptx": "esbuild public/pptx-preview.js --bundle --outfile=public/pptx-preview-bundle.js --format=iife --platform=browser --minify", + "bundle:jsonc": "esbuild node_modules/jsonc-parser/lib/esm/main.js --bundle --outfile=public/jsonc-parser-bundle.js --format=iife --global-name=JsoncParser --platform=browser --minify" }, "dependencies": { "@aiden0z/pptx-renderer": "1.2.4", diff --git a/preload.js b/preload.js index 789980b0..420b643b 100644 --- a/preload.js +++ b/preload.js @@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('api', { saveMemory: (filePath, content) => ipcRenderer.invoke('save-memory', filePath, content), getProjects: (showArchived) => ipcRenderer.invoke('get-projects', showArchived), getHarnesses: () => ipcRenderer.invoke('get-harnesses'), + getCodexModels: () => ipcRenderer.invoke('get-codex-models'), getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'), getActiveTerminals: () => ipcRenderer.invoke('get-active-terminals'), stopSession: (id) => ipcRenderer.invoke('stop-session', id), @@ -63,6 +64,7 @@ contextBridge.exposeInMainWorld('api', { saveProjectBrief: (id, content) => ipcRenderer.invoke('save-project-brief', id, content), createProjectFile: (id, name, content) => ipcRenderer.invoke('create-project-file', id, name, content), addProjectFiles: (id, sourcePaths) => ipcRenderer.invoke('add-project-files', id, sourcePaths), + listRecentProjectFiles: (id) => ipcRenderer.invoke('list-recent-project-files', id), getProjectPlan: (id) => ipcRenderer.invoke('get-project-plan', id), setPlanItem: (id, kind, line, done) => ipcRenderer.invoke('set-plan-item', id, kind, line, done), appendPlanItem: (id, kind, text) => ipcRenderer.invoke('append-plan-item', id, kind, text), diff --git a/projects.js b/projects.js index 3e437d03..26c990ad 100644 --- a/projects.js +++ b/projects.js @@ -326,6 +326,48 @@ function addedFilesAtRoot(root) { return { dirPath, files }; } +// Bounds for the Overview's Recent Files walk, so a project folder that +// collects something huge cannot stall the page. +const RECENT_FILES_MAX_DEPTH = 6; +const RECENT_FILES_MAX_ENTRIES = 5000; + +/** + * The newest files in the project folder, by when each was created, so a plan + * edited every hour does not crowd out a file added today. Skips the briefs + * every project starts with, attached repositories under repos/, hidden + * entries and dependency folders. Symlinks are not followed. + */ +function listRecentProjectFiles(projectId, limit = 5) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const count = Math.max(1, Math.min(50, Number(limit) || 5)); + const root = project.root; + const found = []; + let visited = 0; + const walk = (rel, depth) => { + let entries; + try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (++visited > RECENT_FILES_MAX_ENTRIES) return; + if (entry.name.startsWith('.')) continue; + const childRel = rel ? path.join(rel, entry.name) : entry.name; + if (entry.isDirectory()) { + if ((!rel && entry.name === REPOS_DIR) || ENV_SKIP_DIRS.has(entry.name) || depth >= RECENT_FILES_MAX_DEPTH) continue; + walk(childRel, depth + 1); + } else if (entry.isFile() && !(!rel && BRIEF_FILES.includes(entry.name))) { + let stat; + try { stat = fs.lstatSync(path.join(root, childRel)); } catch { continue; } + // Filesystems that do not record creation time report 0; use mtime there. + const addedMs = stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs; + found.push({ name: entry.name, relativePath: childRel, added: new Date(addedMs).toISOString(), size: stat.size, addedMs }); + } + } + }; + walk('', 0); + found.sort((a, b) => b.addedMs - a.addedMs || a.relativePath.localeCompare(b.relativePath)); + return { ok: true, files: found.slice(0, count).map(({ addedMs, ...file }) => file) }; +} + function listAddedFiles(projectId) { const project = db.getProject(projectId); if (!project) return { error: 'Project not found' }; @@ -1306,7 +1348,11 @@ function legacyScheduleConfig(cli = {}) { dangerouslySkipPermissions: false, worktree: false, worktreeName: '', chrome: false, mcpEmulation: false, preLaunchCmd: '', - // Model and budget are deliberately omitted from legacy migration. + // The old runner passed --model. Effort did not exist, so it is pinned + // unset rather than picking up a folder default the old run never had. + model: cli.model || '', + effort: '', + // Budget is deliberately omitted from legacy migration. } }); } @@ -1744,7 +1790,7 @@ module.exports = { projectsRoot, slugify, uniqueSlug, defaultBrief, createProject, updateProject, deleteProject, attachFolder, detachFolder, folderGitStatus, folderGitInfo, projectGitInfo, projectGitDiff, - syncProjectBrief, syncAllProjectBriefs, saveBrief, createProjectFile, addProjectFiles, listAddedFiles, + syncProjectBrief, syncAllProjectBriefs, saveBrief, createProjectFile, addProjectFiles, listAddedFiles, listRecentProjectFiles, launchContext, mergeAddDirs, worktreeParentFor, PROJECT_FILES, ADDED_FILES_DIR, readProjectPlan, setPlanItem, appendPlanItem, recordPlanLink, adoptPlan, diff --git a/public/index.html b/public/index.html index e9bf3810..777b7eae 100644 --- a/public/index.html +++ b/public/index.html @@ -116,6 +116,8 @@ + + diff --git a/public/json-reader.js b/public/json-reader.js new file mode 100644 index 00000000..cf0acc46 --- /dev/null +++ b/public/json-reader.js @@ -0,0 +1,226 @@ +// JSON reader: a collapsible tree for .json and .jsonc files, shown by the +// viewer's Preview button next to the editor. +// +// Parsing is jsonc-parser, VS Code's parser (already used for tasks.json). +// Its tree keeps what JSON.parse loses and a reader should show: key order, +// duplicate keys, and exact numbers, taken from the file's own text because +// the parsed value is rounded. Its errors carry offsets; V8's JSON.parse +// messages often have none. .jsonc allows comments and trailing commas; .json +// stays strict. +// +// Children are built when a node is first opened, so a large file costs only +// what is on screen. +(function (root) { + const jsonc = typeof module !== 'undefined' && module.exports ? require('jsonc-parser') : root.JsoncParser; + // jsonc-parser recurses once per level and overflows the stack on very deep + // input, so deeper than this is reported as an error before it parses. + const MAX_DEPTH = 512; + // The top level opens its containers only while there are few of them. + const AUTO_OPEN_CHILDREN = 20; + // jsonc-parser's error codes, in words. + const MESSAGES = { + InvalidSymbol: 'Unexpected character', + InvalidNumberFormat: 'Invalid number', + PropertyNameExpected: 'Expected a property name in double quotes', + ValueExpected: 'Expected a value', + ColonExpected: "Expected ':' after the property name", + CommaExpected: "Expected ','", + CloseBraceExpected: "Expected '}'", + CloseBracketExpected: "Expected ']'", + EndOfFileExpected: 'Unexpected content after the end of the JSON value', + InvalidCommentToken: 'Comments are not allowed in .json (they are in .jsonc)', + UnexpectedEndOfComment: 'Unterminated comment', + UnexpectedEndOfString: 'Unterminated string', + UnexpectedEndOfNumber: 'Invalid number', + InvalidUnicode: 'Invalid \\u escape', + InvalidEscapeCharacter: 'Invalid escape', + InvalidCharacter: 'Unescaped control character in a string', + }; + + /** Offset where nesting first passes MAX_DEPTH, or -1. The scanner does not recurse. */ + function tooDeepAt(text) { + const scanner = jsonc.createScanner(text, true); + const K = jsonc.SyntaxKind; + let depth = 0; + for (let token = scanner.scan(); token !== K.EOF; token = scanner.scan()) { + if (token === K.OpenBraceToken || token === K.OpenBracketToken) { + if (++depth > MAX_DEPTH) return scanner.getTokenOffset(); + } else if (token === K.CloseBraceToken || token === K.CloseBracketToken) { + depth--; + } + } + return -1; + } + + /** jsonc-parser's nodes into the reader's own shape. Only called on a tree without errors. */ + function convert(node, text) { + switch (node.type) { + case 'object': + return { type: 'object', entries: (node.children || []).map(property => ({ + key: property.children[0].value, value: convert(property.children[1], text), + })) }; + case 'array': + return { type: 'array', items: (node.children || []).map(child => convert(child, text)) }; + case 'number': + return { type: 'number', text: text.slice(node.offset, node.offset + node.length) }; + case 'string': + case 'boolean': + return { type: node.type, value: node.value }; + default: + return { type: 'null' }; + } + } + + /** 1-based line and column of an offset, counted the way the editor counts. */ + function lineColumn(text, offset) { + let line = 1; + let lineStart = 0; + for (let k = 0; k < offset && k < text.length; k++) { + if (text.charCodeAt(k) === 10) { line++; lineStart = k + 1; } + } + return { line, column: offset - lineStart + 1 }; + } + + /** + * { ok: true, root } or { ok: false, message, offset, line, column }. + * comments: true reads JSONC (comments and trailing commas allowed). + */ + function parseJsonForReader(text, { comments = false } = {}) { + const source = String(text ?? ''); + // A byte order mark is not JSON, but editors write one; read past it and + // keep reported positions in terms of the file as the editor shows it. + const skip = source.charCodeAt(0) === 0xfeff ? 1 : 0; + const body = skip ? source.slice(1) : source; + const fail = (message, offset) => ({ ok: false, message, offset: offset + skip, ...lineColumn(source, offset + skip) }); + + const deep = tooDeepAt(body); + if (deep >= 0) return fail(`Nested more than ${MAX_DEPTH} levels, too deep to show`, deep); + const errors = []; + const tree = jsonc.parseTree(body, errors, { disallowComments: !comments, allowTrailingComma: comments, allowEmptyContent: false }); + if (errors.length) { + const code = jsonc.printParseErrorCode(errors[0].error); + return fail(MESSAGES[code] || code, errors[0].offset); + } + if (!tree) return fail('Expected a value', 0); + return { ok: true, root: convert(tree, body) }; + } + + // ── Tree ────────────────────────────────────────────────────────── + + function childCount(node) { + if (node.type === 'object') return node.entries.length; + if (node.type === 'array') return node.items.length; + return 0; + } + + function scalarText(node) { + if (node.type === 'string') return JSON.stringify(node.value); + if (node.type === 'number') return node.text; + if (node.type === 'boolean') return String(node.value); + return 'null'; + } + + function el(tag, className, text) { + const node = document.createElement(tag); + node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function appendKey(row, key) { + if (!key) return; + const label = el('span', key.index ? 'json-reader-key json-reader-index' : 'json-reader-key', + key.index ? String(key.name) : JSON.stringify(key.name)); + if (key.duplicate) { + label.className += ' json-reader-dup'; + label.setAttribute('title', 'Duplicate key. Most JSON readers keep only the last one.'); + } + row.appendChild(label); + row.appendChild(el('span', 'json-reader-colon', ': ')); + } + + function buildNode(node, key, path, depth, state, firstRender, siblings) { + if (node.type !== 'object' && node.type !== 'array') { + const row = el('div', 'json-reader-row json-reader-leaf'); + appendKey(row, key); + row.appendChild(el('span', `json-reader-value json-reader-${node.type}`, scalarText(node))); + return row; + } + const [open, close] = node.type === 'object' ? ['{', '}'] : ['[', ']']; + const count = childCount(node); + if (!count) { + const row = el('div', 'json-reader-row json-reader-leaf'); + appendKey(row, key); + row.appendChild(el('span', 'json-reader-brace', open + close)); + return row; + } + + const details = el('details', 'json-reader-node'); + const summary = el('summary', 'json-reader-row'); + appendKey(summary, key); + summary.appendChild(el('span', 'json-reader-brace', `${open}…${close}`)); + const noun = node.type === 'object' ? (count === 1 ? 'key' : 'keys') : (count === 1 ? 'item' : 'items'); + summary.appendChild(el('span', 'json-reader-count', `${count} ${noun}`)); + details.appendChild(summary); + const body = el('div', 'json-reader-children'); + details.appendChild(body); + + let built = false; + const buildChildren = () => { + if (built) return; + built = true; + if (node.type === 'array') { + node.items.forEach((item, index) => { + body.appendChild(buildNode(item, { name: index, index: true }, `${path}/${index}`, depth + 1, state, firstRender, count)); + }); + return; + } + const totals = new Map(); + for (const entry of node.entries) totals.set(entry.key, (totals.get(entry.key) || 0) + 1); + const seen = new Map(); + for (const entry of node.entries) { + const nth = seen.get(entry.key) || 0; + seen.set(entry.key, nth + 1); + // encodeURIComponent never emits '#', so a duplicate's marker cannot + // collide with another key's name. + const childPath = `${path}/${encodeURIComponent(entry.key)}${nth ? `#${nth}` : ''}`; + body.appendChild(buildNode(entry.value, { name: entry.key, duplicate: totals.get(entry.key) > 1 }, + childPath, depth + 1, state, firstRender, count)); + } + }; + + // First render: the top is open, and its containers too while there are + // few. After that (a reload, a buffer edit) whatever the user left open. + const startOpen = firstRender + ? depth === 0 || (depth === 1 && siblings <= AUTO_OPEN_CHILDREN) + : state.open.has(path); + details.open = startOpen; + if (startOpen) { + state.open.add(path); + buildChildren(); + } + details.addEventListener('toggle', () => { + if (details.open) { + state.open.add(path); + buildChildren(); + } else { + state.open.delete(path); + } + }); + return details; + } + + /** + * Render a parsed tree into container. state.open is the set of expanded + * node paths; pass the same state object back on a re-render to keep them. + */ + function renderTree(container, tree, state) { + const firstRender = !state.open; + if (firstRender) state.open = new Set(); + container.appendChild(buildNode(tree, null, '', 0, state, firstRender, 1)); + } + + const api = { parseJsonForReader, renderTree, lineColumn }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.JsonReader = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/public/projects-view.js b/public/projects-view.js index 3f1f4fe2..7fcdb57c 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -1538,7 +1538,12 @@ async function appendTodo(project, text) { /** Open one of the project's own files on the Files tab, in the page's editor. */ function openProjectFileInEditor(project, name) { - filesState(project).selected = name; + const state = filesState(project); + state.selected = name; + // A nested file only has a row in the tree once its folders are open. + const sep = name.includes('/') ? '/' : '\\'; + const parts = name.split(sep); + for (let i = 1; i < parts.length; i++) state.expanded.add(parts.slice(0, i).join(sep)); setProjectTab(project, 'files'); if (!projectViewer || projectViewer.style.display === 'none') showProjectOverview(); else renderOverview(); @@ -2218,6 +2223,57 @@ function bindAddedFilesCard(project, side) { }; } +// Recent Files on the Overview. The side cards redraw several times while a +// page loads, so one listing is reused for a few seconds. +const recentFilesByProject = new Map(); // projectId → { at, files, error, pending } + +function loadRecentFiles(project) { + const entry = recentFilesByProject.get(project.id); + if (entry?.pending) return entry.pending; + if (entry && Date.now() - entry.at < FILES_LIST_TTL_MS) return Promise.resolve(entry); + const pending = Promise.resolve() + .then(() => window.api.listRecentProjectFiles(project.id)) + .then(result => ({ at: Date.now(), files: result?.ok ? result.files : [], error: result?.ok ? null : (result?.error || 'Could not list the files.') })) + .catch(err => ({ at: Date.now(), files: [], error: err.message })) + .then(next => { recentFilesByProject.set(project.id, next); return next; }); + recentFilesByProject.set(project.id, { at: 0, files: entry?.files ?? null, error: null, pending }); + return pending; +} + +function recentFilesHtml(project) { + const entry = recentFilesByProject.get(project.id); + if (!entry || entry.files === null) return '
Looking for files…
'; + if (entry.error) return `
${escapeHtml(entry.error)}
`; + if (!entry.files.length) return '
Nothing yet besides the brief. Files you or a session add to the project folder show up here.
'; + return entry.files.map(file => { + const dir = file.relativePath.slice(0, file.relativePath.length - file.name.length).replace(/[\\/]$/, ''); + return ``; + }).join(''); +} + +function bindRecentFilesCard(project, side) { + const list = side.querySelector('#ws-recent-files'); + if (!list) return; + side.querySelector('#ws-recent-files-all').onclick = () => { setProjectTab(project, 'files'); renderOverview(); }; + const bindRows = () => list.querySelectorAll('.ws-recent-file').forEach(row => { + row.onclick = () => openProjectFileInEditor(project, row.dataset.rel); + }); + bindRows(); + loadRecentFiles(project).then(() => { + if (!list.isConnected || selectedProject()?.id !== project.id) return; + const html = recentFilesHtml(project); + if (list._html === html) return; + list._html = html; + list.innerHTML = html; + bindRows(); + }); +} + function renderSideCards(project, side) { const brief = briefSummary(fileContent(project, 'CLAUDE.md')); const plan = parsePlan(fileContent(project, 'plan-tracker.md')); @@ -2245,6 +2301,10 @@ function renderSideCards(project, side) {
${open.length ? open.slice(0, 8).map(t => `
${escapeHtml(t.text)}
`).join('') : '
Nothing open. Any session can add to todos.md.
'}${open.length > 8 ? `` : ''}
Any session can add here. Try "add a todo: …" in a session of this project.
+
+
Recent Files
+
${recentFilesHtml(project)}
+
Attached Folders
${(project.folders || []).length ? (project.folders || []).map(f => ` @@ -2272,6 +2332,7 @@ function renderSideCards(project, side) { side.querySelector('#ws-plan-tracker').onclick = () => openProjectFileInEditor(project, 'plan-tracker.md'); side.querySelector('#ws-folders-add').onclick = () => attachFolderAsk(project); bindAddedFilesCard(project, side); + bindRecentFilesCard(project, side); side.querySelectorAll('.ws-todo input').forEach(box => { box.onchange = async () => { box.disabled = true; diff --git a/public/session-config-form.js b/public/session-config-form.js index c7bad5f8..54cc0a5d 100644 --- a/public/session-config-form.js +++ b/public/session-config-form.js @@ -1,10 +1,13 @@ // The same settings form is mounted by New Session and Create/Edit Schedule. (function (root) { + let mounts = 0; function mount(container, { runtime, defaults = {}, overrides = {}, inherit = false, onChange = () => {} }) { + const uid = ++mounts; let saved = SessionConfig.normalizeOverrides(runtime, overrides); let values = SessionConfig.resolveOptions(runtime, defaults, saved); const fields = SessionConfig.fieldsFor(runtime).filter(f => !f.hidden); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const listId = field => `session-config-${uid}-${field.key}`; const keys = field => field.type === 'permission' ? [field.key, 'dangerouslySkipPermissions'] : [field.key]; function permissionButtons(field) { return field.choices.map(c => ``).join('') + @@ -16,10 +19,13 @@ if (field.type === 'boolean') return ``; if (field.type === 'select') return ``; if (field.type === 'textarea') return ``; - return ``; + // Suggestions offer known names without limiting the field to them. + const suggest = field.suggestions || field.catalog; + return `` + + (suggest ? `${(field.suggestions || []).map(name => ``).join('')}` : ''); } container.innerHTML = fields.map(field => `
-
${esc(field.label)}${field.description ? `
${esc(field.description)}
` : ''}${inherit ? '
' : ''}
+
${esc(field.label)}${field.description ? `
${esc(field.description)}
` : ''}${inherit ? '
' : ''}${field.modelField ? '' : ''}
${control(field)}
`).join(''); function inheritance(row, field) { if (!inherit) return; @@ -58,6 +64,51 @@ changed(); }); } + // Codex's reasoning efforts depend on the model, and a scheduled run starts + // with nobody watching. So levels the chosen model does not support are + // disabled, and a selection it does not support goes back to Default with a + // note saying why. Without the catalog (codex never fetched it), or for a + // model it does not list, every level stays available. + const catalogField = fields.find(f => f.catalog === 'codex'); + const effortFields = fields.filter(f => f.modelField); + if (catalogField && effortFields.length && root.api?.getCodexModels) { + root.api.getCodexModels().then(models => { + if (!container.isConnected || !Array.isArray(models) || !models.length) return; + const byModel = new Map(models.map(m => [m.slug, m])); + const datalist = container.querySelector(`#${CSS.escape(listId(catalogField))}`); + if (datalist) datalist.innerHTML = models.filter(m => m.visible) + .map(m => ``).join(''); + const restrict = () => { + for (const field of effortFields) { + const row = container.querySelector(`[data-config-field="${field.key}"]`); + const select = row?.querySelector('select'); + if (!select) continue; + const note = row.querySelector('.session-config-note'); + note.hidden = true; + const model = byModel.get(values[field.modelField]); + const supported = model?.efforts?.length ? new Set(model.efforts) : null; + for (const option of select.options) option.disabled = !!supported && option.value !== '' && !supported.has(option.value); + if (!supported || !values[field.key] || supported.has(values[field.key])) continue; + const label = select.selectedOptions[0]?.textContent || values[field.key]; + saved[field.key] = ''; + values[field.key] = ''; + select.value = ''; + inheritance(row, field); + onChange({ ...saved }); + note.textContent = `${model.slug} does not support ${label}, so this is back to Default`; + note.hidden = false; + } + }; + const modelRow = container.querySelector(`[data-config-field="${catalogField.key}"]`); + modelRow?.querySelector('[data-config-input]')?.addEventListener('input', restrict); + // "Use folder default" changes the model without an input event. + modelRow?.addEventListener('click', restrict); + for (const field of effortFields) { + container.querySelector(`[data-config-field="${field.key}"] select`)?.addEventListener('input', restrict); + } + restrict(); + }).catch(() => {}); + } return { getOverrides: () => SessionConfig.normalizeOverrides(runtime, saved), getOptions: () => SessionConfig.resolveOptions(runtime, defaults, saved), diff --git a/public/session-config.js b/public/session-config.js index 236a882d..301718b1 100644 --- a/public/session-config.js +++ b/public/session-config.js @@ -20,10 +20,20 @@ { value: 'on-request', label: 'On Request', desc: 'Codex decides when to ask' }, { value: 'never', label: 'Never', desc: 'Never ask; failures go back to the model' }, ]; + // Effort levels. Claude's are the ones `claude --help` lists. Codex's are + // the union over its model catalog; each model supports a subset, and the + // form narrows the choice to what the chosen model accepts. + const CLAUDE_EFFORTS = [ + { value: '', label: 'Default' }, { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, { value: 'xhigh', label: 'Extra High' }, { value: 'max', label: 'Max' }, + ]; + const CODEX_EFFORTS = [...CLAUDE_EFFORTS, { value: 'ultra', label: 'Ultra' }]; const FIELDS = { claude: [ { key: 'permissionMode', label: 'Permission Mode', type: 'permission', default: null, choices: PERMISSION_MODES }, { key: 'dangerouslySkipPermissions', type: 'boolean', default: false, hidden: true }, + { key: 'model', label: 'Model', type: 'text', default: '', placeholder: 'default', suggestions: ['fable', 'opus', 'sonnet'], description: "Blank uses Claude's default. An alias or a full model name" }, + { key: 'effort', label: 'Effort', type: 'select', default: '', choices: CLAUDE_EFFORTS, description: "Default uses Claude's own setting" }, { key: 'allowedTools', label: 'Allowed Tools', type: 'text', default: '', wide: true, description: 'Tools allowed without a permission prompt (comma-separated)' }, { key: 'appendSystemPrompt', label: 'Additional System Prompt', type: 'textarea', default: '', wide: true, description: 'Instructions appended to Claude’s system prompt' }, { key: 'worktree', label: 'Worktree', type: 'boolean', default: false, description: 'Run each new session in an isolated git worktree' }, @@ -34,7 +44,8 @@ codex: [ { key: 'codexSandbox', label: 'Sandbox', type: 'select', default: '', choices: CODEX_SANDBOX_MODES, description: 'What Codex is allowed to touch' }, { key: 'codexApproval', label: 'Approval', type: 'select', default: '', choices: CODEX_APPROVAL_POLICIES, description: 'When Codex asks before running a command' }, - { key: 'codexModel', label: 'Model', type: 'text', default: '', placeholder: 'default', description: "Blank uses Codex's default" }, + { key: 'codexModel', label: 'Model', type: 'text', default: '', placeholder: 'default', catalog: 'codex', description: "Blank uses Codex's default" }, + { key: 'codexEffort', label: 'Reasoning Effort', type: 'select', default: '', choices: CODEX_EFFORTS, modelField: 'codexModel', description: "Default uses Codex's own setting" }, { key: 'dangerouslySkipPermissions', label: 'Bypass Approvals and Sandbox', type: 'boolean', default: false, description: 'Disables permission prompts and sandbox restrictions' }, ], }; @@ -92,7 +103,7 @@ return normalizeOverrides(runtime, out); } - const api = { PERMISSION_MODES, CODEX_SANDBOX_MODES, CODEX_APPROVAL_POLICIES, FIELDS, COMMON_FIELDS, + const api = { PERMISSION_MODES, CODEX_SANDBOX_MODES, CODEX_APPROVAL_POLICIES, CLAUDE_EFFORTS, CODEX_EFFORTS, FIELDS, COMMON_FIELDS, fieldsFor, normalizeOverrides, normalizeByCli, resolveOptions, own }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.SessionConfig = api; diff --git a/public/snooze.js b/public/snooze.js index cd5552ff..048d5ed2 100644 --- a/public/snooze.js +++ b/public/snooze.js @@ -81,8 +81,8 @@ /** * The menu's choices. "This evening" appears only while it is more than an - * hour away. On a Sunday "Tomorrow" and "Next week" are the same Monday - * morning, so only "Tomorrow" is offered. + * hour away. "Next week" is the coming Monday, or on a Sunday the Monday + * after, so it never repeats "Tomorrow". */ function resolveSnoozePresets(now = new Date()) { const inAnHour = new Date(now.getTime() + HOUR_MS); @@ -97,11 +97,13 @@ } const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); presets.push({ id: 'tomorrow', label: 'Tomorrow', whenLabel: timeOfDay(tomorrow), snoozedUntil: tomorrow.toISOString() }); - const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; + // The coming Monday. On a Sunday that is tomorrow, which "Tomorrow" + // already offers, so "Next week" moves to the Monday after instead of + // repeating it; the date in its label keeps the two apart. + let daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; + if (daysUntilMonday === 1) daysUntilMonday += 7; const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); - if (nextWeek.getTime() !== tomorrow.getTime()) { - presets.push({ id: 'next-week', label: 'Next week', whenLabel: `${weekday(nextWeek)} ${timeOfDay(nextWeek)}`, snoozedUntil: nextWeek.toISOString() }); - } + presets.push({ id: 'next-week', label: 'Next week', whenLabel: snoozeWakeDescription(nextWeek.toISOString(), now), snoozedUntil: nextWeek.toISOString() }); return presets; } diff --git a/public/style.css b/public/style.css index 0e828241..d5f769ab 100644 --- a/public/style.css +++ b/public/style.css @@ -3771,6 +3771,7 @@ body { display: flex; flex-direction: column; } .session-config-inheritance { margin-top: 4px; color: #8a8aa0; font-size: 11px; } .session-config-reset { padding: 0; border: 0; background: none; color: #a8b9f0; font: inherit; cursor: pointer; } .session-config-reset:hover { text-decoration: underline; } +.session-config-note { margin-top: 4px; color: #e8b04a; font-size: 11px; } .session-config-error { color: #e36a78; font-size: 12px; margin-top: 12px; } .new-session-dialog h3 { @@ -4643,16 +4644,17 @@ body { display: flex; flex-direction: column; } .proj-task-indicator { display: inline-flex; align-items: center; - gap: 2px; + /* Sized and spaced like the session count beside it. 4px here reads as the + dot's 5px: the play triangle leaves ~1px of its box empty on the right. */ + gap: 4px; color: #7a7a90; - font-size: 10px; - line-height: 1; + font-size: 11px; font-variant-numeric: tabular-nums; } .proj-task-indicator svg { - width: 9px; - height: 9px; + width: 10px; + height: 10px; color: #6f9477; } @@ -5163,6 +5165,14 @@ body { display: flex; flex-direction: column; } .ws-added-files-card { padding-bottom: 8px; } +/* Recent Files on the Overview: a whole row opens the file in the Files tab. */ +.ws-recent-files-card { padding-bottom: 6px; } +.ws-recent-file { width: 100%; border: 0; background: none; text-align: left; font-family: inherit; cursor: pointer; } +.ws-recent-file:hover { background: rgba(255,255,255,0.04); } +.ws-recent-file-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +.ws-recent-file-dir { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #6f6f88; font-size: 11px; } +.ws-recent-file .ws-card-meta { flex-shrink: 0; } + .ws-added-drop { display: flex; align-items: center; @@ -6155,6 +6165,39 @@ body { display: flex; flex-direction: column; } .viewer-preview-frame { display: block; width: 100%; height: 100%; border: 0; background: white; } .viewer-preview-error { padding: 24px; text-align: center; color: #b0b0c4; } +/* JSON reader: a collapsible tree over a .json file. Colors follow the editor's Dracula theme. */ +.json-reader { + flex: 1; + overflow: auto; + padding: 14px 18px; + color: #f8f8f2; + font-family: 'SF Mono', 'Fira Code', Menlo, monospace; + font-size: 12.5px; + line-height: 1.6; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.08) transparent; +} +.json-reader-row { padding: 0 4px; border-radius: 4px; white-space: pre-wrap; overflow-wrap: anywhere; } +.json-reader-row:hover { background: rgba(255,255,255,0.04); } +.json-reader-leaf { padding-left: 18px; } +.json-reader-node > summary { list-style: none; cursor: pointer; } +.json-reader-node > summary::-webkit-details-marker { display: none; } +.json-reader-node > summary::before { content: '\25B8'; display: inline-block; width: 14px; color: #6a6a80; transition: transform 0.1s; } +.json-reader-node[open] > summary::before { transform: rotate(90deg); } +.json-reader-children { margin-left: 11px; padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.06); } +.json-reader-key { color: #8be9fd; } +.json-reader-index { color: #6a6a80; } +.json-reader-dup { text-decoration: underline wavy #e8b04a; } +.json-reader-colon, .json-reader-brace { color: #6a6a80; } +.json-reader-string { color: #f1fa8c; } +.json-reader-number { color: #bd93f9; } +.json-reader-boolean { color: #ff79c6; } +.json-reader-null { color: #ff79c6; font-style: italic; } +.json-reader-count { margin-left: 8px; color: #6a6a80; font-size: 11px; } +.json-reader-error { padding: 24px; color: #b0b0c4; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px; } +.json-reader-jump { margin-top: 12px; padding: 5px 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; background: rgba(255,255,255,0.05); color: #d0d0e8; font: inherit; cursor: pointer; } +.json-reader-jump:hover { background: rgba(255,255,255,0.1); } + .ws-files-list { flex: 1; min-height: 0; diff --git a/public/viewer-panel.js b/public/viewer-panel.js index 44abda22..203104e1 100644 --- a/public/viewer-panel.js +++ b/public/viewer-panel.js @@ -6,7 +6,7 @@ * Watches files for external changes and reloads automatically. * * Toolbar buttons are shown/hidden automatically based on file type: - * - Preview: shown for markdown and HTML files + * - Preview: shown for markdown, HTML and JSON/JSONC files (JSON opens as a tree) * - Images, PDFs and PowerPoint: read-only previews with text editing controls hidden * - Wrap: shown for text (defaults on for markdown, off for others) * - Save: shown if onSave is provided @@ -41,6 +41,8 @@ class ViewerPanel { this.previewType = 'text'; this._objectUrl = null; this._openVersion = 0; + // Expanded nodes in the JSON reader, kept across reloads of the same file. + this._jsonState = { open: null }; // Create toolbar — always include preview, wrap, save; visibility managed in open() this.toolbar = window.createViewerToolbar({ @@ -138,32 +140,37 @@ class ViewerPanel { const previousPath = this.filePath; this.filePath = filePath; + if (previousPath !== filePath) this._jsonState = { open: null }; this.previewUrl = preview.previewUrl || ''; this.previewType = preview.previewType || (/\.html?$/i.test(filePath) ? 'html' : 'text'); this.toolbar.setTitle(title); this.toolbar.setPath(filePath); const isMd = this._isMarkdown(filePath); + const isJson = this._isJson(filePath); const isMedia = this._isMedia(); const isHtml = this.previewType === 'html'; // Show/hide preview button based on file type if (this.toolbar.previewBtn) { - this.toolbar.previewBtn.style.display = !isMedia && (isMd || isHtml) ? '' : 'none'; + this.toolbar.previewBtn.style.display = !isMedia && (isMd || isHtml || isJson) ? '' : 'none'; } for (const key of ['wrapBtn', 'gotoLineBtn', 'saveBtn', 'copyContentBtn']) { if (this.toolbar[key]) this.toolbar[key].style.display = isMedia ? 'none' : ''; } // Save preview preference before resetting - const wantPreview = isHtml || (isMd && this.opts.storageKey && localStorage.getItem(this.opts.storageKey) === 'true'); + // JSON opens in the reader unless this viewer was last switched to editing it. + const jsonKey = this._jsonPreviewKey(); + const wantPreview = isHtml || (isMd && this.opts.storageKey && localStorage.getItem(this.opts.storageKey) === 'true') + || (isJson && (!jsonKey || localStorage.getItem(jsonKey) !== 'false')); // Reset to edit mode before updating content (without touching localStorage) this.editorEl.style.display = isMedia ? 'none' : ''; this.previewMode = false; this.toolbar.setPreviewMode(false); if (this.toolbar.previewBtn) { - this.toolbar.previewBtn.title = isHtml ? 'Preview HTML' : 'Toggle markdown preview'; + this.toolbar.previewBtn.title = this._previewLabel(); this.toolbar.previewBtn.setAttribute('aria-label', this.toolbar.previewBtn.title); this.toolbar.previewBtn.setAttribute('aria-pressed', 'false'); } @@ -239,18 +246,20 @@ class ViewerPanel { } _togglePreview() { - if (this._isMedia() || (!this._isMarkdown(this.filePath) && this.previewType !== 'html')) return; + if (this._isMedia() || (!this._isMarkdown(this.filePath) && !this._isJson(this.filePath) && this.previewType !== 'html')) return; this.previewMode = !this.previewMode; if (this.previewMode) this._renderPreview(); else this._clearPreview(); this.editorEl.style.display = this.previewMode ? 'none' : ''; this.toolbar.setPreviewMode(this.previewMode); - const label = this.previewMode ? 'Back to editor' : this.previewType === 'html' ? 'Preview HTML' : 'Toggle markdown preview'; + const label = this._previewLabel(); this.toolbar.previewBtn.title = label; this.toolbar.previewBtn.setAttribute('aria-label', label); this.toolbar.previewBtn.setAttribute('aria-pressed', String(this.previewMode)); if (this._isMarkdown(this.filePath) && this.opts.storageKey) { localStorage.setItem(this.opts.storageKey, String(this.previewMode)); + } else if (this._isJson(this.filePath) && this._jsonPreviewKey()) { + localStorage.setItem(this._jsonPreviewKey(), String(this.previewMode)); } } @@ -322,6 +331,10 @@ class ViewerPanel { frame.srcdoc = '\n' + doc.documentElement.outerHTML; } this.previewEl.appendChild(frame); + } else if (this._isJson(this.filePath)) { + this.previewEl.className = 'json-reader'; + this.previewEl.style.display = 'block'; + this._renderJson(); } else { this.previewEl.className = 'markdown-preview'; this.previewEl.style.display = 'block'; @@ -329,6 +342,36 @@ class ViewerPanel { } } + /** The tree from the live buffer, or where the file breaks and a way to get there. */ + _renderJson() { + const parsed = window.JsonReader.parseJsonForReader(this.getContent(), { comments: /\.jsonc$/i.test(this.filePath) }); + if (parsed.ok) { + window.JsonReader.renderTree(this.previewEl, parsed.root, this._jsonState); + return; + } + const error = document.createElement('div'); + error.className = 'json-reader-error'; + const message = document.createElement('div'); + message.textContent = `This file is not valid JSON: ${parsed.message} (line ${parsed.line}, column ${parsed.column}).`; + const jump = document.createElement('button'); + jump.type = 'button'; + jump.className = 'json-reader-jump'; + jump.textContent = 'Show in editor'; + jump.addEventListener('click', () => { + // Going to fix one broken file is not a choice to stop reading JSON as a tree. + const key = this._jsonPreviewKey(); + const saved = key ? localStorage.getItem(key) : null; + this.goToLocation(parsed.line, parsed.column); + if (key) { + if (saved === null) localStorage.removeItem(key); + else localStorage.setItem(key, saved); + } + }); + error.appendChild(message); + error.appendChild(jump); + this.previewEl.appendChild(error); + } + _clearPreview() { this.previewEl.replaceChildren(); this.previewEl.style.display = 'none'; @@ -400,6 +443,7 @@ class ViewerPanel { this._clearPreview(); this.filePath = ''; this.previewMode = false; + this._jsonState = { open: null }; } _destroyEditor() { @@ -459,6 +503,22 @@ class ViewerPanel { const ext = filePath.split('.').pop()?.toLowerCase(); return ext === 'md' || ext === 'mdx'; } + + _isJson(filePath) { + return !!filePath && /\.jsonc?$/i.test(filePath); + } + + // Remembered apart from markdown's, so switching one does not switch the other. + _jsonPreviewKey() { + return this.opts.storageKey ? `${this.opts.storageKey}:json` : null; + } + + _previewLabel() { + if (this.previewMode) return 'Back to editor'; + if (this.previewType === 'html') return 'Preview HTML'; + if (this._isJson(this.filePath)) return 'Read JSON as a tree'; + return 'Toggle markdown preview'; + } } window.ViewerPanel = ViewerPanel; diff --git a/test/harness-claude.test.js b/test/harness-claude.test.js index 2236734c..2539c2ce 100644 --- a/test/harness-claude.test.js +++ b/test/harness-claude.test.js @@ -60,6 +60,22 @@ test('addDirs splits on commas and trims, skipping empties', () => { assert.deepEqual(args, ['--session-id', 'a', '--add-dir', '/one', '--add-dir', '/two']); }); +test('model and effort pass through; empty and unknown levels are left to claude', () => { + assert.deepEqual( + claude.buildLaunchArgs({ sessionId: 'a', isNew: true, options: { model: 'opus', effort: 'xhigh' } }), + ['--session-id', 'a', '--model', 'opus', '--effort', 'xhigh'] + ); + assert.deepEqual( + claude.buildLaunchArgs({ sessionId: 'a', isNew: true, options: { model: '', effort: '' } }), + ['--session-id', 'a'] + ); + assert.deepEqual( + claude.buildLaunchArgs({ sessionId: 'a', isNew: true, options: { effort: 'ultra' } }), + ['--session-id', 'a'], + 'claude has no ultra level' + ); +}); + test('appendSystemPrompt goes last', () => { const args = claude.buildLaunchArgs({ sessionId: 'a', isNew: true, options: { chrome: true, appendSystemPrompt: 'hi' }, diff --git a/test/harness-codex.test.js b/test/harness-codex.test.js index 1074bb6f..c33d7ad3 100644 --- a/test/harness-codex.test.js +++ b/test/harness-codex.test.js @@ -264,6 +264,40 @@ test('every flag codex is launched with is one the CLI declares', () => { } }); +test('reasoning effort goes through -c, since codex has no flag for it; anything else is dropped', () => { + assert.deepEqual( + argsWithoutForcedConfig({ sessionId: ID, isNew: true, options: { codexModel: 'gpt-5.5', codexEffort: 'high' } }), + ['--model', 'gpt-5.5', '-c', 'model_reasoning_effort="high"'] + ); + // The value is parsed as TOML: a stored value must never add its own keys. + assert.deepEqual(argsWithoutForcedConfig({ sessionId: ID, isNew: true, options: { codexEffort: 'high" model="x' } }), []); + assert.deepEqual(argsWithoutForcedConfig({ sessionId: ID, isNew: true, options: { codexEffort: '' } }), []); +}); + +test("the model catalog is read from codex's own cache, and is empty when that is missing or unreadable", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-')); + const previous = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + try { + assert.deepEqual(codex.readModelCatalog(), []); + fs.writeFileSync(path.join(home, 'models_cache.json'), '{not json'); + assert.deepEqual(codex.readModelCatalog(), []); + fs.writeFileSync(path.join(home, 'models_cache.json'), JSON.stringify({ models: [ + { slug: 'gpt-5.5', display_name: 'GPT-5.5', visibility: 'list', default_reasoning_level: 'medium', + supported_reasoning_levels: [{ effort: 'low', description: 'Fast' }, { effort: 'xhigh' }] }, + { slug: 'hidden-one', visibility: 'hide', supported_reasoning_levels: ['high'] }, + { display_name: 'No slug' }, + ] })); + assert.deepEqual(codex.readModelCatalog(), [ + { slug: 'gpt-5.5', label: 'GPT-5.5', efforts: ['low', 'xhigh'], defaultEffort: 'medium', visible: true }, + { slug: 'hidden-one', label: 'hidden-one', efforts: ['high'], defaultEffort: null, visible: false }, + ]); + } finally { + if (previous === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previous; + fs.rmSync(home, { recursive: true, force: true }); + } +}); + // --- sub-agent threads --- // // codex records sub-agent threads as ordinary rollouts, but refuses to resume diff --git a/test/json-reader.test.js b/test/json-reader.test.js new file mode 100644 index 00000000..ea3ab04c --- /dev/null +++ b/test/json-reader.test.js @@ -0,0 +1,69 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseJsonForReader } = require('../public/json-reader'); + +test('keeps key order, exact numbers and duplicate keys that JSON.parse would lose', () => { + const r = parseJsonForReader('{"b": 1, "2": 2, "a": 12345678901234567890, "a": -0.5e3}'); + assert.equal(r.ok, true); + assert.deepEqual(r.root.entries.map(e => e.key), ['b', '2', 'a', 'a']); + assert.equal(r.root.entries[2].value.text, '12345678901234567890'); + assert.equal(r.root.entries[3].value.text, '-0.5e3'); +}); + +test('reads every value type, escapes, and a leading byte order mark', () => { + const marked = parseJsonForReader('\uFEFF{\n "a": }'); + assert.deepEqual([marked.line, marked.column], [2, 7], 'positions count the mark, as the editor does'); + const r = parseJsonForReader('[true, false, null, "a\\n\\u00e9\\"\\/", {}, [], 0]'); + assert.equal(r.ok, true); + assert.deepEqual(r.root.items.map(n => n.type), ['boolean', 'boolean', 'null', 'string', 'object', 'array', 'number']); + assert.equal(r.root.items[3].value, 'a\né"/'); +}); + +test('agrees with JSON.parse on what is valid', () => { + const samples = ['{}', '[]', '0', '-1.5E+2', '"x"', ' {"a":[1,{"b":null}]} ', '{"a":1,}', '[1,]', '01', '1.', '.5', + '+1', "{'a':1}", '{"a" 1}', '"\\x"', '"tab\there"', 'tru', 'nul', '[1 2]', '{"a":1}{', '', ' ', '"\\u12G4"', + 'NaN', '[', '-', '1e', '"\\u00e9"', '[-0, 1e-7, 2E3]', '{"":""}']; + for (const s of samples) { + let native = true; + try { JSON.parse(s); } catch { native = false; } + assert.equal(parseJsonForReader(s).ok, native, `disagrees on ${JSON.stringify(s)}`); + } +}); + +test('a broken file reports where it first goes wrong, as line and column', () => { + const token = parseJsonForReader('{\n "a": 1,\n "b": }'); + assert.equal(token.ok, false); + assert.match(token.message, /Expected a value/); + assert.deepEqual([token.line, token.column], [3, 8]); + + const trailing = parseJsonForReader('{\n "a": 1,\n}'); + assert.match(trailing.message, /property name/); + assert.deepEqual([trailing.line, trailing.column], [3, 1]); + + const cut = parseJsonForReader('[1, 2'); + assert.match(cut.message, /Expected '\]'/); + assert.deepEqual([cut.line, cut.column], [1, 6]); + + const open = parseJsonForReader('{"a": "never closed'); + assert.match(open.message, /Unterminated string/); + assert.deepEqual([open.line, open.column], [1, 7]); +}); + +test('nesting too deep to show is an error, not a stack overflow', () => { + const r = parseJsonForReader('['.repeat(100000) + ']'.repeat(100000)); + assert.equal(r.ok, false); + assert.match(r.message, /too deep/); +}); + +test('.json stays strict about comments and trailing commas; .jsonc allows both', () => { + const text = '// settings\n{\n "a": 1, /* note */\n "b": [2,],\n}'; + const strict = parseJsonForReader(text); + assert.equal(strict.ok, false); + assert.match(strict.message, /Comments are not allowed/); + assert.deepEqual([strict.line, strict.column], [1, 1]); + const loose = parseJsonForReader(text, { comments: true }); + assert.equal(loose.ok, true); + assert.deepEqual(loose.root.entries.map(e => e.key), ['a', 'b']); + assert.equal(loose.root.entries[1].value.items.length, 1); +}); diff --git a/test/projects.test.js b/test/projects.test.js index a6f18901..2eb6817c 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -663,6 +663,39 @@ test('addProjectFiles copies dropped files into added-files and preserves duplic } }); +test('listRecentProjectFiles: newest added first, without the briefs, repos, hidden, dependency or too-deep folders', async () => { + const t = setup(); + try { + const { project } = await projects.createProject({ name: 'Recent' }); + assert.equal(projects.listRecentProjectFiles('missing').error, 'Project not found'); + assert.deepEqual(projects.listRecentProjectFiles(project.id).files, [], 'a new project has only its briefs'); + const root = project.root; + const write = async (rel) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), rel); + await new Promise(resolve => setTimeout(resolve, 15)); + }; + // Oldest to newest. Everything after five.md is newer and must still not show. + for (const rel of ['one.md', 'notes/two.md', 'added-files/three.pdf', 'four.md', 'notes/five.md', + 'repos/app/src/index.js', '.hidden/secret.md', '.env', 'node_modules/pkg/index.js', 'deep/a/b/c/d/e/f/too-deep.md']) { + await write(rel); + } + fs.symlinkSync(path.join(root, 'notes'), path.join(root, 'linked-notes')); + fs.rmSync(path.join(root, 'CLAUDE.md')); + await write('CLAUDE.md'); + await write('six.md'); + + const recent = projects.listRecentProjectFiles(project.id); + assert.equal(recent.ok, true); + assert.deepEqual(recent.files.map(f => f.relativePath), + ['six.md', path.join('notes', 'five.md'), 'four.md', path.join('added-files', 'three.pdf'), path.join('notes', 'two.md')]); + assert.equal(recent.files[1].name, 'five.md'); + assert.ok(Date.parse(recent.files[0].added) >= Date.parse(recent.files[1].added)); + assert.equal(projects.listRecentProjectFiles(project.id, 2).files.length, 2); + assert.ok(!projects.listRecentProjectFiles(project.id, 50).files.some(f => /^(linked-notes|repos|node_modules|\.)|too-deep|CLAUDE\.md$/.test(f.relativePath))); + } finally { t.cleanup(); } +}); + test('readProjectPlan, setPlanItem, appendPlanItem and links work on the tracker and todos', async () => { const t = setup(); try { @@ -1263,17 +1296,20 @@ Do the task.`); assert.equal(saved.allowedTools, 'Read,Bash(git status:*)'); assert.equal(saved.appendSystemPrompt, meta.cli['append-system-prompt']); assert.equal(saved.addDirs, '/one, /two'); - assert.equal(saved.model, undefined, 'model is deliberately omitted'); + assert.equal(saved.model, 'old-model', 'the old runner passed --model, so the model carries over'); + assert.equal(saved.effort, '', 'effort did not exist before, so it is pinned unset'); assert.equal(saved.maxBudgetUsd, undefined, 'budget is deliberately omitted'); const options = config.resolveOptions('claude', { permissionMode: 'bypassPermissions', dangerouslySkipPermissions: true, worktree: true, worktreeName: 'new-default', chrome: true, mcpEmulation: true, preLaunchCmd: 'other-prefix', addDirs: '/other', allowedTools: 'Bash', appendSystemPrompt: 'other instructions', + model: 'folder-model', effort: 'max', }, saved); assert.deepEqual(options, saved, 'all previous launch defaults are explicitly pinned'); const args = claude.buildLaunchArgs({ sessionId: 'imported', isNew: true, options: { ...options, initialPrompt: row.prompt, scheduleId: row.id } }); - assert.ok(!args.includes('--model')); + assert.equal(args[args.indexOf('--model') + 1], 'old-model'); + assert.ok(!args.includes('--effort'), 'a folder effort does not reach an imported schedule'); assert.ok(!args.includes('--print')); assert.ok(!args.includes('--max-budget-usd')); assert.ok(!args.includes('--worktree')); diff --git a/test/session-config.test.js b/test/session-config.test.js index b2a022bc..4f925a53 100644 --- a/test/session-config.test.js +++ b/test/session-config.test.js @@ -73,6 +73,41 @@ test('scheduled launches use current folder defaults with only the selected CLI assert.deepEqual(folders, ['/first', '/moved']); }); +test('model and effort are per-CLI settings that default to unset', () => { + const claude = SessionConfig.resolveOptions('claude', {}, {}); + assert.equal(claude.model, ''); + assert.equal(claude.effort, ''); + assert.equal(SessionConfig.resolveOptions('codex', {}, {}).codexEffort, ''); + assert.deepEqual(SessionConfig.normalizeOverrides('claude', { model: ' opus ', effort: 'max' }), { model: 'opus', effort: 'max' }); + assert.throws(() => SessionConfig.normalizeOverrides('claude', { effort: 'ultra' }), /Invalid Effort/); + assert.equal(SessionConfig.normalizeOverrides('codex', { codexEffort: 'ultra' }).codexEffort, 'ultra'); + assert.throws(() => SessionConfig.normalizeOverrides('codex', { effort: 'high' }), /Unsupported/); + assert.throws(() => SessionConfig.normalizeOverrides('claude', { codexEffort: 'high' }), /Unsupported/); +}); + +test("a schedule's model and effort reach the argv of the CLI it runs on", async () => { + const launches = []; + const context = vm.createContext({ SessionConfig, window: { api: { + getEffectiveSettings: async () => ({ model: 'folder-model', effort: 'low', codexModel: '', codexEffort: 'low' }), + } }, launchNewSession: async (target, options) => { launches.push(options); } }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/schedules.js'), 'utf8'), context); + const schedule = { id: 'schedule', prompt: 'Do it', sessionConfig: { + claude: { model: 'opus', effort: 'high' }, codex: { codexModel: 'gpt-5.5', codexEffort: 'xhigh' }, + } }; + await context.launchScheduledSession({ schedule, target: { projectPath: '/p' }, runtime: 'claude' }); + await context.launchScheduledSession({ schedule, target: { projectPath: '/p' }, runtime: 'codex' }); + const claudeArgs = require('../harnesses/claude').buildLaunchArgs({ sessionId: 's', isNew: true, options: launches[0] }); + assert.equal(claudeArgs[claudeArgs.indexOf('--model') + 1], 'opus'); + assert.equal(claudeArgs[claudeArgs.indexOf('--effort') + 1], 'high'); + const codexArgs = require('../harnesses/codex').buildLaunchArgs({ sessionId: 's', isNew: true, options: launches[1] }); + assert.equal(codexArgs[codexArgs.indexOf('--model') + 1], 'gpt-5.5'); + assert.ok(codexArgs.includes('model_reasoning_effort="xhigh"')); + assert.ok(!codexArgs.includes('--effort'), 'the Claude flag never reaches codex'); + // Nothing set anywhere: no flag, so each CLI keeps its own default. + await context.launchScheduledSession({ schedule: { ...schedule, sessionConfig: { claude: { model: '', effort: '' } } }, target: { projectPath: '/p' }, runtime: 'claude' }); + const bare = require('../harnesses/claude').buildLaunchArgs({ sessionId: 's', isNew: true, options: launches[2] }); + assert.ok(!bare.includes('--model') && !bare.includes('--effort')); +}); test('additional Claude instructions preserve multiline text through settings and launch', () => { const appendSystemPrompt = 'First line.\n Indented instructions.\n'; diff --git a/test/snooze.test.js b/test/snooze.test.js index 21e33530..abbf1073 100644 --- a/test/snooze.test.js +++ b/test/snooze.test.js @@ -56,12 +56,15 @@ test('presets: tomorrow and next week land at 9:00 on the right calendar days', assert.deepEqual([byId['next-week'].getDay(), byId['next-week'].getDate(), byId['next-week'].getHours()], [1, 14, 9]); }); -test('presets: on a Sunday "Tomorrow" and "Next week" are the same Monday, so only one is offered', () => { +test('presets: on a Sunday "Next week" is the Monday after tomorrow, not a copy of "Tomorrow"', () => { const sunday = new Date(2026, 8, 13, 9, 0, 0); assert.equal(sunday.getDay(), 0); - const ids = resolveSnoozePresets(sunday).map(p => p.id); - assert.ok(ids.includes('tomorrow')); - assert.ok(!ids.includes('next-week')); + const byId = Object.fromEntries(resolveSnoozePresets(sunday).map(p => [p.id, p])); + const tomorrow = new Date(byId.tomorrow.snoozedUntil); + const nextWeek = new Date(byId['next-week'].snoozedUntil); + assert.deepEqual([tomorrow.getDay(), tomorrow.getDate()], [1, 14]); + assert.deepEqual([nextWeek.getDay(), nextWeek.getDate(), nextWeek.getHours()], [1, 21, 9]); + assert.match(byId['next-week'].whenLabel, /^Sep 21, 9:00/, 'the date keeps it apart from Tomorrow'); }); test('presets: every wake time is ahead of now', () => { diff --git a/test/viewer-panel.test.js b/test/viewer-panel.test.js index 0337b3bf..af426625 100644 --- a/test/viewer-panel.test.js +++ b/test/viewer-panel.test.js @@ -24,12 +24,13 @@ function setup() { document: { createElement: element }, localStorage: { getItem() { return null; } }, Uint8Array, Blob, atob, setTimeout, URL: { createObjectURL: () => `blob:${++nextUrl}`, revokeObjectURL: url => revoked.push(url) }, - window: { api, createViewerToolbar: () => toolbar, createEditableViewer: (_el, content) => ({ + window: { api, JsoncParser: require('jsonc-parser'), createViewerToolbar: () => toolbar, createEditableViewer: (_el, content) => ({ state: { doc: { toString: () => content, length: content.length } }, dispatch({ changes }) { if (changes) { content = changes.insert; this.state.doc.length = content.length; } }, destroy() { this.destroyed = true; }, }) }, }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/json-reader.js'), 'utf8'), context); vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/viewer-panel.js'), 'utf8'), context); const panel = new context.window.ViewerPanel(element(), { language: 'auto', onSave: async () => { saved++; return { ok: true }; } }); const media = { previewType: 'image', mimeType: 'image/png', base64: 'AAEC' }; @@ -183,3 +184,54 @@ test('a delayed PowerPoint frame load is ignored after switching files', () => { frame.load(); assert.equal(panel.getContent(), 'Keep this'); }); + +test('JSON opens as a tree that keeps what the user closed across a reload, and can go back to editing', async () => { + const { panel, toolbar, api } = setup(); + panel.open('Config', '/config.json', '{"name": "app", "scripts": {"build": "x"}, "empty": []}'); + assert.equal(toolbar.previewBtn.style.display, ''); + assert.equal(panel.previewMode, true); + assert.equal(panel.previewEl.className, 'json-reader'); + assert.equal(panel.editorEl.style.display, 'none'); + const tree = panel.previewEl.children[0]; + assert.equal(tree.open, true); + const rows = tree.children[1].children; + assert.deepEqual(rows[0].children.map(c => c.textContent), ['"name"', ': ', '"app"']); + const scripts = rows[1]; + assert.equal(scripts.open, true, 'a small top level opens its containers'); + assert.equal(scripts.children[0].children.at(-1).textContent, '1 key'); + assert.deepEqual(rows[2].children.map(c => c.textContent), ['"empty"', ': ', '[]']); + + scripts.open = false; + scripts.toggle(); + api.readFileForPanel = async () => ({ ok: true, content: '{"name": "app", "scripts": {"build": "y", "test": "z"}, "empty": []}' }); + await panel._reloadFromDisk(); + const reloaded = panel.previewEl.children[0].children[1].children[1]; + assert.equal(reloaded.open, false, 'a node the user closed stays closed'); + assert.equal(reloaded.children[1].children.length, 0, 'closed nodes build no rows'); + assert.equal(reloaded.children[0].children.at(-1).textContent, '2 keys'); + + panel._togglePreview(); + assert.equal(panel.previewMode, false); + assert.equal(panel.editorEl.style.display, ''); + assert.equal(panel.previewEl.children.length, 0); + panel.open('Text', '/notes.txt', 'plain'); + assert.equal(toolbar.previewBtn.style.display, 'none'); +}); + +test('a broken JSON file says where it breaks, and its button jumps there in the editor', () => { + const { panel } = setup(); + panel.open('Broken', '/bad.json', '{\n "a": }'); + const error = panel.previewEl.children[0]; + assert.equal(error.className, 'json-reader-error'); + assert.match(error.children[0].textContent, /line 2, column 8/); + const moves = []; + panel.goToLocation = (line, column) => moves.push([line, column]); + error.children[1].click(); + assert.deepEqual(moves, [[2, 8]]); +}); + +test('a .jsonc file with comments and a trailing comma reads as a tree, not an error', () => { + const { panel } = setup(); + panel.open('Settings', '/settings.jsonc', '// editor settings\n{"tabSize": 2,}'); + assert.equal(panel.previewEl.children[0].className, 'json-reader-node'); +}); From 6d308925ebc42e80e827227a9492b2e4f838fc1e Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Mon, 14 Sep 2026 12:30:40 -0700 Subject: [PATCH 21/24] Remove the Brief card from the project Overview The card showed the first paragraph of CLAUDE.md outside Switchboard's managed block, with an Edit button. It no longer earns its place at the top of the right column. The brief files stay: every session reads them, and the Files tab still edits and saves them through saveBrief. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XNY9bj19PRXuqTY9RS9pWF --- README.md | 2 +- public/projects-view.js | 29 ++--------------------------- 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index c0bbd4bc..972375f1 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ The two CLIs handle this differently: ### Overview and working mode -The Projects tab lists projects only. Selecting one opens its **Overview**: the brief, the plan's progress, open todos, attached folders, and one card per track with its latest sessions. +The Projects tab lists projects only. Selecting one opens its **Overview**: the plan's progress, open todos, attached folders, and one card per track with its latest sessions. Opening a session from there switches to working mode: a slim project strip on top, a session list beside the terminal, and the plan and todo counts at the foot of the list. diff --git a/public/projects-view.js b/public/projects-view.js index 7fcdb57c..7156b643 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -3,7 +3,7 @@ // The Projects tab is not another session tree. The sidebar lists projects, // nothing else. Selecting one opens its workspace in the main area: // -// Overview — header, then tracks as cards beside the brief, plan, todos +// Overview — header, then tracks as cards beside the plan, todos // and folders. Settings is a second tab of the same page. // Working — opening one of its sessions keeps you in the project: a slim // strip on top, then tracks | sessions | terminal side by side. @@ -1441,7 +1441,7 @@ function openTrackInPanes(project, key) { if (sessions.length) openSession(sessions[0]); } -// --- Side cards: brief, plan, todos, folders --- +// --- Side cards: plan, todos, recent files, folders --- function fileKey(project, name) { return `${project.id}:${name}`; } function fileContent(project, name) { return projectsUi.files.get(fileKey(project, name))?.content ?? null; } @@ -1458,25 +1458,6 @@ async function loadProjectFiles(project, { force = false } = {}) { })); } -/** - * The brief's own words: whatever the user wrote outside Switchboard's managed - * block. The block holds the title, the project folder, the working rules and - * the attached folders, none of which is worth showing back to them. - */ -function briefSummary(content) { - if (!content) return { text: '', placeholder: true }; - const outside = content.replace(/[\s\S]*?/g, ''); - const para = []; - for (const raw of outside.split(/\r?\n/)) { - const line = raw.trim(); - if (line.startsWith('