Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a08936d
feat(restore): add "Restore on click" startup mode
abate Oct 10, 2026
cb2ad3a
fix(restore): no dormant resume on a key repeat, and hold candidates …
abate Oct 10, 2026
eb13ad8
test(restore): continuation of a dormant session, opens during the ma…
abate Oct 10, 2026
938e717
fix(restore): move a dormant entry to its continuation, and skip sess…
abate Oct 10, 2026
a3ec4b0
fix(restore): Restore on click with fresh entries and the exit flush …
abate Oct 11, 2026
f196ad4
test(restore): a dormant session whose continuation is open, or opene…
abate Oct 11, 2026
8c5225e
fix(restore): drop a dormant entry whose continuation is already open…
abate Oct 11, 2026
9857113
test(restore): a continuation refused during the marking stays saved,…
abate Oct 11, 2026
8ece8ef
fix(restore): keep a candidate opened under its continuation in fligh…
abate Oct 11, 2026
1bac309
test(restore): Restore on click with merged web-session rows, and pin…
abate Oct 12, 2026
1c4d562
fix(restore): Restore on click finds a saved member of a merged web-s…
abate Oct 12, 2026
1fd88a1
test(restore): a merged row with a reattached member keeps no dormant…
abate Oct 12, 2026
d48b537
fix(restore): a member of a merged row with an open member is not mar…
abate Oct 12, 2026
b4757b2
test(restore): a merged-row member is marked while a sibling's open i…
abate Oct 13, 2026
0904b19
fix(restore): a merged-row member is marked while a sibling's open is…
abate Oct 13, 2026
b87f268
test(restore): load the Restore on click state in tests main added, a…
abate Oct 14, 2026
c499bc4
test(restore): an open in flight during the marking, or under a secon…
abate Oct 14, 2026
e52abdf
fix(restore): an open in flight is not counted as open by the marking…
abate Oct 14, 2026
665059b
test(restore): a running member whose reattach waited for the head's …
abate Oct 15, 2026
9ede0f2
fix(restore): the marking's reattaches leave the active session alone…
abate Oct 15, 2026
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
2 changes: 2 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv

- **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`.

- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. `openSessionNow`'s early return on an open member of the row waits first when that member's open is in flight (in `openingSessions`, and not this open), so a second click on the row or the session keys landing on a dormant sibling never clear the row before that open settles: if the member is open then, the row is cleared and the member shown; if not, the call returns and leaves the dormant members, unless the open it waited for was automatic and this one is not (a click during a member's reattach-only open that found no PTY), which then resumes that member. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited once that open has settled (its entry in `openSessions` is not `closed` and it is not in `openingSessions`); one whose open is still in flight is marked, so a failed open leaves it marked and saved, and the open's own `clearDormantRow` drops it on success. It takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. The marking's reattaches also pass `activate: false`, and `openSessionNow` honours `activate` (with `activeFrom`) on its early returns too, so they never change the active session: a session the user opens while the marking runs stays shown. The marking counts a candidate as reattached only when that id is then open and not `closed` in `openSessions`; any other outcome, including the `undefined` return when its reattach waited on another member's open (production rows carry `headSessionId`, so `rowOpenId` on a member looks at the head first) and that open failed, falls through to the clear-or-mark branches, so the candidate is cleared when its row is open and marked otherwise. When the awaited open succeeds the candidate's row is open and it is cleared; its own PTY, still running, is not reattached. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. `indexedRowOf` maps a member to its whole row through `localMergedMemberRows` (rebuilt by `dedup`), falling back to a scan of `sessionMap` only for a remote row's member: the marking clears, instead of marking, a candidate whose row already has an open member whose open has settled (open and not `closed` in `openSessions`, and not in `openingSessions`), so after a reload a member reattached earlier or later in the saved order leaves no other member of its row dormant; a member whose open is still in flight (a click during the `getActiveSessions` await) does not count, so the candidate is marked, stays marked and saved if that open fails, and is dropped by the open's own `clearDormantRow` if it succeeds. That clear also drops the members of the row an earlier tick marked, which happens when they were indexed into the row after its open member opened; and `navigateSession` opens the whole row when it lands on a dormant member of a row with an open member, so `openSessionNow` shows that member and clears the row rather than resuming a second one. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's first dormant id in `rowSessionIds` order, which for a click is the head first (#564), so the newest marked part; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`.

- **Neither the working-set restore nor the reload path resumes a session that is live in another process.** `runRestore` and the post-`loadProjects` re-open of `sessionStorage.activeSessionId` call `openSession(..., { automatic: true })`, which skips the session without a prompt when `guardResume` reports it live elsewhere; the skipped entry is not activated, stays in the persisted working set at its saved position, and is reported by a one-line notice. See `.ai/contexts/cli-session-state.md` ("Live elsewhere").

- **`session_cache.scheduleSlug` holds nullable schedule provenance.** The runner writes the schedule identity on its pre-seeded user record. Schema reconciliation adds this column without a reindex or cache wipe; runs indexed before the marker stay null unless their transcript supplies it on a later read. A prompt prefix or matching CLI slug does not establish provenance. See `.ai/contexts/schedule-runner.md` for grouping and historical-run limits.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc

### New
- **Send to background**, in a running session's header and right-click menu, moves it to the Claude daemon and keeps it open as an attached tab, reattached after each restart. Remote, sandboxed and pre-launch-command sessions cannot move, and a busy session must be idle first; running subagents require confirmation because Claude may stop them. (#546)
- **Restore Sessions on Startup** has a new **Restore on click** option: the sessions open when Switchboard last closed are marked in the sidebar with an outlined dot, and each one is resumed only when you open it, by a click or with the previous/next session keys, instead of starting a `claude` process for every one at launch. **Don't restore** in a marked session's right-click menu removes it without starting it. (#441)

### Changed
- Conversations now show the number of running subagents below their title, even when their child list is collapsed or filtered, replacing the green dots on child rows and collapsed carets. The grid shows the same count in place of coloured pills; no count appears when none are running or activity is unknown. (#627)
Expand Down
53 changes: 52 additions & 1 deletion docs/session-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ closed.
|---|---|
| **Don't restore** | Nothing is reopened |
| **Ask on startup** (default) | A bar asks *Restore N session(s) from last time?*, with **Restore** and **Dismiss** |
| **Restore on click** | Nothing is started; the sessions are marked in the sidebar, and each is resumed when you click it — see [Restore on click](#restore-on-click) |
| **Restore automatically** | The sessions are reopened without asking |

The setting is read at launch; a change applies from the next start.
Expand All @@ -35,7 +36,9 @@ creates a worktree, since the saved project path already is the folder to
start in. Once its first prompt makes it a real session, the set is saved
again with it as an ordinary entry. A session without a transcript is still
left out when it runs on a remote host, or was started as a fork or in a new
worktree: starting it anew would not give back the same thing.
worktree: starting it anew would not give back the same thing. In **Restore on
click** mode a fresh entry not in the index is dropped instead, since that
mode starts nothing by itself and the entry has no row to click.

## Closing the app

Expand Down Expand Up @@ -144,5 +147,53 @@ exists. A live background continuation uses the existing attach path and its fin
is saved with `bg: true`, so every later restore reattaches it while the job is
live. A saved entry without that mark still skips a session live elsewhere.

## Restore on click

No `claude` process is started at launch. Each saved session that is in the
index gets an outlined dot in the sidebar and stays visible there whatever the
visible-session count and age limit say. The grid starts empty, and no session
is selected, the last active one included.

- Clicking a marked session resumes it, like any other click. It stays marked
until it is open: if it is live in another process and you decline to resume
it, or its terminal fails to start, it is still marked and still saved. One
whose conversation continued under another id is resumed there, and the new
id takes its place in the saved set; if that continuation is already open,
it is shown and the marked session is simply dropped from the saved set. A
continuation declined or failing to start stays marked and saved under the
new id, also when you open it before the marking has reached it. A saved
session you click while the marking is still running is marked too until it
is open, so it stays saved if it fails to start.
- **Don't restore**, in a marked session's right-click menu, removes it from
the saved set without starting it. So does deleting the session.
- A marked session never clicked stays in the saved set, so the next start
marks it again. A saved session not yet marked, because indexing has not
reached it, is kept too when the set is saved before then.
- Opening another session does not stop the marking, which goes on until
indexing is over.
- A project folded because its sessions are old opens when it holds a marked
session.
- The previous/next session keys stop on marked sessions too, and resume the
one they land on. Holding a key down skips them: the repeats cycle through
the open sessions only, so they never resume a row of marked sessions one
after another. The grid has no card for a marked session, so in the grid
they skip it.
- A saved session that is a member of a merged web-session row marks that
row. Clicking the row resumes its newest marked member, or shows the member
already open; either way the row is open, so every member of it is dropped
from the marked and saved sessions. A second click, or the session keys,
while a member is still starting wait for that start: if it fails, the row's
marked members stay marked and saved. **Don't restore** and deleting the row drop every
marked member of it, and the session keys resume a marked member under its
own id, or show the member already open. After a reload, a member whose row
already has a running member is not marked, whatever its place in the saved
set: the row is open.
- Reloading the window starts nothing. A saved session whose process is still
running is reattached, the selected one included; the others are marked. A
process that ends during the reload is not started again. Reattaching never
changes the selected session, so a session you open meanwhile stays shown.
A running member of a merged row whose reattach waited for another member you
opened meanwhile is marked if that open fails.

Within one run of the app, going back to a session whose process still runs is
a reattach: the terminal replays its buffered output onto the same process.
2 changes: 1 addition & 1 deletion docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ for one launch — see [Launching sessions](launching-sessions.md).
|---|---|---|---|
| Terminal Theme | `terminalTheme` | Switchboard | Terminal colours, applied on save — see [Terminal](terminal.md#theme) |
| Terminal Right-Click | `terminalRightClick` | Context menu (`menu`) | `menu`, `paste`, `default` (Native), `none` — see [Terminal](terminal.md#right-click) |
| Restore Sessions on Startup | `restoreOnStartup` | Ask on startup (`ask`) | `off`, `ask`, `auto`; read at launch — see [Session restore](session-restore.md) |
| Restore Sessions on Startup | `restoreOnStartup` | Ask on startup (`ask`) | `off`, `ask`, `lazy` (Restore on click), `auto`; read at launch — see [Session restore](session-restore.md) |
| Shell Profile | `shellProfile` | Auto (detect) | The shell for sessions and terminals; new sessions only — see [Launching sessions](launching-sessions.md#how-the-command-is-built) |
| Max Visible Sessions | `visibleSessionCount` | 10 (1–100) | Sessions shown per project before `+ N older` |
| Session Max Age (days) | `sessionMaxAgeDays` | 3 (1–365) | Older sessions go behind `+ N older`; older projects start collapsed |
Expand Down
7 changes: 7 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ const rendererCrossFileGlobals = {
setActiveSession: 'readonly',
activePtyIds: 'writable',
pendingSessions: 'readonly',
dormantWorkingSet: 'readonly',
dismissDormantSession: 'readonly',
isDormantSession: 'readonly',
indexedSessionById: 'readonly',
indexedRowOf: 'readonly',
revealDormantSessions: 'readonly',
sessionMap: 'writable',
lastActivityTime: 'readonly',
sortedOrder: 'writable',
Expand Down Expand Up @@ -193,6 +199,7 @@ const rendererCrossFileGlobals = {
rowSessionIds: 'readonly',
rowHeadId: 'readonly',
rowOpenId: 'readonly',
localMergedMemberRows: 'readonly',
mergedRowSnapshot: 'readonly',
storedActivitySnapshot: 'readonly',
snapshotForLocal: 'readonly',
Expand Down
3 changes: 3 additions & 0 deletions 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

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, initialScheduleProjects, scanSchedules, setScheduleEnabled } = require('./schedule-runner');
const { archivePlanForGroups, validArchiveGroups } = require('./archived-projects');
const { encodeProjectPath } = require('./encode-project-path');
Expand Down Expand Up @@ -483,8 +483,8 @@
getMainWindow: () => mainWindow,
log,
});
const { readSessionFile, readFolderFromFilesystem,

Check warning on line 486 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 486 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
backfillEntrypoints, backfillCustomTitles, revealIfSdkSession, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 487 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, isIndexingFinished } = sessionCache;
// see .ai/contexts/session-cache.md ("Projects query worker")
const { createProjectsWorkerClient } = require('./projects-worker-client');
Expand Down Expand Up @@ -2549,6 +2549,9 @@
}
}

// see .ai/contexts/session-cache.md ("Restore on click")
if (sessionOptions?.reattachOnly) return { ok: false, notLive: true };

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach")
if (!isNew) {
let cachedFolder = null;
Expand Down Expand Up @@ -2639,7 +2642,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 2645 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
Loading
Loading