Skip to content

fix(sidebar): retire the unpublished-title fallback that minted an agent row and both dots (STA-2926) - #14708

Open
brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/split-row-recycle-2926b
Open

brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/split-row-recycle-2926b

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

What this actually is — read this first

This is not a live user-facing fix, and the original framing of it was wrong. Live validation established that the symptom STA-2926 describes — closing a split pane and having its agent row re-point at the survivor — no longer reproduces on current main, in either arm. resolveTabTitleAfterPaneClose (terminal-pane-close-identity.ts:12, from #8569) already resets a tab to its stable fallback on pane close, with a comment describing exactly this case. App restart doesn't preserve it either.

What this PR does is retire a branch that is wrong whenever it is entered, and stop the dot from outliving the row. The removed fallback minted an agent row and both dots from a tab.title that no pane was publishing — verified live:

shape before after
tab.title only, runtimePaneTitles = {} rows 1, worktree dot working, tab dot working rows 0, both dots active
same title published on a pane rows 1, working, working unchanged

Why the dot half is in here too, and why it is not optional. Removing only the row fallback would have left the dot still reading stale tab.title — producing a dot spinning "working" forever over zero agent rows, which is #9040. CI caught that, and the pre-fix half of that state was observed directly during validation. The two halves are one change; shipping the row half alone reintroduces a fixed bug.

Verified not to under-report: a plain shell with no agent, including a surviving split pane, gives rows 0 / both dots active in both arms. A live agent in a split or unsplit pane still gets its row and dots — the pane branch is untouched.

One path is untested: web-session-tabs-sync publishes host-synced tab titles and could still deliver an agent-shaped tab.title with empty runtime pane titles. That is the one place the precondition might still arise, and it was not exercised.

Original framing, kept for the record

The before: table further down comes from executing the pre-fix row builder against constructed store states, not from a running app. It shows what the branch does when entered — which is real — but should not be read as a captured live repro of the ticket.

What Changed

One branch removed from buildTitleDerivedAgentRows: the tab.title fallback that ran when a tab had no runtime pane titles at all.

// removed
const leafId = layout?.activeLeafId ?? collectLeafIds(layout?.root ?? null)[0]
const row = buildTitleDerivedAgentRow({ tab, leafId, title: tab.title, ... })

The matching branch is removed from the worktree dot in worktree-status.ts — which the tab-bar dot also resolves through — see Scope expanded beyond the ticket below for why that is in this PR.

A title-derived row is now minted only from runtimePaneTitlesByTabId[tabId] — the pane-scoped title channel. Everything else in the file is untouched: identity resolution, the Claude token guard, the owner fallback, the Cursor/claude agents carve-outs, the leaf resolver.

Scope expanded beyond the ticket

This PR now also changes the worktree card DOT, not just the sidebar row. That was not in STA-2926's scope.

And the tab-bar dot with it. resolveTerminalTabActivityStatus (terminal-tab-activity-status.ts:151) delegates straight to resolveWorktreeStatus"Reuse the WorktreeCard status vocabulary and resolver so the tab's live states resolve identically to the sidebar." One resolver, so the dot rule lands on both surfaces at once. That is the correct outcome (the tab-bar dot had the same "spinning over zero rows" hole), but it is a second surface changed, so it is stated here rather than left to the diff. It cost three more rewritten tests and one new one, listed below.

It was done because the alternative was shipping a regression, and CI caught it. worktree-status-spinner-launch-agent.test.ts > #9040 spinner attribution matches named-provider dot/row agreement failed on the row-only change — and it is not a stale expectation. #9040 and STA-2926 feed structurally identical inputs (single-leaf layout, runtimePaneTitles = {}, one live PTY, an agent-identifying tab.title) and demand opposite outputs. Every candidate discriminator was checked — launchAgent present/absent, spinner vs. named identity, PTY count, leaf count, layout.titlesByLeafId — and both suites cover both sides of each. No narrowing guard can separate them.

Why the two halves cannot be allowed to disagree is written into the source. worktree-status.ts:106-109 says its spinner fallback exists specifically to mirror "the row builder's spinner fallback (#9647) so the dot and the sidebar row agree" — the exact fallback the row half removes. Removing one side and keeping the other is not minimal scope; it is a half-applied change that breaks the agreement that comment describes, leaving the dot spinning working over zero sidebar rows. That is precisely the "0 agents" symptom #9040 was filed to fix.

So the dot gets the same rule: with no pane publishing a title, there is nothing to attribute.

#9040's real feature survives — this fixes it rather than reverting it. Spinner attribution runs through the pane-title branch, which is untouched: spins for a spinner-only pane title when the tab was launched as claude passes unchanged before and after. What is removed is only the tab-title variant, reachable only once a pane has stopped publishing.

Named-provider tab titles: intended, not collateral

The row half also stops minting rows from named-provider tab titles (claude [working]), not only stale spinners. This is intended, and the guard is deliberately not narrowed.

The defect is attribution, not title content. tab.title carries no pane provenance at all; the removed branch assigned it to layout.activeLeafId ?? firstLeaf — a guess at which leaf owned it. That guess is exactly as unfounded for claude [working] as for ⠋ implementing the feature: the title's specificity changes what the row would say, not which pane it belongs to. The PR's own test shows the named case is a live bug — ✳ Claude Code recycled onto both a promoted sibling and a replacement session.

Named providers lose nothing while they are alive. onTitleChange writes setRuntimePaneTitle for every pane (pane 0 of an unsplit tab included) and only then mirrors the focused pane into tab.title, so a live agent always occupies a pane slot and is rowed by the pane branch. Empty slots plus an agent-shaped tab.title is, by construction, the cleared/stale state. The rewritten control assertion pins this: claude [working] published on a pane still produces both a spinning dot and exactly one row.

The 12 rewritten tests

Each keeps its original assertion; the status source moves from tab.title to the pane-title channel that actually feeds the heuristic in production. None was weakened to pass.

worktree-status.test.ts

  1. prioritizes permission over other live activity states — assertion unchanged (permission beats working + browser presence); the two titles are now published on panes.
  2. still spins on an agent-attributable braille-spinner pane title — renamed; same working assertion, via the pane channel.
  3. lets heuristic working beat hasLiveDone (newer in-progress signal wins) — assertion unchanged; added runtimePaneTitlesByTabId.
  4. honors heuristic permission over hasLiveDone (priority: permission > done) — assertion unchanged; added runtimePaneTitlesByTabId.
  5. newdoes not spin on an agent title no pane is publishing — pins the new contract directly.

WorktreeCard.test.ts

  1. keeps terminal agent states higher priority than browser presence — both assertions unchanged (permission and working each beat browser presence); titles published on panes.

worktree-status-spinner-launch-agent.test.ts (#9040)

  1. spins for a Claude spinner title when the tab was launched as claude → replaced by does not spin for a Claude spinner tab title no pane is publishing, asserting the new contract. Its positive twin (spins for a spinner-only pane title…) already existed and still passes, so [Bug] No processing indicators #9040's feature keeps direct coverage.
  2. over-reports a non-agent spinner in a tab an agent was launched in — the documented fix(sidebar): attribute spinner-only pane titles to the launched agent #9647 trade-off, re-pinned on the pane channel where it is still real; both bounds intact.
  3. produces a sidebar row alongside the dot, like a named provider does — the invariant test, now asserting dot/row agreement through the pane channel for both the spinner and the named provider.
  4. newmoves neither the dot nor a row when no pane publishes a title — the same invariant in the negative direction, for both title shapes.

Two further tests in that file — stays active for a spinner title with no launch identity and does not manufacture activity from a non-spinner title with a launch identity — still passed after the change, but only because everything without pane titles now returns active. They had gone vacuous, asserting nothing about the #9647 gate they exist to pin, so they were moved to the pane channel as well. They are not counted among the failures.

terminal-tab-spinner-launch-agent.test.ts (#9040, tab-bar dot)

  1. reports working for a Claude spinner pane title on a live tab — renamed; same working assertion, via the pane channel.
  2. reports working for a named-provider pane title — the parity control, same assertion, via the pane channel.
  3. newdoes not report working for an agent title no pane is publishing — the negative contract on the tab-bar surface, for both the spinner and the named provider.

stays out of working for a spinner pane title with no launch identity was also moved to the pane channel for the same vacuity reason as the two sidebar cases above.

terminal-tab-activity-status.test.ts

  1. falls back to a live working title when hook status is stale — assertion unchanged (working beats a >30m-stale done hook); the title is now published on a pane, which is what "live" means after this change.
  2. does not revive an unconfirmed restored row from its preserved title — assertion unchanged (active); still load-bearing, since without the restoredUnconfirmed suppression the published pane title would classify as working.
  3. reports a live shell with no agent as active (no activity glyph) — assertion unchanged; zsh published on a pane so it reaches the classifier instead of stopping at the new no-pane-titles guard.

Nothing was deleted. Every case above kept its original assertion; none was weakened into vacuity, so no test lost its reason to exist.

Mutation-proved, not assumed. Three independent mutations, each run against the rebased tree:

Mutation Result
Restore the old tab.title fallback in worktree-status.ts 2 failed | 36 passeddoes not spin on an agent title no pane is publishing and its tab-bar twin does not report working for an agent title no pane is publishing
Neutralize the #9040 spinner gate (titleStatusIsAgentAttributablefalse) 4 failed | 7 passed — including spins for a spinner-only pane title when the tab was launched as claude and reports working for a Claude spinner pane title on a live tab
Revert the row-builder fix STA-2926's own two tests and the two-way invariant test go red

The second mutation is the direct evidence for #9040's feature surviving on both dots: those cases are only green because the spinner→launch-identity attribution still runs, through the pane-title branch this PR does not touch. Option B fixes #9040 rather than reverting it.

Why

Two title channels, only one of which knows about panes.

runtimePaneTitlesByTabId[tabId][paneId] tab.title
Scope one pane the tab
Written every OSC frame, per pane (onTitleChange) only by the focused pane ("otherwise two agents in split panes cause rapid title flickering")
Cleared when its pane dies yesclearRuntimePaneTitle on PTY exit (pty-connection.ts), on pane close (use-terminal-pane-lifecycle.ts), and on parked-pane exit (shouldDeferParkedPtyExitTabClose) no

So the removed branch ran precisely when every pane's live title had been cleared, and the string it read was, by construction, produced by a pane that is no longer publishing. It then attributed that string to layout.activeLeafId — the pane that survived the close. That is the recycle.

The intended reset already exists and silently no-ops. onPaneClosed does try to fix this at the source:

updateTabTitle(tabId, resolveTabTitleAfterPaneClose(paneTitles, newActivePane.id))
// "an empty update resets the tab to its stable fallback instead of
//  leaving the closed pane's agent title attached to an untitled survivor"

but applyTerminalTabTitleUpdates resolves the empty string through getFallbackTabTitle, whose last resort is tab.title itself:

tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || tab.defaultTitle?.trim() || tab.title || 'Terminal 1'

A tab with no custom title, no quick-command label and no defaultTitle (defaultTitle is only ever stamped from a Terminal N title, so a tab that never wore one has none) gets its stale title handed straight back, currentTab.title === nextTitle, no update. The reset is a no-op exactly when it is needed.

tab.title is unfixable in place. There is no signal in this function that separates "this tab title belongs to the survivor" from "it belongs to the pane that just closed" — the tab title carries no pane provenance at all. So the only correct action is to stop minting a pane row from it.

Symmetry with the sweep's other two identity fixes

Relationship to #14702 (same file)

#14702 fixed the other branch of this function — which leaf a live pane title resolves to (STA-3264) — and explicitly left this one open:

STA-2926 / #11372 — 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.

This is that branch. The two are independent and land in either order — see the merge note under Testing. Nothing in #14702's leaf-resolution work is re-done here.

Not #11069 / STA-2811 (split rows sharing one conversation name) — that is getAgentRowConversationName, a different file, addressed by #11070.

No PR is superseded. #11372 is an issue, not a PR; 11372 in:body and STA-2926 in:body both return nothing open.

Linked Issue

Fixes #11372 (STA-2926)

Visual Proof

Now captured live. An isolated Orca dev instance running this branch (own ORCA_DEV_USER_DATA_PATH, own CDP port, getIdentity() verified per arm, never the instance the user is running), with a real claude session in a real split pane. Arms differ only by git checkout origin/main -- on the two production files, with a full relaunch per armelectron-vite dev does not rebuild the main process, so HMR would have measured the same build twice.

Full log: sta2926-live-validation.txt · postfix-live.png

All three changed surfaces were read from the live renderer store on every sample — buildWorktreeAgentRows (rows), resolveWorktreeStatus (worktree-card dot), resolveTerminalTabActivityStatus (tab-bar dot).

The removed branch, when reached. Same tab, same title, launchAgent: 'claude', one live PTY, single-leaf layout; the only difference is whether a pane is publishing:

shape pre-fix post-fix
tab.title only, runtimePaneTitles = {} rows=1, worktree dot=working, tab dot=working rows=0, both dots active
same title published on a pane rows=1, working, working rows=1, working, working — unchanged

So the branch really did mint a row and both dots from a title no pane was publishing, and after the fix all three go silent together. That is the dot/row agreement the scope expansion exists to preserve: no state was found in which a dot spins working over zero rows.

Live scenarios, all run in both arms:

  • Agent live and focused, plain-shell sibling — rows=1 (claude), both dots agree. No under-report.
  • Braille spinner published on a pane, launchAgent: 'claude' — rows=1 working, both dots working. Identical in both arms; the untouched pane branch is unaffected.
  • Plain shell with no agent, including a surviving split pane — rows=0, both dots active. Removing the fallback does not under-report a pane that has no agent.

Reachability — stated plainly, because it cuts against the ticket's story

I could not reach the removed branch through ordinary user actions on this tree:

  • Closing the split pane (the exact repro) ends identically in both arms: runtimePaneTitles → {}, tab.title → "Terminal N", rows=0, both dots active. No recycled row in either arm. The cause is already on origin/main: resolveTabTitleAfterPaneClose (terminal-pane-close-identity.ts:12, from fix(native-chat): target native chat to the active split leaf #8569) resets the tab to its stable fallback rather than "leaving the closed pane's agent title attached to an untitled survivor."
  • Restarting the app does not preserve the shape either — tab.title reverts to its defaultTitle, and the panes republish. Sampled every 2s for 40s across the boot window in both arms: rows and both dots agreed at every sample.

So on current main the branch this PR deletes looks dead for the paths the ticket describes. That makes the change safe — a dead-branch removal that also restores dot/row symmetry — but it means the recycle itself was not live-reproducible here, and the before: outputs below come from executing the pre-fix builder against constructed states rather than from a running app.

Not exhausted: the remote/SSH path. web-session-tabs-sync publishes host-synced tab titles, which could still deliver an agent-shaped tab.title with empty runtime pane titles; that was not tested.

The constructed-state outputs previously quoted here remain accurate for what the pre-fix builder does when the branch is reached — real assertion output from the pre-fix source, and now corroborated by the live pre-fix arm above:

before:  tab-1:LEAF_ID_1   claude   working   "Running"   <- pane that never ran Claude
after:   (no row)

title "⠋ Codex"                 before: codex  working    after: (no row)
title "✳ refactor the resolver" before: codex  idle       after: (no row)
title "⏸ Gemini CLI"            before: gemini idle       after: (no row)   <- agent the tab never launched

Testing

  • I manually tested these changes locally — live in an isolated dev instance with a real claude session, both arms, all three surfaces; see Visual Proof. Constructed store states were used additionally, to reach the branch that ordinary actions no longer reach.
  • Automated tests added/updated

New testsworktree-title-derived-agent-rows.test.ts, new closed split pane block, asserting externally visible projection only (paneKey, agentType, state):

  1. does not recycle a closed pane row onto the sibling or a replacement session — survivor is the promoted sibling, and separately a pane opened after the close; both expect zero rows.
  2. does not synthesize a row from a stale spinner or identity tab title — the three titles above, including the wrong-agent case.
  3. keeps the surviving pane on its own identity rather than the closed pane title — survivor publishes ⠋ Codex while tab.title still says ✳ Claude Code; the row must be Codex on the survivor's leaf.
  4. still rows a live pane that publishes its own runtime title — the visibility guard.

Red/green oracle. Reverting only worktree-title-derived-agent-rows.ts and keeping the tests: 2 failed | 19 passed. Tests 1 and 2 fail; 3 and 4 pass both ways and are stated as guards on the pane-scoped path this PR preserves, not as evidence of the fix. Restored: 21/21. Each of the three titles in test 2 was additionally verified to recycle pre-fix individually (a loop stops at its first failure), which is where the before: table above comes from.

Is the removed branch load-bearing? Checked before writing anything: with the fallback neutralized outright, src/renderer/src/components/sidebar + src/renderer/src/components/dashboard ran 295 files / 2640 tests, all passing — no existing test depended on it. Both consumers of buildWorktreeAgentRows (useWorktreeAgentRows, build-dashboard-snapshot) are inside that scope.

Regression scope (re-run on the rebased tree).

  • sidebar + lib/worktree-status* + tab-bar + dashboard: 358 files / 3068 tests passed.
  • oxlint and React Doctor across all 8 changed files: clean. Proved the gate was live rather than silently dead by appending a debugger/loose-equality probe to worktree-status.ts — it reported eslint(no-debugger) — then reverting. No max-lines suppression added. oxfmt --check clean on all 8.
  • Full pnpm typecheck deliberately not run (OOM risk on this host, per the sweep constraint). The only type surface touched is the deletion of one call site plus test-fixture fields.

CI red → green, cause by cause

Check Cause State
tests node 24/26 · 2/16terminal-tab-spinner-launch-agent.test.ts, 2 failures Real. The tab-bar dot shares resolveWorktreeStatus, so the dot fix reached it and two tab.title-fed cases went red. Fixed — moved to the pane channel, assertions unchanged; new negative case added.
tests node 24/26 · 8/16terminal-tab-activity-status.test.ts, 1 failure Same cause: falls back to a live working title when hook status is stale fed its title through tab.title. Fixed — same treatment.
verify Aggregate only; its inputs were TEST: failure, everything else success. Fixed by the above.
test vs non-test LoC Not a ratio failure. .github/workflows/pr-test-loc.yml fetches .github/scripts/pr-test-loc-{table,summary}.mjs from pull/14708/head; the branch predated those scripts, so gh api returned 404 and base64 --decode failed. The job has no threshold at all — pr-test-loc-summary.mjs only counts and PATCHes this body. Fixed by rebasing onto main (82 commits), which brings the scripts onto the head ref. No code change could have fixed it.

Rebase safety. git merge-tree --write-tree origin/main HEAD was clean before rebasing, and git log HEAD..origin/main -- <the 8 files> was emptymain had not touched any file in this PR, so the rebase is mechanical. All three commits replayed without conflict; the full scoped suite above was re-run afterwards, and all three mutation proofs were re-run on the rebased tree.

Merge-collision check — run locally with git merge-tree --write-tree, not read off GitHub's mergeable flag (independently verified stale during this sweep). Against each in-flight PR's head SHA:

PR Result
#14486 (119-file sidebar reorg) clean
#14654, #14682, #14650, #14615 clean
#14702 conflicts — same function, same test file

I did not stop at the flags:

Platforms. Written and run on macOS. Nothing platform-dependent is introduced or removed: no shortcuts, modifier keys, shortcut labels, path construction, shell invocation or Electron platform APIs. Windows, Linux and a live SSH host were not exercised.

AI Disclosure

Claude Opus 5 (Claude Code), on macOS.

Review

  • Security. No new surface. No command execution, path handling, auth, secrets, IPC channel, dependency, persisted state or schema change. Strictly a removal in renderer-local row derivation.
  • Cross-platform. No process.platform / navigator.userAgent branching added or removed; nothing OS-specific to diverge.
  • Remote SSH. This is the path SSH cares about most — 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. Their live titles arrive through setRuntimePaneTitle exactly like local panes, so the surviving branch covers them unchanged; what is removed is a source that could only ever fire once a pane had stopped publishing. No probing, no Git commands, no local-only assumptions. Identical for folder workspaces and git worktrees — nothing reads git state.
  • Mobile / remote wire. No wire change: no RPC params, no stream opcodes, nothing new published to a paired client.
  • Backwards compatibility. Nothing persisted, no migration, no downgrade hazard.
  • Behavior removed, stated plainly. A tab with a live PTY, an agent-shaped tab.title, and zero runtime pane titles no longer gets a row. Beyond the closed-pane case that is only reachable in the window between a pane's spawn and its first OSC title frame, where the correct display is "no pane evidence yet" rather than a persisted string. If that window ever wants a row, fix(sidebar): keep owned agent panes visible when their title carries no agent frame (#14464) #14650's owner fallback is the right mechanism for it — durable, pane-scoped for a single-leaf tab, and gated on a live PTY — not a title with no provenance.
  • Performance (STA-3354, Urgent P0 — rows must not multiply per-render status-map work). Strictly less work. One branch deleted; no status-map scan, no store subscription, no new selector, no added pass over anything. Row count can only shrink, so nothing downstream multiplies.

Checklist

  • This PR is small and focused — 2 source files, 6 test files (see Scope expanded beyond the ticket)
  • 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1272fc3e-529e-4c22-b155-f20bbe1b4c5a

📥 Commits

Reviewing files that changed from the base of the PR and between 5e189d6 and 37b4635.

📒 Files selected for processing (8)
  • src/renderer/src/components/sidebar/WorktreeCard.test.ts
  • src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts
  • src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts
  • src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts
  • src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts
  • src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts
  • src/renderer/src/lib/worktree-status.test.ts
  • src/renderer/src/lib/worktree-status.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/renderer/src/components/sidebar/WorktreeCard.test.ts
  • src/renderer/src/lib/worktree-status.test.ts
  • src/renderer/src/lib/worktree-status.ts
  • src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts
  • 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; 3 remain after this review.


📝 Walkthrough

Walkthrough

buildTitleDerivedAgentRows no longer creates rows from the tab-level tab.title. It now uses resolved live pane-title entries. tabHasStatus also requires runtime pane-title data. Tests cover replacement panes, stale closed-pane titles, spinner and identity titles, live pane identity, status classification, and row agreement.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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 identifies removal of the unpublished-title fallback and its impact on agent rows and status dots.
Description check ✅ Passed The description covers the change, rationale, linked issue, visual proof, testing, risks, AI disclosure, and checklist with sufficient detail.
✨ Finishing Touches 💡 1
📝 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.

@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.

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

74-81: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce the inline comment detail.

Keep only the pane-provenance reason near the production logic. Remove the test-history explanation because the describe and test names state the scenario.

  • src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts#L74-L81: replace the multi-line history with one concise comment about using only pane-scoped runtime titles.
  • src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts#L387-L391: remove or reduce the scenario-history comment to one line.

As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aeaaef83-cf48-4a6e-a0e4-b3ebf4428a34

📥 Commits

Reviewing files that changed from the base of the PR and between aaa877d and fde0328.

📒 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

@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.

🧹 Nitpick comments (1)
src/renderer/src/lib/worktree-status.ts (1)

66-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shorten the added rationale comments.

Keep one concise comment only when the test name or code cannot state the reason.

  • src/renderer/src/lib/worktree-status.ts#L66-L71: replace the six-line rationale with one sentence that states the live-pane provenance rule.
  • src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts#L48-L50: remove or reduce the comment to one sentence.
  • src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts#L61-L63: remove the explanation that repeats the test name.
  • src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts#L149-L152: reduce the dot-row agreement explanation to one sentence.
  • src/renderer/src/lib/worktree-status.test.ts#L146-L148: reduce the stale-title explanation to one sentence.

As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0be415fc-bdab-404f-bd0a-f2e7d090a4a3

📥 Commits

Reviewing files that changed from the base of the PR and between fde0328 and 334b66d.

📒 Files selected for processing (4)
  • src/renderer/src/components/sidebar/WorktreeCard.test.ts
  • src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts
  • src/renderer/src/lib/worktree-status.test.ts
  • src/renderer/src/lib/worktree-status.ts

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

…STA-2926)

The row half of this PR stopped `tab.title` from synthesizing an agent row once
every pane's runtime title slot is cleared. The dot half still read that same
title, so a closed agent pane left the worktree dot spinning "working" over zero
sidebar rows -- the exact "0 agents" symptom #9040 was filed to fix.

worktree-status.ts:106-109 states its spinner fallback exists to mirror the row
builder's fallback "so the dot and the sidebar row agree", so removing one side
and keeping the other breaks the agreement that comment describes. Apply the
same rule to the dot: with no pane publishing a title, there is nothing to
attribute.

#9040's real feature is unaffected -- spinner attribution runs through the
pane-title branch, which is untouched.
…STA-2926)

The tab-bar dot resolves through resolveWorktreeStatus, so the dot fix reaches it
too: a `tab.title` no pane is publishing no longer feeds the heuristic. Five cases
fed their titles through `tab.title` alone and went red or vacuous; each keeps its
original assertion and now publishes the title on a pane, which is where a live
title actually lives in production.

Adds the negative contract directly -- an agent-shaped tab title with every pane
slot cleared resolves to 'active', for both a spinner and a named provider.
Restoring the old `tab.title` fallback turns that case red.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/split-row-recycle-2926b branch from 334b66d to 37b4635 Compare August 17, 2026 01:33
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status

State: MERGEABLE / CLEAN against main. CI green — 44 successful checks, 1 skipped, 0 failing. 8 files (2 source, 6 test), +277/-134. Branch is 3 ahead / 55 behind main.

What the review loop established. Two title channels exist, and only one knows about panes. runtimePaneTitlesByTabId[tabId][paneId] is pane-scoped, written on every OSC frame, and cleared when its pane dies (PTY exit, pane close, parked-pane exit). tab.title is tab-scoped, written only by the focused pane, and never cleared. The removed branch therefore ran precisely when every pane's live title had been cleared, read a string that by construction came from a pane no longer publishing, and attributed it to layout.activeLeafId — the pane that survived the close. That is the recycle.

The loop also found why the intended reset doesn't help: onPaneClosed does call updateTabTitle(tabId, resolveTabTitleAfterPaneClose(...)), but applyTerminalTabTitleUpdates resolves the empty string through getFallbackTabTitle, whose last resort is tab.title itself. A tab with no customTitle, no quickCommandLabel and no defaultTitle gets its stale title handed straight back, so currentTab.title === nextTitle and no update fires. The reset is a no-op exactly when it is needed. tab.title carries no pane provenance at all, so there is no in-place fix — the only correct action is to stop minting a pane row from it.

Scope expanded past STA-2926 — read this before merging

This PR also changes the worktree-card dot and the tab-bar dot, neither of which was in STA-2926's scope. The tab-bar dot comes along because resolveTerminalTabActivityStatus delegates straight to resolveWorktreeStatus — one resolver, two surfaces.

The expansion is deliberate and was forced by CI, not chosen for tidiness. The row-only change failed worktree-status-spinner-launch-agent.test.ts > #9040 spinner attribution matches named-provider dot/row agreement, and that is not a stale expectation: #9040 and STA-2926 feed structurally identical inputs (single-leaf layout, runtimePaneTitles = {}, one live PTY, an agent-identifying tab.title) and demand opposite outputs. Every candidate discriminator was checked — launchAgent present/absent, spinner vs. named identity, PTY count, leaf count, layout.titlesByLeafId — and both suites cover both sides of each. The body's conclusion is that no narrowing guard can separate the two cases, and the source agrees: worktree-status.ts:106-109 says its spinner fallback exists specifically to mirror the row builder's fallback "so the dot and the sidebar row agree". Removing one side and keeping the other leaves the dot spinning working over zero sidebar rows — which is the "0 agents" symptom #9040 was filed to fix.

#9040's actual feature survives: spinner attribution runs through the pane-title branch, which is untouched. Only the tab-title variant is removed, reachable only once a pane has stopped publishing.

A second intended consequence, stated so it is not discovered later: the change also stops minting rows from named-provider tab titles (claude [working]), not only stale spinners, and the guard is deliberately not narrowed. The reasoning is that the defect is attribution rather than title content — tab.title has no pane provenance, so assigning it to activeLeafId ?? firstLeaf is exactly as unfounded for claude [working] as for a braille spinner. Named providers lose nothing while alive, because onTitleChange writes setRuntimePaneTitle for every pane (pane 0 of an unsplit tab included) before mirroring the focused pane into tab.title.

Behaviour removed, plainly: a tab with a live PTY, an agent-shaped tab.title, and zero runtime pane titles no longer gets a row or a dot. Beyond the closed-pane case that is reachable only in the window between a pane's spawn and its first OSC title frame.

Validation — what was and was not done.

Done: a red/green oracle (reverting only the row builder gives 2 failed / 19 passed; tests 3 and 4 pass both ways and are stated as guards, not evidence). Three independent mutations run against the rebased tree — restoring the old tab.title fallback goes red on the two new negative cases; neutralizing the #9040 spinner gate goes red on 4 cases including the ones that prove #9040's feature still works through the pane branch; reverting the row-builder fix reds STA-2926's own tests plus the two-way invariant. The removed branch was first checked to be non-load-bearing: neutralized outright, sidebar + dashboard ran 295 files / 2640 tests all passing. Regression scope on the rebased tree: 358 files / 3068 tests. oxlint/React Doctor clean on all 8 files, with the gate proven live by appending a debugger probe and confirming it reported, then reverting.

The 12 rewritten tests each keep their original assertion with the status source moved from tab.title to the pane-title channel; none was weakened. Two further tests are explicitly called out as having gone vacuous (they still passed only because everything without pane titles now returns active) and were moved to the pane channel for that reason — they are not counted among the failures.

Not done:

  • No visual proof was captured, and the body says so rather than implying otherwise. The before:/after: blocks are captured assertion output from driving buildWorktreeAgentRows directly against constructed store states — not a run of the packaged app. Staging it on screen needs a split tab with a hookless agent closed at the right moment, a rig that was not stood up.
  • macOS only. Windows, Linux and a live SSH host were not exercised. Nothing platform-dependent is introduced or removed.
  • Full pnpm typecheck not run (OOM risk); the only type surface touched is one deleted call site plus test fixtures.

Conflict with #14702 — re-verified live for this comment. I ran git merge-tree --write-tree between the two current PR heads (37b4635 and 3992533): they conflict, in both worktree-title-derived-agent-rows.ts and its test file. The body's claim holds. Neither PR is stacked on the other and both are based on main, so either can land first; whichever lands second takes the resolution the body records — keep #14702's hoisted leafIds/liveSlotIds/liveSlotsAreDense and its resolveLeafIdForTitleFallback signature, drop the paneTitleEntries.length > 0 wrapper and the tab.title block below it, keep both test blocks, delete #14702's now-duplicate inner LEAF_ID_3. That resolution was materialized and run (25/25 in the file, 295 files / 2648 tests across sidebar + dashboard). The other in-flight PRs checked (#14654, #14682, #14650, #14615, and #14486 which has since merged) were clean, and #14486 was materialized and run rather than trusted on the flag.

Supersedes nothing#11372 is an issue, not a PR, and searches for open PRs against STA-2926 return none. Does not fix #11069 / STA-2811 (split rows sharing a conversation name), which is getAgentRowConversationName in a different file.

Wire / compat: no wire change, nothing persisted, no migration. Row count can only shrink.

@brennanb2025 brennanb2025 changed the title fix(sidebar): stop a closed split pane's title from recycling its agent row (STA-2926) fix(sidebar): retire the unpublished-title fallback that minted an agent row and both dots (STA-2926) Aug 18, 2026

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

Development

Successfully merging this pull request may close these issues.

[Bug]: Title-derived agent rows recycle after split pane close [Bug] No processing indicators

1 participant