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
21 changes: 20 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator');
Expand Down Expand Up @@ -453,8 +453,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 456 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 456 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 457 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');

Expand Down Expand Up @@ -484,6 +484,25 @@
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);
Expand Down Expand Up @@ -938,7 +957,7 @@
reconcileCacheFromFilesystem();
}

return buildProjectsFromCache(showArchived);
return annotateRemoteAttachable(buildProjectsFromCache(showArchived));
} catch (err) {
console.error('Error listing projects:', err);
return [];
Expand Down Expand Up @@ -2065,7 +2084,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2087 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
8 changes: 6 additions & 2 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
41 changes: 36 additions & 5 deletions test/dom-sidebar-remote-session.test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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]);
Expand All @@ -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(); }
});

Expand Down
8 changes: 6 additions & 2 deletions test/get-projects-cold-start-reconcile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down
Loading