Skip to content

feat(terminal): window starts from and follows main's terminal layout (core C3) - #26293

Closed
Jinwoo-H wants to merge 8 commits into
topo-c2-publisher-connectedfrom
topo-c3-mirror-apply
Closed

Jinwoo-H wants to merge 8 commits into
topo-c2-publisher-connectedfrom
topo-c3-mirror-apply

Conversation

@Jinwoo-H

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

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 10 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​739 $\color{#cf222e}{\Huge{\mathbf{−}}}$​67 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​672
Prod 20 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​432 $\color{#cf222e}{\Huge{\mathbf{−}}}$​35 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​397

ELI5

Main keeps the saved copy of every worktree's terminal tabs and panes, and since C2 it announces that copy whenever it changes. With this PR the window starts from main's copy and keeps following it. Before, the window only read the saved file at startup, and after that it kept its own copy.

This is core step C3 of 7 in the terminal layout refactor. The core steps are reviewed as separate stacked PRs and merged together as one unit, so main never gets a half-finished version. This PR is stacked on C2 (#26279). C1 (#26151) is a sibling on another base.

What Changed

What users will see once the whole core lands: the window always shows main's terminal layout. A split or tab that main creates (setup script split, CLI split or create, a move) shows up in the window even if the separate "reveal" message never arrives. That fixes STA-9417: a worktree's setup terminal showing up in two tabs, one of them frozen, after the first time you open the worktree.

What C3 alone does (it is not a complete user state; C4 to C7 finish it, see the parity notes):

  • Mirror apply. A new store action, applyTerminalTopologySlice, takes one worktree's layout from main and replaces only what main owns:

    • the tab row's creation fields (launchAgent, defaultTitle, shellOverride, startupCwd, forceHostRuntime, quickCommandLabel, createdAt);
    • each layout's tree, pane-to-terminal bindings and pane titles;
    • the worktree's sleeping-agent records.

    Everything the window owns stays the same: title, custom title, color, pin, order, view mode, generation, agentLaunchPane, pendingActivationSpawn, active/expanded pane, buffers, and ptyIdsByTabId (which terminals are running). An existing tab's ptyId also stays, because in the window it means "the terminal this tab is attached to right now", and it is null at startup until reconnect. A tab main created seeds its ptyId from main, the same way a revealed tab does.

  • Tabs come and go with main. A new tab is added to the active tab group without being focused. A removed tab is taken out of its tab group, matched by entityId or id. Editor and browser tabs are not touched. Tabs a runtime: server publishes under the same worktree id are left alone.

  • Unchanged means untouched. Rows and layouts that didn't change keep the same object, so an identical update changes nothing but the sequence number, and React and the pane reconciler don't re-run.

  • No echo. The apply writes the per-worktree publishSeq in the same store update. The session writer treats any update that changes that map as main's own state and doesn't save it back. A later local edit still saves normally.

  • Order. Main's publishSeq decides which update is newer: an update older than the last one applied is ignored.

  • Startup. Right after the window hydrates the saved session, it subscribes to the push, then pulls every layout and applies it, and only then reconnects terminals. Doing it after hydration keeps the older startup read from overwriting a newer update. If the pull fails, the window logs it and keeps following pushes. The startup abort stops the subscription.

  • Types. Preload now declares the pull, the push, and the optional publishSeq on the pty:spawn, pty:moveLeafToNewTab and close-surface replies (C4 to C6 read it). Web clients get empty no-ops, because they follow their host's session tabs instead.

  • To stay under the file length limit, listRuntimeSessionHostIdsForStartup moved, unchanged, into startup/startup-runtime-session-hosts.ts.

Why

STA-9417 happens because the window builds terminal layout from whatever messages reach it, and when one is missed, the next sweep guesses wrong. The fix is one source of truth: main owns the layout and the window shows it. Main's projection and push already exist (B2-1, C2). This PR adds the reader with as little new state as possible:

  • one map (terminalTopologySeqByWorktree), which does two jobs: it orders updates, and it tells the session writer that an update came from main;
  • the existing tab-group helpers (ensureGroup, pickNextActiveTab, sanitizeRecentTabIds, dedupeTabOrder);
  • the existing structural and layout equality helpers.

Alternatives I rejected:

  • A module-level "applying" flag for the session writer. That is a second piece of state, when the sequence map already marks these updates.
  • Reusing reconcileWorktreeTabModel for tab groups. It also sweeps orphan tabs and writes ptyIdsByTabId, which the mirror must not touch.
  • Mirroring an existing tab's ptyId. That would put a terminal that died before the restart back on the tab ahead of reconnect, and undo the window's clear on exit.

Linked Issue

STA-9417 (fixed by the core as a unit). Prerequisites before the core merges: the SSH fixes #26088 and #26098. Each worktree shows only its home partition's copy, and those PRs make that copy the right one.

Visual Proof

N/A. This is a stacked core step. The visible fix (no duplicate setup tab) shows only once C4 to C7 land, and the parity check below records what C3 changes on its own.

Testing

  • pnpm tc passes. pnpm run check:code-quality:changed passes.
  • New store-level tests (terminal-topology-mirror.test.ts, terminal-topology-follow.test.ts):
    • window-owned fields survive an apply: presentation, pendingActivationSpawn, agentLaunchPane, generation and the live ptyId. ptyIdsByTabId keeps the same object;
    • an optional field main dropped is cleared;
    • an identical update keeps every store map's object and schedules no save. A changed update doesn't echo, and a later local edit still saves;
    • a change to one tab keeps every other layout's object;
    • the entityId join works for removal, and an existing entry with a different id isn't duplicated. A removed tab leaves unifiedTabs and its group, and focus moves to the most recent remaining tab. Editor tabs are untouched;
    • a new tab is appended to the active group without being focused;
    • a runtime: tab under the same worktree id is untouched;
    • only this worktree's sleeping records are replaced;
    • a lower publishSeq is ignored;
    • startup order: subscribe before pull, and the pull is applied before the step resolves (the hook awaits it before reconnectPersistedTerminals). A push that raced ahead of an older pull wins, a lower push after the pull is ignored, and abort unsubscribes. A failed pull still follows pushes.
  • STA-9417 gate tests from test(terminal): pin layout invariants the mirror refactor must keep #26073 now pass. Both it.fails cases in setup-split-activation-single-owner.test.ts (the reveal never arrives, and 8 create-then-activate cycles) are plain it now. The test pushes main's slice, which holds the setup split, before the first activation.
  • Every src/main/persistence/terminal-topology, src/renderer/src/store/terminals, session-writer and web test passes, apart from two web socket timeouts that also fail on the base. I ran the full unit suite too: every failing file outside those is either on C2's recorded base failure list or environmental (bun runtime, node:sqlite, lone-surrogate strings, socket timeouts). The one renderer file I spot-checked, terminal-shutdown-layout-capture, fails the same way on C2's head.
  • Parity: ORCA_BACKGROUND_LAUNCH=1 pnpm run test:e2e:terminal-layout-parity -- --base origin/main --head HEAD (base 80d45095d2f) FAILS, as expected for a lone core step. I declared no differences, because none of them is the named fix: the tool has no STA-9417 scenario yet (C7 adds it). Each difference:
    1. drag-out-to-tab breaks on head (2 panes stay visible). Main's push of the move reaches the window before the moveLeafToNewTab reply. The window's own detach code then can't find the leaf it is moving, so it closes the new tab. Goes away with C5, which drops the window-side detach writes and keeps only main's move, plus C4, which removes the pane by reconciling.
    2. split-right-down and cli-split: the window's stored tree takes main's grafted shape (root vertical graft instead of the real nested or horizontal split). The panes on screen and the saved session are unchanged. Goes away with C1 (placement carries the proposed tree) and C4 (the reconciler applies main's geometry).
    3. startupCwd: <repo> appears on tab rows in every scenario, in the window and in the save. Main's spawn binding stamps the spawn cwd onto rows it creates (spawn-commit-persist.ts:63), and today the window's save overwrites it. This one does not go away on its own: after C7 main's row is authoritative. It is functionally neutral (it is the default cwd), but C1 or C7 should decide whether main stamps startupCwd only when the spawn asked for an explicit cwd (open question).
  • Platforms: macOS locally. No platform-specific code.

Review

Agent skill upstream boundary

  • Not applicable.

Notes

Known gaps C3 leaves to later core steps (all inside the merge unit):

  • A tab the window has created but main hasn't bound yet is removed by any push for that worktree (C5, pending tabs).
  • An older push can revert a local layout or sleeping-record edit still inside the 150 ms save debounce (C4 gesture pending, C6 setLayout, C5 sleepLeaf/wakeLeaf).
  • The SSH importer still rehydrates from the peer snapshot (C5, importPeerTopology). The degraded startup path doesn't follow pushes.

Other areas:

  • SSH: worktrees follow their ssh: home copy. Mobile and web: unchanged, since they don't subscribe.
  • Performance: an apply is one store update. It compares one worktree's rows and layouts by value, plus the sleeping map.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • N/A screenshots (stacked core step)
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote considered
  • Typecheck, targeted unit tests, changed-code gate pass locally

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.
…-mirror-apply

# Conflicts:
#	src/renderer/src/lib/setup-split-activation-single-owner.test.ts
…e in main's layout

Waking a slept split after a relaunch reattaches each pane to a process the sleep
stopped. The host answers absent, and main retired the pane's surface (exit
retirement and the persisted owner retirement) before the fresh spawn re-minted
it: the tab was rebuilt from main's copy with the split reordered, and with the
wake hint gone the agent resumed into an extra tab. Main's copy did this before
the mirror too; the window's saves hid it.

The absence is now a 'replaced' stop: the fresh spawn takes the pane, so main
keeps the pane and its binding until that spawn's bind swaps it, and only the
tab row lets go of the gone process, as the window's hydration does.
… store call

The stable-owner absent path called retirePtyBinding through an optional
method only because some test stores lacked it; those stores now supply it.
The keeps-pane fixture builds a checked WorkspaceSessionState instead of a cast.
@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