diff --git a/main.js b/main.js index cc7b0167..7ec36aa2 100644 --- a/main.js +++ b/main.js @@ -484,6 +484,25 @@ const remoteAttachAdapter = createTmuxAttachAdapter({ log, }); +// Joins the sidebar's remote sessions to the indexer's live descriptors so the +// renderer can route a click without ever naming an attach mechanism itself +// — see .ai/contexts/session-cache.md ("Remote hosts — tmux attach"). +function annotateRemoteAttachable(projects) { + const descriptorsByAlias = new Map(); + for (const project of projects) { + for (const session of project.sessions) { + if (!session.remoteAlias) continue; + if (!descriptorsByAlias.has(session.remoteAlias)) { + const descriptors = remoteIndexer.getRemoteSessions(session.remoteAlias); + descriptorsByAlias.set(session.remoteAlias, new Map(descriptors.map(d => [d.sessionId, d]))); + } + const descriptor = descriptorsByAlias.get(session.remoteAlias).get(session.sessionId); + session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor)); + } + } + return projects; +} + /** Directory holding a folder key's transcripts, local or mirrored. */ function projectsDirForFolder(folder) { return resolveFolderDir(folder); @@ -938,7 +957,7 @@ ipcMain.handle('get-projects', async (_event, showArchived) => { reconcileCacheFromFilesystem(); } - return buildProjectsFromCache(showArchived); + return annotateRemoteAttachable(buildProjectsFromCache(showArchived)); } catch (err) { console.error('Error listing projects:', err); return []; diff --git a/public/sidebar.js b/public/sidebar.js index 0226d3e4..eb1f619e 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1087,11 +1087,15 @@ function rebindSidebarEvents(projects) { return; } + // see .ai/contexts/session-cache.md ("Remote SSH hosts") + if (session.remoteAlias && !session.remoteAttachable) { + item.title = 'Not currently attachable — opening the transcript instead'; + } + item.onclick = () => { if (item.dataset.subagent && session.parentSessionId) { showSubagentTranscript(session); - } else if (session.remoteAlias) { - // Observation only. see .ai/contexts/session-cache.md ("Remote SSH hosts") + } else if (session.remoteAlias && !session.remoteAttachable) { showJsonlViewer(session); } else { openSession(session); diff --git a/test/dom-sidebar-remote-session.test.js b/test/dom-sidebar-remote-session.test.js index 8081e9f2..85073e5e 100644 --- a/test/dom-sidebar-remote-session.test.js +++ b/test/dom-sidebar-remote-session.test.js @@ -1,6 +1,8 @@ -// Issue #201: a session mirrored from an SSH host is observation-only. Its row -// must say so, and clicking it must open the read-only transcript instead of -// trying to `claude --resume` in a cwd this machine does not have. +// Issue #201: a session mirrored from an SSH host defaults to observation +// only. Its row says so via a badge naming the host. Issue #221: when the +// main process reports the session as attachable, the click opens a terminal +// instead; otherwise it still falls back to the read-only transcript rather +// than trying to `claude --resume` in a cwd this machine does not have. const test = require('node:test'); const assert = require('node:assert/strict'); @@ -45,7 +47,7 @@ test('a remote session row carries a badge naming its host', () => { } finally { ctx.destroy(); } }); -test('clicking a remote session opens the transcript, never a resume', () => { +test('a remote session with no live attachable descriptor opens the transcript, never a resume', () => { const ctx = setupSidebarDom(); try { register(ctx, [REMOTE_SESSION]); @@ -56,10 +58,39 @@ test('clicking a remote session opens the transcript, never a resume', () => { ctx.window.openSession = (s) => opened.push(s.sessionId); ctx.window.showJsonlViewer = (s) => viewed.push(s.sessionId); - ctx.document.getElementById('si-remote-1').onclick(); + const item = ctx.document.getElementById('si-remote-1'); + item.onclick(); assert.deepEqual(viewed, ['remote-1']); assert.deepEqual(opened, [], 'openSession would spawn a PTY in a cwd that is not on this machine'); + assert.match(item.title, /not currently attachable/i, 'the row must say why it fell back to the transcript'); + assert.doesNotMatch(item.title, /tmux/i, 'the renderer must never name a multiplexer'); + } finally { ctx.destroy(); } +}); + +test('a remote session with a live attachable descriptor opens a terminal, not the transcript', () => { + const ctx = setupSidebarDom(); + try { + const attachable = { ...REMOTE_SESSION, sessionId: 'remote-2', remoteAttachable: true }; + register(ctx, [attachable]); + ctx.sidebar.renderProjects([makeSampleProject({ + projectPath: '/srv/supervision', + folder: 'planificator::-srv-supervision', + remoteAlias: 'planificator', + sessions: [attachable], + })], true); + + const opened = []; + const viewed = []; + ctx.window.openSession = (s) => opened.push(s.sessionId); + ctx.window.showJsonlViewer = (s) => viewed.push(s.sessionId); + + const item = ctx.document.getElementById('si-remote-2'); + item.onclick(); + + assert.deepEqual(opened, ['remote-2']); + assert.deepEqual(viewed, [], 'an attachable remote session must open a terminal, not the read-only transcript'); + assert.ok(!item.title, 'an attachable session carries no fallback-reason title'); } finally { ctx.destroy(); } }); diff --git a/test/get-projects-cold-start-reconcile.test.js b/test/get-projects-cold-start-reconcile.test.js index 96f98239..b5131332 100644 --- a/test/get-projects-cold-start-reconcile.test.js +++ b/test/get-projects-cold-start-reconcile.test.js @@ -45,13 +45,17 @@ function makeHandler(mocks) { const fn = new Function( 'isCachePopulated', 'isSearchIndexPopulated', 'isInitialScanComplete', 'populateCacheViaWorker', - 'reconcileCacheFromFilesystem', 'buildProjectsFromCache', 'showArchived', + 'reconcileCacheFromFilesystem', 'buildProjectsFromCache', 'annotateRemoteAttachable', 'showArchived', body ); + // annotateRemoteAttachable (remote-attach join, issue #221) is irrelevant to + // the populate/reconcile/build ordering this file locks down -- a passthrough + // stands in for it unless a test overrides it. + const annotateRemoteAttachable = mocks.annotateRemoteAttachable || (projects => projects); return () => fn( mocks.isCachePopulated, mocks.isSearchIndexPopulated, mocks.isInitialScanComplete, mocks.populateCacheViaWorker, - mocks.reconcileCacheFromFilesystem, mocks.buildProjectsFromCache, false + mocks.reconcileCacheFromFilesystem, mocks.buildProjectsFromCache, annotateRemoteAttachable, false ); }