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:
parent
279135ed07
commit
151c567074
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue