From bca13b077de7a23f02335f1b8ddffc34659a39c4 Mon Sep 17 00:00:00 2001 From: NovakPAai Date: Sun, 2 Aug 2026 10:19:18 +0300 Subject: [PATCH 1/3] feat: unify local + external agents in the Running agents sidebar tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree only ever showed agents in external native terminals — an agent running inside codbash's own Workspace pane silently disappeared from it, so a project with both an in-app Claude session and an iTerm one only showed half the picture. That asymmetry (plus dimmed "idle" rows reading as ambiguous "maybe gone") is what read as messy/hard-to-trust grouping. - _wsRunningGroups(mode) replaces _wsRunningByProject(): still built from activeSessions (a live ps-scan re-polled every 5s, so an exited process is simply absent next tick — no separate "ghost" filtering needed), but now includes local (in-codbash) agents alongside external ones instead of dropping them. - Grouping is now a per-browser toggle (project → agent, or agent → project), persisted to localStorage['codedash-running-group'], exposed as a compact 2-button segmented control in the tree header. - Rows are color-coded by where they run: blue dot = inside codbash, orange dot = external native terminal (iTerm/Terminal.app/Warp/cmux). Idle still dims to muted gray, on top of either color. - jumpToRunningAgent now dispatches on the `local` tag: local agents jump straight to their Workspace tab/pane (found by matching cwd against the live pane list); external agents keep the existing /api/focus path. Updates the design doc and CLAUDE.md (which documented "external only" as intentional) and the existing test suite for the new function names/shape. --- CLAUDE.md | 2 +- docs/design/running-agents-external.md | 65 +++++++++++ src/frontend/styles.css | 29 ++++- src/frontend/workspace.js | 150 ++++++++++++++++++------- test/running-agents-external.test.js | 42 +++++-- 5 files changed, 235 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8046a8f..061bfbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ docs/ - **Crash-safety** — every HTTP route dispatch is wrapped in try/catch → 500 (one bad session never takes down the server); `findSessionFile` looks up its index with `Object.prototype.hasOwnProperty.call(...)` to avoid prototype-pollution DoS; delete / bulk-delete validate `SAFE_SESSION_ID` - **Desktop app is a thin shell** — `desktop/main.js` spawns the *unmodified* server as a Node child and points a `BrowserWindow` at it. Keep the server desktop-agnostic; desktop-only capabilities are exposed through `preload.js` (`window.codbashDesktop`) and detected at runtime in the frontend (e.g. the native folder picker is only wired up when `window.codbashDesktop.pickFolder` exists) - **View-aware chrome** — `render()` stamps `document.body` with `data-view`; the session toolbar is hidden in Overview/Workspace via `body[data-view="workspace"|"overview"] .toolbar { display:none }` -- **Running-agents sidebar tree** (Workspace) is built from `activeSessions` grouped by real `cwd` and labeled by agent — do NOT reconstruct it from static project config. It lists agents in **external native terminals** only: `getActiveSessions()` tags each with `local` (true = descends from a codbash browser-pty pane, false = external), and the tree shows `!local` — codbash's own panes are already visible as tabs. Clicking a row raises that real terminal window via `POST /api/focus` (`focusTerminalByPid`); it must NEVER spawn a blank in-app terminal (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). A still-running agent's PTY cannot be mirrored/attached from the browser terminal — focus the real window instead. See `docs/design/running-agents-external.md`. +- **Running-agents sidebar tree** (persistent, in the main sidebar — not just Workspace) is built from `activeSessions`, a live `ps`-scan-backed map (`GET /api/active`, polled every 5s), so a truly-exited agent simply isn't in it — no "ghost" filtering needed. It shows **every** currently-running agent, both inside codbash and in external native terminals: `getActiveSessions()` tags each with `local` (true = descends from a codbash browser-pty pane, false = external). `_wsRunningGroups(mode)` (`workspace.js`) groups by project (`mode:'project'`, default) or by agent kind (`mode:'agent'`) — user-toggleable via the compact segmented control in the tree header, persisted to `localStorage['codedash-running-group']`. Rows are colored by where they run (blue = inside codbash, orange = external; dimmed = idle, not gone). Clicking dispatches on `local`: local → `jumpToWorkspacePane` (found by matching `cwd` against live panes); external → `POST /api/focus` (`focusTerminalByPid`) to raise the real window. It must NEVER spawn a blank in-app terminal as a stand-in (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). See `docs/design/running-agents-external.md`. - **Saved layouts round-trip the full pane** — `sanitizePane` preserves `cmd` + `prefill` + `cwd` (not just `cmd`); dropping any of these silently loses the user's launch command on restore - **No `window.prompt` in Electron** — use `codbashPrompt()` (app.js) for any text input; the native prompt is a no-op in the desktop shell - **Two update paths, mutually exclusive** — the npm CLI self-updates via `POST /api/update` (`npm i -g codbash-app@latest` + restart). The **desktop app updates in-place via `electron-updater`** (download-on-click → restart, driven by the frontend banner over `window.codbashDesktop.updater` IPC and `main.js`). `desktop/main.js` sets `CODBASH_DESKTOP=1` so the server **refuses `/api/update` (400)** — running `npm i -g` inside the signed, read-only app bundle would update an unrelated global copy and the restart would land back on the bundled old version. macOS in-place update needs the **`.zip` target + `latest-mac.yml`** (Squirrel.Mac can't apply a DMG) and a signed build; on failure the banner falls back to opening the releases page (`codbash:open-releases`). See `desktop/RELEASE.md` §4. diff --git a/docs/design/running-agents-external.md b/docs/design/running-agents-external.md index abf63b5..df6e724 100644 --- a/docs/design/running-agents-external.md +++ b/docs/design/running-agents-external.md @@ -1,5 +1,13 @@ # Running Agents = agents in external terminals (focus, don't spawn) +> **Amendment (unified tree):** the sidebar tree now shows local (in-codbash) +> AND external agents together, colored differently, with a project/agent +> grouping toggle. The `local` tag and the "focus by PID, never spawn a blank +> terminal" rule below are unchanged for external agents — only the "external +> only" scoping was reversed. See "Unified local + external tree" at the +> bottom of this doc for the current design; the sections above it describe +> the external-focus mechanics, which still apply verbatim. + ## Goal The Workspace "Running agents" sidebar should list agents actually running in @@ -127,3 +135,60 @@ regression; a full keyboard-navigable list is a follow-up, `deferred_to: issue`) raises the native terminal window. **Touch targets:** tree rows keep their existing height (unchanged). + +## Unified local + external tree + +### Goal + +Users report the "external-only" tree was confusing: it silently hid agents +running in codbash's own panes, so a user watching a project with both an +in-app Claude session and an iTerm one saw only half the picture. Merge them +into one tree, colored by where each agent runs, and let the user pick whether +the tree groups by project (default) or by agent kind. + +### Data model + +No server change: `getActiveSessions()` already tags every live agent with +`local`. The frontend just stops dropping `local:true` entries. + +`_wsRunningGroups(mode)` (`workspace.js`) replaces `_wsRunningByProject()`: +- `mode: 'project'` (default) — groups by `cwd`, same as before, but items now + include local agents too. +- `mode: 'agent'` — groups by `kind` (tool), items are the projects that tool + is running in. + +Each item carries `{agent, cwd, projName, kind}` so either grouping can label +its rows correctly (tool name in project-mode rows, project name in +agent-mode rows). + +### Click dispatch + +`jumpToRunningAgent(cwd, sessionId, kind, pid, local)`: +- `local === true` → `_wsFindLivePaneForCwd(cwd)` looks up a live (connected, + not exited) pane whose shell cwd matches, then `jumpToWorkspacePane(tabId, + paneId)`. If no pane is found (a stale tag right after a tab closed), falls + back to `setView('workspace')` rather than doing nothing. +- `local === false` — unchanged: `POST /api/focus` by PID. + +### Grouping preference + +Stored in `localStorage['codedash-running-group']` (`'project'` | `'agent'`), +not a server setting — it's a per-browser display toggle, not something that +needs to sync across machines. A compact 2-button segmented control sits in +the tree header (`.ws-run-mode`). + +### "No ghost sessions" + +The tree was never actually showing ghosts in the sense of dead processes — +`activeSessions` is a live `ps` scan re-polled every 5s (1s while Workspace is +open), so an exited process drops out on the next poll. The dimmed rows in the +old design were `status: 'waiting'` (idle — low CPU, sleeping — but still a +live process), which reads as "maybe gone" without a legend. The new tree +keeps that dimming for idle but adds an explicit color legend (blue/orange) +for *where* an agent runs, so dimmed no longer doubles as an ambiguous signal. + +### Color legend + +- Blue dot — running inside a codbash browser-pty pane (`local: true`). +- Orange dot — running in an external native terminal (`local: false`). +- Either dimmed to muted gray — idle (waiting for input), not exited. diff --git a/src/frontend/styles.css b/src/frontend/styles.css index ca847ad..06966b6 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -3074,10 +3074,27 @@ body[data-view="overview"] .toolbar { display: none; } .ws-running-tree { margin: 8px 8px 4px; padding-top: 8px; border-top: 1px solid var(--border); } +.ws-run-head { + display: flex; align-items: center; justify-content: space-between; gap: 6px; + padding: 0 8px 4px; +} .ws-run-title { font-size: 10px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; - color: var(--text-muted); padding: 0 8px 4px; + color: var(--text-muted); +} +/* Compact 2-way segmented control — the "grouping" setting the user toggles + inline instead of hunting for it in a settings dialog. */ +.ws-run-mode { + display: inline-flex; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; flex-shrink: 0; } +.ws-run-mode-btn { + font-size: 9px; font-weight: 600; letter-spacing: 0.02em; line-height: 1; + padding: 3px 6px; background: transparent; color: var(--text-muted); + border: none; cursor: pointer; +} +.ws-run-mode-btn + .ws-run-mode-btn { border-left: 1px solid var(--border); } +.ws-run-mode-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); } +.ws-run-mode-btn.active { color: #fff; background: var(--accent-blue); } .ws-run-proj { display: flex; align-items: center; gap: 6px; padding: 4px 8px; font-size: 12px; color: var(--text-primary); cursor: pointer; border-radius: 6px; @@ -3089,6 +3106,8 @@ body[data-view="overview"] .toolbar { display: none; } font-size: 10px; color: var(--text-muted); background: var(--bg-hover); border-radius: 8px; padding: 0 6px; min-width: 16px; text-align: center; } +/* Per-agent row — dot color is the terminal-type legend: orange = running in + an external native terminal (default), blue = running inside codbash itself. */ .ws-run-term { position: relative; padding: 2px 8px 2px 22px; font-size: 11px; color: var(--text-secondary); @@ -3096,11 +3115,15 @@ body[data-view="overview"] .toolbar { display: none; } } .ws-run-term::before { content: ''; position: absolute; left: 11px; top: 50%; transform: translateY(-50%); - width: 5px; height: 5px; border-radius: 50%; background: var(--accent-green); + width: 5px; height: 5px; border-radius: 50%; background: var(--accent-orange, #f59e0b); } +.ws-run-term.ws-run-local::before { background: var(--accent-blue); } +.ws-run-term:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); } +/* Idle (waiting for input, still a live process — not "gone") always wins over + the terminal-type color so a dimmed dot reliably means "idle", not a second + meaning collision with local/external. */ .ws-run-term.ws-run-idle { color: var(--text-muted); } .ws-run-term.ws-run-idle::before { background: var(--text-muted); } -.ws-run-term:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); } /* In-app prompt (codbashPrompt) — replaces window.prompt (no-op in Electron). */ .cb-prompt-overlay { diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js index 3c2b525..13c4a09 100644 --- a/src/frontend/workspace.js +++ b/src/frontend/workspace.js @@ -271,30 +271,64 @@ function _wsToolLabel(kind) { return _WS_TOOL_LABELS[kind] || (kind.charAt(0).toUpperCase() + kind.slice(1)); } -// Group *running agents in EXTERNAL native terminals* (from the live /api/active -// map, not Workspace panes) by their real project folder. Agents running inside -// a codbash browser-pty pane (tagged `local` server-side) are excluded — they -// are already visible as Workspace tabs, so surfacing them here too is just -// noise. See docs/design/running-agents-external.md. Used by the sidebar tree. -function _wsRunningByProject() { +// Grouping mode for the sidebar tree — 'project' (project → agents, default) +// or 'agent' (agent kind → projects). A per-browser preference, not a synced +// setting: it's a display toggle, not something that needs to follow the user +// across machines. +var WS_RUN_GROUP_KEY = 'codedash-running-group'; +function _wsGetRunningGroupMode() { + try { + return window.localStorage.getItem(WS_RUN_GROUP_KEY) === 'agent' ? 'agent' : 'project'; + } catch (e) { return 'project'; } +} +function _wsSetRunningGroupMode(mode) { + mode = mode === 'agent' ? 'agent' : 'project'; + if (_wsGetRunningGroupMode() === mode) return; + try { window.localStorage.setItem(WS_RUN_GROUP_KEY, mode); } catch (e) {} + _wsRunTreeSig = ''; // force a rebuild even though activeSessions didn't change + _wsRenderRunningTree(); +} + +// Find a live (connected, not exited) Workspace pane whose shell sits in `cwd`. +// Used to make an agent tagged `local:true` (running inside a codbash pty) +// clickable — jump straight to its tab/pane instead of the external-focus path. +function _wsFindLivePaneForCwd(cwd) { + if (!cwd || typeof _wsAllPanes !== 'function') return null; + var all = _wsAllPanes(); + for (var i = 0; i < all.length; i++) { + var x = all[i]; + if (x.pane && !x.pane.exited && x.pane.cwd === cwd) return x; + } + return null; +} + +// Group *every currently-running agent* (from the live /api/active map, which +// is a fresh `ps` scan each poll — an agent whose process has actually exited +// simply isn't in it, so there is no "ghost" entry to filter) by either their +// real project folder or their agent kind, depending on `mode`. Each agent +// carries `local` (server-tagged: true = running inside a codbash browser-pty +// pane, false = an external native terminal) so the render step can color and +// route clicks correctly. See docs/design/running-agents-external.md for the +// external-focus half of this; the local half jumps to the matching pane. +function _wsRunningGroups(mode) { var map = (typeof activeSessions === 'object' && activeSessions) || {}; var groups = {}, order = []; Object.keys(map).forEach(function (k) { var a = map[k]; - // Skip codbash's own panes: this tree is for agents in external terminals, - // the ones that have no other UI home. (Undefined `local` — an older server - // payload — is treated as external so nothing silently disappears.) - if (a && a.local === true) return; var cwd = (a && a.cwd) || ''; - // Only skip entries with no cwd (can't group or focus a window). + // Only skip entries with no known folder — nothing to group or act on. if (!cwd) return; var isHome = /^(\/Users\/[^/]+|\/home\/[^/]+|\/root)\/?$/.test(cwd); - var key = cwd; // group by full path so two same-named folders don't merge + var projName = isHome ? '~' : _wsProjectBasename(cwd); + var kind = (a && a.kind) || ''; + var key = mode === 'agent' ? (kind || 'agent') : cwd; if (!groups[key]) { - groups[key] = { name: isHome ? '~' : _wsProjectBasename(cwd), cwd: cwd, items: [] }; + groups[key] = mode === 'agent' + ? { name: _wsToolLabel(kind), kind: kind, items: [] } + : { name: projName, cwd: cwd, items: [] }; order.push(key); } - groups[key].items.push(a); + groups[key].items.push({ agent: a, cwd: cwd, projName: projName, kind: kind }); }); return order.map(function (k) { return groups[k]; }); } @@ -307,15 +341,24 @@ function _wsPidArg(pid) { return (Number.isInteger(n) && n > 0) ? String(n) : '0'; } -// Click a running-agent row. These rows are agents in EXTERNAL native terminals -// (codbash's own panes are filtered out of the tree), so the honest action is to -// raise that real terminal window by PID — reusing /api/focus (focusTerminalByPid, -// same path the session cards' "Focus Terminal" uses). We deliberately do NOT -// open a blank in-app terminal as a stand-in: an empty shell is not the agent, -// and resuming (claude --continue) would spawn a SECOND instance of a live agent. -// `cwd`/`kind` are kept in the signature for call-site stability but unused here -// (focus is keyed purely by pid); `sessionId` is forwarded for the server log. -function jumpToRunningAgent(cwd, sessionId, kind, pid) { +// Click a running-agent row. Dispatches on where the agent actually runs: +// - local (inside a codbash browser-pty pane) → jump straight to that tab/pane. +// We look the pane up by cwd at click time (not baked into the onclick) so a +// tab opened/closed after the tree last rendered is still found correctly. +// - external (native terminal — iTerm/Terminal.app/Warp/cmux…) → raise that +// real window by PID via /api/focus (focusTerminalByPid, same path the +// session cards' "Focus Terminal" uses). We deliberately do NOT open a blank +// in-app terminal as a stand-in: an empty shell is not the agent, and +// resuming (claude --continue) would spawn a SECOND instance of a live agent. +function jumpToRunningAgent(cwd, sessionId, kind, pid, local) { + if (local) { + var hit = _wsFindLivePaneForCwd(cwd); + if (hit) { jumpToWorkspacePane(hit.tab.id, hit.pane.id); return; } + // The agent is tagged local but we can't find its pane (e.g. a stale tag + // right after a tab closed) — land on Workspace rather than doing nothing. + if (typeof setView === 'function') setView('workspace'); + return; + } var n = typeof pid === 'number' ? pid : parseInt(pid, 10); if (!Number.isInteger(n) || n <= 0) { if (typeof showToast === 'function') showToast('No terminal window to focus for this agent.'); @@ -340,37 +383,64 @@ function jumpToRunningAgent(cwd, sessionId, kind, pid) { }); } -// Render a compact tree at the bottom of the sidebar: each project folder with a -// running agent, the agents underneath labeled by agent name — click to jump. +// Render a compact tree at the bottom of the sidebar of every currently-running +// agent, grouped by project or by agent kind (user's choice, see +// _wsGetRunningGroupMode). Each row is colored by where it runs — blue for +// inside codbash, orange for an external native terminal — and clicking jumps +// straight to it. var _wsRunTreeSig = ''; function _wsRenderRunningTree() { var el = document.getElementById('wsRunningTree'); if (!el) return; - var groups = _wsRunningByProject(); - var sig = groups.map(function (g) { - return g.cwd + ':' + g.items.map(function (a) { - return (a.sessionId || a.pid) + '=' + a.kind + '/' + a.status; + var mode = _wsGetRunningGroupMode(); + var groups = _wsRunningGroups(mode); + // Fold in the live-pane set so a tab opened/closed for a `local` agent's + // folder forces a rebuild even though activeSessions itself didn't change — + // otherwise a freshly-opened pane wouldn't become clickable until the next + // unrelated agent-state change. + var paneSig = (typeof _wsAllPanes === 'function' ? _wsAllPanes() : []) + .filter(function (x) { return x.pane && !x.pane.exited; }) + .map(function (x) { return x.tab.id + '/' + x.pane.id + '=' + x.pane.cwd; }).join(','); + var sig = mode + '|' + paneSig + '|' + groups.map(function (g) { + return (g.cwd || g.kind) + ':' + g.items.map(function (x) { + return (x.agent.sessionId || x.agent.pid) + '=' + x.kind + '/' + x.agent.status + '/' + x.agent.local; }).join(','); }).join('|'); if (sig === _wsRunTreeSig) return; // no change → no rebuild _wsRunTreeSig = sig; if (!groups.length) { el.style.display = 'none'; el.innerHTML = ''; return; } - var html = '
Running agents
'; + + var html = '
' + + 'Running agents' + + '' + + '' + + '' + + '
'; + groups.forEach(function (g) { - // The project header focuses the first agent's window (a reasonable default - // when a folder hosts several). - var headPid = _wsPidArg(g.items[0] && g.items[0].pid); - html += '
' + + var first = g.items[0]; + // The header focuses/jumps to the first item — a reasonable default when a + // group holds several agents. + html += '
' + '' + escHtml(g.name) + '' + '' + g.items.length + '
'; - g.items.forEach(function (a) { + g.items.forEach(function (x) { + var a = x.agent; var waiting = a.status === 'waiting'; - html += '
' + - escHtml(_wsToolLabel(a.kind)) + '
'; + var local = a.local === true; + var label = mode === 'agent' ? x.projName : _wsToolLabel(x.kind); + var whereLabel = local ? 'inside codbash' : 'external terminal'; + html += '
' + + escHtml(label) + '
'; }); }); el.innerHTML = html; diff --git a/test/running-agents-external.test.js b/test/running-agents-external.test.js index dd6befe..3a96a31 100644 --- a/test/running-agents-external.test.js +++ b/test/running-agents-external.test.js @@ -1,12 +1,14 @@ 'use strict'; -// Running-agents = external terminals. See docs/design/running-agents-external.md -// and specs/running-agents-external.feature. +// Running-agents tree = every currently-running agent, local (inside codbash) +// and external (native terminal) alike. See +// docs/design/running-agents-external.md and specs/running-agents-external.feature. // // Core logic under test: _tagLocalAgents — pure ancestry tagging that marks each // live agent local=true when its process tree reaches a codbash-pty pid, else // local=false (an agent running in an external native terminal). The Running -// agents tree shows only the external ones and clicking focuses their real +// agents tree shows BOTH, colored by which, and dispatches clicks differently: +// local → jump to the matching Workspace tab/pane, external → focus the real // window (never spawns a blank terminal). const test = require('node:test'); @@ -87,18 +89,40 @@ function wsSource() { return fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'workspace.js'), 'utf8'); } -test('running-agents tree excludes codbash-pane agents (local=true)', () => { +test('running-agents tree includes both local and external agents', () => { const src = wsSource(); - const fn = src.match(/function _wsRunningByProject\(\)[\s\S]*?\n\}/); - assert.ok(fn, '_wsRunningByProject should exist'); - assert.match(fn[0], /a\.local/, 'must filter out local (codbash-pane) agents'); + const fn = src.match(/function _wsRunningGroups\(mode\)[\s\S]*?\n\}/); + assert.ok(fn, '_wsRunningGroups should exist'); + assert.doesNotMatch(fn[0], /if\s*\(a\.local/, 'must not filter out local (codbash-pane) agents'); + assert.doesNotMatch(fn[0], /return;\s*\/\/.*local/i, 'must not early-return on local agents'); }); -test('clicking a running agent focuses its window via /api/focus', () => { +test('_wsRunningGroups supports grouping by project or by agent kind', () => { + const src = wsSource(); + const fn = src.match(/function _wsRunningGroups\(mode\)[\s\S]*?\n\}/); + assert.ok(fn, '_wsRunningGroups should exist'); + assert.match(fn[0], /mode === 'agent'/, 'must branch on the agent grouping mode'); +}); + +test('the grouping mode preference persists to localStorage', () => { + const src = wsSource(); + assert.match(src, /function _wsSetRunningGroupMode/, '_wsSetRunningGroupMode should exist'); + assert.match(src, /localStorage\.setItem\(WS_RUN_GROUP_KEY/, 'must persist the chosen mode'); +}); + +test('clicking a local running agent jumps to its Workspace pane, not /api/focus', () => { + const src = wsSource(); + const fn = src.match(/function jumpToRunningAgent\([\s\S]*?\n\}/); + assert.ok(fn, 'jumpToRunningAgent should exist'); + assert.match(fn[0], /if \(local\)/, 'must branch on the local flag'); + assert.match(fn[0], /jumpToWorkspacePane/, 'local agents must jump to their pane'); +}); + +test('clicking an external running agent focuses its window via /api/focus', () => { const src = wsSource(); const fn = src.match(/function jumpToRunningAgent\([\s\S]*?\n\}/); assert.ok(fn, 'jumpToRunningAgent should exist'); - assert.match(fn[0], /\/api\/focus/, 'must POST to /api/focus'); + assert.match(fn[0], /\/api\/focus/, 'must POST to /api/focus for external agents'); }); test('clicking a running agent never opens a blank terminal', () => { From 9410084dd7547fc5a9a689c655267fdcd6f0d2e5 Mon Sep 17 00:00:00 2001 From: NovakPAai Date: Sun, 2 Aug 2026 10:30:54 +0300 Subject: [PATCH 2/3] fix: make the Running agents tree an actual 3-level hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass grouped project/agent as a flat 2-level list — one header, then every session underneath as a same-labeled row ("Claude", "Claude", …). That read as alternating noise (session, agent, session, agent) rather than a real hierarchy, especially once a project had 2+ live sessions of the same agent. _wsRunningTree(mode) replaces _wsRunningGroups(mode): a real 3-level tree — outer group -> inner group -> individual sessions — built via a small generic _wsGroupBy() applied twice. mode:'project' nests project -> agent -> sessions; mode:'agent' nests the mirror, agent -> project -> sessions (re-parenting the whole tree, not just relabeling a flat list). A project+agent pair with exactly one live session collapses its leaf row into the subgroup row itself (.ws-run-leaf) — no redundant single-child row. Once it holds 2+ sessions, the subgroup renders as a real subheader and each session gets its own leaf row, labeled via _wsSessionLeafLabel (a live pane's name if local and matched, else the session-id prefix — same convention as the session cards' "Resume last session (id12345)" — else a bare pid) so same-project/same-agent sessions read as genuinely distinct instead of the same label repeated. --- CLAUDE.md | 2 +- docs/design/running-agents-external.md | 32 +++-- src/frontend/styles.css | 43 ++++-- src/frontend/workspace.js | 191 +++++++++++++++++-------- test/running-agents-external.test.js | 18 ++- 5 files changed, 197 insertions(+), 89 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 061bfbb..786e48f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ docs/ - **Crash-safety** — every HTTP route dispatch is wrapped in try/catch → 500 (one bad session never takes down the server); `findSessionFile` looks up its index with `Object.prototype.hasOwnProperty.call(...)` to avoid prototype-pollution DoS; delete / bulk-delete validate `SAFE_SESSION_ID` - **Desktop app is a thin shell** — `desktop/main.js` spawns the *unmodified* server as a Node child and points a `BrowserWindow` at it. Keep the server desktop-agnostic; desktop-only capabilities are exposed through `preload.js` (`window.codbashDesktop`) and detected at runtime in the frontend (e.g. the native folder picker is only wired up when `window.codbashDesktop.pickFolder` exists) - **View-aware chrome** — `render()` stamps `document.body` with `data-view`; the session toolbar is hidden in Overview/Workspace via `body[data-view="workspace"|"overview"] .toolbar { display:none }` -- **Running-agents sidebar tree** (persistent, in the main sidebar — not just Workspace) is built from `activeSessions`, a live `ps`-scan-backed map (`GET /api/active`, polled every 5s), so a truly-exited agent simply isn't in it — no "ghost" filtering needed. It shows **every** currently-running agent, both inside codbash and in external native terminals: `getActiveSessions()` tags each with `local` (true = descends from a codbash browser-pty pane, false = external). `_wsRunningGroups(mode)` (`workspace.js`) groups by project (`mode:'project'`, default) or by agent kind (`mode:'agent'`) — user-toggleable via the compact segmented control in the tree header, persisted to `localStorage['codedash-running-group']`. Rows are colored by where they run (blue = inside codbash, orange = external; dimmed = idle, not gone). Clicking dispatches on `local`: local → `jumpToWorkspacePane` (found by matching `cwd` against live panes); external → `POST /api/focus` (`focusTerminalByPid`) to raise the real window. It must NEVER spawn a blank in-app terminal as a stand-in (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). See `docs/design/running-agents-external.md`. +- **Running-agents sidebar tree** (persistent, in the main sidebar — not just Workspace) is built from `activeSessions`, a live `ps`-scan-backed map (`GET /api/active`, polled every 5s), so a truly-exited agent simply isn't in it — no "ghost" filtering needed. It shows **every** currently-running agent, both inside codbash and in external native terminals, as a **3-level tree**: `_wsRunningTree(mode)` (`workspace.js`) nests project → agent → sessions (`mode:'project'`, default) or the mirror agent → project → sessions (`mode:'agent'`) — user-toggleable via the compact segmented control in the tree header, persisted to `localStorage['codedash-running-group']`. A project+agent pair with exactly one live session collapses to a single leaf row (no redundant 1-child nesting); 2+ sessions get one leaf row each, labeled via `_wsSessionLeafLabel` (pane name if local and matched, else session-id prefix, else pid) so same-project/same-agent sessions read as distinct. `getActiveSessions()` tags each agent with `local` (true = descends from a codbash browser-pty pane, false = external); rows are colored by where they run (blue = inside codbash, orange = external; dimmed = idle, not gone). Clicking dispatches on `local`: local → `jumpToWorkspacePane` (found by matching `cwd` against live panes); external → `POST /api/focus` (`focusTerminalByPid`) to raise the real window. It must NEVER spawn a blank in-app terminal as a stand-in (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). See `docs/design/running-agents-external.md`. - **Saved layouts round-trip the full pane** — `sanitizePane` preserves `cmd` + `prefill` + `cwd` (not just `cmd`); dropping any of these silently loses the user's launch command on restore - **No `window.prompt` in Electron** — use `codbashPrompt()` (app.js) for any text input; the native prompt is a no-op in the desktop shell - **Two update paths, mutually exclusive** — the npm CLI self-updates via `POST /api/update` (`npm i -g codbash-app@latest` + restart). The **desktop app updates in-place via `electron-updater`** (download-on-click → restart, driven by the frontend banner over `window.codbashDesktop.updater` IPC and `main.js`). `desktop/main.js` sets `CODBASH_DESKTOP=1` so the server **refuses `/api/update` (400)** — running `npm i -g` inside the signed, read-only app bundle would update an unrelated global copy and the restart would land back on the bundled old version. macOS in-place update needs the **`.zip` target + `latest-mac.yml`** (Squirrel.Mac can't apply a DMG) and a signed build; on failure the banner falls back to opening the releases page (`codbash:open-releases`). See `desktop/RELEASE.md` §4. diff --git a/docs/design/running-agents-external.md b/docs/design/running-agents-external.md index df6e724..1559b1d 100644 --- a/docs/design/running-agents-external.md +++ b/docs/design/running-agents-external.md @@ -146,20 +146,34 @@ in-app Claude session and an iTerm one saw only half the picture. Merge them into one tree, colored by where each agent runs, and let the user pick whether the tree groups by project (default) or by agent kind. +A first pass grouped project/agent as a flat 2-level list — one header, then +every session underneath as a same-labeled row ("Claude", "Claude", …). Users +found that read as noise/duplication rather than a real hierarchy, so it grew +a third level: outer group → inner group → individual sessions. + ### Data model No server change: `getActiveSessions()` already tags every live agent with `local`. The frontend just stops dropping `local:true` entries. -`_wsRunningGroups(mode)` (`workspace.js`) replaces `_wsRunningByProject()`: -- `mode: 'project'` (default) — groups by `cwd`, same as before, but items now - include local agents too. -- `mode: 'agent'` — groups by `kind` (tool), items are the projects that tool - is running in. - -Each item carries `{agent, cwd, projName, kind}` so either grouping can label -its rows correctly (tool name in project-mode rows, project name in -agent-mode rows). +`_wsRunningTree(mode)` (`workspace.js`) builds a 3-level tree: +- `mode: 'project'` (default) — project → agent kind → sessions. +- `mode: 'agent'` — agent kind → project → sessions (the mirror nesting, not + just a relabel — switching the toggle re-parents the whole tree). + +Built via a small generic `_wsGroupBy(items, keyFn)` applied twice (outer key, +then inner key within each outer group). Each item carries `{agent, cwd, +projName, kind}` so either grouping direction can label its rows correctly. + +A subgroup (project+agent pair) holding exactly one live session collapses +its leaf row into the subgroup row itself — no redundant single-child row — +and takes that session's true color/idle state directly (`.ws-run-leaf`). +Once a subgroup holds 2+ sessions, it renders as a real subheader (neutral +green dot, session count) with one leaf row per session underneath, each +labeled by `_wsSessionLeafLabel` (a live pane's user-given name if local and +matched, else the session id prefix — same convention as the session cards' +"Resume last session (id12345)" — else a bare pid) so same-project, +same-agent sessions read as distinct instead of repeating the same label. ### Click dispatch diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 06966b6..5feab9c 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -3095,35 +3095,52 @@ body[data-view="overview"] .toolbar { display: none; } .ws-run-mode-btn + .ws-run-mode-btn { border-left: 1px solid var(--border); } .ws-run-mode-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); } .ws-run-mode-btn.active { color: #fff; background: var(--accent-blue); } -.ws-run-proj { +/* Level 1 — outer group (project, or agent kind in agent-mode). */ +.ws-run-l1 { display: flex; align-items: center; gap: 6px; padding: 4px 8px; font-size: 12px; color: var(--text-primary); cursor: pointer; border-radius: 6px; } -.ws-run-proj:hover { background: rgba(255,255,255,0.06); } +.ws-run-l1:hover { background: rgba(255,255,255,0.06); } .ws-run-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-green); flex-shrink: 0; } .ws-run-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: Menlo, Monaco, monospace; } .ws-run-count { font-size: 10px; color: var(--text-muted); background: var(--bg-hover); border-radius: 8px; padding: 0 6px; min-width: 16px; text-align: center; } -/* Per-agent row — dot color is the terminal-type legend: orange = running in - an external native terminal (default), blue = running inside codbash itself. */ -.ws-run-term { - position: relative; - padding: 2px 8px 2px 22px; font-size: 11px; color: var(--text-secondary); +/* Level 2 — inner group (agent kind, or project in agent-mode). Neutral green + dot while it holds 2+ sessions (a real subheader); collapses to the leaf's + own color (.ws-run-leaf) when it holds exactly one, so a single running + instance doesn't get a redundant extra row underneath it. */ +.ws-run-l2 { + display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 20px; + font-size: 11px; color: var(--text-secondary); cursor: pointer; border-radius: 6px; +} +.ws-run-l2:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); } +.ws-run-l2-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent-green); flex-shrink: 0; } +.ws-run-l2-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Level 3 — one row per individual running session. Also reused by .ws-run-l2 + when it collapses to a single-session leaf. Dot color is the terminal-type + legend: orange = external native terminal (default), blue = inside codbash. */ +.ws-run-l3, .ws-run-l2.ws-run-leaf .ws-run-l2-dot { position: relative; } +.ws-run-l3 { + padding: 2px 8px 2px 32px; font-size: 10.5px; color: var(--text-secondary); cursor: pointer; border-radius: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ws-run-term::before { - content: ''; position: absolute; left: 11px; top: 50%; transform: translateY(-50%); +.ws-run-l3::before { + content: ''; position: absolute; left: 21px; top: 50%; transform: translateY(-50%); width: 5px; height: 5px; border-radius: 50%; background: var(--accent-orange, #f59e0b); } -.ws-run-term.ws-run-local::before { background: var(--accent-blue); } -.ws-run-term:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); } +.ws-run-l3.ws-run-local::before { background: var(--accent-blue); } +.ws-run-l3:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); } +.ws-run-l2.ws-run-leaf .ws-run-l2-dot { background: var(--accent-orange, #f59e0b); } +.ws-run-l2.ws-run-leaf.ws-run-local .ws-run-l2-dot { background: var(--accent-blue); } /* Idle (waiting for input, still a live process — not "gone") always wins over the terminal-type color so a dimmed dot reliably means "idle", not a second meaning collision with local/external. */ -.ws-run-term.ws-run-idle { color: var(--text-muted); } -.ws-run-term.ws-run-idle::before { background: var(--text-muted); } +.ws-run-l3.ws-run-idle, +.ws-run-l2.ws-run-leaf.ws-run-idle { color: var(--text-muted); } +.ws-run-l3.ws-run-idle::before, +.ws-run-l2.ws-run-leaf.ws-run-idle .ws-run-l2-dot { background: var(--text-muted); } /* In-app prompt (codbashPrompt) — replaces window.prompt (no-op in Electron). */ .cb-prompt-overlay { diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js index 13c4a09..1d6e2d5 100644 --- a/src/frontend/workspace.js +++ b/src/frontend/workspace.js @@ -302,35 +302,78 @@ function _wsFindLivePaneForCwd(cwd) { return null; } -// Group *every currently-running agent* (from the live /api/active map, which -// is a fresh `ps` scan each poll — an agent whose process has actually exited -// simply isn't in it, so there is no "ghost" entry to filter) by either their -// real project folder or their agent kind, depending on `mode`. Each agent -// carries `local` (server-tagged: true = running inside a codbash browser-pty -// pane, false = an external native terminal) so the render step can color and -// route clicks correctly. See docs/design/running-agents-external.md for the -// external-focus half of this; the local half jumps to the matching pane. -function _wsRunningGroups(mode) { +// Plain two-key groupBy: returns [{key, items}] in first-seen order. +function _wsGroupBy(items, keyFn) { + var map = {}, order = []; + items.forEach(function (it) { + var k = keyFn(it); + if (!Object.prototype.hasOwnProperty.call(map, k)) { map[k] = []; order.push(k); } + map[k].push(it); + }); + return order.map(function (k) { return { key: k, items: map[k] }; }); +} + +// A short, stable per-instance label for a running-agent leaf row — needed +// once a project+agent pair has more than one live session, so the rows read +// as distinct sessions instead of the same label repeated. Prefers a pane's +// user-given name (local agents), falls back to the session id prefix (same +// convention as the session cards' "Resume last session (id12345)"), then a +// bare pid. +function _wsSessionLeafLabel(x) { + var a = x.agent; + if (a.local) { + var hit = _wsFindLivePaneForCwd(x.cwd); + if (hit && hit.pane && hit.pane.name) return hit.pane.name; + } + if (a.sessionId) return a.sessionId.slice(0, 8); + if (a.pid) return 'pid ' + a.pid; + return 'session'; +} + +// Build the 3-level running-agents tree: outer group → inner group → individual +// sessions. `mode: 'project'` (default) nests project → agent → sessions; +// `mode: 'agent'` nests agent → project → sessions — same data, mirrored +// nesting order, so switching the toggle re-parents instead of just relabeling +// a flat list. Built from the live /api/active map (a fresh `ps` scan each +// poll — an exited agent simply isn't in it, so there is no separate "ghost" +// entry to filter). Each agent carries `local` (server-tagged: true = running +// inside a codbash browser-pty pane, false = an external native terminal) so +// the render step can color and route clicks correctly. See +// docs/design/running-agents-external.md for the external-focus half of this; +// the local half jumps to the matching pane. +function _wsRunningTree(mode) { var map = (typeof activeSessions === 'object' && activeSessions) || {}; - var groups = {}, order = []; + var items = []; Object.keys(map).forEach(function (k) { var a = map[k]; var cwd = (a && a.cwd) || ''; - // Only skip entries with no known folder — nothing to group or act on. - if (!cwd) return; + if (!cwd) return; // nothing to group or act on without a known folder var isHome = /^(\/Users\/[^/]+|\/home\/[^/]+|\/root)\/?$/.test(cwd); - var projName = isHome ? '~' : _wsProjectBasename(cwd); - var kind = (a && a.kind) || ''; - var key = mode === 'agent' ? (kind || 'agent') : cwd; - if (!groups[key]) { - groups[key] = mode === 'agent' - ? { name: _wsToolLabel(kind), kind: kind, items: [] } - : { name: projName, cwd: cwd, items: [] }; - order.push(key); - } - groups[key].items.push({ agent: a, cwd: cwd, projName: projName, kind: kind }); + items.push({ agent: a, cwd: cwd, projName: isHome ? '~' : _wsProjectBasename(cwd), kind: a.kind || '' }); + }); + + var outerKeyFn = mode === 'agent' ? function (it) { return it.kind || 'agent'; } : function (it) { return it.cwd; }; + var innerKeyFn = mode === 'agent' ? function (it) { return it.cwd; } : function (it) { return it.kind || 'agent'; }; + + return _wsGroupBy(items, outerKeyFn).map(function (outer) { + var rep = outer.items[0]; + return { + name: mode === 'agent' ? _wsToolLabel(rep.kind) : rep.projName, + cwd: mode === 'agent' ? '' : rep.cwd, + kind: mode === 'agent' ? rep.kind : '', + count: outer.items.length, + rep: rep, + subgroups: _wsGroupBy(outer.items, innerKeyFn).map(function (inner) { + var rep2 = inner.items[0]; + return { + name: mode === 'agent' ? rep2.projName : _wsToolLabel(rep2.kind), + count: inner.items.length, + rep: rep2, + sessions: inner.items, + }; + }), + }; }); - return order.map(function (k) { return groups[k]; }); } // Sanitize a pid for an inline onclick arg: a positive integer, else 0. Mirrors @@ -383,64 +426,90 @@ function jumpToRunningAgent(cwd, sessionId, kind, pid, local) { }); } -// Render a compact tree at the bottom of the sidebar of every currently-running -// agent, grouped by project or by agent kind (user's choice, see -// _wsGetRunningGroupMode). Each row is colored by where it runs — blue for -// inside codbash, orange for an external native terminal — and clicking jumps -// straight to it. +// One
for a jumpToRunningAgent(...) call bound to a specific agent instance. +function _wsRunJumpAttr(x) { + var a = x.agent; + return 'jumpToRunningAgent(' + _wsJsStr(x.cwd) + ',' + _wsJsStr(a.sessionId || '') + ',' + + _wsJsStr(x.kind || '') + ',' + _wsPidArg(a.pid) + ',' + (a.local === true) + ')'; +} + +// Render a compact 3-level tree at the bottom of the sidebar: outer group → +// inner group → individual sessions (project → agent → sessions, or the +// mirror — agent → project → sessions — depending on the user's toggle, see +// _wsGetRunningGroupMode). A subgroup with a single session collapses its +// leaf row into the subgroup row itself (no redundant 1-child nesting); once +// a project+agent pair has 2+ live sessions, each gets its own leaf row so +// they read as distinct sessions instead of the same label repeated. Rows are +// colored by where the agent runs — blue inside codbash, orange external — +// and clicking any row jumps straight to that instance. var _wsRunTreeSig = ''; function _wsRenderRunningTree() { var el = document.getElementById('wsRunningTree'); if (!el) return; var mode = _wsGetRunningGroupMode(); - var groups = _wsRunningGroups(mode); - // Fold in the live-pane set so a tab opened/closed for a `local` agent's - // folder forces a rebuild even though activeSessions itself didn't change — - // otherwise a freshly-opened pane wouldn't become clickable until the next - // unrelated agent-state change. + var tree = _wsRunningTree(mode); + // Fold in the live-pane set (incl. names, for the leaf-label lookup) so a + // tab opened/closed/renamed for a `local` agent's folder forces a rebuild + // even though activeSessions itself didn't change. var paneSig = (typeof _wsAllPanes === 'function' ? _wsAllPanes() : []) .filter(function (x) { return x.pane && !x.pane.exited; }) - .map(function (x) { return x.tab.id + '/' + x.pane.id + '=' + x.pane.cwd; }).join(','); - var sig = mode + '|' + paneSig + '|' + groups.map(function (g) { - return (g.cwd || g.kind) + ':' + g.items.map(function (x) { - return (x.agent.sessionId || x.agent.pid) + '=' + x.kind + '/' + x.agent.status + '/' + x.agent.local; - }).join(','); + .map(function (x) { return x.tab.id + '/' + x.pane.id + '=' + x.pane.cwd + '/' + (x.pane.name || ''); }).join(','); + var sig = mode + '|' + paneSig + '|' + tree.map(function (g) { + return (g.cwd || g.kind) + ':' + g.subgroups.map(function (sg) { + return sg.sessions.map(function (x) { + return (x.agent.sessionId || x.agent.pid) + '=' + x.kind + '/' + x.agent.status + '/' + x.agent.local; + }).join(','); + }).join(';'); }).join('|'); if (sig === _wsRunTreeSig) return; // no change → no rebuild _wsRunTreeSig = sig; - if (!groups.length) { el.style.display = 'none'; el.innerHTML = ''; return; } + if (!tree.length) { el.style.display = 'none'; el.innerHTML = ''; return; } var html = '
' + 'Running agents' + '' + '' + + 'aria-pressed="' + (mode === 'project') + '" title="Project → agent → sessions" onclick="_wsSetRunningGroupMode(\'project\')">Project' + '' + + 'aria-pressed="' + (mode === 'agent') + '" title="Agent → project → sessions" onclick="_wsSetRunningGroupMode(\'agent\')">Agent' + '
'; - groups.forEach(function (g) { - var first = g.items[0]; - // The header focuses/jumps to the first item — a reasonable default when a - // group holds several agents. - html += '
' + + tree.forEach(function (g) { + html += '
' + '' + escHtml(g.name) + '' + - '' + g.items.length + '
'; - g.items.forEach(function (x) { - var a = x.agent; - var waiting = a.status === 'waiting'; - var local = a.local === true; - var label = mode === 'agent' ? x.projName : _wsToolLabel(x.kind); - var whereLabel = local ? 'inside codbash' : 'external terminal'; - html += '
' + - escHtml(label) + '
'; + '' + g.count + '
'; + + g.subgroups.forEach(function (sg) { + if (sg.sessions.length === 1) { + // Single session in this project+agent pair — the subgroup row IS the + // leaf, so it takes the leaf's true color/idle state directly instead + // of an extra indistinguishable child row underneath. + var only = sg.sessions[0]; + var a0 = only.agent; + var waiting0 = a0.status === 'waiting'; + var local0 = a0.local === true; + html += '
' + + '' + escHtml(sg.name) + '
'; + return; + } + // Multiple sessions — the subgroup is a real header (neutral color), + // and each session gets its own leaf row underneath. + html += '
' + + '' + escHtml(sg.name) + '' + + '' + sg.sessions.length + '
'; + sg.sessions.forEach(function (x) { + var a = x.agent; + var waiting = a.status === 'waiting'; + var local = a.local === true; + var leafLabel = _wsSessionLeafLabel(x); + html += '
' + escHtml(leafLabel) + '
'; + }); }); }); el.innerHTML = html; diff --git a/test/running-agents-external.test.js b/test/running-agents-external.test.js index 3a96a31..8e26460 100644 --- a/test/running-agents-external.test.js +++ b/test/running-agents-external.test.js @@ -91,17 +91,25 @@ function wsSource() { test('running-agents tree includes both local and external agents', () => { const src = wsSource(); - const fn = src.match(/function _wsRunningGroups\(mode\)[\s\S]*?\n\}/); - assert.ok(fn, '_wsRunningGroups should exist'); + const fn = src.match(/function _wsRunningTree\(mode\)[\s\S]*?\n\}/); + assert.ok(fn, '_wsRunningTree should exist'); assert.doesNotMatch(fn[0], /if\s*\(a\.local/, 'must not filter out local (codbash-pane) agents'); assert.doesNotMatch(fn[0], /return;\s*\/\/.*local/i, 'must not early-return on local agents'); }); -test('_wsRunningGroups supports grouping by project or by agent kind', () => { +test('_wsRunningTree supports grouping by project or by agent kind, 3 levels deep', () => { const src = wsSource(); - const fn = src.match(/function _wsRunningGroups\(mode\)[\s\S]*?\n\}/); - assert.ok(fn, '_wsRunningGroups should exist'); + const fn = src.match(/function _wsRunningTree\(mode\)[\s\S]*?\n\}/); + assert.ok(fn, '_wsRunningTree should exist'); assert.match(fn[0], /mode === 'agent'/, 'must branch on the agent grouping mode'); + assert.match(fn[0], /subgroups/, 'must nest an inner group under the outer group (3-level tree)'); + assert.match(fn[0], /sessions:/, 'each subgroup must carry its individual sessions, not just a flat label'); +}); + +test('a subgroup with a single session collapses instead of adding a redundant leaf row', () => { + const src = wsSource(); + assert.match(src, /ws-run-leaf/, 'single-session subgroups must render with the collapsed leaf style'); + assert.match(src, /sg\.sessions\.length === 1/, 'render must special-case the single-session subgroup'); }); test('the grouping mode preference persists to localStorage', () => { From 3017efdaa83ec8791052029553f3ea420c5cc9d2 Mon Sep 17 00:00:00 2001 From: NovakPAai Date: Sun, 2 Aug 2026 11:55:54 +0300 Subject: [PATCH 3/3] feat: make the Running agents tree an accordion, collapsed by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level (L1) groups — project in project-mode, agent kind in agent-mode — now start collapsed. Clicking a header expands it in place to reveal its agent/project subgroups and individual sessions; the same accordion logic applies to both grouping directions. - _wsRunExpanded (in-memory, keyed `mode|groupKey`) tracks open/closed per group so switching the Project/Agent toggle doesn't share expand state across the two different hierarchies. - _wsToggleRunGroup flips a `collapsed` class directly on the group's wrapper DOM node instead of forcing a full tree rebuild — cheap, and a later rebuild (triggered by a real activeSessions change) re-reads _wsRunExpanded so the user's open/closed choices survive it. - The L1 header's click now means "toggle" rather than "jump to the first session" — with a header disclosing multiple children there's no single unambiguous default target, so the jump action lives entirely on leaf rows (.ws-run-l2.ws-run-leaf / .ws-run-l3), unchanged from before. - Basic keyboard support: role="button", aria-expanded, Enter/Space to toggle. --- CLAUDE.md | 2 +- docs/design/running-agents-external.md | 15 ++++++++ src/frontend/styles.css | 11 ++++++ src/frontend/workspace.js | 52 +++++++++++++++++++++----- test/running-agents-external.test.js | 27 +++++++++++++ 5 files changed, 96 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 786e48f..156602e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ docs/ - **Crash-safety** — every HTTP route dispatch is wrapped in try/catch → 500 (one bad session never takes down the server); `findSessionFile` looks up its index with `Object.prototype.hasOwnProperty.call(...)` to avoid prototype-pollution DoS; delete / bulk-delete validate `SAFE_SESSION_ID` - **Desktop app is a thin shell** — `desktop/main.js` spawns the *unmodified* server as a Node child and points a `BrowserWindow` at it. Keep the server desktop-agnostic; desktop-only capabilities are exposed through `preload.js` (`window.codbashDesktop`) and detected at runtime in the frontend (e.g. the native folder picker is only wired up when `window.codbashDesktop.pickFolder` exists) - **View-aware chrome** — `render()` stamps `document.body` with `data-view`; the session toolbar is hidden in Overview/Workspace via `body[data-view="workspace"|"overview"] .toolbar { display:none }` -- **Running-agents sidebar tree** (persistent, in the main sidebar — not just Workspace) is built from `activeSessions`, a live `ps`-scan-backed map (`GET /api/active`, polled every 5s), so a truly-exited agent simply isn't in it — no "ghost" filtering needed. It shows **every** currently-running agent, both inside codbash and in external native terminals, as a **3-level tree**: `_wsRunningTree(mode)` (`workspace.js`) nests project → agent → sessions (`mode:'project'`, default) or the mirror agent → project → sessions (`mode:'agent'`) — user-toggleable via the compact segmented control in the tree header, persisted to `localStorage['codedash-running-group']`. A project+agent pair with exactly one live session collapses to a single leaf row (no redundant 1-child nesting); 2+ sessions get one leaf row each, labeled via `_wsSessionLeafLabel` (pane name if local and matched, else session-id prefix, else pid) so same-project/same-agent sessions read as distinct. `getActiveSessions()` tags each agent with `local` (true = descends from a codbash browser-pty pane, false = external); rows are colored by where they run (blue = inside codbash, orange = external; dimmed = idle, not gone). Clicking dispatches on `local`: local → `jumpToWorkspacePane` (found by matching `cwd` against live panes); external → `POST /api/focus` (`focusTerminalByPid`) to raise the real window. It must NEVER spawn a blank in-app terminal as a stand-in (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). See `docs/design/running-agents-external.md`. +- **Running-agents sidebar tree** (persistent, in the main sidebar — not just Workspace) is built from `activeSessions`, a live `ps`-scan-backed map (`GET /api/active`, polled every 5s), so a truly-exited agent simply isn't in it — no "ghost" filtering needed. It shows **every** currently-running agent, both inside codbash and in external native terminals, as a **3-level tree**: `_wsRunningTree(mode)` (`workspace.js`) nests project → agent → sessions (`mode:'project'`, default) or the mirror agent → project → sessions (`mode:'agent'`) — user-toggleable via the compact segmented control in the tree header, persisted to `localStorage['codedash-running-group']`. A project+agent pair with exactly one live session collapses to a single leaf row (no redundant 1-child nesting); 2+ sessions get one leaf row each, labeled via `_wsSessionLeafLabel` (pane name if local and matched, else session-id prefix, else pid) so same-project/same-agent sessions read as distinct. Top-level (L1) groups are an **accordion, collapsed by default** — `_wsRunExpanded` (in-memory, resets on reload) tracks open/closed per `mode|groupKey`; `_wsToggleRunGroup` flips a `collapsed` class directly on the group's wrapper DOM node rather than forcing a full rebuild, and a later rebuild re-reads `_wsRunExpanded` so open/closed choices survive it. Because the L1 header's click means "toggle", it does NOT jump to a session — that action lives entirely on leaf rows (`.ws-run-l2.ws-run-leaf` / `.ws-run-l3`). `getActiveSessions()` tags each agent with `local` (true = descends from a codbash browser-pty pane, false = external); rows are colored by where they run (blue = inside codbash, orange = external; dimmed = idle, not gone). Clicking a leaf dispatches on `local`: local → `jumpToWorkspacePane` (found by matching `cwd` against live panes); external → `POST /api/focus` (`focusTerminalByPid`) to raise the real window. It must NEVER spawn a blank in-app terminal as a stand-in (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). See `docs/design/running-agents-external.md`. - **Saved layouts round-trip the full pane** — `sanitizePane` preserves `cmd` + `prefill` + `cwd` (not just `cmd`); dropping any of these silently loses the user's launch command on restore - **No `window.prompt` in Electron** — use `codbashPrompt()` (app.js) for any text input; the native prompt is a no-op in the desktop shell - **Two update paths, mutually exclusive** — the npm CLI self-updates via `POST /api/update` (`npm i -g codbash-app@latest` + restart). The **desktop app updates in-place via `electron-updater`** (download-on-click → restart, driven by the frontend banner over `window.codbashDesktop.updater` IPC and `main.js`). `desktop/main.js` sets `CODBASH_DESKTOP=1` so the server **refuses `/api/update` (400)** — running `npm i -g` inside the signed, read-only app bundle would update an unrelated global copy and the restart would land back on the bundled old version. macOS in-place update needs the **`.zip` target + `latest-mac.yml`** (Squirrel.Mac can't apply a DMG) and a signed build; on failure the banner falls back to opening the releases page (`codbash:open-releases`). See `desktop/RELEASE.md` §4. diff --git a/docs/design/running-agents-external.md b/docs/design/running-agents-external.md index 1559b1d..a1a4004 100644 --- a/docs/design/running-agents-external.md +++ b/docs/design/running-agents-external.md @@ -191,6 +191,21 @@ not a server setting — it's a per-browser display toggle, not something that needs to sync across machines. A compact 2-button segmented control sits in the tree header (`.ws-run-mode`). +### Accordion (L1 collapsed by default) + +Top-level groups start collapsed; clicking a project (project-mode) or agent +kind (agent-mode) header expands it to reveal its running sessions. Expand +state is in-memory only (`_wsRunExpanded`, resets on reload), keyed +`mode|groupKey` so project-mode and agent-mode expand choices don't collide. +Toggling flips a `collapsed` class directly on the group's wrapper DOM node +(`.ws-run-group`) rather than forcing a full tree rebuild — cheap, and a later +rebuild triggered by a real `activeSessions` change re-reads `_wsRunExpanded` +so the user's open/closed choices survive it. Because the header's click now +means "toggle", the "jump to a session" action moved entirely to leaf rows +(`.ws-run-l2.ws-run-leaf` / `.ws-run-l3`) — there is no single-click shortcut +from an L1 header to a specific session anymore, by design (an accordion +header disclosing multiple children has no unambiguous single default action). + ### "No ghost sessions" The tree was never actually showing ghosts in the sense of dead processes — diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 5feab9c..0c12374 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -3095,11 +3095,22 @@ body[data-view="overview"] .toolbar { display: none; } .ws-run-mode-btn + .ws-run-mode-btn { border-left: 1px solid var(--border); } .ws-run-mode-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); } .ws-run-mode-btn.active { color: #fff; background: var(--accent-blue); } +/* Accordion wrapper for one L1 group + its body. Collapsed by default (see + _wsRunExpanded) — .ws-run-l1-body is hidden until the header is clicked. */ +.ws-run-group + .ws-run-group { margin-top: 1px; } +.ws-run-l1-body { display: block; } +.ws-run-group.collapsed .ws-run-l1-body { display: none; } +.ws-run-l1-chevron { + font-size: 8px; color: var(--text-muted); flex-shrink: 0; + transition: transform 0.15s; transform: rotate(90deg); /* expanded: pointing down */ +} +.ws-run-group.collapsed .ws-run-l1-chevron { transform: rotate(0deg); } /* collapsed: pointing right */ /* Level 1 — outer group (project, or agent kind in agent-mode). */ .ws-run-l1 { display: flex; align-items: center; gap: 6px; padding: 4px 8px; font-size: 12px; color: var(--text-primary); cursor: pointer; border-radius: 6px; } +.ws-run-l1:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: -2px; } .ws-run-l1:hover { background: rgba(255,255,255,0.06); } .ws-run-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-green); flex-shrink: 0; } .ws-run-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: Menlo, Monaco, monospace; } diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js index 1d6e2d5..ae33173 100644 --- a/src/frontend/workspace.js +++ b/src/frontend/workspace.js @@ -289,6 +289,28 @@ function _wsSetRunningGroupMode(mode) { _wsRenderRunningTree(); } +// Accordion state for the tree's top-level (L1) groups — collapsed by default +// so the tree opens compact; expanding a project (or agent, in agent-mode) +// reveals its running sessions. In-memory only (resets on reload), keyed by +// `mode|groupKey` so project-mode and agent-mode expand state don't collide. +var _wsRunExpanded = {}; +function _wsRunGroupKey(mode, g) { + return mode + '|' + (mode === 'agent' ? g.kind : g.cwd); +} +// Toggles the DOM directly (cheap, no full rebuild) and records the choice so +// a later full rebuild (triggered by a real activeSessions change) preserves it. +function _wsToggleRunGroup(rowEl) { + var wrap = rowEl.closest ? rowEl.closest('.ws-run-group') : null; + if (!wrap) return; + var key = wrap.getAttribute('data-key') || ''; + var collapsed = wrap.classList.toggle('collapsed'); + _wsRunExpanded[key] = !collapsed; + rowEl.setAttribute('aria-expanded', String(!collapsed)); +} +function _wsRunGroupKeydown(ev, rowEl) { + if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _wsToggleRunGroup(rowEl); } +} + // Find a live (connected, not exited) Workspace pane whose shell sits in `cwd`. // Used to make an agent tagged `local:true` (running inside a codbash pty) // clickable — jump straight to its tab/pane instead of the external-focus path. @@ -433,15 +455,17 @@ function _wsRunJumpAttr(x) { _wsJsStr(x.kind || '') + ',' + _wsPidArg(a.pid) + ',' + (a.local === true) + ')'; } -// Render a compact 3-level tree at the bottom of the sidebar: outer group → -// inner group → individual sessions (project → agent → sessions, or the -// mirror — agent → project → sessions — depending on the user's toggle, see -// _wsGetRunningGroupMode). A subgroup with a single session collapses its -// leaf row into the subgroup row itself (no redundant 1-child nesting); once -// a project+agent pair has 2+ live sessions, each gets its own leaf row so -// they read as distinct sessions instead of the same label repeated. Rows are -// colored by where the agent runs — blue inside codbash, orange external — -// and clicking any row jumps straight to that instance. +// Render a compact 3-level accordion tree at the bottom of the sidebar: outer +// group → inner group → individual sessions (project → agent → sessions, or +// the mirror — agent → project → sessions — depending on the user's toggle, +// see _wsGetRunningGroupMode). Top-level (L1) groups start collapsed — +// clicking a project (or agent, in agent-mode) header expands it to reveal +// its running sessions; the jump-to-instance action lives on the leaf rows +// instead. A subgroup with a single session collapses its leaf row into the +// subgroup row itself (no redundant 1-child nesting); once a project+agent +// pair has 2+ live sessions, each gets its own leaf row so they read as +// distinct sessions instead of the same label repeated. Rows are colored by +// where the agent runs — blue inside codbash, orange external. var _wsRunTreeSig = ''; function _wsRenderRunningTree() { var el = document.getElementById('wsRunningTree'); @@ -476,10 +500,17 @@ function _wsRenderRunningTree() { '
'; tree.forEach(function (g) { - html += '
' + + var key = _wsRunGroupKey(mode, g); + var expanded = _wsRunExpanded[key] === true; // collapsed by default + html += '
'; + html += '
' + + '' + '' + escHtml(g.name) + '' + '' + g.count + '
'; + html += '
'; g.subgroups.forEach(function (sg) { if (sg.sessions.length === 1) { // Single session in this project+agent pair — the subgroup row IS the @@ -511,6 +542,7 @@ function _wsRenderRunningTree() { 'onclick="' + _wsRunJumpAttr(x) + '">' + escHtml(leafLabel) + '
'; }); }); + html += '
'; // .ws-run-l1-body, .ws-run-group }); el.innerHTML = html; el.style.display = ''; diff --git a/test/running-agents-external.test.js b/test/running-agents-external.test.js index 8e26460..b5abffe 100644 --- a/test/running-agents-external.test.js +++ b/test/running-agents-external.test.js @@ -112,6 +112,33 @@ test('a subgroup with a single session collapses instead of adding a redundant l assert.match(src, /sg\.sessions\.length === 1/, 'render must special-case the single-session subgroup'); }); +// ── Accordion (L1 groups collapsed by default) ────────────────────────────── + +test('top-level groups render collapsed by default', () => { + const src = wsSource(); + const fn = src.match(/function _wsRenderRunningTree\(\)[\s\S]*?\n\}/); + assert.ok(fn, '_wsRenderRunningTree should exist'); + assert.match(fn[0], /_wsRunExpanded\[key\] === true/, 'a group must be expanded only when explicitly recorded true — collapsed is the default'); +}); + +test('the L1 header toggles the accordion instead of jumping to a session', () => { + const src = wsSource(); + const fn = src.match(/function _wsRenderRunningTree\(\)[\s\S]*?\n\}/); + assert.ok(fn, '_wsRenderRunningTree should exist'); + const l1Row = fn[0].match(/
/); + assert.ok(l1Row, 'the L1 row markup should exist'); + assert.match(l1Row[0], /_wsToggleRunGroup\(this\)/, 'clicking the L1 header must toggle expand/collapse'); + assert.doesNotMatch(l1Row[0], /jumpToRunningAgent/, 'the L1 header must not jump — that action lives on leaf rows'); +}); + +test('_wsToggleRunGroup flips the collapsed class and records the choice', () => { + const src = wsSource(); + const fn = src.match(/function _wsToggleRunGroup\([\s\S]*?\n\}/); + assert.ok(fn, '_wsToggleRunGroup should exist'); + assert.match(fn[0], /classList\.toggle\('collapsed'\)/, 'must toggle the collapsed class on the group wrapper'); + assert.match(fn[0], /_wsRunExpanded\[key\]/, 'must record the expand choice for later rebuilds'); +}); + test('the grouping mode preference persists to localStorage', () => { const src = wsSource(); assert.match(src, /function _wsSetRunningGroupMode/, '_wsSetRunningGroupMode should exist');