Promote stale agent targets when live titles prove they are sendable (#7043)

* Promote stale agent targets when live titles prove they are sendable

- Extract centralized `detectAgentSendTitleStatus` helper to determine if an agent is ready or needs permission based on pane and tab titles.
- Prevent stale hook-backed status rows from disabling active targets when fresh live titles and PTYs prove they are sendable.
- Fallback to the tab's launch agent type when status-backed targets have unknown agent types.
- Add comprehensive test coverage for promoting stale pane targets and preserving permission blocks.

* Prevent split panes from borrowing stale tab titles

Introduce `resolveRuntimePaneTitleLeafResolution` to track whether a
tab has any reported runtime pane titles. Use this to ensure that a split
pane does not fall back to the overall tab title when another pane in the
same tab already has active title evidence, avoiding stale target status.

* Resolve single pane title if tab layout lacks root

When a tab layout does not have a root, we can only attribute a single
reported pane title. This ensures that any pane title still suppresses
the stale tab-title fallback.
This commit is contained in:
Jinjing 2026-07-02 00:52:01 -07:00 committed by GitHub
parent 279135ed07
commit 151c567074
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 540 additions and 123 deletions

View File

@ -0,0 +1,31 @@
import { type AgentStatus, detectAgentStatusFromTitle, getAgentLabel } from './agent-status'
const EXPLICIT_IDLE_SEND_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i
const CLAUDE_IDLE_PREFIX = '\u2733'
const GEMINI_IDLE_PREFIX = '\u25c7'
const PI_IDLE_PREFIX = '\u03c0 - '
export function detectAgentSendTitleStatus(title: string | null | undefined): AgentStatus | null {
if (!title || getAgentLabel(title) === null) {
return null
}
const status = detectAgentStatusFromTitle(title)
if (status !== 'idle') {
return status
}
// Why: selected-target sends are immediate. A bare agent name proves identity,
// but not that the CLI is ready for submitted input yet.
return isExplicitIdleSendTitle(title) ? status : null
}
function isExplicitIdleSendTitle(title: string): boolean {
return (
EXPLICIT_IDLE_SEND_TITLE_RE.test(title) ||
title.startsWith(CLAUDE_IDLE_PREFIX) ||
title.startsWith('* ') ||
title.includes(GEMINI_IDLE_PREFIX) ||
title.startsWith(PI_IDLE_PREFIX)
)
}

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry,
type AgentStatusState
} from '../../../shared/agent-status-types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import {
@ -13,6 +17,7 @@ const LAUNCH_TAB_ID = 'tab-launch'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
const NOW = 10_000
const OLD_STATUS_UPDATED_AT = NOW - AGENT_STATUS_STALE_AFTER_MS - 1
function tab(id: string, overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
@ -28,15 +33,21 @@ function tab(id: string, overrides: Partial<TerminalTab> = {}): TerminalTab {
}
}
function entry(paneKey: string, state: AgentStatusState = 'done'): AgentStatusEntry {
function entry(
paneKey: string,
state: AgentStatusState = 'done',
updatedAt = NOW,
overrides: Partial<AgentStatusEntry> = {}
): AgentStatusEntry {
return {
paneKey,
state,
prompt: '',
updatedAt: NOW,
stateStartedAt: NOW,
updatedAt,
stateStartedAt: updatedAt,
agentType: 'codex',
stateHistory: []
stateHistory: [],
...overrides
}
}
@ -340,6 +351,177 @@ describe('notes send agent targets', () => {
})
})
it('promotes a stale status-backed launch-agent pane when live title and PTY prove it is sendable', () => {
const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B)
const targets = deriveNotesSendAgentTargets(
state({
agentStatusByPaneKey: {
[paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT)
},
tabsByWorktree: {
[WORKTREE_ID]: [
tab(LAUNCH_TAB_ID, { title: 'Previous Codex session', launchAgent: 'codex' })
]
},
terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') },
runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } }
}),
WORKTREE_ID,
NOW
)
expect(targets).toEqual([
{
paneKey,
tabId: LAUNCH_TAB_ID,
leafId: LEAF_B,
agentType: 'codex',
tabTitle: 'Previous Codex session',
status: 'eligible'
}
])
})
it('uses launch ownership when promoting a stale unknown status-backed pane', () => {
const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B)
const targets = deriveNotesSendAgentTargets(
state({
agentStatusByPaneKey: {
[paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT, {
agentType: 'unknown'
})
},
tabsByWorktree: {
[WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })]
},
terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') },
runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } }
}),
WORKTREE_ID,
NOW
)
expect(targets).toEqual([
expect.objectContaining({
paneKey,
agentType: 'codex',
status: 'eligible'
})
])
})
it('keeps a stale status-backed launch-agent pane disabled with only a bare agent title', () => {
const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B)
const targets = deriveNotesSendAgentTargets(
state({
agentStatusByPaneKey: {
[paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT)
},
tabsByWorktree: {
[WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })]
},
terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }
}),
WORKTREE_ID,
NOW
)
expect(targets).toEqual([
expect.objectContaining({
paneKey,
status: 'disabled',
disabledReason: 'Agent status is stale'
})
])
})
it('keeps a stale status-backed launch-agent pane disabled when the live title needs permission', () => {
const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B)
const targets = deriveNotesSendAgentTargets(
state({
agentStatusByPaneKey: {
[paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT)
},
tabsByWorktree: {
[WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })]
},
terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') },
runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex - action required' } }
}),
WORKTREE_ID,
NOW
)
expect(targets).toEqual([
expect.objectContaining({
paneKey,
status: 'disabled',
disabledReason: 'Agent needs permission'
})
])
})
it('does not let a stale status-backed split pane hide a different live active launch-agent pane', () => {
const stalePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_A)
const livePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B)
const targets = deriveNotesSendAgentTargets(
state({
agentStatusByPaneKey: {
[stalePaneKey]: entry(stalePaneKey, 'done', OLD_STATUS_UPDATED_AT)
},
tabsByWorktree: {
[WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })]
},
terminalLayoutsByTabId: {
[LAUNCH_TAB_ID]: splitLayout(LEAF_B, { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' })
},
runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 2: 'Codex ready' } }
}),
WORKTREE_ID,
NOW
)
expect(targets).toEqual([
expect.objectContaining({
paneKey: stalePaneKey,
status: 'disabled',
disabledReason: 'Agent status is stale'
}),
expect.objectContaining({
paneKey: livePaneKey,
status: 'eligible'
})
])
})
it('does not borrow a stale tab title for an active split pane after another pane has title evidence', () => {
const stalePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_A)
const targets = deriveNotesSendAgentTargets(
state({
agentStatusByPaneKey: {
[stalePaneKey]: entry(stalePaneKey, 'done', OLD_STATUS_UPDATED_AT)
},
tabsByWorktree: {
[WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })]
},
terminalLayoutsByTabId: {
[LAUNCH_TAB_ID]: splitLayout(LEAF_B, { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' })
},
runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'zsh' } }
}),
WORKTREE_ID,
NOW
)
expect(targets).toEqual([
expect.objectContaining({
paneKey: stalePaneKey,
status: 'disabled',
disabledReason: 'Agent status is stale'
})
])
})
it('skips a launch-agent tab whose active leaf is not a terminal leaf', () => {
const targets = deriveNotesSendAgentTargets(
state({

View File

@ -1,18 +1,17 @@
import type { AgentType } from '../../../shared/agent-status-types'
import type { AppState } from '@/store/types'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { detectAgentStatusFromTitle, getAgentLabel } from './agent-status'
import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id'
import type { TerminalTab } from '../../../shared/types'
import { detectAgentSendTitleStatus } from './agent-send-title-status'
import {
resolveRuntimePaneTitleLeafResolution,
type RuntimePaneTitleLeafResolution
} from './runtime-pane-title-leaf-id'
import {
deriveRunningAgentSendTargets,
type RunningAgentTargetState
} from './running-agent-targets'
const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i
const CLAUDE_IDLE_PREFIX = '\u2733'
const GEMINI_IDLE_PREFIX = '\u25c7'
const PI_IDLE_PREFIX = '\u03c0 - '
export type NotesSendAgentTargetState = RunningAgentTargetState &
Pick<AppState, 'runtimePaneTitlesByTabId'>
@ -26,41 +25,19 @@ export type NotesSendAgentTarget = {
disabledReason?: string
}
function isRecognizedAgentTitle(title: string | null): boolean {
return (
title !== null && detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null
)
}
function detectLaunchAgentPaneStatus(paneTitle: string | null, tabTitle: string) {
if (paneTitle !== null && isRecognizedAgentTitle(paneTitle)) {
return detectLaunchAgentStatusFromTitle(paneTitle)
function detectLaunchAgentPaneStatus(
paneTitleResolution: RuntimePaneTitleLeafResolution,
tabTitle: string
) {
if (paneTitleResolution.title !== null) {
return detectAgentSendTitleStatus(paneTitleResolution.title)
}
// Why: mirror isTerminalRunningAgent — the OSC-enriched tab title only counts
// when the leaf has no runtime pane title of its own yet.
return paneTitle === null && isRecognizedAgentTitle(tabTitle)
? detectLaunchAgentStatusFromTitle(tabTitle)
: null
}
function detectLaunchAgentStatusFromTitle(title: string) {
const status = detectAgentStatusFromTitle(title)
if (status !== 'idle') {
return status
if (paneTitleResolution.hasAnyPaneTitle) {
return null
}
// Why: selected-target sends are immediate. A bare launch title like
// "Codex" proves agent identity, but not that the CLI is ready for input.
return isExplicitIdleLaunchTitle(title) ? status : null
}
function isExplicitIdleLaunchTitle(title: string): boolean {
return (
EXPLICIT_IDLE_TITLE_RE.test(title) ||
title.startsWith(CLAUDE_IDLE_PREFIX) ||
title.startsWith('* ') ||
title.includes(GEMINI_IDLE_PREFIX) ||
title.startsWith(PI_IDLE_PREFIX)
)
return detectAgentSendTitleStatus(tabTitle)
}
/**
@ -88,59 +65,113 @@ export function deriveNotesSendAgentTargets(
paneKey: target.paneKey,
tabId: target.tabId,
leafId: target.leafId,
agentType: target.entry.agentType,
agentType: resolveNotesTargetAgentType(target.entry.agentType, target.tab.launchAgent),
tabTitle: target.tab.title,
status: target.status,
...(target.disabledReason ? { disabledReason: target.disabledReason } : {})
})
)
// Why: dedupe by tab, not pane. A launch-agent tab already surfaced through a
// live status entry must not also emit a hint row — its active leaf may be a
// split shell pane, which would list a second bogus row for the same tab.
const statusBackedTabIds = new Set(targets.map((target) => target.tabId))
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
if (!tab.launchAgent || statusBackedTabIds.has(tab.id)) {
const launchTarget = deriveLaunchAgentTarget(state, tab)
if (!launchTarget) {
continue
}
const layout = state.terminalLayoutsByTabId[tab.id]
const leafId = layout?.activeLeafId
if (!leafId || !isTerminalLeafId(leafId)) {
continue
}
const ptyId = layout.ptyIdsByLeafId?.[leafId] ?? null
if (!ptyId || !state.ptyIdsByTabId[tab.id]?.includes(ptyId)) {
continue
}
const paneTitle = resolveRuntimePaneTitleForLeaf(
layout,
state.runtimePaneTitlesByTabId[tab.id],
leafId
)
const launchStatus = detectLaunchAgentPaneStatus(paneTitle, tab.title)
if (!launchStatus) {
// Why: launchAgent is set the instant Orca spawns the tab, but the runtime
// only accepts a send once the pane reads as an agent. Skipping until the
// title is recognized keeps "listed ⇒ sendable" and avoids the boot-window
// "not a recognized agent session" error.
continue
}
const disabledReason = launchStatus === 'permission' ? 'Agent needs permission' : undefined
targets.push({
paneKey: makePaneKey(tab.id, leafId),
tabId: tab.id,
leafId,
agentType: tab.launchAgent,
tabTitle: tab.title,
status: disabledReason ? 'disabled' : 'eligible',
...(disabledReason ? { disabledReason } : {})
})
mergeLaunchAgentTarget(targets, launchTarget)
}
return targets
}
function resolveNotesTargetAgentType(
entryAgentType: AgentType | null | undefined,
launchAgent: AgentType | null | undefined
): AgentType | null | undefined {
if (entryAgentType && entryAgentType !== 'unknown') {
return entryAgentType
}
return launchAgent ?? entryAgentType
}
function deriveLaunchAgentTarget(
state: NotesSendAgentTargetState,
tab: TerminalTab
): NotesSendAgentTarget | null {
if (!tab.launchAgent) {
return null
}
const layout = state.terminalLayoutsByTabId[tab.id]
const leafId = layout?.activeLeafId
if (!leafId || !isTerminalLeafId(leafId)) {
return null
}
const ptyId = layout.ptyIdsByLeafId?.[leafId] ?? null
if (!ptyId || !state.ptyIdsByTabId[tab.id]?.includes(ptyId)) {
return null
}
const paneTitles = state.runtimePaneTitlesByTabId[tab.id]
const paneTitleResolution = resolveRuntimePaneTitleLeafResolution(layout, paneTitles, leafId)
const launchStatus = detectLaunchAgentPaneStatus(paneTitleResolution, tab.title)
if (!launchStatus) {
// Why: launchAgent is set the instant Orca spawns the tab, but the runtime
// only accepts a send once the pane reads as an agent. Skipping until the
// title is recognized keeps "listed ⇒ sendable" and avoids the boot-window
// "not a recognized agent session" error.
return null
}
const disabledReason = launchStatus === 'permission' ? 'Agent needs permission' : undefined
return {
paneKey: makePaneKey(tab.id, leafId),
tabId: tab.id,
leafId,
agentType: tab.launchAgent,
tabTitle: tab.title,
status: disabledReason ? 'disabled' : 'eligible',
...(disabledReason ? { disabledReason } : {})
}
}
function mergeLaunchAgentTarget(
targets: NotesSendAgentTarget[],
launchTarget: NotesSendAgentTarget
): void {
const samePaneIndex = targets.findIndex((target) => target.paneKey === launchTarget.paneKey)
if (samePaneIndex !== -1) {
const existing = targets[samePaneIndex]
if (existing.status === 'eligible' || existing.disabledReason === 'Agent needs permission') {
return
}
// Why: hook-backed status can outlive the CLI after sleep/resume. When the
// same live launch-agent pane has a fresh title proof, prefer the sendable
// runtime evidence over the stale retained status row.
targets[samePaneIndex] = {
...launchTarget,
agentType:
existing.agentType && existing.agentType !== 'unknown'
? existing.agentType
: launchTarget.agentType,
tabTitle: existing.tabTitle || launchTarget.tabTitle
}
return
}
// Why: dedupe by tab for fresh/permission status rows. Their active leaf may
// be a split shell pane, which would list a second bogus row for the same tab.
if (
targets.some(
(target) =>
target.tabId === launchTarget.tabId &&
(target.status === 'eligible' || target.disabledReason === 'Agent needs permission')
)
) {
return
}
targets.push(launchTarget)
}

View File

@ -205,7 +205,127 @@ describe('running agent send targets', () => {
])
})
it('disables stale agent status rows even when the pane still has a leaf PTY', () => {
it('keeps stale agent status rows disabled when no live title proves the agent is sendable', () => {
const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID)
const target = resolveRunningAgentSendTarget(
state({
agentStatusByPaneKey: {
[stalePaneKey]: entry(stalePaneKey, 'waiting', NOW - 31 * 60 * 1000)
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: RIGHT_LEAF_ID },
activeLeafId: RIGHT_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' }
}
}
}),
WORKTREE_ID,
stalePaneKey,
NOW
)
expect(target).toMatchObject({
paneKey: stalePaneKey,
ptyId: 'pty-right',
status: 'disabled',
disabledReason: 'Agent status is stale'
})
})
it('keeps stale agent status rows disabled when only a bare agent title remains', () => {
const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID)
const target = resolveRunningAgentSendTarget(
state({
agentStatusByPaneKey: {
[stalePaneKey]: entry(stalePaneKey, 'done', NOW - 31 * 60 * 1000)
},
tabsByWorktree: { [WORKTREE_ID]: [{ ...tab(TAB_ID), title: 'Codex' }] },
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: RIGHT_LEAF_ID },
activeLeafId: RIGHT_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' }
}
}
}),
WORKTREE_ID,
stalePaneKey,
NOW
)
expect(target).toMatchObject({
paneKey: stalePaneKey,
ptyId: 'pty-right',
status: 'disabled',
disabledReason: 'Agent status is stale'
})
})
it('promotes stale agent status rows when a live pane title proves the agent is sendable', () => {
const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID)
const target = resolveRunningAgentSendTarget(
state({
agentStatusByPaneKey: {
[stalePaneKey]: entry(stalePaneKey, 'done', NOW - 31 * 60 * 1000)
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: RIGHT_LEAF_ID },
activeLeafId: RIGHT_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' }
}
},
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex ready' } }
}),
WORKTREE_ID,
stalePaneKey,
NOW
)
expect(target).toMatchObject({
paneKey: stalePaneKey,
ptyId: 'pty-right',
status: 'eligible'
})
expect(target).not.toHaveProperty('disabledReason')
})
it('treats a missing tab title as absent live title evidence', () => {
const paneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID)
const target = resolveRunningAgentSendTarget(
state({
agentStatusByPaneKey: {
[paneKey]: entry(paneKey, 'done')
},
tabsByWorktree: {
[WORKTREE_ID]: [{ ...tab(TAB_ID), title: undefined } as unknown as TerminalTab]
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: RIGHT_LEAF_ID },
activeLeafId: RIGHT_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' }
}
}
}),
WORKTREE_ID,
paneKey,
NOW
)
expect(target).toMatchObject({
paneKey,
ptyId: 'pty-right',
status: 'eligible'
})
})
it('keeps stale agent status rows disabled when the live pane title needs permission', () => {
const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID)
const target = resolveRunningAgentSendTarget(
state({
@ -231,7 +351,7 @@ describe('running agent send targets', () => {
paneKey: stalePaneKey,
ptyId: 'pty-right',
status: 'disabled',
disabledReason: 'Agent status is stale'
disabledReason: 'Agent needs permission'
})
})

View File

@ -5,12 +5,9 @@ import {
} from '../../../shared/agent-status-types'
import type { TerminalTab } from '../../../shared/types'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import {
detectAgentStatusFromTitle,
getAgentLabel,
isExplicitAgentStatusFresh
} from './agent-status'
import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id'
import { isExplicitAgentStatusFresh } from './agent-status'
import { detectAgentSendTitleStatus } from './agent-send-title-status'
import { resolveRuntimePaneTitleLeafResolution } from './runtime-pane-title-leaf-id'
export type RunningAgentTargetState = Pick<
AppState,
@ -61,13 +58,22 @@ export function deriveRunningAgentSendTargets(
: null
let disabledReason: string | undefined
// Why: hook-backed rows can go stale while the same PTY is still a live
// agent; live titles are the runtime proof that the row remains targetable.
const liveTitleStatus = ptyId
? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title)
: null
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
disabledReason = 'Agent status is stale'
if (liveTitleStatus === 'permission') {
disabledReason = 'Agent needs permission'
} else if (liveTitleStatus === null) {
disabledReason = 'Agent status is stale'
}
} else if (!ptyId) {
disabledReason = 'Terminal is no longer available'
} else if (entry.state === 'blocked' || entry.state === 'waiting') {
disabledReason = 'Agent needs permission'
} else if (hasPermissionPaneTitle(state, parsed.tabId, parsed.leafId, tab.title)) {
} else if (liveTitleStatus === 'permission') {
disabledReason = 'Agent needs permission'
}
@ -86,22 +92,22 @@ export function deriveRunningAgentSendTargets(
return targets
}
function hasPermissionPaneTitle(
function detectLiveAgentPaneStatus(
state: RunningAgentTargetState,
tabId: string,
leafId: string,
tabTitle: string
): boolean {
): ReturnType<typeof detectAgentSendTitleStatus> {
const layout = state.terminalLayoutsByTabId[tabId]
const paneTitle = resolveRuntimePaneTitleForLeaf(
layout,
state.runtimePaneTitlesByTabId?.[tabId],
leafId
)
const paneTitles = state.runtimePaneTitlesByTabId?.[tabId]
const paneTitleResolution = resolveRuntimePaneTitleLeafResolution(layout, paneTitles, leafId)
// Why: runtime pane titles are the freshest title signal for split panes; use
// the tab title only before the runtime has reported a pane title for the leaf.
const title = paneTitle ?? tabTitle
return detectAgentStatusFromTitle(title) === 'permission' && getAgentLabel(title) !== null
const title = paneTitleResolution.title ?? (paneTitleResolution.hasAnyPaneTitle ? null : tabTitle)
if (title === null) {
return null
}
return detectAgentSendTitleStatus(title)
}
export function resolveRunningAgentSendTarget(

View File

@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { TerminalLayoutSnapshot } from '../../../shared/types'
import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id'
import {
resolveRuntimePaneTitleForLeaf,
resolveRuntimePaneTitleLeafResolution
} from './runtime-pane-title-leaf-id'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
@ -45,3 +48,28 @@ describe('resolveRuntimePaneTitleForLeaf', () => {
expect(resolveRuntimePaneTitleForLeaf(undefined, { 7: 'Codex' }, LEAF_A)).toBe('Codex')
})
})
describe('resolveRuntimePaneTitleLeafResolution', () => {
it('reports when no pane titles are present', () => {
expect(resolveRuntimePaneTitleLeafResolution(leafLayout, {}, LEAF_A)).toEqual({
title: null,
hasAnyPaneTitle: false
})
})
it('reports an unrelated sparse split title without attributing it to the leaf', () => {
expect(resolveRuntimePaneTitleLeafResolution(splitLayout, { 2: 'Codex' }, LEAF_A)).toEqual({
title: null,
hasAnyPaneTitle: true
})
})
it('reports the matching leaf title when one resolves', () => {
expect(
resolveRuntimePaneTitleLeafResolution(splitLayout, { 1: 'zsh', 2: 'Codex' }, LEAF_B)
).toEqual({
title: 'Codex',
hasAnyPaneTitle: true
})
})
})

View File

@ -2,6 +2,11 @@ import { FIRST_PANE_ID } from '../../../shared/pane-key'
import { isTerminalLeafId } from '../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../shared/types'
export type RuntimePaneTitleLeafResolution = {
title: string | null
hasAnyPaneTitle: boolean
}
function getLeftmostLeafId(node: TerminalPaneLayoutNode): string {
return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first)
}
@ -55,34 +60,48 @@ export function resolveRuntimePaneTitleForLeaf(
paneTitles: Record<number, string> | undefined,
leafId: string
): string | null {
return resolveRuntimePaneTitleLeafResolution(tabLayout, paneTitles, leafId).title
}
export function resolveRuntimePaneTitleLeafResolution(
tabLayout: { root?: TerminalLayoutSnapshot['root'] } | undefined,
paneTitles: Record<number, string> | undefined,
leafId: string
): RuntimePaneTitleLeafResolution {
if (!paneTitles) {
return null
return { title: null, hasAnyPaneTitle: false }
}
const titleEntries = Object.entries(paneTitles)
if (titleEntries.length === 0) {
return null
}
const titlesByPaneId = paneTitles as Record<string, string>
let firstTitle: string | null = null
let hasOnePaneTitle = false
let hasMultiplePaneTitles = false
if (tabLayout?.root) {
for (const [runtimePaneId, title] of titleEntries) {
if (resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) === leafId) {
return title
}
for (const runtimePaneId in titlesByPaneId) {
if (!Object.prototype.hasOwnProperty.call(titlesByPaneId, runtimePaneId)) {
continue
}
return null
}
if (titleEntries.length === 1) {
return titleEntries[0][1]
}
const title = titlesByPaneId[runtimePaneId]
if (hasOnePaneTitle) {
hasMultiplePaneTitles = true
} else {
firstTitle = title
hasOnePaneTitle = true
}
for (const [runtimePaneId, title] of titleEntries) {
if (resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) === leafId) {
return title
return { title, hasAnyPaneTitle: true }
}
}
return null
// Why: without a layout root, only a single reported pane title can be
// attributed; any pane title still suppresses stale tab-title fallback.
if (!tabLayout?.root && hasOnePaneTitle && !hasMultiplePaneTitles) {
return { title: firstTitle, hasAnyPaneTitle: true }
}
return { title: null, hasAnyPaneTitle: hasOnePaneTitle }
}
export function resolveRuntimePaneTitleLeafIdFromRoot(