Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/changelog.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
9 changes: 8 additions & 1 deletion src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1638,6 +1640,11 @@ function render() {
var stats = document.getElementById('stats');
if (!content) return;

// Reflect the active view on <body> 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.
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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). */
Expand Down
113 changes: 90 additions & 23 deletions src/frontend/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div class="ws-run-title">Running in projects</div>';
var html = '<div class="ws-run-title">Running agents</div>';
groups.forEach(function (g) {
var first = g.items[0];
html += '<div class="ws-run-proj" title="' + escHtml(g.name) + '" ' +
'onclick="jumpToWorkspacePane(\'' + escHtml(first.tab.id) + '\',\'' + escHtml(first.pane.id) + '\')">' +
html += '<div class="ws-run-proj" title="' + escHtml(g.cwd) + '" ' +
'onclick="jumpToRunningAgent(' + _wsJsStr(g.cwd) + ',null,null)">' +
'<span class="ws-run-dot"></span><span class="ws-run-name">' + escHtml(g.name) + '</span>' +
'<span class="ws-run-count">' + g.items.length + '</span></div>';
g.items.forEach(function (x) {
html += '<div class="ws-run-term" ' +
'onclick="jumpToWorkspacePane(\'' + escHtml(x.tab.id) + '\',\'' + escHtml(x.pane.id) + '\')">' +
escHtml(_wsPaneLabel(x.pane)) + '</div>';
g.items.forEach(function (a) {
var waiting = a.status === 'waiting';
html += '<div class="ws-run-term' + (waiting ? ' ws-run-idle' : '') + '" ' +
'title="' + escHtml(_wsToolLabel(a.kind) + (waiting ? ' — idle' : ' — active')) + '" ' +
'onclick="jumpToRunningAgent(' + _wsJsStr(g.cwd) + ',' + _wsJsStr(a.sessionId || '') + ',' + _wsJsStr(a.kind || '') + ')">' +
escHtml(_wsToolLabel(a.kind)) + '</div>';
});
});
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// 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');
Expand Down Expand Up @@ -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 || '',
};
}),
};
}),
};
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions src/workspace-layouts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions test/workspace-layouts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading