diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 87330d1c..c0755e7e 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -55,6 +55,40 @@ the terminal header's stop button, and the grid card's stop button all funnel through it) — it now asks `resolveSessionStop` which IPC to call instead of always calling `stopSession`. +### Archive/delete are stop-then-archive/delete (issue #271) + +`public/sidebar.js`'s four archive/delete call sites (`.project-archive-btn`, +`.slug-group-archive-btn`, `.session-delete-btn`, `.session-archive-btn`) used +to call bare `stopSession` gated on `activePtyIds` — a detach for an attached +remote row, and nothing at all for an unattached one, while the archive/delete +proceeded regardless. They now share `stop-session-ui.js`'s `stopBeforeArchive(session)`, +built on `resolveSessionStop` plus `isRemoteSessionAlive(session)` (the +`remote-ssh` adapter's own `remoteSessionStates` snapshot when one exists — +authoritative over a stale `session.remoteDescriptorSeen` right after this app +itself just stopped it — falling back to `remoteDescriptorSeen` otherwise): + +| kind | alive / has PTY | call | +|---|---|---| +| remote | alive | `remoteStopSession(alias, sessionId)` | +| remote | not alive | nothing — `{ok:true}` | +| local | has PTY (`activePtyIds`) | `stopSession(sessionId)` | +| local | no PTY | nothing — `{ok:true}` | + +A `{ok:false, error}` return skips that session's archive/delete and surfaces +the failure on its own button — `sidebar.js`'s `surfaceStopFailure(btn, message)`, +the same flash-and-title-with-restore convention `confirmAndStopSession` uses. +For the two group archives (project header, slug group), one session's +refusal only skips that session; the loop continues to the rest. The project +header's confirmation names the host alias(es) it is about to stop, computed +with the same `isRemoteSessionAlive` check. + +**Delete never calls `stopBeforeArchive` for a remote session.** `delete-session` +is refused server-side for remote regardless (`REMOTE_READ_ONLY`, main.js) — +stopping the process first would strand a killed remote session behind a +delete that never happens, so the delete site checks +`resolveSessionStop(session).remote` itself and skips the stop entirely for +that kind, local sessions unaffected. + **The remote stop, main-side (`remote-stop.js`).** `createRemoteStopAdapter().stop(alias, descriptor)` builds one non-interactive ssh command (same `buildRemoteCommandArgs` transport as `remote-attach.js`'s probe/restore calls) that: (1) reuses diff --git a/docs/session-browser.md b/docs/session-browser.md index 6cc9737a..5c8a266c 100644 --- a/docs/session-browser.md +++ b/docs/session-browser.md @@ -42,7 +42,7 @@ Use **archive** instead if you only want the session out of the way — archivin The confirmation dialog states what will be removed: the project, how many files are on disk, and how many subagent transcripts belong to the session. Subagent transcripts are removed with their parent, and their search/index entries with them. -If the session is still running it is stopped first when Switchboard knows it is live; otherwise the deletion is refused with a reason rather than pulling a transcript out from under a running process. Anything that resolves outside `~/.claude/projects` — a symlinked transcript, for instance — is refused and logged. A session that never started has no transcript to remove, so deleting it just clears the leftover card. +If a local session is still running it is stopped first; otherwise the deletion is refused with a reason rather than pulling a transcript out from under a running process. A session on a declared remote host is never stopped for a delete — Switchboard only observes remote sessions, so deleting one is refused outright regardless of whether it is still running; use the stop button first if you also want the process on the host ended. Anything that resolves outside `~/.claude/projects` — a symlinked transcript, for instance — is refused and logged. A session that never started has no transcript to remove, so deleting it just clears the leftover card. ## Stop a running session @@ -53,7 +53,7 @@ A remote session you are not currently viewing keeps running on the host even th ## Star and archive - **Star** — right-click a session and choose Star, or use the star icon in the session header. Starred sessions appear at the top of their project group. -- **Archive** — right-click and choose Archive to hide a session from the default view. Archived sessions reappear when you enable the Archived filter. +- **Archive** — right-click and choose Archive to hide a session from the default view. Archived sessions reappear when you enable the Archived filter. Archiving a running session stops it first — on its declared remote host, not just Switchboard's local view of it — the same as the stop button; the "archive all" buttons on a project or a same-slug group do this for every session they archive, and skip (and flag) any one that fails to stop rather than leaving it silently unarchived. ## Session count limits diff --git a/eslint.config.js b/eslint.config.js index 6a9e1912..72f90618 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -168,8 +168,10 @@ const rendererCrossFileGlobals = { // public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md) setRemoteAttached: 'readonly', applyRemoteStopped: 'readonly', - // public/stop-session-ui.js (pure stop-vs-detach decision, see .ai/contexts/session-state.md) + // public/stop-session-ui.js — see .ai/contexts/session-state.md (issue #271) resolveSessionStop: 'readonly', + isRemoteSessionAlive: 'readonly', + stopBeforeArchive: 'readonly', // read by sidebar.js's parentHasActiveSubagent() — see .ai/contexts/subagent-observability.md remoteSessionStates: 'readonly', // public/local-transcript-adapter.js (local-transcript adapter, see .ai/contexts/session-state.md) diff --git a/public/index.html b/public/index.html index 30d76676..85b3ce7f 100644 --- a/public/index.html +++ b/public/index.html @@ -138,14 +138,14 @@ + + - - diff --git a/public/sidebar.js b/public/sidebar.js index a92ad56e..186480a3 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -7,7 +7,7 @@ // Depends on: cleanDisplayName, formatDate, escapeHtml (utils.js), ICONS (icons.js), // showSession (terminal-manager.js), confirmAndStopSession, pollActiveSessions, // showNewSessionPopover, openSettingsViewer, showResumeSessionDialog, -// showJsonlViewer, forkSession, openSession, loadProjects (app.js/dialogs.js) +// showJsonlViewer, forkSession, openSession, loadProjects (app.js/dialogs.js), resolveSessionStop, isRemoteSessionAlive, stopBeforeArchive (stop-session-ui.js) function slugId(slug) { return 'slug-' + slug.replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -17,6 +17,16 @@ function folderId(projectPath) { return 'project-' + projectPath.replace(/[^a-zA-Z0-9_-]/g, '_'); } +// Surfaces a stopBeforeArchive() failure on the button — see .ai/contexts/session-state.md ("The two lifecycle verbs"). +function surfaceStopFailure(btn, message) { + console.error('[stop-before-archive]', message); + if (!btn) return; + if (typeof window.flashButtonText === 'function') window.flashButtonText(btn, 'Failed', 1500); + const originalTitle = btn.title; + btn.title = message; + setTimeout(() => { btn.title = originalTitle; }, 3000); +} + // see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") and // .ai/contexts/cli-session-state.md (local sessions use the same field pair) function formatStatusAge(epochMs) { @@ -1036,10 +1046,21 @@ function rebindSidebarEvents(projects) { const sessions = project.sessions.filter(s => !s.parentSessionId && !s.archived); if (sessions.length === 0) return; const shortName = shortProjectPath(project.projectPath); - if (!confirm(`Archive all ${sessions.length} session${sessions.length > 1 ? 's' : ''} in ${shortName}?`)) return; + // issue #271 / .ai/contexts/session-state.md: archive is stop-then-archive. + const aliasesToStop = [...new Set( + sessions.filter(s => s.remoteAlias && isRemoteSessionAlive(s)).map(s => s.remoteAlias) + )]; + let message = `Archive all ${sessions.length} session${sessions.length > 1 ? 's' : ''} in ${shortName}?`; + if (aliasesToStop.length > 0) { + message += ` This stops the running session${aliasesToStop.length > 1 ? 's' : ''} on ${aliasesToStop.join(', ')} first.`; + } + if (!confirm(message)) return; for (const s of sessions) { - if (activePtyIds.has(s.sessionId)) { - await window.api.stopSession(s.sessionId); + const stopResult = await stopBeforeArchive(s); + if (!stopResult.ok) { + const item = document.getElementById('si-' + s.sessionId); + surfaceStopFailure(item && item.querySelector('.session-archive-btn'), stopResult.error); + continue; } await window.api.archiveSession(s.sessionId, 1); s.archived = 1; @@ -1137,7 +1158,11 @@ function rebindSidebarEvents(projects) { const sid = item.dataset.sessionId; const session = sessionMap.get(sid); if (!session || session.archived) continue; - if (activePtyIds.has(sid)) await window.api.stopSession(sid); + const stopResult = await stopBeforeArchive(session); + if (!stopResult.ok) { + surfaceStopFailure(item.querySelector('.session-archive-btn'), stopResult.error); + continue; + } await window.api.archiveSession(sid, 1); session.archived = 1; } @@ -1264,8 +1289,13 @@ function rebindSidebarEvents(projects) { e.stopPropagation(); const ok = await showDeleteSessionDialog(session); if (!ok) return; - if (activePtyIds.has(session.sessionId)) { - await window.api.stopSession(session.sessionId); + // issue #271: delete is refused server-side for remote — see .ai/contexts/session-state.md. + if (!resolveSessionStop(session).remote) { + const stopResult = await stopBeforeArchive(session); + if (!stopResult.ok) { + surfaceStopFailure(deleteBtn, stopResult.error); + return; + } pollActiveSessions(); } const res = await window.api.deleteSession(session.sessionId); @@ -1297,8 +1327,12 @@ function rebindSidebarEvents(projects) { archiveBtn.onclick = async (e) => { e.stopPropagation(); const newVal = session.archived ? 0 : 1; - if (newVal && activePtyIds.has(session.sessionId)) { - await window.api.stopSession(session.sessionId); + if (newVal) { + const stopResult = await stopBeforeArchive(session); + if (!stopResult.ok) { + surfaceStopFailure(archiveBtn, stopResult.error); + return; + } pollActiveSessions(); } await window.api.archiveSession(session.sessionId, newVal); diff --git a/public/stop-session-ui.js b/public/stop-session-ui.js index ce3d3c40..32ec02ff 100644 --- a/public/stop-session-ui.js +++ b/public/stop-session-ui.js @@ -9,6 +9,39 @@ function resolveSessionStop(session) { return { remote: false, alias: null, confirmText: 'Stop this session?' }; } +// Is this remote session's process still running? See .ai/contexts/session-state.md ("stopBeforeArchive"). +function isRemoteSessionAlive(session) { + if (!session) return false; + if (typeof remoteSessionStates !== 'undefined' && remoteSessionStates.has(session.sessionId)) { + const liveness = remoteSessionStates.get(session.sessionId).snapshot().liveness; + if (liveness === 'dead') return false; + if (liveness === 'alive') return true; + } + return !!session.remoteDescriptorSeen; +} + +// Stop-then-archive/delete verb shared by sidebar.js's archive/delete call sites — see .ai/contexts/session-state.md ("stopBeforeArchive"). +async function stopBeforeArchive(session) { + if (!session) return { ok: true }; + const alias = session.remoteAlias; + if (alias) { + if (!isRemoteSessionAlive(session)) return { ok: true }; + const result = await window.api.remoteStopSession(alias, session.sessionId); + if (!result || result.ok === false) { + return { ok: false, error: (result && result.error) || 'unknown error' }; + } + if (typeof applyRemoteStopped === 'function') applyRemoteStopped(session.sessionId); + return { ok: true }; + } + if (typeof activePtyIds === 'undefined' || !activePtyIds.has(session.sessionId)) return { ok: true }; + const result = await window.api.stopSession(session.sessionId); + if (result && result.ok === false) { + return { ok: false, error: result.error || 'unknown error' }; + } + activePtyIds.delete(session.sessionId); + return { ok: true }; +} + if (typeof module !== 'undefined' && module.exports) { - module.exports = { resolveSessionStop }; + module.exports = { resolveSessionStop, isRemoteSessionAlive, stopBeforeArchive }; } diff --git a/test/delete-session.test.js b/test/delete-session.test.js index 29ef5209..323661ec 100644 --- a/test/delete-session.test.js +++ b/test/delete-session.test.js @@ -54,7 +54,16 @@ test('delete button: rendered on session cards and confirms before deleting', () const handler = sidebar.slice(start, sidebar.indexOf('const archiveBtn', start)); assert.match(handler, /await showDeleteSessionDialog\(session\)/, 'an irreversible action must be confirmed, via the styled dialog'); - assert.match(handler, /stopSession/, 'a running session must be stopped before deletion'); + // issue #271: a running LOCAL session is stopped via the shared + // stop-then-archive/delete helper (stopBeforeArchive, public/stop-session-ui.js); + // a REMOTE session must skip the stop entirely — delete is refused + // server-side for remote regardless (REMOTE_READ_ONLY, main.js), so + // stopping first would strand a killed process behind a delete that never + // happens. + assert.match(handler, /stopBeforeArchive\(session\)/, + 'a running session must be stopped before deletion, via the shared helper'); + assert.match(handler, /resolveSessionStop\(session\)\.remote/, + 'the stop must be skipped for a remote session — delete is refused server-side for remote anyway'); assert.match(handler, /window\.api\.deleteSession/); assert.doesNotMatch(handler, /window\.alert\(/, 'alert() is modal and hard to dismiss — a failure must not block the renderer'); diff --git a/test/dom-setup.js b/test/dom-setup.js index 2a34c5e1..4e21b5d6 100644 --- a/test/dom-setup.js +++ b/test/dom-setup.js @@ -138,6 +138,9 @@ function setupSidebarDom() { evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity-dom.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity.js')); + // stop-session-ui.js: sidebar.js's archive/delete stop helper (issue #271, see .ai/contexts/session-state.md). + evalInWindow(dom, path.join(PUBLIC_DIR, 'stop-session-ui.js')); + // sidebar.js, then remote-activity-ui.js (seedRemoteActivity, called from // renderProjects) and local-transcript-adapter.js (onSessionTranscriptActivity). evalInWindow(dom, path.join(PUBLIC_DIR, 'sidebar.js')); @@ -163,6 +166,8 @@ function setupSidebarDom() { attentionSessions: read('attentionSessions'), responseReadySessions: read('responseReadySessions'), setActivity: read('setActivity'), + // remote-activity-ui.js's per-session adapter state (const, not a window property — see .ai/contexts/session-state.md). + remoteSessionStates: read('remoteSessionStates'), // Simulate the main process emitting subagent-spawned/subagent-completed // (session-transitions.js) by invoking the callback sidebar.js registered // via window.api.onSubagentSpawned/onSubagentCompleted at eval time. diff --git a/test/dom-sidebar-stop-before-archive.test.js b/test/dom-sidebar-stop-before-archive.test.js new file mode 100644 index 00000000..86208589 --- /dev/null +++ b/test/dom-sidebar-stop-before-archive.test.js @@ -0,0 +1,314 @@ +// Coverage for issue #271 — the sidebar's four archive/delete call sites +// (.project-archive-btn, .slug-group-archive-btn, .session-delete-btn, +// .session-archive-btn) must stop a running session on its actual host +// before archiving/deleting it, not just detach a local PTY. They share one +// decision, public/stop-session-ui.js's stopBeforeArchive() (built on the +// same resolveSessionStop()/isRemoteSessionAlive() the individual stop +// control uses) — see .ai/contexts/session-state.md ("The two lifecycle +// verbs: detach and stop"). +// +// Delete is the one exception: main.js refuses to delete a remote session +// outright (REMOTE_READ_ONLY), so the delete site must skip the stop for a +// remote row entirely rather than kill the process and then fail to delete +// it — see the per-session-delete tests below. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom } = require('./dom-setup'); + +function installRecordingApi(ctx, overrides = {}) { + const calls = []; + const impls = { + stopSession: () => Promise.resolve({ ok: true }), + remoteStopSession: () => Promise.resolve({ ok: true }), + archiveSession: () => Promise.resolve({ ok: true }), + deleteSession: () => Promise.resolve({ ok: true, removed: ['x'], subagents: 0 }), + deleteSessionPreview: () => Promise.resolve({ ok: true, transcripts: 1, subagents: 0, running: false }), + ...overrides, + }; + ctx.window.api = new Proxy({}, { + get(_target, prop) { + return (...args) => { + calls.push({ method: String(prop), args }); + const impl = impls[prop]; + return impl ? impl(...args) : Promise.resolve({ ok: true }); + }; + }, + }); + return calls; +} + +function localSession(id, extra = {}) { + return { + sessionId: id, projectPath: '/home/dev/proj', name: id, summary: id, + modified: '2026-05-22T10:00:00Z', archived: 0, ...extra, + }; +} + +function remoteSession(id, alias, alive, extra = {}) { + return { + sessionId: id, projectPath: '/home/dev/proj', remoteAlias: alias, remoteDescriptorSeen: alive, + name: id, summary: id, modified: '2026-05-22T10:00:00Z', archived: 0, ...extra, + }; +} + +function headerFor(ctx, project) { + return ctx.document.getElementById('ph-' + ctx.sidebar.folderId(project.projectPath)); +} + +// --------------------------------------------------------------------------- +// stopBeforeArchive() — the decision table directly. +// --------------------------------------------------------------------------- + +test('stopBeforeArchive: local session with an active PTY stops it and clears activePtyIds', async () => { + const ctx = setupSidebarDom(); + try { + const calls = installRecordingApi(ctx); + ctx.window.activePtyIds.add('s1'); + const result = await ctx.window.stopBeforeArchive({ sessionId: 's1' }); + assert.equal(result.ok, true); + assert.deepEqual(calls.map(c => c.method), ['stopSession']); + assert.equal(calls[0].args[0], 's1'); + assert.equal(ctx.window.activePtyIds.has('s1'), false); + } finally { ctx.destroy(); } +}); + +test('stopBeforeArchive: local session with no PTY calls nothing', async () => { + const ctx = setupSidebarDom(); + try { + const calls = installRecordingApi(ctx); + const result = await ctx.window.stopBeforeArchive({ sessionId: 's1' }); + assert.equal(result.ok, true); + assert.deepEqual(calls, []); + } finally { ctx.destroy(); } +}); + +test('stopBeforeArchive: an alive remote session calls remoteStopSession with the alias, never bare stopSession', async () => { + const ctx = setupSidebarDom(); + try { + const calls = installRecordingApi(ctx); + const result = await ctx.window.stopBeforeArchive({ sessionId: 's1', remoteAlias: 'vps', remoteDescriptorSeen: true }); + assert.equal(result.ok, true); + assert.deepEqual(calls.map(c => c.method), ['remoteStopSession']); + assert.deepEqual(calls[0].args, ['vps', 's1']); + } finally { ctx.destroy(); } +}); + +test('stopBeforeArchive: a remote session that is not alive calls nothing', async () => { + const ctx = setupSidebarDom(); + try { + const calls = installRecordingApi(ctx); + const result = await ctx.window.stopBeforeArchive({ sessionId: 's1', remoteAlias: 'vps', remoteDescriptorSeen: false }); + assert.equal(result.ok, true); + assert.deepEqual(calls, []); + } finally { ctx.destroy(); } +}); + +test('stopBeforeArchive: a dead adapter snapshot wins over a stale remoteDescriptorSeen=true', async () => { + const ctx = setupSidebarDom(); + try { + const calls = installRecordingApi(ctx); + ctx.remoteSessionStates.set('s1', { snapshot: () => ({ liveness: 'dead' }) }); + const result = await ctx.window.stopBeforeArchive({ sessionId: 's1', remoteAlias: 'vps', remoteDescriptorSeen: true }); + assert.equal(result.ok, true); + assert.deepEqual(calls, [], 'this app already stopped it — the descriptor just has not caught up yet'); + } finally { ctx.destroy(); } +}); + +test('stopBeforeArchive: a failed remote stop surfaces { ok: false, error }', async () => { + const ctx = setupSidebarDom(); + try { + installRecordingApi(ctx, { remoteStopSession: () => Promise.resolve({ ok: false, error: 'pid now belongs to a non-claude process' }) }); + const result = await ctx.window.stopBeforeArchive({ sessionId: 's1', remoteAlias: 'vps', remoteDescriptorSeen: true }); + assert.equal(result.ok, false); + assert.equal(result.error, 'pid now belongs to a non-claude process'); + } finally { ctx.destroy(); } +}); + +// --------------------------------------------------------------------------- +// .project-archive-btn +// --------------------------------------------------------------------------- + +test('project archive-all: an alive remote session is stopped on its host, alias named in the confirmation', async () => { + const ctx = setupSidebarDom(); + try { + const project = { projectPath: '/home/dev/proj', sessions: [remoteSession('r1', 'vps', true)] }; + const calls = installRecordingApi(ctx); + let prompt = null; + ctx.window.confirm = (m) => { prompt = m; return true; }; + + ctx.sidebar.renderProjects([project], true); + await headerFor(ctx, project).querySelector('.project-archive-btn').onclick(new ctx.window.MouseEvent('click')); + + assert.match(prompt, /vps/, 'the confirmation must name the host alias that will be stopped'); + assert.deepEqual(calls.filter(c => c.method === 'remoteStopSession').map(c => c.args), [['vps', 'r1']]); + assert.deepEqual(calls.filter(c => c.method === 'stopSession'), [], 'a remote session must never reach bare stopSession'); + assert.deepEqual(calls.filter(c => c.method === 'archiveSession').map(c => c.args[0]), ['r1']); + } finally { ctx.destroy(); } +}); + +test('project archive-all: no alias is named when no session in the group is a live remote', async () => { + const ctx = setupSidebarDom(); + try { + const project = { projectPath: '/home/dev/proj', sessions: [remoteSession('r1', 'vps', false), localSession('s2')] }; + installRecordingApi(ctx); + let prompt = null; + ctx.window.confirm = (m) => { prompt = m; return false; }; + + ctx.sidebar.renderProjects([project], true); + await headerFor(ctx, project).querySelector('.project-archive-btn').onclick(new ctx.window.MouseEvent('click')); + + assert.doesNotMatch(prompt, /vps/, 'a dead remote session is nothing to stop, so it must not be named'); + } finally { ctx.destroy(); } +}); + +test('project archive-all: a stop refusal skips that session\'s archive and surfaces it; the other proceeds', async () => { + const ctx = setupSidebarDom(); + try { + const project = { projectPath: '/home/dev/proj', sessions: [remoteSession('r1', 'vps', true), localSession('s2')] }; + ctx.window.activePtyIds.add('s2'); + const calls = installRecordingApi(ctx, { + remoteStopSession: () => Promise.resolve({ ok: false, error: 'pid now belongs to a non-claude process' }), + }); + ctx.window.confirm = () => true; + let flashed = null; + ctx.window.flashButtonText = (btn, text) => { flashed = { btn, text }; }; + + ctx.sidebar.renderProjects([project], true); + await headerFor(ctx, project).querySelector('.project-archive-btn').onclick(new ctx.window.MouseEvent('click')); + + assert.deepEqual(calls.filter(c => c.method === 'archiveSession').map(c => c.args[0]), ['s2'], + 'the refused remote session must not be archived; the local one still proceeds'); + assert.ok(flashed, 'the failure must flash a button'); + assert.equal(flashed.text, 'Failed'); + const failedBtn = ctx.document.getElementById('si-r1').querySelector('.session-archive-btn'); + assert.match(failedBtn.title, /non-claude/, 'the error must be surfaced on the button title'); + } finally { ctx.destroy(); } +}); + +// --------------------------------------------------------------------------- +// .slug-group-archive-btn +// --------------------------------------------------------------------------- + +test('slug-group archive: a stop refusal on one remote session skips it, the other (local) session still archives', async () => { + const ctx = setupSidebarDom(); + try { + const r1 = remoteSession('r1', 'vps', true, { slug: 'grp' }); + const s2 = localSession('s2', { slug: 'grp' }); + const project = { projectPath: '/home/dev/proj', sessions: [r1, s2] }; + ctx.window.sessionMap.set('r1', r1); + ctx.window.sessionMap.set('s2', s2); + ctx.window.activePtyIds.add('s2'); + const calls = installRecordingApi(ctx, { + remoteStopSession: () => Promise.resolve({ ok: false, error: 'ssh: connection refused' }), + }); + let flashed = null; + ctx.window.flashButtonText = (btn, text) => { flashed = { btn, text }; }; + + ctx.sidebar.renderProjects([project], true); + const group = ctx.document.getElementById('slug-grp'); + assert.ok(group, 'the two same-slug sessions must render as a slug group'); + await group.querySelector('.slug-group-archive-btn').onclick(new ctx.window.MouseEvent('click')); + + assert.deepEqual(calls.filter(c => c.method === 'remoteStopSession').map(c => c.args), [['vps', 'r1']]); + assert.deepEqual(calls.filter(c => c.method === 'stopSession').map(c => c.args[0]), ['s2']); + assert.deepEqual(calls.filter(c => c.method === 'archiveSession').map(c => c.args[0]), ['s2'], + 'the refused remote session must not be archived; the local one still proceeds'); + assert.ok(flashed && flashed.text === 'Failed', 'the refusal must flash a button'); + } finally { ctx.destroy(); } +}); + +// --------------------------------------------------------------------------- +// .session-delete-btn +// --------------------------------------------------------------------------- + +test('per-session delete: a local session with an active PTY is stopped before the delete IPC', async () => { + const ctx = setupSidebarDom(); + try { + const s1 = localSession('s1'); + ctx.window.sessionMap.set('s1', s1); + ctx.window.activePtyIds.add('s1'); + const calls = installRecordingApi(ctx); + + ctx.sidebar.renderProjects([{ projectPath: '/home/dev/proj', sessions: [s1] }], true); + const item = ctx.document.getElementById('si-s1'); + item.querySelector('.session-delete-btn').click(); + // showDeleteSessionDialog awaits deleteSessionPreview — flush both that + // microtask and the dialog's own promise chain before confirming. + await new Promise(r => setTimeout(r, 0)); + ctx.document.getElementById('dss-confirm').click(); + await new Promise(r => setTimeout(r, 0)); + await new Promise(r => setTimeout(r, 0)); + + const methods = calls.map(c => c.method); + assert.ok(methods.indexOf('stopSession') !== -1, 'the running local session must be stopped'); + assert.ok(methods.indexOf('stopSession') < methods.indexOf('deleteSession'), + 'the stop must happen before the delete IPC call'); + } finally { ctx.destroy(); } +}); + +test('per-session delete: a remote session is never stopped — delete is refused server-side for remote regardless', async () => { + const ctx = setupSidebarDom(); + try { + const r1 = remoteSession('r1', 'vps', true); + ctx.window.sessionMap.set('r1', r1); + const calls = installRecordingApi(ctx, { + deleteSession: () => Promise.resolve({ ok: false, error: 'remote sessions are read-only — this build observes them, it does not attach to them' }), + }); + + ctx.sidebar.renderProjects([{ projectPath: '/home/dev/proj', sessions: [r1] }], true); + const item = ctx.document.getElementById('si-r1'); + item.querySelector('.session-delete-btn').click(); + await new Promise(r => setTimeout(r, 0)); + ctx.document.getElementById('dss-confirm').click(); + await new Promise(r => setTimeout(r, 0)); + await new Promise(r => setTimeout(r, 0)); + + assert.deepEqual(calls.filter(c => c.method === 'stopSession'), [], 'a remote delete must never stop the process'); + assert.deepEqual(calls.filter(c => c.method === 'remoteStopSession'), [], 'nor call the remote stop IPC'); + assert.ok(calls.some(c => c.method === 'deleteSession'), 'the delete IPC is still attempted (and refused server-side)'); + } finally { ctx.destroy(); } +}); + +// --------------------------------------------------------------------------- +// .session-archive-btn (per-session toggle) +// --------------------------------------------------------------------------- + +test('per-session archive toggle: local session with an active PTY is stopped, then archived', async () => { + const ctx = setupSidebarDom(); + try { + const s1 = localSession('s1'); + ctx.window.sessionMap.set('s1', s1); + ctx.window.activePtyIds.add('s1'); + const calls = installRecordingApi(ctx); + + ctx.sidebar.renderProjects([{ projectPath: '/home/dev/proj', sessions: [s1] }], true); + await ctx.document.getElementById('si-s1').querySelector('.session-archive-btn').onclick(new ctx.window.MouseEvent('click')); + + const methods = calls.map(c => c.method); + assert.deepEqual(methods, ['stopSession', 'archiveSession'], 'stop must precede archive'); + } finally { ctx.destroy(); } +}); + +test('per-session archive toggle: a failed remote stop blocks the archive and flashes the button', async () => { + const ctx = setupSidebarDom(); + try { + const r1 = remoteSession('r1', 'vps', true); + ctx.window.sessionMap.set('r1', r1); + const calls = installRecordingApi(ctx, { + remoteStopSession: () => Promise.resolve({ ok: false, error: 'pid now belongs to a non-claude process' }), + }); + let flashed = null; + ctx.window.flashButtonText = (btn, text) => { flashed = { btn, text }; }; + + ctx.sidebar.renderProjects([{ projectPath: '/home/dev/proj', sessions: [r1] }], true); + const archiveBtn = ctx.document.getElementById('si-r1').querySelector('.session-archive-btn'); + await archiveBtn.onclick(new ctx.window.MouseEvent('click')); + + assert.deepEqual(calls.filter(c => c.method === 'archiveSession'), [], 'a refused stop must not archive'); + assert.ok(flashed && flashed.text === 'Failed'); + assert.match(archiveBtn.title, /non-claude/, 'the error must be surfaced on the button title'); + assert.equal(r1.archived, 0, 'the session object itself must stay unarchived'); + } finally { ctx.destroy(); } +}); diff --git a/test/stop-session-ui.test.js b/test/stop-session-ui.test.js index a12f01a3..e3ac224c 100644 --- a/test/stop-session-ui.test.js +++ b/test/stop-session-ui.test.js @@ -7,7 +7,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { resolveSessionStop } = require('../public/stop-session-ui'); +const { resolveSessionStop, isRemoteSessionAlive } = require('../public/stop-session-ui'); test('a local session (no remoteAlias) resolves to the plain stop dialog and the local IPC', () => { const plan = resolveSessionStop({ sessionId: 's1' }); @@ -26,3 +26,20 @@ test('a remote session resolves to the remote IPC with the host alias named in t assert.match(plan.confirmText, /vps/, 'the host alias must be shown, same text shape as the local dialog'); assert.match(plan.confirmText, /^Stop this session/, 'same text shape as the local confirm()'); }); + +// --- isRemoteSessionAlive (issue #271) --- +// No remoteSessionStates global exists under plain require() — these pin the +// fallback path (session.remoteDescriptorSeen). The adapter-snapshot path +// (authoritative over a stale descriptor flag right after this app stopped +// the session itself) needs jsdom and is covered in +// test/dom-sidebar-stop-before-archive.test.js. + +test('isRemoteSessionAlive: undefined session is never alive', () => { + assert.equal(isRemoteSessionAlive(undefined), false); +}); + +test('isRemoteSessionAlive: falls back to remoteDescriptorSeen when no adapter snapshot exists', () => { + assert.equal(isRemoteSessionAlive({ sessionId: 's1', remoteAlias: 'vps', remoteDescriptorSeen: true }), true); + assert.equal(isRemoteSessionAlive({ sessionId: 's1', remoteAlias: 'vps', remoteDescriptorSeen: false }), false); + assert.equal(isRemoteSessionAlive({ sessionId: 's1', remoteAlias: 'vps' }), false, 'missing field must not read as alive'); +});