Skip to content

Add inert layout commands and runtime transitions - #26749

Merged
Jinwoo-H merged 28 commits into
mainfrom
layout-pr1-commands
Oct 9, 2026
Merged

Jinwoo-H merged 28 commits into
mainfrom
layout-pr1-commands

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1295 $\color{#cf222e}{\Huge{\mathbf{−}}}$​20 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1275
Prod 31 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1655 $\color{#cf222e}{\Huge{\mathbf{−}}}$​108 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1547

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) in src/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 in closeTabs: tab_pinned for senders that confirm pinned closes themselves, editor_tab_has_unsaved_draft unless forced). Closing something already gone succeeds with alreadyClosed. Results carry the new ids; side effects the runtime must run after replying (stop or start terminals) are returned, never performed.
  • applyLayoutTransition(model, transition) in workspace-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.
  • Ids are never changed by a command: a pane dragged out to its own tab keeps its id, so its terminal, records and scrollback (all keyed by pane id) go with it with nothing moved by hand; a group's only tab split beside another group keeps the group's id.
  • After every command and transition, one function (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.
  • Opening a preview editor tab replaces the group's preview tab through closeTabs, so a preview with unsaved edits is kept (reported in refused, like any close) and the new tab opens beside it. There is no create-empty-group command: groups never stay empty.
  • Moves into the shared folder so the renderer, today's headless host and the commands share one implementation (no logic changes): the renderer's tab-order helpers (tabs-tab-order.ts and dedupeTabOrder → 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, in headless-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.ts extends 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

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

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

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…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.
@Jinwoo-H
Jinwoo-H changed the base branch from layout-pr1-module to main October 9, 2026 19:05
# 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
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Walkthrough

Walkthrough

The 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 16e69

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 | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 42.53% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 87 functions across 36 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly summarizes the main change: adding inert layout commands and runtime transitions.
Description check Passed The description is detailed and covers the change, motivation, testing, visual proof, scope, and checklist. It does not provide an actual linked issue in the required Fixes # format, and it leaves l…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.


  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c815623a-3430-46b6-a340-f382576ed396
📥 Commits

Reviewing files that changed from the base of the PR and between 9021133 and 16e694e.

📒 Files selected for processing (37)
  • src/main/runtime/headless-tab-group-selection.test.ts
  • src/main/runtime/headless-tab-group-selection.ts
  • src/main/runtime/headless-terminal-split-layout.ts
  • src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts
  • src/renderer/src/lib/tab-number-shortcuts.ts
  • src/renderer/src/lib/unified-tab-anchor-insertion.ts
  • src/renderer/src/store/slices/tab-group-state.ts
  • src/renderer/src/store/slices/tabs-hydration.ts
  • src/renderer/src/store/slices/tabs/tabs-close-actions.ts
  • src/renderer/src/store/slices/tabs/tabs-create-actions.ts
  • src/renderer/src/store/slices/tabs/tabs-drop-actions.ts
  • src/renderer/src/store/slices/tabs/tabs-label-actions.ts
  • src/renderer/src/store/slices/tabs/tabs-move-actions.ts
  • src/renderer/src/store/slices/tabs/tabs-reconciliation.ts
  • src/renderer/src/store/terminals/terminal-tab-creation.ts
  • src/shared/workspace-layout/stored-record-fields.ts
  • src/shared/workspace-layout/tab-group-layout-tree.ts
  • src/shared/workspace-layout/tab-group-moves.test.ts
  • src/shared/workspace-layout/tab-group-moves.ts
  • src/shared/workspace-layout/tab-order.ts
  • src/shared/workspace-layout/terminal-pane-tree.ts
  • src/shared/workspace-layout/terminal-tab-ordinal.ts
  • src/shared/workspace-layout/workspace-layout-command-model.test.ts
  • src/shared/workspace-layout/workspace-layout-command-persistence.test.ts
  • src/shared/workspace-layout/workspace-layout-command-steps.ts
  • src/shared/workspace-layout/workspace-layout-command-types.ts
  • src/shared/workspace-layout/workspace-layout-command.test-fixture.ts
  • src/shared/workspace-layout/workspace-layout-commands.test.ts
  • src/shared/workspace-layout/workspace-layout-commands.ts
  • src/shared/workspace-layout/workspace-layout-content-commands.ts
  • src/shared/workspace-layout/workspace-layout-group-commands.ts
  • src/shared/workspace-layout/workspace-layout-owner-transitions.ts
  • src/shared/workspace-layout/workspace-layout-pane-commands.ts
  • src/shared/workspace-layout/workspace-layout-process-commands.ts
  • src/shared/workspace-layout/workspace-layout-tab-commands.ts
  • src/shared/workspace-layout/workspace-layout-transitions.test.ts
  • src/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.

Comment on lines +18 to +21
: kept && group.tabOrder.includes(kept)
? kept
: (group.tabOrder[0] ?? null)
return { ...previous, id: group.id, tabOrder: group.tabOrder, activeTabId }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

@Jinwoo-H
Jinwoo-H merged commit a8ce574 into main Oct 9, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant