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
34 changes: 34 additions & 0 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/session-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,14 @@
<script src="stats-view.js"></script>
<script src="jsonl-viewer.js"></script>
<script src="dialogs.js"></script>
<!-- stop-session-ui.js: stop-vs-detach decision (app.js) and the stop-then-archive/delete helper (sidebar.js, issue #271). -->
<script src="stop-session-ui.js"></script>
<script src="sidebar.js"></script>
<script src="remote-activity-ui.js"></script>
<script src="local-transcript-adapter.js"></script>
<script src="memory-workfiles-view.js"></script>
<!-- restore-plan.js decides working-set restore vs. defer-and-retry; consumed by app.js only. -->
<script src="restore-plan.js"></script>
<!-- stop-session-ui.js: pure stop-vs-detach decision for confirmAndStopSession; consumed by app.js only. -->
<script src="stop-session-ui.js"></script>
<script src="app.js"></script>
</body>
</html>
52 changes: 43 additions & 9 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '_');
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 34 additions & 1 deletion public/stop-session-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
11 changes: 10 additions & 1 deletion test/delete-session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 5 additions & 0 deletions test/dom-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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.
Expand Down
Loading
Loading