Skip to content

refactor(terminal): window saves carry presentation only; delete the rebase and binding replay (core C7) - #26383

Closed
Jinwoo-H wants to merge 131 commits into
mainfrom
topo-c7-presentation-save
Closed

Jinwoo-H wants to merge 131 commits into
mainfrom
topo-c7-presentation-save

Conversation

@Jinwoo-H

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

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 127 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​8121 $\color{#cf222e}{\Huge{\mathbf{−}}}$​3346 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​4775
Prod 138 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3681 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1972 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1709

ELI5

Orca keeps your terminal tabs and split panes in two places: the main process (the part that starts terminals) and each window or client that shows them. Until now several of them could write the saved layout, so a window holding an older picture could overwrite the real one, and a pile of repair code tried to patch the result back. This change makes main the only writer of which tabs and panes exist. Windows, phones, the CLI and other Orca servers ask main for a change and then show what main publishes; a window still saves how things look (titles, colours, order, focus, scrollback).

This PR is the merge entry point for the whole terminal-layout core (C1–C7). Retargeted to main, its diff is the entire core. It should land as one squash commit; the step PRs below are review aids and individual steps are not expected to pass every test alone.

Steps: C1 #26151 placement applies · C2 #26279 publisher connected · C3 #26293 window mirror applies · C4 #26301 reconciler · C5a #26307 pending panes · C6 #26304 set-layout requests · C5b #26318 binding split · C5c #26317 sleep/wake · C5d #26320 SSH import/export · C7 (this) presentation-only window saves.

What Changed

What you see: terminals Orca creates itself, such as the split a setup script opens on a new worktree, no longer duplicate or vanish the first time you open that worktree (STA-9417), and saved layouts stop drifting from what is on screen after quits, restarts, sleep/resume and reconnects.

Mechanism:

  • Main decides tab and pane structure (C1) and publishes it to every connected surface (C2). Windows apply main's picture instead of keeping their own (C3); a reconciler turns differences into requests to main (C4).
  • Panes that are being created show as pending until main confirms them (C5a); split/close/move from any surface go through one set-layout request (C6).
  • Terminal bindings, sleeping agents and SSH import/export are owned by main (C5b–C5d).
  • Every window save goes through one merge that keeps main's structure and takes only presentation from the window (C7). The repair code for the old two-writer design (membership rebase, binding replay, save-time retirement cleanup, host-admitted flag) is deleted.

Why

With one writer there is nothing for a rebase or replay to fix, so deleting them is safer than tuning them. One save entry point covers every path (including quit, which bypassed the old per-caller guards) by construction.

Known differences from main

  • STA-9417 fixed: setup split on first activation keeps one tab holding both terminals; saved version counters are no longer rewritten by window saves.
  • Web/remote clients follow desktop pane sizes instead of keeping their own split ratios.
  • Sleep → quit → resume: waking a slept split no longer retires its panes, so the split keeps its order and the agent resumes in its own pane instead of an extra tab.
  • SSH reconnect: after a relay restart, main no longer uploads its pre-drop view (dead terminals' tabs already removed) before the reconnect download, which could empty the host's saved workspace.
  • Other Orca servers (runtime: workspaces): main no longer replays remembered terminal bindings; the server re-sends them on reconnect.
  • Older relays widen an existing race where an export during shutdown can miss the last change.
  • First connect to a remote split (C4): a pane can be briefly missing until the host's next publish.
  • Phone/CLI closing a split pane can release the pane (process keeps running until exit) instead of closing it; main clears its agent status when it exits.
  • A terminal change main refuses (for example when the worktree's home can't be resolved) is undone at once with a toast, instead of staying on screen until an unrelated later update reverted it silently.
  • Sleeping-agent records are kept only beside a tab main holds. A record a window writes for a tab main never held is refused; on main it resumed as an extra tab on the next activation.
  • Folder workspaces whose host can't be determined (no host set and their repos on more than one host): main refuses layout edits, new tabs, adopted terminals and sleeping-agent records there, where main saved them in the local bucket. Orca's shared host lookup requires such folders to fail closed, the sidebar already won't open one, and saving them locally could split one workspace across two buckets.
  • A phone or CLI split whose pane main had not recorded yet is recorded with its terminal. In a repo with a version counter, main's save merge dropped that pane, leaving its process running with no pane.

Linked Issue

STA-9417 (internal).

Visual Proof

N/A — the layout parity tool captures the rendered layout and saved session for every scenario against main; the only differences are the declared STA-9417 ones.

Testing

Run on the C7 head with main (4f4f021) merged into every step, macOS, background launch:

  • pnpm tc on every step; pnpm run check:code-quality:changed on C7: pass.
  • Unit tests by project (main / renderer / shared / everything else): renderer all pass. The rest fail only in tests that fail identically on main on this machine (browser-manager, a daemon repro and fish-shell tests; a relay test that needs pg).
  • test(terminal): pin layout invariants the mirror refactor must keep #26073 layout invariants, and the terminal topology model test at 300 seeds: pass.
  • Docker SSH e2e: reconnect pane restore, lost-kill resurrection (both cases), reconnect tab destruction, reattach home partition, startup local shadow, cold activation, transport drop (6 cases), emptied worktree, restart tab accumulation, stale-resume host scope: pass.
  • Layout parity against main, all 11 scenarios: PASS, with only the declared STA-9417 differences. Sleep → quit → resume and setup split, run repeatedly: no pane swap or extra tab. After sleep → quit → resume, the split tab's saved tab-level terminal is its first pane's in 15 of 15 runs; main saves the second pane's in 5 of 15, and the window's own pane order varies the same way on both, so those paths are recorded as unstable on main.

Notes

  • After sleep → quit → resume, a split tab's saved tab-level terminal stays on the pane it named before the sleep: main keeps it, like the pane's own binding, until that pane's new terminal replaces it. (Before, main cleared it and whichever pane reconnected first took it.)

Agent skill upstream boundary

  • Not applicable.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • 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
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Jinwoo-H and others added 30 commits October 7, 2026 00:52
…eport-only

The window now says where each fresh terminal goes: the first pane of a tab
sends new-tab with the tab's creation fields, a split sends the parent leaf,
direction, ratio and the tree after the split, background launches send the
same, and a Codex restart that rebuilds a rootless layout sends root. A
reattach sends nothing. Main still only records whether placement names the
tab its binding write picks; its 'absent' value now counts only new leaves
without placement, so it reads the fallback mint and graft directly.

Extends the shared placement type with optional row, ratio and proposedRoot,
each dropped alone if malformed, so older and newer peers keep working.
…he tab lacks

The adopted tab is created without launchAgent, so a row naming one would
diverge from the renderer's tab once main applies placement.
…order

wrapInSplit now places the new pane first itself, so the pane-created
handler's proposedRoot sees the real post-split tree instead of the order
before the subtree split moved it.
Core step C1 of 7. When a new terminal's binding names a tab main does not
have yet, main builds the row from the creation fields the window sent; when
it names a split whose parent is in the tab, main takes the window's tree
(holding exactly today's leaves plus the new one) or splits at the parent with
the given direction and ratio. Spawns without placement, or whose placement
doesn't fit the tab's current shape, keep today's mint and root graft.

The headless split reuses the same split-at-leaf op, and the agreement span
now reports exactly when placement was applied.
…ow (no reader yet)

Core step C2: the runtime owns a TerminalTopologyPublisher fed by the
store's session-write hook. Each worktree is projected from its one home
partition (local or ssh:, never runtime:); an owner that can't be resolved,
like an ambiguous folder, keeps its last slice instead of going empty.
Slices go to the window on session:terminal-topology-changed, the window
can pull them with session:get-terminal-topology-slices, and close-surface
and leaf-move replies name the publishSeq that carries their write.
The spawn reply now carries the publishSeq of the push holding its binding,
like close-surface and leaf-move. No reader yet.
Core step C3 (plan B2-4). After hydration the window subscribes to main's
topology push, pulls every slice, and applies them before reconnect. The
apply replaces only main-owned topology; presentation, pendingActivationSpawn,
the live ptyId and ptyIdsByTabId stay. Unchanged rows and layouts keep their
identity and an apply never echoes back as a session save. Flips the STA-9417
gate tests.
…'s row

Main stamped the spawn's cwd on every tab it built from placement. Once
main's copy is authoritative that would add startupCwd to rows the window
never gave one.
…or every tab

The pane reconciler ran only for web clients and remote-server tabs. It now
runs for every tab, so a desktop window follows main's layout:

- a leaf main dropped is detached, never killed, so a PTY main moved to
  another tab keeps one output owner; a pane still starting its PTY is
  kept until main binds it;
- removals apply before insertions, and an inserted leaf with no PTY
  spawns through the normal mount;
- divider ratios and orientation apply in place, so web and remote
  clients now follow desktop pane sizes; a divider mid-drag is left alone;
- the write-back after insertions is gone.
…main publishes it

A divider drag, divider reset, equalize or pane reorder now sends the tab's
tree to main (`session:terminal-set-layout` -> commit-module `setLayout`).
Main takes it only when it names exactly the tab's current panes, in every
owner partition holding the tab, and refuses any other tree. The reply
carries the publishSeq of the push holding the commit.

Until the window has applied that push, a push with the same panes keeps
the gesture's tree, so an older push no longer snaps a divider back between
drag end and main's commit. A push whose tab has other panes wins at once.

The layout save no longer merges live transport PTY ids into the layout's
bindings; main's push owns them.
…topology

Core step C5a (plan B2-6a). A tab or pane this window creates or closes is
pending until main's topology catches up, so the mirror and the pane
reconciler show it ahead of main without letting an older push undo it:

- createTab marks the tab pending; a pane the window makes that main's
  layout doesn't name yet is pending. An add ends when a slice names it.
- Every tab or pane close goes through commitTerminalSurfaceClose, which
  hides it until the mirror holds main's reply publishSeq; a refused close
  shows main's copy again.
- The reconciler keeps pending panes and never re-inserts a closed one;
  this replaces the "spawn still pending" check and trackRetiredLeafIds.
- Drag-out to a new tab waits for main's move and its push, then only
  places, activates and moves the live attachment. It no longer creates
  the tab or writes layouts itself, so a push that beats the reply no
  longer closes the new tab. A pane main doesn't hold keeps the local move.
- replaceTerminalLayoutPanePtyId is deleted: leaf bindings come from main.
…o-c6-set-layout

# Conflicts:
#	src/renderer/src/store/slices/terminals.ts
#	src/renderer/src/store/terminals/terminal-actions.ts
#	src/renderer/src/store/terminals/terminal-state.ts
#	src/renderer/src/store/terminals/terminal-topology-mirror.ts
…tion

setLayout wrote every local/ssh partition holding the tab. A worktree has
one home (the partition main's push projects), so main now resolves it with
the same owner rule and writes only there; an unresolved home writes nothing
and replies home_unresolved.
…ing tabs and panes

One record of what the window keeps over main's pushes until main publishes
it: a gesture's tree is a 'layout' entry in pendingTerminalPanes, settled
like a close (by the reply's publishSeq) or at once when main's tab has
other panes. Closes and gestures share one commit helper; the separate
gesture map and its file are gone.
moveLeaf rewrote every local/ssh partition holding the source tab. A
worktree has one home (the partition main's push projects), so the move now
resolves it with the same rule setLayout uses and writes only there; an
unresolved home writes nothing and replies home_unresolved. A stray copy in
another partition is left as it is (D-core-3).
The window sends main its own sleeping-record changes (sleep, wake) through
two batched IPCs; main keeps each record beside its tab in every owner
partition that holds it, and its next topology push carries the result. The
quit capture rides the synchronous before-unload stage, committed before the
sessions are staged. Mirror applies, tab closes and pane moves send nothing:
main already holds those.

Co-Authored-By: Claude <noreply@anthropic.com>
… holds which PTY

updateTabPtyId and the pane-level binding writers no longer write a local or
SSH pane's layout binding (on spawn, handle rotation, exit or close): main
records it on spawn and the mirror brings it. Their liveness half is
unchanged (ptyIdsByTabId, tab.ptyId as the live attachment, the direct-SSH
ledger, pendingActivationSpawn, Codex notice migration, activity bumps).
A remote runtime's pane bindings stay window-written, for the push to its
host.

After an exit main keeps the binding (R17), so a remount does one stale
reattach, then spawns fresh. A closed pane's binding is main's stale copy
until main's push drops the leaf, so a late reattach reply to a closed pane
is still retired.
…terminal

Re-attaching an agent's live terminal that no pane claims into its pane wrote
the pane's binding only in the window, which main's push overwrites, so the
pane in a split started a new shell. The window now asks main to record it
(session:terminal-bind-leaf -> bindLeaf, a reattach bind that never creates
the pane, in the worktree's home partition); main's push carries it to the
pane.
A drag made before main recorded a just-created split sent a tree naming a
pane main didn't have yet; main refused it (leaves_differ) and the drag was
lost, and an unrelated push could drop it sooner. The drag's pending layout
now ignores panes this window added and main hasn't named, and its commit
waits until main names them, then sends once.
…es; imports go through main

The window no longer uploads to SSH relays. Main exports a host's workspace after any
session write whose share for that host differs from what the two last agreed on, and
reports each export back to the window as a push-status event. The window's snapshot
import now commits its merge through main in one call (remoteWorkspace:importPeerTopology)
with the pull's revision, host observation and outcome; that sets the agreement, so an
import and the window's save-back of it never export, and a conflicted pull keeps the host
out of exports until a whole pull agrees again. The first-connect seed of an empty host is
main's export too.

Co-Authored-By: Claude <noreply@anthropic.com>
The same owner rule main's push projects, exposed for writes that land in
one partition. Identical to C6's hunk so the branches merge cleanly.
…ore keeps

Covers inventory rows A5, A14, A19, A20, A44, A45, B16, B17, B26, B36.
Covers inventory rows A25, A40, A41, A50, A55, A56.
…tore main's slice on a refused change, keep a stale client's focus out, apply old close records on load

- main reconcile and the window's apply batch read sleeping-agent records once, not once per worktree
- a refused or rolled-back topology commit re-applies main's slice for the worktree at once and toasts
- a headless client's stale pane tree leaves main's whole layout, as on main
- close records an older build left are applied and cleared at load, as main's first write did
Covers inventory rows P6, P7, P8, P10, P11, P12, R8, C-extra3.
…, floating and web tabs

Covers inventory rows B56, B57, B62, B79, B86, K2.
… does

Load keeps them, as on main; the first write applies and clears them, so main's
identity-migration assertion holds again unchanged.
@Jinwoo-H
Jinwoo-H requested a review from brennanb2025 as a code owner October 8, 2026 08:29
@Jinwoo-H

Jinwoo-H commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Closing: the layout rework is being redesigned so the runtime holds the only copy of the layout and every window/client is a view. This branch is kept for reference.

@Jinwoo-H Jinwoo-H closed this Oct 8, 2026
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