Skip to content

fix(sidebar): attribute each split pane's runtime title to its own leaf (STA-3264) - #14702

Open
brennanb2025 wants to merge 1 commit into
mainfrom
brennanb2025/split-pane-identity-3264
Open

brennanb2025 wants to merge 1 commit into
mainfrom
brennanb2025/split-pane-identity-3264

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​111 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​111
Prod 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​65 $\color{#cf222e}{\Huge{\mathbf{−}}}$​10 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​55

ELI5

Two agents in one split terminal tab each get their own sidebar row. Orca works out which row a pane's status belongs to by looking at the pane's terminal title — but it matched titles to panes by position in a list, and that list is not in pane order. So one pane's status could land on its sibling's row: you finish one agent and the sidebar keeps both rows spinning "Running", while the pane that actually finished is the one still showing work.

This makes each pane title resolve to its own pane by identity instead of by position.

What Changed

buildTitleDerivedAgentRows resolves each runtime pane title to the layout leaf that actually owns it.

runtimePaneTitlesByTabId[tabId] is keyed by runtime pane id, and it holds two disjoint id spaces:

Slot Written by Numbering
>= 1 live PaneManager (this.nextPaneId++) pane-creation order
-(leafIndex + 1) fallbackParkedPaneCandidates for parked (unmounted) tabs in-order leaf index, negated

Neither space is ordered like the layout tree's in-order leaf traversal, and the two can be present at the same time. The old resolver sorted every slot numerically and used the slot's index into collectLeafIds(root), which crosses both spaces.

The new resolution, per slot:

  1. Single-leaf tab → the only leaf. (Unchanged, just moved to the front so it outranks the title match below.)
  2. Parked slot (< 1)leafIds[-paneId - 1], the exact inverse of how fallbackParkedPaneCandidates mints it.
  3. Live slot, ids dense from FIRST_PANE_IDresolveRuntimePaneTitleLeafIdFromRoot, the repo's existing creation-order resolver already used by worktree-status.ts, smart-attention.ts, worktree-agent-rows.ts, and ai-vault-original-pane.ts. This PR does not add a resolver; it stops the sidebar from being the one place that hand-rolls its own.
  4. Unique titlesByLeafId match → unchanged, but demoted below id-based resolution. It compares a live OSC title against a user-assigned pane name, so it must not take a leaf away from the pane whose id names it.
  5. Live slot, sparse ids → index within the live slots only, never across both spaces. This is the old behavior, preserved for the case creation order genuinely cannot answer.

Slots are also ordered live-before-parked, so when a revealed tab carries both, the live slot claims the leaf and seenPaneKeys drops the stale parked duplicate rather than the other way round.

Deliberately gated on density (step 3). Pane ids only encode creation order while they are the dense sequence a fresh mount or replay allocates. An in-session pane close leaves survivors sparse ({2, 3}), where creation order would answer confidently and wrongly. Sparse falls to step 5, which is exactly today's behavior — so no in-session-close case regresses.

Why

The literal string Running reaches a sidebar agent row through exactly one line in the renderer — worktree-title-derived-agent-rows.ts — so STA-3264's "both rows Running" is specifically about title-derived rows, the path hookless agents (Pi, Codex over SSH, Cursor Agent) depend on. Everything upstream of it is already pane-scoped: agentStatusByPaneKey, selectLiveAgentStatusEntriesForWorktree, and the explicit-entry loop in buildWorktreeAgentRows all key on tabId:leafId. The tab-scoped error is in the last step — deciding which leaf a pane title belongs to.

I drove buildWorktreeAgentRows directly to confirm the mechanism rather than reason about it. Real output, same builder the sidebar renders from, before the fix:

Parked split tab. Leaf 1 finished (idle title in slot -1), leaf 2 still working (slot -2). Sorting ascending gives [-2, -1], which is the reverse of the leaf list the slots were numbered against:

before:  leaf-1  working  "Running"      <- the pane that FINISHED
         leaf-2  idle     "Idle"         <- the pane still WORKING
after:   leaf-1  idle     "Idle"
         leaf-2  working  "Running"

Revealed parked tab — this is the reported symptom. disposeParkedTabWatchers tears down watchers without clearing their title slots, so mounting adds live slots {1, 2} alongside the parked {-1, -2}. Four slots, two leaves: indices 0 and 1 are the two stale parked spinner titles, and the live idle title of the pane that finished is pushed to index 2 and dropped entirely.

before:  leaf-1  working  "Running"      <- stale parked title
         leaf-2  working  "Running"      <- stale parked title
         (the finished pane's live idle title is discarded)
after:   leaf-1  idle     "Idle"
         leaf-2  working  "Running"

Three panes, left pane split again. Tree traversal is [1, 3, 2]; creation order is [1, 2, 3]. Panes 2 and 3 take each other's title, so sibling panes inherit each other's agent identity and state:

before:  leaf-1 antigravity idle | leaf-3 codex working | leaf-2 gemini working
after:   leaf-1 antigravity idle | leaf-2 codex working | leaf-3 gemini working

That last case is why the fix is identity-based rather than another positional tweak: no ordering of a positional index can be right, because pane ids and tree position are independent facts.

Which of the four cluster tickets this is — and is not

Three distinct root causes sit behind the four tickets. They are not one bug.

Ticket Root cause Status
STA-3264 / #12228 — split-pane agents jointly Running Runtime pane title → leaf attribution (this PR) Fixed here
STA-2811 / #11069 — split rows share one name that flips on click getAgentRowConversationName reads tab.title, which carries only the focused pane's title Still open. Addressed by #11070 (@pythonstrup) — see below
STA-2926 / #11372 — title-derived row recycles after a split pane closes A different branch of the same function: once the closed pane's title slot is cleared, the tab falls through to the tab.title fallback and re-synthesizes a row from the tab's stale spinner title Still open, untouched here
STA-2637 — detaching a completed split pane split one session into two rows Already Done

No PR is superseded. There is no open or closed PR against STA-3264 (searched 12228 in:body and STA-3264 in:body, both empty), and #11372 is an issue, not a PR.

#11070 (@pythonstrup) is complementary, not competing, and is not superseded. It suppresses the tab's live title as a conversation name source for split rows; this PR fixes which pane a title's lifecycle state is attributed to. STA-3264 puts the name half explicitly out of scope ("conversation names and lifecycle states are separate projections and should not be coupled"), and STA-2811 is listed under STA-3264's Out of Scope. The two changes touch disjoint files — #11070 edits agent-row-conversation-name / use-agent-row-conversation-name / build-dashboard-snapshot; this edits worktree-title-derived-agent-rows — and land independently. Worth noting they compose in the right direction: #11070's fallback for a split row is the pane's own prompt keyed by paneKey, which is only correct once that paneKey is the right pane, which is what this PR establishes.

Not the subagent-clobbers-parent class (STA-2112/2035, STA-2243). Nothing here reads or writes orchestration.parentPaneKey, and no row's preview or completion state is derived from another row. The change is confined to choosing a leaf id for one pane's own title.

Prior art this builds on

Linked Issue

Fixes #12228 (STA-3264)

Does not fix #11069 (STA-2811) or #11372 (STA-2926) — see the table above.

Visual Proof

Captured on a live app. See the full validation comment for the rig and the SSH leg.

Three panes in one tab (split once, then split the first pane, so traversal order [1,3,2] diverges from creation order [1,2,3]), each running a hookless process emitting a real OSC 0 agent title. Before/after is a one-line diff on a single live instance: the production hunk is reverted in place, Vite HMR reloads it, and the same panes are re-measured — each sample asserts which source version is actually served.

pane (visible banner) leaf pre-fix row says post-fix row says
PANE A — Antigravity, idle 59d0919d antigravity / idle antigravity / idle
PANE B — Codex, working 2522ba8a gemini / idle codex / working
PANE C — Gemini, idle 2d238208 codex / working gemini / idle

With focus on PANE A, clicking the row showing the Codex spinner lands on PANE C (the idle Gemini pane) pre-fix, and on PANE B (the pane actually running) post-fix.

before
after

Read the images with this caveat. A runtime title carries the agent name and its lifecycle state together, so mis-attribution moves both — the rendered multiset of (name, state) pairs is identical either way, and what changes is which pane each row is bound to. The shots therefore differ in row order and per-row icon, not in the name text. Also, all three rows render the same name there because of STA-2811 / #11069, which this PR does not fix; per-row identity is correct in the accessible summary (Antigravity idle; Gemini idle; Codex working).

Not captured: the parked/revealed path. I parked a split tab for real (pane manager gone, parked watchers owning it) but never observed a negative title slot — resolveParkedTerminalPaneCandidates prefers capturedPanesByTabId, so a tab that has mounted once keeps reusing its positive ids while parked. The {-1,-2,1,2} state behind the "both rows Running" block under Why remains unverified on a live app (that block is still constructed-state output).

Testing

  • I manually tested these changes locally — on a live dev app, local and over a real SSH host, with a HMR-based one-line before/after (see Visual Proof and the validation comment). The constructed-store runs below are retained as the unit-level oracle.
  • Automated tests added/updated

New testssrc/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts, new split-pane runtime title attribution block. They assert externally visible projection only (paneKey, agentType, lifecycle state, row count), per STA-3264's testing decisions:

  1. keeps a finished split pane out of Running while its sibling keeps working — parked split tab, mixed lifecycle.
  2. lets a revealed tab's live slots outrank the parked slots it left behind — both id spaces present; asserts two rows, correct states, no duplicates.
  3. does not let sibling panes inherit each other's agent or state — three panes, left pane re-split; three different agents so a crossed mapping cannot pass by coincidence.
  4. does not recycle a closed split pane's row onto a surviving sibling — survivors' ids sparse after a close; asserts each survivor keeps its own leaf and no row exists for the closed leaf.

Red/green oracle. Reverting only worktree-title-derived-agent-rows.ts and keeping the tests: 3 failed | 18 passed. Tests 1–3 fail; test 4 passes both ways and is stated as a guard on the sparse-id path this PR deliberately preserves, not as evidence of a fix. Restored: 21/21.

Regression scope.

  • src/renderer/src/components/sidebar + src/renderer/src/components/dashboard: the two suites that own the changed behavior (worktree-title-derived-agent-rows, useWorktreeAgentRows, WorktreeCardAgents) pass 79/79. Running the full pair of directories surfaces failures in heavy WorktreeCard.* render tests, all timeouts at the 20s/60s marks and all pre-existing: WorktreeCard.pr-display.test.tsx fails 2 tests on the pre-fix source and 1 with the change applied, i.e. it is a timing flake in both directions and not caused here. Nothing in the agent-row path fails.
  • Every other consumer of the shared resolver — paired-reconnect-sidebar-agent-count, runtime-pane-title-leaf-id, worktree-status, smart-attention: 84 passed.
  • check:code-quality:changed: 0 new findings across 2 changed files, on all three gates (code quality, type-aware, React Doctor). No max-lines suppression added.
  • Full pnpm typecheck deliberately not run (OOM risk on this host). The type-aware gate above covers the changed files. Types touched are narrow: one internal function's argument object and two imports.

Rebase. Rebased onto origin/main (c4e188a25f) from 146 commits behind; no conflicts, merge-tree --write-tree clean, red/green oracle re-run after the rebase (3 failed reverted → 21/21 restored). Confirmed worktree-title-derived-agent-rows.ts is byte-identical between the branch base and current main and that WorktreeCardAgents.tsx → useWorktreeAgentRows → buildWorktreeAgentRows → buildTitleDerivedAgentRows is still the live render chain.

SSH. Exercised for real against a throwaway ubuntu:22.04 SSH target with a real relay (remotePlatform: linux): three hookless agents in one remote split tab reproduce the same swap pre-fix and are correct post-fix.

Known limit, verified live and worth restating. The in-session-close case is not merely untested, it is a surviving mis-attribution of the same class: split → close pane 2 → split again leaves live ids sparse ({1,3,4}), liveSlotsAreDense is false, and with this PR applied PANE D (OpenCode, working) still reports gemini/idle while PANE C (Gemini, idle) reports opencode/working. worktree-status.ts calls resolveRuntimePaneTitleLeafIdFromRoot directly with no density gate and is likewise untouched.

Merge-collision check. git merge-tree --write-tree against the head SHA of each in-flight PR that touches this area — #14486 (the 119-file worktree-list reorg), #14682, #14654 — all three merge with no conflict. I did not stop at the flag: I materialized the #14486 merge into a scratch worktree and ran the agent-row suites on the merged tree — 79 passed, so the merged result is green, not merely mergeable. (GitHub's mergeable field was independently verified stale during this sweep; I did not rely on it.)

Platforms. Written and run on macOS. Nothing platform-dependent is introduced: no shortcuts, modifier keys, shortcut labels, path construction, shell invocation, or Electron platform APIs. The change is integer/string id resolution over an in-memory layout tree. I did not exercise Windows, Linux, or a live SSH host.

Review

  • Security. No new surface. No command execution, path handling, auth, secrets, IPC channel, dependency, persisted state, or schema change. The only inputs are ids the renderer already owns.
  • Cross-platform. No branching on process.platform or navigator.userAgent; nothing OS-specific to diverge.
  • Remote SSH. This is the path that matters most for SSH: hookless agents over SSH (Codex Codex status hooks are dead in SSH worktrees — CODEX_HOME is injected only by the local PTY provider, while orca agent hooks status still reports codex: installed #8711, OpenCode [Bug] opencode is displayed as Claude Code #8940) surface only decorated titles, so their rows are title-derived and were the most exposed to mis-attribution. No probing, no Git commands, no local-only assumptions — identical for local, remote-runtime, and SSH panes, and for folder workspaces as well as git worktrees (nothing reads git state).
  • Mobile / remote wire. No wire change: no RPC params, no stream opcodes, and nothing new published to a paired client. This is renderer-local row derivation.
  • Backwards compatibility. Nothing persisted, no migration, no downgrade hazard. The single-leaf and sparse-live-id paths keep their existing behavior byte for byte.
  • Performance (STA-3354, Urgent P0 — tab-agent status resolution already scans the global status map per tab per render; rows must not multiply that). This reduces per-render work. It touches no status-map scan and adds no store subscription. Per tab with n leaves and k title slots, the old code ran collectLeafIds (O(n)) and an Object.entries(titlesByLeafId) scan per slot — O(k·n). The new code hoists the leaf list to once per tab and resolves each slot in O(n) worst case, with the titlesByLeafId scan now only reached when id-based resolution fails, which for a normally-mounted tab is never. Net: strictly fewer scans than today, one added O(k) pass to compute liveSlotIds, and no change to row count — so nothing downstream multiplies either.

AI Disclosure

Claude Opus 5 (Claude Code), on macOS.

Checklist

  • This PR is small and focused — 1 source file, 1 test file
  • I explained what changed and why (including ELI5)
  • 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) — scoped equivalents run locally and green; full typecheck left to CI per the OOM constraint above

Author

  • X / Twitter: @BrennanKB5

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Runtime pane titles now sort live slots before parked slots. Leaf resolution supports single-leaf layouts, parked slots, dense live IDs, duplicate titles, and sparse live IDs. The implementation uses runtime layout mapping and live-slot indexes instead of positional mapping across all title entries. Tests cover parked slots, live-slot precedence, nested splits, sparse IDs, and closed panes.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Title check ✅ Passed The title clearly and concisely identifies the primary fix: assigning each split pane's runtime title to its own leaf.
Description check ✅ Passed The description covers the required sections, linked issue, implementation, tests, visual proof, limitations, and compatibility considerations.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status

State: MERGEABLE / CLEAN against main. CI green — 44 successful checks, 1 skipped, 0 failing. 2 files, +176/-10 (1 source, 1 test). The branch is 141 commits behind main (1 ahead) — it merges clean, but the merged-tree evidence below predates a lot of that; see the merge-collision note.

What the review loop established. runtimePaneTitlesByTabId[tabId] holds two disjoint id spaces — live PaneManager ids (>= 1, pane-creation order) and -(leafIndex + 1) slots minted by fallbackParkedPaneCandidates for parked tabs (in-order leaf index, negated). Neither is ordered like the layout tree's in-order leaf traversal, and both can be present at once. The old resolver sorted every slot numerically and used the slot's index into collectLeafIds(root), crossing both spaces. That is the whole bug: one pane's status landing on its sibling's row.

Three before/after captures were produced by driving buildWorktreeAgentRows directly — the same builder the sidebar renders from — rather than by reasoning: a parked split tab where ascending sort reverses the leaf order; a revealed parked tab where disposeParkedTabWatchers leaves stale parked slots alongside new live ones, so four slots compete for two leaves and the finished pane's live title is pushed to index 2 and dropped entirely; and a three-pane tab where tree traversal [1,3,2] diverges from creation order [1,2,3] so siblings inherit each other's agent identity and state. That last case is why the fix is identity-based — no ordering of a positional index can be right when pane ids and tree position are independent facts.

Scope: 3 distinct root causes behind 4 tickets, 1 fixed here. STA-3264/#12228 is fixed. STA-2811/#11069 (rows share one name that flips on click — getAgentRowConversationName reads tab.title, which carries only the focused pane's title) and STA-2926/#11372 (a different branch of the same function falling through to the tab.title fallback after a pane closes) are untouched here. STA-2637 was already Done. Sibling PRs #14707 and #14708 in this batch target STA-2811 and STA-2926 respectively.

Supersedes nothing, and the claim holds: no other PR targets STA-3264 (a STA-3264 in:body search returns only this PR and the two sibling PRs that reference it in their scope tables), and #11372 is an issue rather than a PR. #11070 (@pythonstrup) is complementary and not superseded — it suppresses the tab's live title as a conversation name source; this fixes which pane a title's lifecycle state is attributed to. Disjoint files, and they compose in the right direction: #11070's split-row fallback is the pane's own prompt keyed by paneKey, which is only correct once that paneKey is the right pane. #11070 is still OPEN.

Known limit — reasoned from source, not reproduced

Step 3 (the identity-based creation-order resolution) is deliberately gated on id density: liveSlotsAreDense requires the live slot ids to be exactly FIRST_PANE_ID + index and to match the leaf count. That gate exists because PaneManager.nextPaneId is a monotonic counter that never rewinds (pane-manager.ts:71,430), so an in-session pane close leaves survivors sparse, where creation order would answer confidently and wrongly.

The consequence is that the in-session-close case is not fixed by this PR. A sparse id set falls to step 5 — positional index within the live slots only — which is today's behaviour, preserved on purpose. The PR's own test 4 (closed split pane) passes both with and without the fix and is stated as a guard on that preserved path, not as evidence of a fix. When resolution fails outright the title is dropped (if (!leafId) continue), so the row silently disappears rather than showing wrong data.

The underlying resolver is still positional: resolveRuntimePaneTitleLeafIdFromRoot returns leafIds[numericPaneId - FIRST_PANE_ID] (src/renderer/src/lib/runtime-pane-title-leaf-id.ts:110-122). This PR's density gate is what keeps that resolver off the sparse case for the sidebar. The other production consumer, worktree-status.ts:71, calls it ungated and is not changed here — so the positional-index exposure remains on that path. None of this was reproduced live; it is read from source.

Validation — what was and was not done.

Done: 4 new tests in a split-pane runtime title attribution block asserting externally visible projection only (paneKey, agentType, lifecycle state, row count). Red/green oracle run properly — reverting only worktree-title-derived-agent-rows.ts while keeping the tests gives 3 failed / 18 passed; tests 1–3 fail, test 4 passes both ways as noted above. Restored: 21/21. Regression scope: the three owning suites 79/79; every other consumer of the shared resolver 84 passed. check:code-quality:changed clean on all three gates across 2 files; no max-lines suppression.

Not done — be explicit about this:

  • No visual proof was captured. The PR says so plainly rather than implying otherwise. Capturing it needs a parked split tab holding two hookless agent panes in a specific mixed lifecycle state, then revealed — a rig that was not stood up. The before/after blocks are real output from driving the row builder against constructed store states, not a run of the packaged app.
  • Written and run on macOS only. Nothing platform-dependent is introduced (integer/string id resolution over an in-memory layout tree), but Windows, Linux, and a live SSH host were not exercised — which is worth noting given that hookless SSH agents (Codex, OpenCode) are the population most exposed to this bug, since their rows are title-derived.
  • Full pnpm typecheck not run (OOM risk); the type-aware gate covers the two changed files.
  • Running the full sidebar + dashboard directories surfaces WorktreeCard.* render timeouts. These were checked in both directions — WorktreeCard.pr-display.test.tsx fails 2 tests on the pre-fix source and 1 with the change applied — so it is a pre-existing timing flake, not caused here. Nothing in the agent-row path fails.

Merge-collision check, with a caveat. git merge-tree --write-tree was run against #14486, #14682, and #14654, all clean, and the #14486 merge was materialized into a scratch worktree and the agent-row suites actually run on the merged tree (79 passed) rather than stopping at the mergeable flag. Since then #14486 has merged to main, and this branch does not contain it — the branch is 141 behind. The materialized run is still the relevant evidence, but it was taken against #14486's head at the time, not against current main. A rebase before merge would put that back on solid ground.

@brennanb2025
brennanb2025 force-pushed the brennanb2025/split-pane-identity-3264 branch from 3992533 to 95cdff9 Compare August 17, 2026 22:47

@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

🧹 Nitpick comments (1)
src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts (1)

283-307: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Condense the slot-mapping comments.

The two docblocks repeat the same ID-space context and describe implementation details. Keep one short comment per helper that states its rule.

As per coding guidelines, “Comments must be concise, non-obvious, and brief—prefer one line.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ab1f7bdf-b914-47e5-8dd7-df02a66f515b

📥 Commits

Reviewing files that changed from the base of the PR and between 1412ae2 and 95cdff9.

📒 Files selected for processing (2)
  • src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts
  • src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts

Included review availability: Your plan includes up to 10 reviews per rolling hour; 4 remain after this review.

Comment on lines +73 to +92
? Object.entries(paneTitles).sort(compareRuntimePaneTitleSlots)
: []

if (paneTitleEntries.length > 0) {
// Why: hoisted per tab — the leaf lists are layout-derived, not pane-derived.
const leafIds = collectLeafIds(layout?.root ?? null)
const liveSlotIds = paneTitleEntries
.map(([paneId]) => Number(paneId))
.filter((paneId) => paneId >= FIRST_PANE_ID)
// Why: pane ids only encode creation order while they are the dense sequence a
// fresh mount or replay allocates; an in-session pane close leaves them sparse.
const liveSlotsAreDense =
liveSlotIds.length === leafIds.length &&
liveSlotIds.every((paneId, index) => paneId === FIRST_PANE_ID + index)
for (const [paneId, title] of paneTitleEntries) {
const leafId = resolveLeafIdForTitleFallback({
layout,
paneTitleEntries,
leafIds,
liveSlotIds,
liveSlotsAreDense,

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Claim the leaf when a live slot resolves.

Sorting only changes processing order. If a live title produces no row, Lines 107-111 do not mark its pane key as seen. A stale parked title for the same leaf can then create an incorrect agent row.

Track resolved live leaf IDs before title classification. Skip parked slots for those leaf IDs. Add a regression case with a plain live title and a stale parked working title.

Proposed fix
+      const liveLeafIds = new Set<string>()
       for (const [paneId, title] of paneTitleEntries) {
+        const numericPaneId = Number(paneId)
         const leafId = resolveLeafIdForTitleFallback({
           layout,
           leafIds,
           liveSlotIds,
           liveSlotsAreDense,
-          paneId: Number(paneId),
+          paneId: numericPaneId,
           title
         })
         if (!leafId) {
           continue
         }
+        const isLiveSlot = numericPaneId >= FIRST_PANE_ID
+        if (!isLiveSlot && liveLeafIds.has(leafId)) {
+          continue
+        }
+        if (isLiveSlot) {
+          liveLeafIds.add(leafId)
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
? Object.entries(paneTitles).sort(compareRuntimePaneTitleSlots)
: []
if (paneTitleEntries.length > 0) {
// Why: hoisted per tab — the leaf lists are layout-derived, not pane-derived.
const leafIds = collectLeafIds(layout?.root ?? null)
const liveSlotIds = paneTitleEntries
.map(([paneId]) => Number(paneId))
.filter((paneId) => paneId >= FIRST_PANE_ID)
// Why: pane ids only encode creation order while they are the dense sequence a
// fresh mount or replay allocates; an in-session pane close leaves them sparse.
const liveSlotsAreDense =
liveSlotIds.length === leafIds.length &&
liveSlotIds.every((paneId, index) => paneId === FIRST_PANE_ID + index)
for (const [paneId, title] of paneTitleEntries) {
const leafId = resolveLeafIdForTitleFallback({
layout,
paneTitleEntries,
leafIds,
liveSlotIds,
liveSlotsAreDense,
? Object.entries(paneTitles).sort(compareRuntimePaneTitleSlots)
: []
if (paneTitleEntries.length > 0) {
// Why: hoisted per tab — the leaf lists are layout-derived, not pane-derived.
const leafIds = collectLeafIds(layout?.root ?? null)
const liveSlotIds = paneTitleEntries
.map(([paneId]) => Number(paneId))
.filter((paneId) => paneId >= FIRST_PANE_ID)
// Why: pane ids only encode creation order while they are the dense sequence a
// fresh mount or replay allocates; an in-session pane close leaves them sparse.
const liveSlotsAreDense =
liveSlotIds.length === leafIds.length &&
liveSlotIds.every((paneId, index) => paneId === FIRST_PANE_ID + index)
const liveLeafIds = new Set<string>()
for (const [paneId, title] of paneTitleEntries) {
const numericPaneId = Number(paneId)
const leafId = resolveLeafIdForTitleFallback({
layout,
leafIds,
liveSlotIds,
liveSlotsAreDense,
paneId: numericPaneId,
title
})
if (!leafId) {
continue
}
const isLiveSlot = numericPaneId >= FIRST_PANE_ID
if (!isLiveSlot && liveLeafIds.has(leafId)) {
continue
}
if (isLiveSlot) {
liveLeafIds.add(leafId)
}

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Live validation on the packaged-equivalent dev app (closes the visual-proof gap)

Rebased onto current main and validated the rebased tree in a running Orca instance. The PR previously stated the before/after blocks were "real output from driving the row builder against constructed store states, not a run of the packaged app". That gap is now closed: everything below is a real app, real PTYs, real OSC title bytes, and a real SSH host.

Rebase

Branch was 146 commits behind. Rebased onto origin/main (c4e188a25f), no conflicts, force-pushed with an explicit lease → new head 95cdff9019. git merge-tree --write-tree origin/main HEAD is clean.

Checked for the split/hoist trap before trusting anything: worktree-title-derived-agent-rows.ts is byte-identical between the branch base and current main, and the render chain is intact — WorktreeCardAgents.tsxuseWorktreeAgentRowsbuildWorktreeAgentRowsbuildTitleDerivedAgentRows. The changed function is still the one the sidebar renders through.

Red/green oracle re-run after the rebase: reverting only the production file gives 3 failed | 18 passed; restored gives 21/21. Tests 1–3 fail, test 4 passes both ways, exactly as the PR states.

Rig

Isolated dev instance (own ORCA_DEV_USER_DATA_PATH, own CDP port, ORCA_AGENT_HOOK_ENDPOINT/PORT/TOKEN blanked in the child env). Verified empirically that hook posts could not reach the real app: a spawned pane's ORCA_AGENT_HOOK_ENDPOINT resolved to /tmp/val14702/userdata/agent-hooks/…, i.e. this instance's own profile.

Panes run a real process that repaints its OSC 0 title from a control file — the same byte sequence a hookless TUI agent emits, with no hook posts. That is precisely the affected population: agents whose rows are title-derived.

Before/after is a true one-line diff on one live instance — the fix is reverted in place, Vite HMR reloads it, and the same panes/state are re-measured. Each measurement re-fetches the served source and asserts which version is running (servedSourceHasFix), so an unapplied HMR cannot be mistaken for "no difference" (it caught exactly that once).

1. Local: three panes, nested split — the reachable divergent case

Split once, then split the first pane. Traversal order [1, 3, 2], creation order [1, 2, 3]. All panes live and mounted, no parking involved.

pane (visible banner) leaf pre-fix row says post-fix row says
PANE A — Antigravity, idle 59d0919d antigravity / idle antigravity / idle
PANE B — Codex, working 2522ba8a gemini / idle codex / working
PANE C — Gemini, idle 2d238208 codex / working gemini / idle

Siblings inherit each other's agent identity and state pre-fix; correct post-fix.

User-visible consequence. With focus parked on PANE A, clicking the sidebar row that shows the Codex spinner:

  • pre-fix → focus lands on PANE C, the idle Gemini pane
  • post-fix → focus lands on PANE B, the pane that is actually running

BEFORE-final.png
AFTER-final.png

2. SSH — the population this PR names as most exposed

Ran for real, on a throwaway ubuntu:22.04 container as an SSH target: real ssh.connect, real relay (build:relay + node-pty native build on the host), remotePlatform: linux. Three hookless agents in one remote split tab, mixed lifecycle, titles arriving as OSC bytes over the relay ({1: "⠋ Codex", 2: "OpenCode", 3: "✦ Gemini CLI"}).

remote pane pre-fix row says post-fix row says
PANE 1 — Codex, working codex / working codex / working
PANE 2 — OpenCode, idle gemini / working opencode / idle
PANE 3 — Gemini, working opencode / idle gemini / working

Same defect, same fix, over a real remote host.

SSH-AFTER.png

3. The known limit is real, and it is a live mis-attribution — not just an untested path

Reproduced the in-session-close case live: split to three panes, close pane 2, split again. Live ids stay sparse ({1, 3, 4}), so liveSlotsAreDense is false and resolution falls to the indexOf path. With this PR applied, the rows are still wrong:

pane leaf post-fix row says
PANE D — OpenCode, working 2b4deef5 gemini / idle
PANE C — Gemini, idle 45626cac opencode / working

LIMIT-sparse-misattribution.png

So the limit is stated correctly in substance — the sparse case never reaches the creation-order resolver and keeps today's positional index — but it is worth stating more plainly in the PR: this is not merely "no regression", it is a surviving mis-attribution of the same class the PR fixes, reachable by an ordinary split → close → split. In this configuration the failure mode is a swap, not a dropped row; a row would only vanish where the live-slot count exceeds the leaf count.

What is still NOT proven

  • The parked / revealed path (tests 1 and 2) was not reproduced. I got a tab genuinely parked (pane manager gone, parked watchers owning it) but never observed a negative title slot. resolveParkedTerminalPaneCandidates prefers capturedPanesByTabId, so a tab that has mounted at least once keeps reusing its positive ids while parked — a title changed while parked landed in slot 1, not -1. The -(leafIndex + 1) space needs parked watchers running with no capture, which is an in-memory map that only empties on app restart; and on restart the local PTY processes are reaped, so nothing was emitting titles for a never-mounted tab. I could not construct the four-slot {-1,-2,1,2} state on which the PR's headline "both rows Running" before/after rests. It may still be reachable (an SSH/daemon-backed pty surviving an app restart is the obvious candidate) — I am reporting it as unverified, not as refuted.
  • The literal "two rows, two different names, swapped" screenshot is not obtainable, and I want to be precise about why rather than imply the shots show something they do not. A runtime title carries the agent name and its lifecycle state together, so moving a title to the wrong leaf moves both. The rendered multiset of (name, state) pairs is therefore identical pre- and post-fix; what changes is which pane each row is bound to. That is why the evidence above is the leaf→row projection plus the click-to-focus behaviour, and why the screenshots differ in row order and per-row icon, not in the name text.
  • Separately: in the local three-pane shots all rows render the same name (the tab title). That is STA-2811 / [Bug]: Split panes in one tab share a single sidebar agent-row name that flips to whichever pane was clicked last #11069, which this PR explicitly does not fix, and it is why the row icons rather than the row text carry per-row identity in those images. Per-row identity is correct in the accessible summary (Antigravity idle; Gemini idle; Codex working).
  • worktree-status.ts calls resolveRuntimePaneTitleLeafIdFromRoot directly with no density gate and is untouched here, so it can still resolve a sparse id to the wrong leaf. Its leafId is only used to skip panes already covered by an agent-status row, so the blast radius is a worktree status heuristic rather than a row identity — but it is the same positional assumption, unfixed.
  • Windows and Linux desktop still not exercised. pnpm typecheck still deferred to CI.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant