From 151c567074cb29dfc9cb4b6f49fc2d8e3cce3bd3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:52:01 -0700 Subject: [PATCH] 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. --- .../src/lib/agent-send-title-status.ts | 31 +++ .../src/lib/notes-send-agent-targets.test.ts | 192 +++++++++++++++++- .../src/lib/notes-send-agent-targets.ts | 191 +++++++++-------- .../src/lib/running-agent-targets.test.ts | 124 ++++++++++- src/renderer/src/lib/running-agent-targets.ts | 40 ++-- .../lib/runtime-pane-title-leaf-id.test.ts | 30 ++- .../src/lib/runtime-pane-title-leaf-id.ts | 55 +++-- 7 files changed, 540 insertions(+), 123 deletions(-) create mode 100644 src/renderer/src/lib/agent-send-title-status.ts diff --git a/src/renderer/src/lib/agent-send-title-status.ts b/src/renderer/src/lib/agent-send-title-status.ts new file mode 100644 index 000000000..11f949fde --- /dev/null +++ b/src/renderer/src/lib/agent-send-title-status.ts @@ -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) + ) +} diff --git a/src/renderer/src/lib/notes-send-agent-targets.test.ts b/src/renderer/src/lib/notes-send-agent-targets.test.ts index 018e63b6a..a87ba7392 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.test.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.test.ts @@ -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 { return { @@ -28,15 +33,21 @@ function tab(id: string, overrides: Partial = {}): TerminalTab { } } -function entry(paneKey: string, state: AgentStatusState = 'done'): AgentStatusEntry { +function entry( + paneKey: string, + state: AgentStatusState = 'done', + updatedAt = NOW, + overrides: Partial = {} +): 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({ diff --git a/src/renderer/src/lib/notes-send-agent-targets.ts b/src/renderer/src/lib/notes-send-agent-targets.ts index e2909a22b..3ce0e61f7 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.ts @@ -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 @@ -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) +} diff --git a/src/renderer/src/lib/running-agent-targets.test.ts b/src/renderer/src/lib/running-agent-targets.test.ts index 47e24fbf0..847052d2e 100644 --- a/src/renderer/src/lib/running-agent-targets.test.ts +++ b/src/renderer/src/lib/running-agent-targets.test.ts @@ -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' }) }) diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 8e310c86d..332f419db 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -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 { 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( diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts index 3ae0b9a49..be00d6b70 100644 --- a/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts index dd7eb849d..0847f1e3d 100644 --- a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts @@ -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 | undefined, leafId: string ): string | null { + return resolveRuntimePaneTitleLeafResolution(tabLayout, paneTitles, leafId).title +} + +export function resolveRuntimePaneTitleLeafResolution( + tabLayout: { root?: TerminalLayoutSnapshot['root'] } | undefined, + paneTitles: Record | 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 + 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(