Repository navigation
Add inert layout commands and runtime transitions - #26749
Conversation
…izer One model of a workspace's tab layout (all tab kinds, groups, split trees, pane bindings, titles/colors/pin, sleeping records) in src/shared/workspace-layout, with a Loader from today's stored session partition and a Serializer back to it. The structural rules check and the pure pane/group tree helpers move into the same folder so the module can run them; their callers import the new paths. Nothing in the app calls the new module yet.
applyLayoutCommand applies every layout command a client can send (create, split, close, move, reorder, rename, recolor, pin, open editor/browser/agent tabs, start/restart, sleep/wake) to the layout model, and applyLayoutTransition applies the facts the runtime sees (process started or exited, SSH lease ended, orphan adopted, owner removed or renamed, agent launch verdict, sleeping records). Pure tab-order, group-move and pane-split helpers move into the shared module so the renderer, today's headless host and the new commands share one implementation. Nothing in the app calls the new commands.
The mobile bundle includes src/shared and Hermes lacks Array#toSorted.
…le it applies The row's terminal id, preview flag, worktree id and execution host are now derived on save instead of kept a second time. Groups never stay empty. The Loader reports row/tab-bar disagreements, re-minted ids, worktree id and host disagreements, keeps a row stored in two workspaces where the tab bar names it, and gives a pane id repeated in a second tab a new unbound pane. The rules check runs on what the Serializer writes. Fixtures become test fixtures, and a test lists every model field with the one fact it holds.
…rowing createGroup is gone (a group only exists while it holds a tab); a lone tab split beside another group moves its group leaf and keeps the id; moveTab refuses when the move cannot be built; renaming onto an existing workspace is refused; worktree and execution host ids come from the workspace.
…ew fact per editor tab An editor tab names its file's owner (an SSH target or paired runtime), every other tab its partition. Where the stored tab and file preview flags disagree the tab loads permanent, so a draft can never be replaced. A row terminal that differs from the one derived from its focused pane is now reported.
…ow a reassigned pane A legacy row with no pane layout now loads as a one-pane tab bound to its terminal (reusing the leaf its records name), so the null-panes branches and legacyPtyId are gone. A pane given a new id because two tabs shared it takes its sleeping and incarnation records, scrollback and focus with it. A terminal's live title is one fact (the tab-bar label wins on load). Every tab-bar entry names its execution host by one rule. The fact-location test is now keyed by on-disk field and checked by mutating every source.
…s closed-tab records Every terminal tab has panes now, so commands lose their null-panes branches and a close always releases the tab's terminals. The pane-record rekey is shared with the Loader. Renaming a workspace renames its closed-tab records' worktree id, so a later removal or partition move still selects them.
The Loader now picks values by fixed precedence only and reports one field-by-field diff of the stored session against what the next save writes, over the disk-field tables (now in workspace-layout-disk-fields.ts, with session fields derived from SESSION_FIELD_OWNERS). Sleeping and closed-tab records live in their workspace without the pane key, tab and worktree id their key and workspace already give; the Serializer fills them. Tab-bar entries are written in the one tab order.
…no selection orphanAdopted, legacyWorkerRecovered, clientHostedPageAnnounced, agentLaunchVerdict, sleepingRecordsCaptured and moveWorkspaceToPartition ship with the PRs that produce them. The shared group moves take and return LayoutGroup only; the headless host keeps its selected tab beside them. Commands keep sleeping and closed-tab records in their workspace, so rename and removal need no record patching.
…legacy record - Each pane's data (terminal, incarnation, title, sleeping agent) is one leaf record keyed by leaf id, and scrollback beside it is keyed the same way. Pane keys are derived only when saving. The Loader gives a repeated leaf id a new id before attaching records, so rekeyPaneRecords is gone; records naming no pane are carried as stored. - The rules check runs on the model directly (ungrouped tab, empty group, group tree that disagrees with the groups, pane data without a pane); the disk-format rules stay separate. - The row-ownership marker and topology revision move into one legacy record outside WorkspaceLayout, updated by updateLegacyPersistence after each apply. - Fixtures include a terminal tab whose tab-bar id and terminal id differ.
… update - Commands and transitions read and write each pane's one leaf record; drag-out moves only the tree, so the pane's data and scrollback follow by structure. No command advances the topology revision itself: applyLayoutCommand and applyLayoutTransition run updateLegacyPersistence after each apply, which also marks a workspace as owning its rows once it holds a terminal. - Preview replacement closes the preview through closeTabs, so an unsaved draft refuses it as any close does. - Tests: create then close then save with and without a reload, commands on a tab whose tab-bar and terminal ids differ, drag-out scrollback, the preview refusal.
# Conflicts: # src/renderer/src/store/slices/tabs/tabs-create-actions.ts # src/shared/workspace-layout/stored-record-fields.ts # src/shared/workspace-layout/tab-group-moves.ts # src/shared/workspace-layout/terminal-pane-tree.ts
📝 WalkthroughWalkthroughThe change adds shared workspace-layout command contracts and operations for tabs, groups, panes, content, and terminal processes. It adds workspace and process transitions, tree and ordering helpers, and tests for command behavior, persistence, operation ordering, and generated model operations. Runtime code applies tab selection after group changes and uses shared pane-tree helpers. Renderer and store code import tab-order and terminal-ordinal helpers from shared modules. Priority: ➖ Normal Merge Risk: 🔵 Low · up to Moving a tab in a headless session can select the wrong remaining tab and leave recent-tab history stale. This is a bounded issue to fix or explicitly accept before merging. Pre-merge checks |
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
c815623a-3430-46b6-a340-f382576ed396
📒 Files selected for processing (37)
src/main/runtime/headless-tab-group-selection.test.tssrc/main/runtime/headless-tab-group-selection.tssrc/main/runtime/headless-terminal-split-layout.tssrc/main/runtime/orca-runtime-move-headless-mobile-session-tab.tssrc/renderer/src/lib/tab-number-shortcuts.tssrc/renderer/src/lib/unified-tab-anchor-insertion.tssrc/renderer/src/store/slices/tab-group-state.tssrc/renderer/src/store/slices/tabs-hydration.tssrc/renderer/src/store/slices/tabs/tabs-close-actions.tssrc/renderer/src/store/slices/tabs/tabs-create-actions.tssrc/renderer/src/store/slices/tabs/tabs-drop-actions.tssrc/renderer/src/store/slices/tabs/tabs-label-actions.tssrc/renderer/src/store/slices/tabs/tabs-move-actions.tssrc/renderer/src/store/slices/tabs/tabs-reconciliation.tssrc/renderer/src/store/terminals/terminal-tab-creation.tssrc/shared/workspace-layout/stored-record-fields.tssrc/shared/workspace-layout/tab-group-layout-tree.tssrc/shared/workspace-layout/tab-group-moves.test.tssrc/shared/workspace-layout/tab-group-moves.tssrc/shared/workspace-layout/tab-order.tssrc/shared/workspace-layout/terminal-pane-tree.tssrc/shared/workspace-layout/terminal-tab-ordinal.tssrc/shared/workspace-layout/workspace-layout-command-model.test.tssrc/shared/workspace-layout/workspace-layout-command-persistence.test.tssrc/shared/workspace-layout/workspace-layout-command-steps.tssrc/shared/workspace-layout/workspace-layout-command-types.tssrc/shared/workspace-layout/workspace-layout-command.test-fixture.tssrc/shared/workspace-layout/workspace-layout-commands.test.tssrc/shared/workspace-layout/workspace-layout-commands.tssrc/shared/workspace-layout/workspace-layout-content-commands.tssrc/shared/workspace-layout/workspace-layout-group-commands.tssrc/shared/workspace-layout/workspace-layout-owner-transitions.tssrc/shared/workspace-layout/workspace-layout-pane-commands.tssrc/shared/workspace-layout/workspace-layout-process-commands.tssrc/shared/workspace-layout/workspace-layout-tab-commands.tssrc/shared/workspace-layout/workspace-layout-transitions.test.tssrc/shared/workspace-layout/workspace-layout-transitions.ts
💤 Files with no reviewable changes (1)
- src/renderer/src/store/slices/tab-group-state.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.
| : kept && group.tabOrder.includes(kept) | ||
| ? kept | ||
| : (group.tabOrder[0] ?? null) | ||
| return { ...previous, id: group.id, tabOrder: group.tabOrder, activeTabId } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Preserve MRU selection and update recent-tab history.
If the active tab moves out of a group, this fallback selects the first tab rather than the most recently selected survivor. For example, with tabOrder changing from ['a', 'b', 'c'] to ['a', 'c'] and recentTabIds equal to ['a', 'c', 'b'], it selects a instead of c. The returned groups also retain b in the source history and do not add b to the destination history. The headless move paths persist these groups. Use a valid MRU entry for the source fallback, remove the moved tab from source history, and record its selection in destination history.
Stacked on #26748. Review that one first.
ELI5
The previous PR added one data model for a workspace's tab layout. This PR adds the actions that change it: open a terminal, split, close, move or reorder tabs and panes, rename, recolor, pin, open editor/browser/agent tabs, sleep and wake, plus the things the runtime itself observes (a terminal started or exited, an SSH connection dropped, a worktree was deleted or renamed). Each action is a plain function: model in, new model (or a refusal with a reason) out. Nothing in the app calls them yet. No behaviour changes.
What Changed
applyLayoutCommand(model, command, context)insrc/shared/workspace-layout/workspace-layout-commands.ts: every command from the design's command table, each with its refusals (tab_not_found,pane_not_found,group_not_found,same_pane,last_pane,pane_structure_changed,group_set_changed,pane_sleeping; per tab incloseTabs:tab_pinnedfor senders that confirm pinned closes themselves,editor_tab_has_unsaved_draftunless forced). Closing something already gone succeeds withalreadyClosed. Results carry the new ids; side effects the runtime must run after replying (stop or start terminals) are returned, never performed.applyLayoutTransition(model, transition)inworkspace-layout-transitions.ts: process started (binds the pane; refuses binding one terminal to a second pane), process exited (today's retirement rules: a pane now showing another terminal or incarnation is left alone), SSH lease ended (unbinds; not treated as an exit), owner removed, identity renamed. Transitions whose producer does not exist yet (orphan adopted, legacy worker recovery, client-hosted browser page announced, agent launch verdict, sleeping records captured, and moving a workspace to another host's partition) are not in this PR: each ships with the PR that produces it (PRs 4 and 6), written against the real producer.updateLegacyPersistence) updates the values only older builds read: the per-repo topology revision advances when a repo's terminal panes change (once per change; an exit retirement advances it even if the pane was already gone, as today), and a workspace that holds a terminal keeps its row list even after its last terminal closes. No command bumps the revision itself.closeTabs, so a preview with unsaved edits is kept (reported inrefused, like any close) and the new tab opens beside it. There is no create-empty-group command: groups never stay empty.tabs-tab-order.tsanddedupeTabOrder→tab-order.ts), the "Terminal N" ordinal, the headless host's group move/split (headless-tab-group-split-layout.ts→tab-group-moves.ts, which now holds only group order and membership; the headless host keeps each group's selected tab itself, inheadless-tab-group-selection.ts). The headless pane split now uses the shared pane-tree insert and remove (same output).Why
PR 1 of the layout rework, second half: the runtime will apply these commands in arrival order on one thread, publish the result to every view, and save it (later PRs). They take ids, not trees, so two clients' commands never overwrite each other. Refusals that need knowledge the pure module does not hold (unknown workspace in the catalog, shell override support, file outside workspace, terminal liveness) stay with the runtime and are listed at the top of the command types file.
Linked Issue
Part of the workspace tab layout rework (STA-9417 family). Inert.
Visual Proof
N/A: nothing in the app calls the new code.
Testing
workspace-layout-command-model.test.tsextends the seeded model test from test(terminal): pin layout invariants the mirror refactor must keep #26073: same seeds and plain reference model, now driven by commands from two clients plus process starts and exits, with the structural rules and id stability checked after every step, save/load in place of quit and relaunch, and three race pairs (close tab vs split, close pane vs drag-out, exit vs close tab) in random order. Default 6 seeds × 60 steps; run locally with 3,000 seeds, all green. Checked that it fails when a command is broken.workspace-layout-transitions.test.ts: each race pair in both arrival orders, and each transition's guards.workspace-layout-command-persistence.test.ts: create, close, save keeps the empty row list with and without a reload in between; which commands advance the revision; commands on a tab whose tab-bar id and terminal id differ save pane records under the terminal id; a preview with unsaved edits is not replaced.workspace-layout-commands.test.ts: refusals per command, plus placement (anchor, pinned run, ordinal reuse), drag-out carrying scrollback and pane data, equalize, preview replacement, browser and agent-session tabs.Existing renderer tab tests and headless split/move tests pass unchanged.
I manually tested these changes locally (inert)
Automated tests added/updated, or explained why not below
Agent skill upstream boundary
Notes
Pure module, no Electron/IPC/I/O. One deliberate difference from today's window: splitting a group's only tab beside another group keeps that group's id instead of minting a new one, because the structural rules treat a new id for the same tabs as a remount. Inert until the switch PR.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)