From 133bdb37e41714fd9518a5cbbf5e49a115e4e6e7 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 17 Sep 2026 16:44:53 -0700 Subject: [PATCH] wip(session): register a client-local park-scrollback field (foundation only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOT FOR MERGE as-is — the field is declared, registered and persisted, but nothing writes or reads it yet. Preserved so the cadence-split PR does not redo the registry plumbing. parkedScrollbackByTabId is a top-level session field because exportRemoteWorkspaceSession is an explicit allowlist of named top-level fields, so it is omitted from the remote projection for free. A field inside TerminalLayoutSnapshot would ride along instead, because layout entries are copied whole. Five exhaustive 'satisfies Record' constraints reject the field until every registry has an entry, which is why this has no safe partial state and why it belongs in its own PR rather than as a commit on a data-loss fix. Typecheck clean. Still to build: capture routing, the single resolver, the prune cap, and re-pointed e2e coverage. --- ...ofile-project-session-field-disposition.ts | 5 +++++ .../session-worktree-ownership.ts | 1 + .../workspace-session-browser-history.test.ts | 1 + .../workspace-session-editor-drafts.test.ts | 1 + .../lib/workspace-session-liveness.test.ts | 1 + .../workspace-session-relevant-fields.test.ts | 1 + src/renderer/src/lib/workspace-session.ts | 3 +++ src/renderer/src/store/slices/terminals.ts | 1 + .../src/store/terminals/terminal-actions.ts | 2 ++ .../store/terminals/terminal-layout-state.ts | 17 ++++++++++++++++ .../src/store/terminals/terminal-state.ts | 3 +++ .../workspace-session-host-field-ownership.ts | 3 +++ ...pace-session-schema-field-coverage.test.ts | 1 + src/shared/workspace-session-schema.ts | 6 ++++++ src/shared/workspace-session-state-types.ts | 20 +++++++++++++++++++ 15 files changed, 66 insertions(+) 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. */