From ce4082488d681732a64ddc639c09b098e0962086 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Tue, 8 Sep 2026 10:07:07 +0200 Subject: [PATCH] fix(remote): disable the new-session button on remote project groups The "+" button in a project-group header was created unconditionally, including for a remote-mirrored project (project.remoteAlias set). Its click handler reached showNewSessionPopover -> launchNewSession -> the open-terminal IPC with isNew and the remote host's projectPath, which this machine does not have on disk. The generic fs.existsSync guard in main.js caught it with a misleading "project directory no longer exists" message for a path that was never local. Disable the button rather than hide it: sessions under a missing project already use this project's convention of an explanatory title instead of removing the control, so the "+" button follows the same idiom, with a title naming the mirrored host. The click handler is also guarded directly so a disabled button never reaches showNewSessionPopover even if invoked programmatically. Refs #214 --- public/sidebar.js | 13 +++- public/style.css | 14 ++++ ...dom-sidebar-remote-project-new-btn.test.js | 76 +++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 test/dom-sidebar-remote-project-new-btn.test.js diff --git a/public/sidebar.js b/public/sidebar.js index b7919273..0226d3e4 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -759,7 +759,12 @@ function renderProjects(projects, resort) { const newBtn = document.createElement('button'); newBtn.className = 'project-new-btn'; newBtn.innerHTML = ''; - newBtn.title = 'New session'; + if (project.remoteAlias) { + newBtn.disabled = true; + newBtn.title = 'Read-only mirror of ' + project.remoteAlias + ' — new sessions must be started on that host'; + } else { + newBtn.title = 'New session'; + } header.appendChild(newBtn); const sessionsList = buildSessionsList(fId, visible, older, subagentIndex, project.projectPath); @@ -924,7 +929,11 @@ function rebindSidebarEvents(projects) { if (!header) continue; const newBtn = header.querySelector('.project-new-btn'); if (newBtn) { - newBtn.onclick = (e) => { e.stopPropagation(); showNewSessionPopover(project, newBtn); }; + if (project.remoteAlias) { + newBtn.onclick = (e) => e.stopPropagation(); + } else { + newBtn.onclick = (e) => { e.stopPropagation(); showNewSessionPopover(project, newBtn); }; + } } const scheduleBtn = header.querySelector('.project-schedule-btn'); if (scheduleBtn) { diff --git a/public/style.css b/public/style.css index 6c488d3f..f19057c8 100644 --- a/public/style.css +++ b/public/style.css @@ -576,6 +576,20 @@ body { display: flex; flex-direction: column; } color: #5ec46a; } +.project-new-btn:disabled { + cursor: default; + opacity: 0.4; +} + +.project-new-btn:disabled:hover { + background: transparent; + color: #808098; +} + +.project-header:hover .project-new-btn:disabled { + opacity: 0.4; +} + .project-archive-btn:hover { background: rgba(120,130,255,0.1); color: #8088ff; diff --git a/test/dom-sidebar-remote-project-new-btn.test.js b/test/dom-sidebar-remote-project-new-btn.test.js new file mode 100644 index 00000000..a086f526 --- /dev/null +++ b/test/dom-sidebar-remote-project-new-btn.test.js @@ -0,0 +1,76 @@ +// Issue #214: a project group mirrored from an SSH host has no local +// filesystem to spawn a PTY in. The "+" new-session button in its header +// must not let a click reach launchNewSession/open-terminal with isNew and +// the remote host's projectPath. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +function remoteProject() { + return makeSampleProject({ + projectPath: '/srv/supervision', + folder: 'planificator::-srv-supervision', + remoteAlias: 'planificator', + sessions: [{ + sessionId: 'remote-1', + summary: 'ripcord protocol', + modified: '2026-09-06T10:00:00.000Z', + starred: false, + archived: 0, + messageCount: 4, + projectPath: '/srv/supervision', + remoteAlias: 'planificator', + }], + }); +} + +test('the new-session button of a remote project group is disabled', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([remoteProject()], true); + const fId = ctx.sidebar.folderId('/srv/supervision'); + const header = ctx.document.getElementById('ph-' + fId); + const newBtn = header.querySelector('.project-new-btn'); + assert.ok(newBtn, 'the button must still render'); + assert.equal(newBtn.disabled, true); + assert.match(newBtn.title, /planificator/); + } finally { ctx.destroy(); } +}); + +test('clicking the new-session button of a remote project group opens nothing', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([remoteProject()], true); + const fId = ctx.sidebar.folderId('/srv/supervision'); + const header = ctx.document.getElementById('ph-' + fId); + const newBtn = header.querySelector('.project-new-btn'); + + const popovers = []; + ctx.window.showNewSessionPopover = (project, btn) => popovers.push(project.projectPath); + + newBtn.onclick({ stopPropagation: () => {} }); + + assert.deepEqual(popovers, [], 'showNewSessionPopover must never be invoked for a remote project group'); + } finally { ctx.destroy(); } +}); + +test('a local project group is unaffected: the new-session button stays enabled and wired', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([makeSampleProject()], true); + const fId = ctx.sidebar.folderId('/home/dev/myproj'); + const header = ctx.document.getElementById('ph-' + fId); + const newBtn = header.querySelector('.project-new-btn'); + assert.equal(newBtn.disabled, false); + assert.equal(newBtn.title, 'New session'); + + const popovers = []; + ctx.window.showNewSessionPopover = (project, btn) => popovers.push(project.projectPath); + + newBtn.onclick({ stopPropagation: () => {} }); + + assert.deepEqual(popovers, ['/home/dev/myproj']); + } finally { ctx.destroy(); } +});