Skip to content

fix(sidebar): name each split-pane agent row from its own pane title (STA-2811) - #14707

Merged
brennanb2025 merged 1 commit into
mainfrom
brennanb2025/split-pane-row-own-name-2811
Aug 18, 2026
Merged

brennanb2025 merged 1 commit into
mainfrom
brennanb2025/split-pane-row-own-name-2811

Conversation

@brennanb2025

Copy link
Copy Markdown
Contributor

ELI5

Split a terminal tab in two, run an agent in each pane, and the sidebar shows two rows — but both rows carried the same name, and that name changed to whichever pane you clicked last. The name came from the tab's title, and a tab only ever holds the focused pane's title.

Each row now reads its own pane's title.

What Changed

getAgentRowConversationName gains an optional paneLiveTitle. Its undefined default is today's behavior, so a single-pane tab is untouched.

Both callers — the sidebar/dashboard hook useAgentRowConversationName and its pop-out-board mirror rowConversationName — now resolve it through one shared helper:

// agent-row-pane-live-title.ts
if (layout?.root?.type !== 'split') return undefined      // tab title IS the pane title
if (!leafId) return null
return resolveRuntimePaneTitleForLeaf(layout, paneTitles, leafId)

Three outcomes, and the middle one is the point:

Tab shape paneLiveTitle Row shows
one pane undefined the tab title — unchanged
split, this pane's title resolves that pane's title its own name
split, no title for this leaf null no live title, so the row keeps its own per-pane label — never a sibling's name

Tab-owned names still apply to every row in the tab. customTitle, quickCommandLabel and generatedTitle short-circuit above liveTitle; the user gave those to the whole tab and none of them flip on focus. Only the live title is pane-specific by nature, and only it is redirected. Pinned by a test.

resolveRuntimePaneTitleForLeaf is the repo's existing leaf-keyed live-title resolver (runtime-pane-title-leaf-id.ts, since #7043), already used by running-agent-targets.ts, notes-send-agent-targets.ts and active-agent-note-target.ts. This PR adds no resolver; it stops the naming path from being the one place that reads a tab-scoped title for a pane-scoped row.

Incidental: one extraction

build-dashboard-snapshot.ts sat at exactly the 300-line max-lines cap, so any addition trips lint and max-lines disables are forbidden. The self-contained subagent-grouping loop moved verbatim to dashboard-subagent-cards.ts (groupSubagentsByParentPaneKey). No behavior change; the file's existing tests cover it.

Why

PR #14702 (STA-3264, the jointly-Running sibling of this bug) located this one precisely and deliberately left it open rather than bundling:

getAgentRowConversationName reads tab.title, which carries only the FOCUSED pane title.

That is the whole mechanism, and it is a different root cause from what #14702 fixed. #14702 corrected runtime-pane-title → leaf attribution inside buildTitleDerivedAgentRows. This is the naming path: everything upstream of it is already pane-scoped (agentStatusByPaneKey, selectLiveAgentStatusEntriesForWorktree, and the row paneKey itself are all tabId:leafId), and then the last step reaches for a tab-scoped field.

pty-connection.ts keeps tab.title on the focused pane on purpose — so two agents in a split don't make the tab title flicker — and use-terminal-pane-lifecycle.ts re-syncs it on every focus change. That is exactly the "flips to whichever pane was clicked last" in the title of #11069.

Introduced by #9989, whose design note reads "row identity consistently follows tab identity". Split panes are the case where one tab holds several independent sessions.

Supersedes #11070 (@pythonstrup)

#11070's diagnosis is correct and this PR keeps it, including the call that tab-owned names must survive in a split. It is superseded on the remedy, not the analysis.

#11070 suppresses the live title in a split (tabHasSplitPanes ? '' : tab.title) and lets every split row fall back to its per-pane label. That stops the flip, but it also states as a known limit:

Orca has no per-pane live title the sidebar can read: the only live per-pane map (runtimePaneTitlesByTabId) is keyed by ephemeral numeric pane ids, while rows are keyed by tabId:leafId. Giving split rows real titles means adding a leaf-keyed live-title channel.

That channel already exists. resolveRuntimePaneTitleForLeaf(layout, paneTitles, leafId) bridges exactly those two id spaces and has since #7043. So the infrastructure #11070 deferred to a follow-up is a one-line call, and its second known limit — "hook-less split agents now fall back to their agent label... both read as duplicates" — does not have to ship. Under #11070 two Codex panes in a split both read Codex; here they read their own titles.

null (no title resolvable for this leaf) reproduces #11070's behavior exactly, so its safe case is this PR's fallback, not its ceiling.

Constraints from the surrounding cluster, checked

  • fix(sidebar): keep owned agent panes visible when their title carries no agent frame (#14464) #14650's split guard is untouched. resolveTitleDerivedPaneOwner returns an owner only for a single-leaf layout — because launchAgent is tab-scoped and a split pane must not brand its sibling. This PR does not touch worktree-title-derived-agent-rows.ts or any owner resolution.

  • STA-3354 (per-render global status-map scans) is not multiplied. The added selector returns a string primitive, so it cannot churn on identity and does not subscribe rows to title frames. Its cost per store update is:

    • single-pane tab (the common shape): one property read (layout?.root?.type) and return. No map scan, no allocation, no tree walk.
    • split tab: iterate that tab's own pane-title slots (2–3), each resolving against that tab's own layout tree (O(leaves)) — ~4 node visits for a two-way split.

    It never touches the global status map, so it adds nothing to the scan STA-3354 is about. parsePaneKey runs once per render (not per store update), matching the parsePaneKey(parentPaneKey) already in the hook. The pop-out builder reuses the worktree-scoped maps already in scope and hoists one selector call that was previously made inline.

Linked Issue

Fixes #11069

Supersedes #11070.

Not superseded, all distinct root causes in the same cluster: #14702 (STA-3264, pane-title → leaf attribution), #14650 (#14464, owner fallback for title-less panes), #14615 (STA-2069, hook status → spawn pane).

Visual Proof

Captured live on one Orca dev instance (isolated ORCA_DEV_USER_DATA_PATH, CDP + Playwright). Before and after are the same running pane: the fix was reverted in place, Vite HMR flipped the live sidebar, then it was restored — so nothing here is a rebuild or a different session.

Two Claude panes in one split tab: the left pane's title is ✳ Linear work log, the right pane's is ✳ Redis cache strategy.

BEFORE — focus on the left pane. Both rows read the left pane's name:

before-focus-a.png

BEFORE — click the right pane. Both rows rename together. This is the reported symptom:

before-focus-b.png

AFTER — same panes. Each row carries its own name:

after.png

AFTER — click the right pane. Nothing renames; the rows are unmoved:

after-focus-b.png

No layout, spacing, color, typography, or component change — docs/STYLEGUIDE.md is not engaged. The only difference is which text an already-existing row resolves.

Testing

Scoped checks only — a full pnpm typecheck OOMs on this host, so it was run against a project narrowed to the touched modules and their transitive graph (clean; it caught one real error in a new fixture, a missing expandedLeafId, which is fixed).

  • oxlint on every changed file — clean. The gate is proven live, not silent: it rejected the first version of this change with max-lines on build-dashboard-snapshot.ts, which is what forced the extraction above.
  • oxlint --config config/oxlint-react-doctor.json (pre-commit) — clean, and it does see the new useAppStore selector.
  • Scoped tsc --noEmit over the touched modules + their consumers (worktree-card-compact-agent-row.tsx, DashboardAgentRow.tsx) — clean.
  • vitest: dashboard + dashboard-popout + sidebar + shared resolver — 297 files / 2661 tests pass.
  • Added tests, each proven non-vacuous by reverting the fix (see below).
  • Manual: reproduced and verified live on an isolated macOS dev instance via CDP — see Visual Proof.

Tests added — and proven to fail without the fix. The fix was reverted in place (liveTitle back to tab.title) and the suite re-run; these four failed and then passed again on restore:

Test File
names a split pane from its own live title, not the tab title src/shared/agent-row-conversation-name.test.ts
gives each pane in one tab its own name use-agent-row-conversation-name.test.ts
does not rename the sibling row when the other pane is clicked use-agent-row-conversation-name.test.ts
lends no name to a split pane with no live title of its own use-agent-row-conversation-name.test.ts

The board half is pinned separately: dropping the paneLiveTitle argument in dashboard-card-labels.ts fails names each pane of a split tab from its own title in build-dashboard-snapshot.test.ts — so the sidebar and the pop-out board cannot silently drift apart.

Tests that must keep passing under that revert, and do — they pin unchanged behavior: leaves a single-leaf tab on its tab title, still lends a tab-owned name to every pane, keeps tab-owned names above the pane title.

agent-row-pane-live-title.test.ts additionally covers a nested split, where replay creation order [A, B, C] differs from tree order [A, C, B] — the case where any positional shortcut would silently hand a pane its sibling's title.

Merge compatibility — verified by materializing and running, not by GitHub's flags (which were stale elsewhere in this sweep):

Against git merge-tree Merged tree tested
#14682 (compact-row secondary dwell) clean ✅ 24 files / 188 tests pass
#14702 (pane-title → leaf attribution) clean ✅ 258 files / 2327 tests pass
#14654 (agent-row ordering) clean no file overlap
#14486 (119-file sidebar reorg) clean no file overlap; does not move any file touched here
#11070 conflicts (expected — superseded)

Platforms: macOS (this host). The change is a string and a tree walk — no shortcuts, accelerators, modifier keys, path construction, shell invocation, or Electron platform APIs — so there is nothing platform-dependent to diverge.

Review

  • Security — no new string reaches the UI; one existing source is redirected to a narrower one. No command execution, path handling, auth, secrets, IPC channel, dependency, persisted state, or schema change.
  • Cross-platform — no platform branching (see above). The resolver's existing Windows/UNC path-rejection cases pass unchanged.
  • Remote SSH / folder workspaces — the layout root and the runtime pane-title map are identical on local, remote-server and SSH hosts, and for folder workspaces as well as git worktrees. No Git commands, no local-only assumptions. Hook-less agents over SSH — the ones that depend on title-derived rows — are the biggest beneficiaries, since their split rows previously read as duplicates.
  • Remote wire compatibility — none engaged. Nothing crosses the client/host wire: no RPC params, no stream opcodes. The pop-out board snapshot's conversationName field is unchanged in shape and bound (boundedLabelOrUndefined still applies); only which per-pane string fills it changes, and it was already an optional field.
  • Mobilemobile/ does not import this resolver (grepped).
  • Backwards compatibility — the new parameter is optional and defaults to today's behavior, so any future caller is correct by default. root: null (a snapshot taken mid-teardown) reads as not-split and reverts to the status quo — degraded to today's behavior, never a new wrong name.
  • Performance — stated with numbers under "Constraints from the surrounding cluster" above.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots attached (same live pane, HMR before/after)
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • Scoped lint / typecheck / tests pass locally; full typecheck and build left to CI (OOM on this host)

Author

  • X / Twitter: @BrennanKB5

…(STA-2811)

Agent rows are per pane, but their conversation name came from `tab.title`,
which carries only the FOCUSED pane's title. In a split tab every row showed
one pane's name, and all of them changed when the user clicked a sibling.

Rows on a multi-pane tab now resolve their own leaf's runtime pane title via
the existing `resolveRuntimePaneTitleForLeaf`, and fall back to no live title
rather than a sibling's. Single-pane tabs pass `undefined` and are unchanged.

Extracts the subagent grouping out of build-dashboard-snapshot.ts, which was
exactly at the 300-line cap.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds runtime pane-title resolution for split terminal layouts. Dashboard row and hook logic now passes each row’s pane title to conversation-name generation. Tab-level custom, quick-command, generated, and single-pane naming behavior remains supported. Dashboard snapshot construction now supplies runtime pane titles and uses centralized subagent grouping. Tests cover layout resolution, pane-specific names, fallback behavior, precedence rules, and split-tab snapshots.

Merge Risk: 🟡 Moderate · up to 8cb5f

In split tabs, an OpenCode live title can override a generated tab-owned name for one row, causing inconsistent naming within the same tab. This is a bounded correctness issue that should be fixed before merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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
Title check ✅ Passed The title clearly identifies the split-pane naming fix and matches the primary change.
Description check ✅ Passed The description covers the required sections, linked issue, visual proof, testing, scope, and compatibility considerations.
Linked Issues check ✅ Passed The changes satisfy issue #11069 by resolving live names per pane while preserving single-pane and tab-owned naming behavior.
Out of Scope Changes check ✅ Passed The changes remain focused on split-pane naming; the subagent extraction supports the lint constraint without changing behavior.
✨ 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/renderer/src/components/dashboard/agent-row-pane-live-title.ts (1)

4-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shorten the new API comments.

  • src/renderer/src/components/dashboard/agent-row-pane-live-title.ts#L4-L15: Replace the long behavior and cost description with one sentence that defines the undefined and null contract.
  • src/shared/agent-row-conversation-name.ts#L117-L121: Reduce the parameter comment to one sentence that defines split-pane null and single-pane undefined.

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: 1f34c9fb-a9fc-43d9-ac2e-45480234dc08

📥 Commits

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

📒 Files selected for processing (10)
  • src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts
  • src/renderer/src/components/dashboard/agent-row-pane-live-title.ts
  • src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
  • src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
  • src/renderer/src/components/dashboard/dashboard-card-labels.ts
  • src/renderer/src/components/dashboard/dashboard-subagent-cards.ts
  • src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts
  • src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts
  • src/shared/agent-row-conversation-name.test.ts
  • src/shared/agent-row-conversation-name.ts

Comment on lines +132 to +133
const liveTitle =
paneLiveTitle === undefined ? (tab.title?.trim() ?? '') : (paneLiveTitle?.trim() ?? '')

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

Preserve generated titles for every split-pane row.

When generatedTitlesEnabled is true and paneLiveTitle is an OpenCode semantic title, Line 134 returns the pane title before the generated-title check. A split tab can then show generatedTitle for one pane and an OpenCode title for another pane. Check generatedTitle before the OpenCode early return when paneLiveTitle !== undefined. This keeps single-pane precedence unchanged. Add a test with generatedTitlesEnabled === true and OC | ... as paneLiveTitle.

Proposed fix
   const liveTitle =
     paneLiveTitle === undefined ? (tab.title?.trim() ?? '') : (paneLiveTitle?.trim() ?? '')
+  const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
+  if (paneLiveTitle !== undefined && generatedTitle) {
+    return generatedTitle
+  }
   if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
     return liveTitle
   }
-  const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
   if (generatedTitle) {
     return generatedTitle
   }

The PR objective requires generated tab-owned names to apply to all rows.

📝 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
const liveTitle =
paneLiveTitle === undefined ? (tab.title?.trim() ?? '') : (paneLiveTitle?.trim() ?? '')
const liveTitle =
paneLiveTitle === undefined ? (tab.title?.trim() ?? '') : (paneLiveTitle?.trim() ?? '')
const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
if (paneLiveTitle !== undefined && generatedTitle) {
return generatedTitle
}
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
return liveTitle
}
if (generatedTitle) {
return generatedTitle
}

@brennanb2025

Copy link
Copy Markdown
Contributor Author

$electron validation — split-pane agent rows are named per pane

Ran on an isolated dev instance of this branch (ORCA_DEV_USER_DATA_PATH=/tmp/val14707/userdata, own CDP port 9383, fresh profile with 0 repos). Identity re-verified before every capture:

window.api.app.getIdentity().devRepoRoot
= /Users/brennanbenson/orca/workspaces/orca/split-row-name-2811

Rig: one tab split into two panes, a different agent in each — claude in the left pane, codex in the right — so the two sidebar rows are distinguishable by both name and agent badge.

runtimePaneTitlesByTabId[<split tab>] = { "1": "✳ Reply with PELICAN", "2": "demo-repo" }
terminalLayoutsByTabId[<split tab>].root.type = "split"

The before/after below is a true A/B on the same live instance: the fix was reverted in place (liveTitle = tab.title?.trim() ?? ''), HMR reloaded the same running app, the shots were taken, and the file was restored with git checkout HEAD --. No relaunch, no second build, same two live agents throughout.

Before — fix reverted, rows take the focused pane's title

Focus on the Claude pane (tab.title = "✳ Reply with PELICAN") Focus on the Codex pane (tab.title = "demo-repo")
C-fixOFF-focus-claude.png D-fixOFF-focus-codex.png
["Reply with PELICAN - PELICAN", "Reply with PELICAN - WALRUS"] ["demo-repo - PELICAN", "demo-repo - WALRUS"]

Both rows carry one name, and both flip when the user clicks the sibling pane. The Codex row is labelled with the Claude pane's title — the exact mislabelling this PR fixes.

After — fix applied, each row is named from its own pane

Focus on the Claude pane (tab.title = "✳ Reply with PELICAN") Focus on the Codex pane (tab.title = "demo-repo")
A-fixON-focus-claude.png B-fixON-focus-codex.png
["Reply with PELICAN - PELICAN", "demo-repo - WALRUS"] ["Reply with PELICAN - PELICAN", "demo-repo - WALRUS"]

Two rows, two different, correct names — and identical across the focus flip, even though tab.title changed between the two captures.

Tab-owned names still survive the split (#11070's diagnosis, kept)

Setting a tab custom title (setTabCustomTitle(tabId, 'Patient sync spike')) on the split tab gives both rows that name, ahead of either pane title — customTitle / quickCommandLabel still return before the new paneLiveTitle logic:

E-fixON-tab-owned-name.png

["Patient sync spike - PELICAN", "Patient sync spike - WALRUS"]

Full window, fix applied

F-full-window-after.png


Review gates

  • Targeted tests 4 files / 56 tests passed (vitest run --config config/vitest.config.ts on the four touched test files).
  • Regression sweep 297 files / 2662 tests passed across components/dashboard, components/sidebar, and src/shared/agent-row-conversation-name.test.ts.
  • Mutation-proofed the tests, not just run them. Reverting each production hunk turns the suite red — agent-row-conversation-name.ts → 5 failures across 3 files; dashboard-card-labels.ts → 1; use-agent-row-conversation-name.ts → 3; removing the 'split' gate → 1; flipping the nullundefined sentinel → 1; neutering dashboard-subagent-cards.ts → 1. One test ("keeps tab-owned names above the pane title") survives the revert — it is a precedence guard for the new param, not a proof of the fix.
  • Lint / format / typecheck oxlint clean (gate proven live by injecting a violation), oxfmt --check clean, tsc --noEmit clean on both the web and node projects.
  • max-lines handled by a real extraction (dashboard-subagent-cards.ts), no disable and no baseline bump. build-dashboard-snapshot.ts now sits ~282 code lines against the 300 cap.
  • Remote wire N/A — the dashboard snapshot goes renderer → main → the pop-out BrowserWindow of the same build; it never crosses the remote/mobile wire (grep -rli dashboard src/relay = 0, conversationName in mobile/ = 0). Nothing persisted is written or migrated.

Known limits (found in review, not blockers — filing/leaving as-is)

  • generatedTitle is still tab-scoped. It is derived from one pane's prompt and stored on the tab (terminal-tab-title-batch.ts), and it outranks the pane's own live title, so with tabAutoGenerateTitle on both split rows still share one name. The setting defaults to false. A real fix needs per-pane generated titles — a store-shape change, out of scope here.
  • Accuracy is bounded by numeric-pane-id → leaf attribution (runtime-pane-title-leaf-id.ts, leafIds[numericPaneId - 1]). Close a pane and re-split and a live pane id can index past the leaf array, returning null for that row. That is fix(sidebar): attribute each split pane's runtime title to its own leaf (STA-3264) #14702's root cause; this PR does not introduce it, but it does promote it from "sidebar activity classification" to "the name the user reads".
  • No pane title resolves ⇒ no live title, rather than the tab title. This diverges from the hasAnyPaneTitle idiom in running-agent-targets.ts / notes-send-agent-targets.ts, which keep the tab title when the runtime has reported nothing at all. I tested this case live — clearing every pane title on the split tab left each row showing its own last-message label ("Reply with only the word PELICAN" / "Reply with only the word WALRUS"), not a blank and not a sibling's name — so the stricter choice degrades gracefully in practice.
  • Legacy numeric pane keys (tabId:<n>) have no leafId, so retained legacy rows on a split tab lose their name rather than showing a possibly-wrong one. Narrow, and the conservative answer.

@brennanb2025
brennanb2025 merged commit 7ba3360 into main Aug 18, 2026
45 checks passed
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Aug 21, 2026
…(STA-2811) (stablyai#14707)

Agent rows are per pane, but their conversation name came from `tab.title`,
which carries only the FOCUSED pane's title. In a split tab every row showed
one pane's name, and all of them changed when the user clicked a sibling.

Rows on a multi-pane tab now resolve their own leaf's runtime pane title via
the existing `resolveRuntimePaneTitleForLeaf`, and fall back to no live title
rather than a sibling's. Single-pane tabs pass `undefined` and are unchanged.

Extracts the subagent grouping out of build-dashboard-snapshot.ts, which was
exactly at the 300-line cap.
dallascrilley pushed a commit to dallascrilley/orca that referenced this pull request Aug 27, 2026
…(STA-2811) (stablyai#14707)

Agent rows are per pane, but their conversation name came from `tab.title`,
which carries only the FOCUSED pane's title. In a split tab every row showed
one pane's name, and all of them changed when the user clicked a sibling.

Rows on a multi-pane tab now resolve their own leaf's runtime pane title via
the existing `resolveRuntimePaneTitleForLeaf`, and fall back to no live title
rather than a sibling's. Single-pane tabs pass `undefined` and are unchanged.

Extracts the subagent grouping out of build-dashboard-snapshot.ts, which was
exactly at the 300-line cap.
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]: Split panes in one tab share a single sidebar agent-row name that flips to whichever pane was clicked last

1 participant