mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user