fix(sidebar): keep owned agent panes visible when their title carries no agent frame

A live agent pane with no hook status has exactly one row source: its terminal
title. buildTitleDerivedAgentRow required that title to classify as agent
ACTIVITY, so a pane whose owner is known but whose title carries no agent frame
produced no row at all -- ownerAgentType was computed and passed in but sat
behind the `!status || !label` gate and was unreachable.

Codex titles its panes with the worktree name, so on Windows every live Codex
pane reads as e.g. "gh-13695" or the neutral "Terminal 1". Those panes are
therefore absent from the sidebar and the Agent Dashboard whenever hook status
is missing -- which is exactly the state after an app restart or update, so a
restored Codex tab vanishes while its PTY stays connected and writable.

Fall back to the pane's known owner for both status (idle) and label, the same
concession already made for Cursor's status-less native title (#10258), widened
from one literal to any known owner. A shell title still wins: it is positive
evidence the agent exited and left its shell, so it must not resurrect a row.
Ownership stays single-leaf-scoped, so a split cannot brand a sibling pane.
This commit is contained in:
OrcaWin
2026-08-14 18:02:36 -07:00
parent 9bb8836bb6
commit 6c59799c11
2 changed files with 132 additions and 2 deletions
@@ -110,6 +110,123 @@ describe('buildTitleDerivedAgentRows', () => {
])
})
// Why: Codex titles its panes with the worktree name, so a live Codex pane's title carries
// neither an agent name nor a status frame. Titles below were captured from
// `orca terminal list --json` on Windows 11 and cross-referenced to each tab's persisted
// launchAgent. Before #14464 every one of these panes produced no row at all, so a restored
// Codex tab disappeared from the sidebar and Agent Dashboard while its PTY stayed connected.
describe('owned panes whose title carries no agent frame (#14464)', () => {
for (const title of [
'gh-13695',
'issue-13626-complete',
'pr-13977-best-review',
'gh-13752-current-main...',
'Terminal 1'
]) {
it(`keeps a live Codex pane titled ${JSON.stringify(title)} as an idle Codex row`, () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title, defaultTitle: 'Terminal 1', launchAgent: 'codex' })],
entries: [],
retained: [],
ptyIdsByTabId: { 'tab-1': ['pty-codex'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.prompt])).toEqual([
['codex', 'idle', 'Codex']
])
expect(rows.map((row) => row.paneKey)).toEqual([makePaneKey('tab-1', LEAF_ID_1)])
})
}
// Why: node-pty reports the foreground process as the console title on Windows, so a
// full-path shell title is positive evidence the agent exited and left its shell behind.
// That must not be resurrected as an agent row just because launchAgent outlived it.
for (const shellTitle of [
'C:\\Program Files\\WindowsApps\\Microsoft.PowerShell_7.6.4.0_x64__8wekyb3d8bbwe\\pwsh.exe',
'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'bash',
'pwsh.exe'
]) {
it(`drops a pane whose title is the shell ${JSON.stringify(shellTitle)}`, () => {
const rows = buildWorktreeAgentRows({
tabs: [
makeTab('tab-1', {
title: shellTitle,
defaultTitle: 'Terminal 1',
launchAgent: 'codex'
})
],
entries: [],
retained: [],
ptyIdsByTabId: { 'tab-1': ['pty-codex'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows).toEqual([])
})
}
it('still drops an unowned pane whose title carries no agent frame', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'gh-13695', defaultTitle: 'Terminal 1' })],
entries: [],
retained: [],
ptyIdsByTabId: { 'tab-1': ['pty-shell'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows).toEqual([])
})
it('still drops an owned pane with no live PTY', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'gh-13695', launchAgent: 'codex' })],
entries: [],
retained: [],
ptyIdsByTabId: {},
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows).toEqual([])
})
// Why: launchAgent is tab-scoped, so inside a split it is not pane ownership — letting it
// seed rows there would brand a sibling pane with the other pane's agent.
it('does not brand split panes from the tab-scoped launch agent', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'gh-13695', launchAgent: 'codex' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: 'gh-13695', 2: 'gh-13695' } },
ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] },
terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() },
now: 2000
})
expect(rows).toEqual([])
})
// Why: the owner is a fallback, never an override — a title that names a live agent still
// decides identity and activity.
it('lets an agent-naming title win over the launch owner', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: '\u280b Codex', launchAgent: 'codex' })],
entries: [],
retained: [],
ptyIdsByTabId: { 'tab-1': ['pty-codex'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state])).toEqual([['codex', 'working']])
})
})
it('does not add title-derived rows for panes without a live PTY', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
@@ -1,6 +1,7 @@
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { formatAgentTypeLabel, isClaudeManagementTitle } from '@/lib/agent-status'
import { isCursorAgentTitle } from '../../../../shared/agent-title-core'
import { isShellProcess } from '../../../../shared/shell-process-detection'
import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type {
@@ -138,6 +139,14 @@ function buildTitleDerivedAgentRow(args: {
// is a separate, stricter question — it decides identity, so it uses ownerAgentType below.
const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent)
const isClaudeAgentsTitle = isClaudeManagementTitle(title)
// Why: an owned pane whose title carries no agent frame is still that owner's session
// (Codex titles its panes with the worktree name, so a live Codex pane reads as
// "gh-13695"). Same concession as Cursor below, widened from one literal to any known
// owner: without it the pane's only row source is its title, so a restored agent tab
// vanishes from the sidebar/dashboard while its PTY stays connected (#14464).
// A shell title is positive evidence the agent left, so it must not resurrect a row.
const ownerIdentifiesPane =
args.ownerAgentType !== null && args.ownerAgentType !== 'unknown' && !isShellProcess(title)
// Why: `claude agents` is a live Claude Code Agent Teams surface, but the
// shared detector keeps it neutral so runtime liveness probes do not treat
// the management/list screen as active work.
@@ -146,8 +155,12 @@ function buildTitleDerivedAgentRow(args: {
// reads idle instead of vanishing (#10258).
const status = isClaudeAgentsTitle
? 'idle'
: (classifyTitleActivity(title) ?? (isCursorAgentTitle(title) ? 'idle' : null))
const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title)
: (classifyTitleActivity(title) ??
(isCursorAgentTitle(title) || ownerIdentifiesPane ? 'idle' : null))
const label = isClaudeAgentsTitle
? 'Claude Code'
: (resolveTitleActivityLabel(title) ??
(ownerIdentifiesPane ? formatAgentTypeLabel(args.ownerAgentType) : null))
if (!status || !label) {
return null
}