diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index e1831ff3443..871760ec088 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -55,6 +55,11 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' }, + // Same tab-id keying as the layouts it shadows, so park scrollback follows the same tab ids. + parkedScrollbackByTabId: { + onRepoRemoval: 'prunedByBespokeRule', + onTransfer: 'copiedByBespokeRule' + }, activeWorktreeIdsOnShutdown: { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts index 5c54af92975..eef14fef7e7 100644 --- a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts +++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts @@ -27,6 +27,7 @@ export const WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND = { activeTabId: 'none', tabsByWorktree: 'owner-keyed-row-arrays', terminalLayoutsByTabId: 'none', + parkedScrollbackByTabId: 'none', activeWorktreeIdsOnShutdown: 'worktree-id-array', openFilesByWorktree: 'owner-keyed-row-arrays', activeFileIdByWorktree: 'owner-keyed', diff --git a/src/renderer/src/lib/workspace-session-browser-history.test.ts b/src/renderer/src/lib/workspace-session-browser-history.test.ts index 63369fa873d..1366866b629 100644 --- a/src/renderer/src/lib/workspace-session-browser-history.test.ts +++ b/src/renderer/src/lib/workspace-session-browser-history.test.ts @@ -12,6 +12,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess tabsByWorktree: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, + parkedScrollbackByTabId: {}, activeTabIdByWorktree: {}, openFiles: [], editorDrafts: {}, diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index e0718028956..03e6c7265ea 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -13,6 +13,7 @@ function createSnapshot( tabsByWorktree: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, + parkedScrollbackByTabId: {}, activeTabIdByWorktree: {}, openFiles: [], editorDrafts: {}, diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index 59a2a9f67de..f6ac5922890 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -12,6 +12,7 @@ function createSnapshot( tabsByWorktree: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, + parkedScrollbackByTabId: {}, activeTabIdByWorktree: {}, openFiles: [], editorDrafts: {}, diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 2dc1346f534..c6f5b25373d 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -13,6 +13,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { tabsByWorktree: true, ptyIdsByTabId: true, terminalLayoutsByTabId: true, + parkedScrollbackByTabId: true, activeTabIdByWorktree: true, openFiles: true, editorDrafts: true, diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index affa2b36ca4..0555c0b8a21 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -34,6 +34,7 @@ export type WorkspaceSessionSnapshot = Pick< | 'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' + | 'parkedScrollbackByTabId' | 'activeTabIdByWorktree' | 'openFiles' | 'editorDrafts' @@ -76,6 +77,7 @@ export const SESSION_RELEVANT_FIELDS = [ 'tabsByWorktree', 'ptyIdsByTabId', 'terminalLayoutsByTabId', + 'parkedScrollbackByTabId', 'activeTabIdByWorktree', 'openFiles', 'editorDrafts', @@ -294,6 +296,7 @@ export function buildWorkspaceSessionPayload( activeTabId: snapshot.activeTabId, tabsByWorktree: buildSanitizedTabsByWorktree(snapshot.tabsByWorktree), terminalLayoutsByTabId: snapshot.terminalLayoutsByTabId, + parkedScrollbackByTabId: snapshot.parkedScrollbackByTabId, // Why: session:set fully replaces the persisted object, so dropping this silently disables eager terminal reconnect on restart. activeWorktreeIdsOnShutdown: terminalSessionData.activeWorktreeIdsOnShutdown, activeTabIdByWorktree: snapshot.activeTabIdByWorktree, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 94c67e3ca7e..72234dd2e75 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -42,6 +42,7 @@ export const createTerminalSlice: StateCreator expandedPaneByTabId: {}, canExpandPaneByTabId: {}, terminalLayoutsByTabId: {}, + parkedScrollbackByTabId: {}, pendingStartupByTabId: {}, pendingInitialCwdByTabId: {}, pendingSetupSplitByTabId: {}, diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 4038ed22091..96c3523f8bf 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -189,6 +189,8 @@ export type TerminalActions = { setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + /** Client-local park scrollback for a tab. Never uploaded; read via `resolveLeafScrollback`. */ + setTabParkedScrollback: (tabId: string, buffersByLeafId: Record | null) => void syncPaneDetachPtyOwnership: (args: { detachedLeafId: string detachedPtyId: string | null diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 42ad3dbc659..e9a371762e8 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -23,9 +23,26 @@ export function createTerminalLayoutActions( | 'setTabPaneExpanded' | 'setTabCanExpandPane' | 'setTabLayout' + | 'setTabParkedScrollback' | 'syncPaneDetachPtyOwnership' > { return { + // Why separate from setTabLayout: park scrollback is client-local and must not ride the remote + // projection. See WorkspaceSessionState.parkedScrollbackByTabId; read via resolveLeafScrollback. + setTabParkedScrollback: (tabId, buffersByLeafId) => { + set((s) => { + const current = s.parkedScrollbackByTabId ?? {} + if (!buffersByLeafId || Object.keys(buffersByLeafId).length === 0) { + if (!(tabId in current)) { + return s + } + const next = { ...current } + delete next[tabId] + return { parkedScrollbackByTabId: next } + } + return { parkedScrollbackByTabId: { ...current, [tabId]: buffersByLeafId } } + }) + }, replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => { set((s) => { const layout = s.terminalLayoutsByTabId[tabId] diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 766f65f5ad8..dca122ff93d 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -44,6 +44,9 @@ export type TerminalState = { expandedPaneByTabId: Record canExpandPaneByTabId: Record terminalLayoutsByTabId: Record + /** Client-local park scrollback, tabId -> leafId -> buffer. Never uploaded to a peer; see + * WorkspaceSessionState.parkedScrollbackByTabId. Read via `resolveLeafScrollback`, never directly. */ + parkedScrollbackByTabId: Record> recentQuickCommandIdByGroup: Record /** Runtime-only claim bridging startup payload consumption until terminal hooks mount. */ automaticAgentResumeClaimsByTabId: Record diff --git a/src/shared/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts index bb36c67254d..eb58848134a 100644 --- a/src/shared/workspace-session-host-field-ownership.ts +++ b/src/shared/workspace-session-host-field-ownership.ts @@ -44,6 +44,9 @@ export const WORKSPACE_SESSION_FIELD_OWNERSHIP = { activeWorkspaceKey: 'global', activeWorktreeIdsOnShutdown: 'worktreeArray', terminalLayoutsByTabId: 'tabKeyed', + // Local-only, never uploaded — but still routed per host so a tab's park scrollback follows its + // own partition rather than merging across hosts the way sleepingAgentKeyed rows do. + parkedScrollbackByTabId: 'tabKeyed', remoteSessionIdsByTabId: 'tabKeyed', browserPagesByWorkspace: 'browserWorkspaceKeyed', markdownFrontmatterVisible: 'fileKeyed', diff --git a/src/shared/workspace-session-schema-field-coverage.test.ts b/src/shared/workspace-session-schema-field-coverage.test.ts index d371d488b67..2ff1cf2c522 100644 --- a/src/shared/workspace-session-schema-field-coverage.test.ts +++ b/src/shared/workspace-session-schema-field-coverage.test.ts @@ -28,6 +28,7 @@ const PERSISTED_WORKSPACE_SESSION_FIELDS = { activeTabId: true, tabsByWorktree: true, terminalLayoutsByTabId: true, + parkedScrollbackByTabId: true, activeWorktreeIdsOnShutdown: true, openFilesByWorktree: true, activeFileIdByWorktree: true, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 0a24551381e..3d3c8d230ce 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -213,6 +213,12 @@ export const workspaceSessionStateSchema: z.ZodType = z.o salvagingRecord(terminalTabIdSchema, terminalLayoutSnapshotSchema), () => ({}) ), + // Client-local park scrollback; see WorkspaceSessionState.parkedScrollbackByTabId for why it is + // not a field on the layout snapshot. Optional so an older profile simply carries none. + parkedScrollbackByTabId: salvagedOptional( + 'parkedScrollbackByTabId', + salvagingRecord(terminalTabIdSchema, leafStringsSchema) + ), activeWorktreeIdsOnShutdown: salvagedOptional( 'activeWorktreeIdsOnShutdown', salvagingArray(worktreeIdSchema) diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index e0cc7190cbf..471dd86952f 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -43,6 +43,26 @@ export type WorkspaceSessionState = { /** Keys may be legacy raw worktree IDs or canonical WorkspaceKey values. */ tabsByWorktree: Record terminalLayoutsByTabId: Record + /** + * Client-local scrollback captured when a pane cold-parks, keyed tabId -> leafId -> buffer. + * + * Why a second home for scrollback rather than `TerminalLayoutSnapshot.buffersByLeafId`: the two + * encode different things. `buffersByLeafId` is **shared with peers** — it rides the remote + * projection so a second desktop can cold-restore a tab this machine parked. This field is + * **local-only**: the ordinary cold park fires every time a workspace is hidden, and shipping + * that in a wholesale `replace-session` costs tens of MiB on the common path for a copy no peer + * consumes. `exportRemoteWorkspaceSession` is an explicit allowlist of named top-level fields, + * so a top-level field is omitted from the upload for free — a field *inside* the layout would + * ride along, because layout entries are copied whole. + * + * Second, weaker-but-real property: the mirrored-tab apply rewrites only `ptyIdsByTabId` and + * `terminalLayoutsByTabId`, so scrollback here cannot be wiped by a host inventory frame at all. + * Moving it back into the layout for tidiness would reintroduce that wipe. + * + * Never read this field directly — resolve through `resolveLeafScrollback` so no consumer has to + * know which of the two homes a leaf's bytes live in. + */ + parkedScrollbackByTabId?: Record> /** Worktree IDs that had at least one tab with a live PTY at shutdown. * Used on startup to eagerly re-spawn PTY processes so the Active filter * works immediately after restart. */