From 23f4e71365905e2ed82af6ce36b3374e303ae141 Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Mon, 20 Jul 2026 14:25:17 +0300 Subject: [PATCH] fix: layout restore, agent-aware running tree, cleaner Terminal view (v7.14.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Saved layouts now capture each pane's working directory (cwd) and any prefilled-but-unrun command, and restore them — previously only cmd was stored, so restored panes opened in the home dir with no command. - Rewrote the sidebar running tree to build from real agent detection (/api/active) instead of Workspace pane initial cwd: groups running agents by their actual project folder and labels each by agent name with a live/idle dot. Fixes detection when an agent cd'd elsewhere or was started outside the app. - Hid the session toolbar in the Terminal and Overview views via a body[data-view] attribute, since neither has a session list to act on. Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/package.json | 2 +- package.json | 2 +- src/changelog.js | 10 +++ src/frontend/app.js | 9 ++- src/frontend/styles.css | 13 ++++ src/frontend/workspace.js | 113 ++++++++++++++++++++++++++------- src/workspace-layouts.js | 20 +++++- test/workspace-layouts.test.js | 29 +++++++++ 8 files changed, 169 insertions(+), 29 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index a6c7fcc..4096fb8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "codbash-desktop", "productName": "codbash", - "version": "7.14.3", + "version": "7.14.4", "private": true, "description": "Desktop shell (Electron) for codbash — wraps the codbash server in a native window.", "main": "main.js", diff --git a/package.json b/package.json index 9326ee4..651c53f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codbash-app", - "version": "7.14.3", + "version": "7.14.4", "description": "Dashboard + CLI for AI coding agents — Claude Code, Codex, Cursor, OpenCode, Kiro. View, search, resume, convert, sync sessions.", "bin": { "codbash": "./bin/cli.js", diff --git a/src/changelog.js b/src/changelog.js index f64ca3d..50e6f47 100644 --- a/src/changelog.js +++ b/src/changelog.js @@ -1,6 +1,16 @@ 'use strict'; const CHANGELOG = [ + { + version: '7.14.4', + date: '2026-07-20', + title: 'Layout restore fixed · agent-aware running tree · cleaner Terminal view', + changes: [ + 'Saved layouts now restore each pane in its original project folder and re-issue its command (including resume commands prefilled from session cards) — previously the folder and prefilled command were dropped on save', + 'The sidebar running tree is now driven by real agent detection: it groups running agents by their actual project folder and labels each by agent name (Claude, Codex, …) with a live/idle dot, so it works even when an agent cd\'d elsewhere or was started outside the app', + 'Hid the session toolbar (search / group / select / AI titles / count) in the Terminal and Overview views, where it has no list to act on', + ], + }, { version: '7.14.3', date: '2026-07-20', diff --git a/src/frontend/app.js b/src/frontend/app.js index 37f8a67..eec57c8 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1177,8 +1177,10 @@ async function pollActiveSessions() { activeSessions = newActive; - // Reflect the new active-agent count on the Overview landing. + // Reflect the new active-agent count on the Overview landing and refresh + // the sidebar running-agents tree (which is driven by activeSessions now). if (typeof _ovRefreshIfCurrent === 'function') _ovRefreshIfCurrent(); + if (typeof _wsRenderRunningTree === 'function') _wsRenderRunningTree(); // Only touch cards that changed document.querySelectorAll('.card').forEach(function(card) { @@ -1638,6 +1640,11 @@ function render() { var stats = document.getElementById('stats'); if (!content) return; + // Reflect the active view on so CSS can hide chrome that only makes + // sense over a session list — e.g. the session toolbar (Search/Group/Select/ + // AI Titles/Refresh + count) is meaningless in the Terminal and Overview views. + document.body.setAttribute('data-view', currentView); + // Detach (don't destroy) the live terminal when navigating away from the // Workspace view: its panes/ptys keep running in a hidden holder and are // re-attached intact on return. diff --git a/src/frontend/styles.css b/src/frontend/styles.css index f5c8df1..510f945 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -567,6 +567,12 @@ body.sidebar-hidden .toolbar { padding-left: 52px; } position: relative; } +/* The session toolbar (search / group / select / AI titles / count) only makes + sense over a session list. Hide it in the Terminal and Overview views, which + have their own chrome and no list to act on. */ +body[data-view="workspace"] .toolbar, +body[data-view="overview"] .toolbar { display: none; } + .search-box { flex: 1; min-width: 200px; @@ -2892,9 +2898,16 @@ body.sidebar-hidden .toolbar { padding-left: 52px; } border-radius: 8px; padding: 0 6px; min-width: 16px; text-align: center; } .ws-run-term { + position: relative; padding: 2px 8px 2px 22px; font-size: 11px; 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%); + width: 5px; height: 5px; border-radius: 50%; background: var(--accent-green); +} +.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). */ diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js index 274eec3..dc6195e 100644 --- a/src/frontend/workspace.js +++ b/src/frontend/workspace.js @@ -146,53 +146,104 @@ function _wsStartStatusLoop() { }, 1000); } -// Group live terminals by their project folder (skips the home dir — those -// aren't "projects"). Used by the sidebar running-terminals tree. +// Human label for an agent kind (the `kind` field from /api/active), e.g. +// 'claude' → 'Claude', 'kiro' → 'Kiro'. Falls back to a capitalized kind. +var _WS_TOOL_LABELS = { + claude: 'Claude', codex: 'Codex', qwen: 'Qwen', opencode: 'OpenCode', + kiro: 'Kiro', 'kiro-cli': 'Kiro', kilo: 'Kilo', cursor: 'Cursor', + copilot: 'Copilot', 'copilot-chat': 'Copilot', pi: 'Pi', gemini: 'Gemini', +}; +function _wsToolLabel(kind) { + if (!kind) return 'Agent'; + return _WS_TOOL_LABELS[kind] || (kind.charAt(0).toUpperCase() + kind.slice(1)); +} + +// Group *running agents* (from the live /api/active map, not Workspace panes) +// by their real project folder — so agents launched here, in another terminal, +// or that cd'd elsewhere are all detected correctly. Skips the home dir and +// entries with no cwd. Used by the sidebar running-agents tree. function _wsRunningByProject() { + var map = (typeof activeSessions === 'object' && activeSessions) || {}; var groups = {}, order = []; + Object.keys(map).forEach(function (k) { + var a = map[k]; + var cwd = (a && a.cwd) || ''; + if (!cwd || /^(\/Users\/[^/]+|\/home\/[^/]+|\/root)\/?$/.test(cwd)) return; + var key = cwd; // group by full path so two same-named folders don't merge + if (!groups[key]) { groups[key] = { name: _wsProjectBasename(cwd), cwd: cwd, items: [] }; order.push(key); } + groups[key].items.push(a); + }); + return order.map(function (k) { return groups[k]; }); +} + +// Find a live Workspace pane sitting in `cwd` (so clicking a running agent can +// jump straight to its terminal when we have one). Returns {tab,pane} or null. +function _wsPaneForCwd(cwd) { + var hit = null; _wsAllPanes().forEach(function (x) { + if (hit) return; var p = x.pane; if (!p.sock || p.exited || p.sock.readyState !== 1) return; - var cwd = p.cwd || ''; - if (!cwd || /^(\/Users\/[^/]+|\/home\/[^/]+|\/root)\/?$/.test(cwd)) return; - var key = _wsProjectBasename(cwd); - if (!groups[key]) { groups[key] = { name: key, items: [] }; order.push(key); } - groups[key].items.push(x); + if ((p.cwd || p.wantCwd) === cwd) hit = x; }); - return order.map(function (k) { return groups[k]; }); + return hit; +} + +// Click a running-agent row: jump to its Workspace pane if one exists here, +// otherwise open the session in a new pane (best-effort) or just show Workspace. +function jumpToRunningAgent(cwd, sessionId, kind) { + var hit = _wsPaneForCwd(cwd); + if (hit) { jumpToWorkspacePane(hit.tab.id, hit.pane.id); return; } + if (sessionId && typeof openSessionInWorkspace === 'function') { + openSessionInWorkspace(sessionId); return; + } + if (cwd && typeof openInWorkspace === 'function') { + openInWorkspace({ name: _wsProjectBasename(cwd), cwd: cwd }); + } } -// Render a compact tree at the bottom of the sidebar: each project folder that -// has running terminals, with its terminals underneath — click to jump. +// 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. var _wsRunTreeSig = ''; function _wsRenderRunningTree() { var el = document.getElementById('wsRunningTree'); if (!el) return; var groups = _wsRunningByProject(); var sig = groups.map(function (g) { - return g.name + ':' + g.items.map(function (x) { return x.pane.id + '=' + _wsPaneLabel(x.pane); }).join(','); + return g.name + ':' + g.items.map(function (a) { + return (a.sessionId || a.pid) + '=' + a.kind + '/' + a.status; + }).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 in projects
'; + var html = '
Running agents
'; groups.forEach(function (g) { - var first = g.items[0]; - html += '
' + + html += '
' + '' + escHtml(g.name) + '' + '' + g.items.length + '
'; - g.items.forEach(function (x) { - html += '
' + - escHtml(_wsPaneLabel(x.pane)) + '
'; + g.items.forEach(function (a) { + var waiting = a.status === 'waiting'; + html += '
' + + escHtml(_wsToolLabel(a.kind)) + '
'; }); }); el.innerHTML = html; el.style.display = ''; } +// Safely embed a JS string literal inside a double-quoted inline onclick="" +// attribute: JS-escape backslash/quote, then HTML-escape the attribute-breaking +// characters so an odd path (spaces, &, quotes) can't break out of either layer. +function _wsJsStr(s) { + var js = "'" + String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; + return js.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + // Jump from a status chip to that pane: show Workspace, activate its tab, focus it. function jumpToWorkspacePane(tabId, paneId) { if (typeof setView === 'function') setView('workspace'); @@ -813,14 +864,23 @@ function _wsLoadLayouts() { .catch(function () { _wsSavedLayouts = []; }); } -// Snapshot the current workspace into the { tabs:[{name,panes:[{cmd}]}] } shape -// the server expects. A pane with no launched command is stored as cmd:''. +// Snapshot the current workspace into the { tabs:[{name,panes}] } shape the +// server expects. Each pane records its launched command (cmd), any typed-but- +// not-run command (prefill, e.g. a resume line from a session card) and the +// folder it opened in (cwd) — so restoring the layout reopens every pane in the +// same project and re-issues the same command. A blank pane is stored as cmd:''. function _wsCaptureLayout() { return { tabs: _wsTabs.map(function (t) { return { name: t.name, - panes: t.panes.map(function (p) { return { cmd: p.cmd || '' }; }), + panes: t.panes.map(function (p) { + return { + cmd: p.cmd || '', + prefill: p.prefill || '', + cwd: p.cwd || p.wantCwd || '', + }; + }), }; }), }; @@ -883,7 +943,14 @@ function applyWorkspaceLayout(id) { _wsTabs = layout.tabs.map(function (t, ti) { var panes = (t.panes && t.panes.length ? t.panes : [{ cmd: '' }]) .slice(0, MAX_WS_PANES) - .map(function (p) { return { id: 'p' + (++_wsPaneSeq), cmd: (p && p.cmd) || null }; }); + .map(function (p) { + return { + id: 'p' + (++_wsPaneSeq), + cmd: (p && p.cmd) || null, + prefill: (p && p.prefill) || null, + wantCwd: (p && p.cwd) || null, + }; + }); return { id: 't' + (++_wsTabSeq), name: t.name || ('Tab ' + (ti + 1)), panes: panes }; }); _wsActiveTabId = _wsTabs[0].id; diff --git a/src/workspace-layouts.js b/src/workspace-layouts.js index 64aec78..3134629 100644 --- a/src/workspace-layouts.js +++ b/src/workspace-layouts.js @@ -34,14 +34,28 @@ function ensureDir(d) { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); } -// A pane is { cmd }. cmd may be '' (a blank pane, no auto-launch). Non-empty +// A pane is { cmd, prefill, cwd }. cmd may be '' (a blank pane, no auto-launch). +// `prefill` is a command typed into the pane but NOT auto-executed (e.g. a +// resume command from a session card); `cwd` is the folder the pane opened in, +// so a restored layout reopens each pane in the same project. Non-empty // commands are validated like saved commands (no newlines / control chars). +// cwd is validated the same way (a path with a control char is rejected); the +// pty spawn re-gates it through isSafeLaunchPath, so this is belt-and-braces. function sanitizePane(p) { const raw = p && typeof p === 'object' ? p.cmd : p; - let cmd = typeof raw === 'string' ? raw.trim() : ''; + const cmd = typeof raw === 'string' ? raw.trim() : ''; if (cmd.length > MAX_COMMAND) return null; if (cmd && CONTROL_CHARS.test(cmd)) return null; - return { cmd }; + const prefillRaw = p && typeof p === 'object' && typeof p.prefill === 'string' ? p.prefill.trim() : ''; + if (prefillRaw.length > MAX_COMMAND) return null; + if (prefillRaw && CONTROL_CHARS.test(prefillRaw)) return null; + const cwdRaw = p && typeof p === 'object' && typeof p.cwd === 'string' ? p.cwd.trim() : ''; + if (cwdRaw.length > MAX_COMMAND) return null; + if (cwdRaw && CONTROL_CHARS.test(cwdRaw)) return null; + const out = { cmd }; + if (prefillRaw) out.prefill = prefillRaw; + if (cwdRaw) out.cwd = cwdRaw; + return out; } function sanitizeTab(t, i) { diff --git a/test/workspace-layouts.test.js b/test/workspace-layouts.test.js index ef89a5c..7488d89 100644 --- a/test/workspace-layouts.test.js +++ b/test/workspace-layouts.test.js @@ -31,6 +31,35 @@ test('sanitizeLayout accepts a multi-tab, multi-pane layout', () => { assert.equal(l.tabs[1].panes[0].cmd, ''); }); +test('sanitizeLayout preserves per-pane prefill and cwd', () => { + const { m } = freshModule(); + const l = m.sanitizeLayout({ + name: 'resume setup', + tabs: [{ name: 'work', panes: [{ cmd: '', prefill: 'claude --resume abc', cwd: '/Users/me/proj' }] }], + }); + assert.ok(l); + const p = l.tabs[0].panes[0]; + assert.equal(p.cmd, ''); + assert.equal(p.prefill, 'claude --resume abc'); + assert.equal(p.cwd, '/Users/me/proj'); +}); + +test('sanitizeLayout omits empty prefill/cwd and rejects control chars in them', () => { + const { m } = freshModule(); + const clean = m.sanitizeLayout({ name: 'x', tabs: [{ name: 't', panes: [{ cmd: 'claude' }] }] }); + assert.equal('prefill' in clean.tabs[0].panes[0], false); + assert.equal('cwd' in clean.tabs[0].panes[0], false); + // a newline in prefill or cwd rejects the whole tab (→ whole layout) + assert.equal( + m.sanitizeLayout({ name: 'x', tabs: [{ name: 't', panes: [{ cmd: '', prefill: 'a\nb' }] }] }), + null, + ); + assert.equal( + m.sanitizeLayout({ name: 'x', tabs: [{ name: 't', panes: [{ cmd: '', cwd: '/a\n/b' }] }] }), + null, + ); +}); + test('sanitizeLayout rejects no-name, no-tabs, and newline injection', () => { const { m } = freshModule(); assert.equal(m.sanitizeLayout({ name: '', tabs: SAMPLE_TABS }), null);