wip(session): register a client-local park-scrollback field (foundation only)

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<keyof WorkspaceSessionState, ...>' 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.
This commit is contained in:
Neil
2026-09-17 16:44:53 -07:00
parent 754134fd67
commit 133bdb37e4
15 changed files with 66 additions and 0 deletions
@@ -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'
@@ -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',
@@ -12,6 +12,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
parkedScrollbackByTabId: {},
activeTabIdByWorktree: {},
openFiles: [],
editorDrafts: {},
@@ -13,6 +13,7 @@ function createSnapshot(
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
parkedScrollbackByTabId: {},
activeTabIdByWorktree: {},
openFiles: [],
editorDrafts: {},
@@ -12,6 +12,7 @@ function createSnapshot(
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
parkedScrollbackByTabId: {},
activeTabIdByWorktree: {},
openFiles: [],
editorDrafts: {},
@@ -13,6 +13,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
tabsByWorktree: true,
ptyIdsByTabId: true,
terminalLayoutsByTabId: true,
parkedScrollbackByTabId: true,
activeTabIdByWorktree: true,
openFiles: true,
editorDrafts: true,
@@ -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,
@@ -42,6 +42,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
expandedPaneByTabId: {},
canExpandPaneByTabId: {},
terminalLayoutsByTabId: {},
parkedScrollbackByTabId: {},
pendingStartupByTabId: {},
pendingInitialCwdByTabId: {},
pendingSetupSplitByTabId: {},
@@ -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<string, string> | null) => void
syncPaneDetachPtyOwnership: (args: {
detachedLeafId: string
detachedPtyId: string | null
@@ -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]
@@ -44,6 +44,9 @@ export type TerminalState = {
expandedPaneByTabId: Record<string, boolean>
canExpandPaneByTabId: Record<string, boolean>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
/** Client-local park scrollback, tabId -> leafId -> buffer. Never uploaded to a peer; see
* WorkspaceSessionState.parkedScrollbackByTabId. Read via `resolveLeafScrollback`, never directly. */
parkedScrollbackByTabId: Record<string, Record<string, string>>
recentQuickCommandIdByGroup: Record<string, string>
/** Runtime-only claim bridging startup payload consumption until terminal hooks mount. */
automaticAgentResumeClaimsByTabId: Record<string, AutomaticAgentResumeClaim>
@@ -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',
@@ -28,6 +28,7 @@ const PERSISTED_WORKSPACE_SESSION_FIELDS = {
activeTabId: true,
tabsByWorktree: true,
terminalLayoutsByTabId: true,
parkedScrollbackByTabId: true,
activeWorktreeIdsOnShutdown: true,
openFilesByWorktree: true,
activeFileIdByWorktree: true,
+6
View File
@@ -213,6 +213,12 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = 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)
@@ -43,6 +43,26 @@ export type WorkspaceSessionState = {
/** Keys may be legacy raw worktree IDs or canonical WorkspaceKey values. */
tabsByWorktree: Record<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
/**
* 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<string, Record<string, string>>
/** 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. */