From bd0533cfe52080d84a4df75add2ed4e0973d6eec Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:52:19 -0700 Subject: [PATCH] Fix active agent detection in split-pane layouts and support early hints (#4949) * Detect active agents from title/launch hints before hooks report * Fix active agent detection in split-pane layouts with lone background ti * Probe runtime for manually started agents during note-send detection - Query runtime via `terminal.isRunningAgent` to detect active agents before titles or status hooks have reported them. - Extract active-agent-target resolution utilities and state selectors to a dedicated `active-agent-note-target.ts` file. --- .../src/components/sidebar/smart-attention.ts | 2 +- .../components/sidebar/worktree-agent-rows.ts | 2 +- .../src/lib/active-agent-note-send.test.ts | 125 +++++++- .../src/lib/active-agent-note-send.ts | 138 +-------- .../src/lib/active-agent-note-target.ts | 290 ++++++++++++++++++ .../runtime-pane-title-leaf-id.ts | 8 +- src/renderer/src/lib/worktree-status.ts | 2 +- 7 files changed, 437 insertions(+), 130 deletions(-) create mode 100644 src/renderer/src/lib/active-agent-note-target.ts rename src/renderer/src/{components/sidebar => lib}/runtime-pane-title-leaf-id.ts (88%) diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 90d5d4dd9..4ca27a7c4 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -1,7 +1,7 @@ import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { resolveRuntimePaneTitleLeafId } from './runtime-pane-title-leaf-id' +import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' import type { AgentStatus } from '../../../../shared/agent-detection' import type { TerminalLayoutSnapshot, TerminalTab, Worktree } from '../../../../shared/types' import { diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 8f38aabc3..d98dfef0d 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -16,7 +16,7 @@ import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/types' -import { resolveRuntimePaneTitleLeafId } from './runtime-pane-title-leaf-id' +import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows' function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null { diff --git a/src/renderer/src/lib/active-agent-note-send.test.ts b/src/renderer/src/lib/active-agent-note-send.test.ts index 18b06eaf3..f3d68a65d 100644 --- a/src/renderer/src/lib/active-agent-note-send.test.ts +++ b/src/renderer/src/lib/active-agent-note-send.test.ts @@ -2,11 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getActiveAgentNoteTarget, + getActiveAgentRuntimeProbeDescriptor, getActiveTerminalNoteTarget, + probeActiveAgentNoteTarget, sendNotesToActiveAgentSession } from './active-agent-note-send' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { makePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot } from '../../../shared/types' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' @@ -24,6 +27,7 @@ const testState = vi.hoisted(() => ({ ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: { 'tab-1': { activeLeafId: '11111111-1111-4111-8111-111111111111', @@ -37,11 +41,16 @@ const testState = vi.hoisted(() => ({ activeTabType: 'terminal' | 'editor' activeTabId: string | null activeTabIdByWorktree: Record - tabsByWorktree: Record + tabsByWorktree: Record ptyIdsByTabId: Record + runtimePaneTitlesByTabId: Record> terminalLayoutsByTabId: Record< string, - { activeLeafId: string | null; ptyIdsByLeafId?: Record } + { + activeLeafId: string | null + root?: TerminalLayoutSnapshot['root'] + ptyIdsByLeafId?: Record + } > agentStatusByPaneKey: Record settings: Record @@ -77,6 +86,7 @@ describe('active agent note send', () => { ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: { 'tab-1': { activeLeafId: LEAF_ID, ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } } }, @@ -119,6 +129,117 @@ describe('active agent note send', () => { expect(getActiveAgentNoteTarget(testState.appState, 'wt-1', NOW)).toBeNull() }) + it('runtime-probes a manually started agent before title or hooks report it', async () => { + testState.callRuntimeRpc.mockImplementation(async (_target, method) => { + if (method === 'terminal.list') { + return { + terminals: [ + { + handle: 'term-1', + worktreeId: 'wt-1', + worktreePath: '/repo', + branch: 'main', + tabId: 'tab-1', + leafId: LEAF_ID, + title: 'repo terminal', + connected: true, + writable: true, + lastOutputAt: 1, + preview: '' + } + ], + totalCount: 1, + truncated: false + } + } + if (method === 'terminal.isRunningAgent') { + return { isRunningAgent: true } + } + throw new Error(`unexpected method ${method}`) + }) + + const descriptor = getActiveAgentRuntimeProbeDescriptor(testState.appState, 'wt-1') + + expect(descriptor).toMatchObject({ + key: `local:wt-1:tab-1:${LEAF_ID}:pty-1`, + noteTarget: { tabId: 'tab-1', leafId: LEAF_ID } + }) + await expect(probeActiveAgentNoteTarget(descriptor!)).resolves.toBe(true) + }) + + it('offers the active terminal send target for a fresh title-detected agent before hooks report', () => { + testState.appState.runtimePaneTitlesByTabId = { + 'tab-1': { 1: 'Codex' } + } + + expect(getActiveAgentNoteTarget(testState.appState, 'wt-1', NOW)).toEqual({ + tabId: 'tab-1', + leafId: LEAF_ID + }) + }) + + it('offers the active terminal send target for an Orca-launched agent before hooks report', () => { + testState.appState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] + } + + expect(getActiveAgentNoteTarget(testState.appState, 'wt-1', NOW)).toEqual({ + tabId: 'tab-1', + leafId: LEAF_ID + }) + }) + + it('does not let an old launch marker override a focused shell title', () => { + testState.appState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] + } + testState.appState.runtimePaneTitlesByTabId = { + 'tab-1': { 1: 'zsh' } + } + + expect(getActiveAgentNoteTarget(testState.appState, 'wt-1', NOW)).toBeNull() + }) + + it('does not offer the active terminal send target for another split pane title', () => { + testState.appState.runtimePaneTitlesByTabId = { + 'tab-1': { 1: 'zsh', 2: 'Codex' } + } + testState.appState.terminalLayoutsByTabId = { + 'tab-1': { + activeLeafId: LEAF_ID, + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: OTHER_LEAF_ID } + }, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + } + + expect(getActiveAgentNoteTarget(testState.appState, 'wt-1', NOW)).toBeNull() + }) + + it('does not treat a lone background split-pane title as the focused pane', () => { + testState.appState.runtimePaneTitlesByTabId = { + 'tab-1': { 2: 'Codex' } + } + testState.appState.terminalLayoutsByTabId = { + 'tab-1': { + activeLeafId: LEAF_ID, + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: OTHER_LEAF_ID } + }, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + } + + expect(getActiveAgentNoteTarget(testState.appState, 'wt-1', NOW)).toBeNull() + }) + it('offers the active terminal send target when the focused pane is a fresh agent session', () => { const paneKey = makePaneKey('tab-1', LEAF_ID) testState.appState.agentStatusByPaneKey = { diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index f6febac6a..dd30bc579 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -1,27 +1,19 @@ -import type { - RuntimeTerminalListResult, - RuntimeTerminalSend, - RuntimeTerminalWait -} from '../../../shared/runtime-types' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../shared/agent-status-types' -import type { AppState } from '@/store/types' +import type { RuntimeTerminalSend, RuntimeTerminalWait } from '../../../shared/runtime-types' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { makePaneKey, isTerminalLeafId } from '../../../shared/stable-pane-id' -import { isExplicitAgentStatusFresh } from './agent-status' +import { findActiveRuntimeTerminal, getActiveTerminalNoteTarget } from './active-agent-note-target' + +export { + getActiveAgentNoteTarget, + getActiveAgentRuntimeProbeDescriptor, + getActiveTerminalNoteTarget, + probeActiveAgentNoteTarget, + useCanSendNotesToActiveTerminal, + type ActiveTerminalNoteTarget +} from './active-agent-note-target' const ACTIVE_AGENT_SEND_TIMEOUT_MS = 8000 const ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS = 15000 -const ACTIVE_AGENT_TERMINAL_LIST_LIMIT = 200 - -export type ActiveTerminalNoteTarget = { - tabId: string - leafId: string -} export type ActiveAgentNotesSendStatus = | 'sent' @@ -35,69 +27,6 @@ export type ActiveAgentNotesSendResult = { status: ActiveAgentNotesSendStatus } -type ActiveTerminalNoteTargetState = { - activeWorktreeId: AppState['activeWorktreeId'] - activeTabType: AppState['activeTabType'] - activeTabId: AppState['activeTabId'] - activeTabIdByWorktree: AppState['activeTabIdByWorktree'] - tabsByWorktree: Record - ptyIdsByTabId?: Record - terminalLayoutsByTabId: Record< - string, - { activeLeafId: string | null; ptyIdsByLeafId?: Record } | undefined - > - agentStatusByPaneKey?: Record -} - -export function getActiveTerminalNoteTarget( - state: ActiveTerminalNoteTargetState, - worktreeId: string -): ActiveTerminalNoteTarget | null { - if (state.activeWorktreeId !== worktreeId) { - return null - } - - const tabId = - state.activeTabType === 'terminal' - ? (state.activeTabId ?? state.activeTabIdByWorktree[worktreeId]) - : state.activeTabIdByWorktree[worktreeId] - if (!tabId || !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId)) { - return null - } - - const leafId = state.terminalLayoutsByTabId[tabId]?.activeLeafId - return leafId ? { tabId, leafId } : null -} - -export function useCanSendNotesToActiveTerminal(worktreeId: string): boolean { - return useAppStore((state) => getActiveAgentNoteTarget(state, worktreeId) !== null) -} - -export function getActiveAgentNoteTarget( - state: ActiveTerminalNoteTargetState, - worktreeId: string, - now = Date.now() -): ActiveTerminalNoteTarget | null { - const noteTarget = getActiveTerminalNoteTarget(state, worktreeId) - if (!noteTarget || !isTerminalLeafId(noteTarget.leafId)) { - return null - } - - const activePtyId = getActivePanePtyId(state, noteTarget) - if (!activePtyId) { - return null - } - - const entry = state.agentStatusByPaneKey?.[makePaneKey(noteTarget.tabId, noteTarget.leafId)] - // Why: an active terminal can be a regular shell; only explicit hook-backed - // agent state is enough to offer the destructive "submit with Enter" target. - if (!entry || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - return null - } - - return noteTarget -} - export async function sendNotesToActiveAgentSession({ worktreeId, prompt, @@ -119,7 +48,12 @@ export async function sendNotesToActiveAgentSession({ } const runtimeTarget = getActiveRuntimeTarget(state.settings) - const terminal = await findActiveRuntimeTerminal(runtimeTarget, worktreeId, noteTarget) + const terminal = await findActiveRuntimeTerminal( + runtimeTarget, + worktreeId, + noteTarget, + ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS + ) if (!terminal) { return { status: 'no-active-terminal' } } @@ -187,44 +121,6 @@ export function activeAgentNotesSendFailureMessage(status: ActiveAgentNotesSendS } } -async function findActiveRuntimeTerminal( - runtimeTarget: ReturnType, - worktreeId: string, - noteTarget: ActiveTerminalNoteTarget -): Promise { - const { terminals } = await callRuntimeRpc( - runtimeTarget, - 'terminal.list', - // Why: worktree ids can look like branch names or paths; keep the lookup unambiguous. - { worktree: toRuntimeWorktreeSelector(worktreeId), limit: ACTIVE_AGENT_TERMINAL_LIST_LIMIT }, - { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } - ) - return ( - terminals.find( - (terminal) => terminal.tabId === noteTarget.tabId && terminal.leafId === noteTarget.leafId - ) ?? null - ) -} - -function getActivePanePtyId( - state: ActiveTerminalNoteTargetState, - noteTarget: ActiveTerminalNoteTarget -): string | null { - const livePtyIds = state.ptyIdsByTabId?.[noteTarget.tabId] ?? [] - if (livePtyIds.length === 0) { - return null - } - - const ptyIdsByLeafId = state.terminalLayoutsByTabId[noteTarget.tabId]?.ptyIdsByLeafId - if (ptyIdsByLeafId && Object.keys(ptyIdsByLeafId).length > 0) { - const activeLeafPtyId = ptyIdsByLeafId[noteTarget.leafId] - // Why: layout maps can survive sleep/reconnect; ptyIdsByTabId is the live - // PTY source of truth for whether submitting with Enter is currently safe. - return activeLeafPtyId && livePtyIds.includes(activeLeafPtyId) ? activeLeafPtyId : null - } - return livePtyIds[0] ?? null -} - function isRuntimeTimeout(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) return message.includes('timeout') diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts new file mode 100644 index 000000000..77a5e63df --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -0,0 +1,290 @@ +import { useEffect, useState } from 'react' +import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../shared/agent-status-types' +import type { AppState } from '@/store/types' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot } from '../../../shared/types' +import { + detectAgentStatusFromTitle, + getAgentLabel, + isExplicitAgentStatusFresh +} from './agent-status' +import { resolveRuntimePaneTitleLeafId } from './runtime-pane-title-leaf-id' + +const ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS = 3000 +const ACTIVE_AGENT_TERMINAL_LIST_LIMIT = 200 + +export type ActiveTerminalNoteTarget = { + tabId: string + leafId: string +} + +export type ActiveTerminalNoteTargetState = { + activeWorktreeId: AppState['activeWorktreeId'] + activeTabType: AppState['activeTabType'] + activeTabId: AppState['activeTabId'] + activeTabIdByWorktree: AppState['activeTabIdByWorktree'] + tabsByWorktree: Record< + string, + readonly { id: string; title?: string; launchAgent?: unknown }[] | undefined + > + ptyIdsByTabId?: Record + terminalLayoutsByTabId: Record< + string, + | { + activeLeafId: string | null + root?: TerminalLayoutSnapshot['root'] + ptyIdsByLeafId?: Record + } + | undefined + > + runtimePaneTitlesByTabId?: Record | undefined> + agentStatusByPaneKey?: Record + settings: Parameters[0] +} + +type ActiveAgentRuntimeProbeDescriptor = { + key: string + worktreeId: string + runtimeTarget: ReturnType + noteTarget: ActiveTerminalNoteTarget +} + +export function getActiveTerminalNoteTarget( + state: ActiveTerminalNoteTargetState, + worktreeId: string +): ActiveTerminalNoteTarget | null { + if (state.activeWorktreeId !== worktreeId) { + return null + } + + const tabId = + state.activeTabType === 'terminal' + ? (state.activeTabId ?? state.activeTabIdByWorktree[worktreeId]) + : state.activeTabIdByWorktree[worktreeId] + if (!tabId || !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId)) { + return null + } + + const leafId = state.terminalLayoutsByTabId[tabId]?.activeLeafId + return leafId ? { tabId, leafId } : null +} + +export function useCanSendNotesToActiveTerminal(worktreeId: string): boolean { + const canSendFromRendererState = useAppStore( + (state) => getActiveAgentNoteTarget(state, worktreeId) !== null + ) + const probeKey = useAppStore( + (state) => getActiveAgentRuntimeProbeDescriptor(state, worktreeId)?.key ?? null + ) + const [runtimeProbe, setRuntimeProbe] = useState<{ key: string; canSend: boolean } | null>(null) + + useEffect(() => { + if (canSendFromRendererState || !probeKey) { + return + } + const probeDescriptor = getActiveAgentRuntimeProbeDescriptor(useAppStore.getState(), worktreeId) + if (!probeDescriptor || probeDescriptor.key !== probeKey) { + return + } + + let cancelled = false + void probeActiveAgentNoteTarget(probeDescriptor) + .then((canSend) => { + if (!cancelled) { + setRuntimeProbe({ key: probeKey, canSend }) + } + }) + .catch(() => { + if (!cancelled) { + setRuntimeProbe({ key: probeKey, canSend: false }) + } + }) + + return () => { + cancelled = true + } + }, [canSendFromRendererState, probeKey, worktreeId]) + + return ( + canSendFromRendererState || + (runtimeProbe !== null && runtimeProbe.key === probeKey && runtimeProbe.canSend) + ) +} + +export function getActiveAgentNoteTarget( + state: ActiveTerminalNoteTargetState, + worktreeId: string, + now = Date.now() +): ActiveTerminalNoteTarget | null { + const noteTarget = getActiveTerminalNoteTarget(state, worktreeId) + if (!noteTarget || !isTerminalLeafId(noteTarget.leafId)) { + return null + } + + const activePtyId = getActivePanePtyId(state, noteTarget) + if (!activePtyId) { + return null + } + + const entry = state.agentStatusByPaneKey?.[makePaneKey(noteTarget.tabId, noteTarget.leafId)] + if (entry && isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + return noteTarget + } + // Why: freshly opened agents can be idle before their first hook event. Use + // renderer title/launch hints only to show the option; runtime still verifies + // the focused terminal is an idle agent before sending Enter. + if (!hasFocusedPaneAgentHint(state, worktreeId, noteTarget)) { + return null + } + + return noteTarget +} + +export function getActiveAgentRuntimeProbeDescriptor( + state: ActiveTerminalNoteTargetState, + worktreeId: string +): ActiveAgentRuntimeProbeDescriptor | null { + const noteTarget = getActiveTerminalNoteTarget(state, worktreeId) + if (!noteTarget || !isTerminalLeafId(noteTarget.leafId)) { + return null + } + const activePtyId = getActivePanePtyId(state, noteTarget) + if (!activePtyId) { + return null + } + const runtimeTarget = getActiveRuntimeTarget(state.settings) + const runtimeKey = + runtimeTarget.kind === 'environment' ? `env:${runtimeTarget.environmentId}` : 'local' + return { + key: `${runtimeKey}:${worktreeId}:${noteTarget.tabId}:${noteTarget.leafId}:${activePtyId}`, + worktreeId, + runtimeTarget, + noteTarget + } +} + +export async function probeActiveAgentNoteTarget({ + worktreeId, + runtimeTarget, + noteTarget +}: ActiveAgentRuntimeProbeDescriptor): Promise { + const terminal = await findActiveRuntimeTerminal( + runtimeTarget, + worktreeId, + noteTarget, + ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS + ) + if (!terminal) { + return false + } + const agentCheck = await callRuntimeRpc<{ isRunningAgent: boolean }>( + runtimeTarget, + 'terminal.isRunningAgent', + { terminal: terminal.handle }, + { timeoutMs: ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS } + ) + return agentCheck.isRunningAgent +} + +export async function findActiveRuntimeTerminal( + runtimeTarget: ReturnType, + worktreeId: string, + noteTarget: ActiveTerminalNoteTarget, + timeoutMs: number +): Promise { + const { terminals } = await callRuntimeRpc( + runtimeTarget, + 'terminal.list', + // Why: worktree ids can look like branch names or paths; keep the lookup unambiguous. + { worktree: toRuntimeWorktreeSelector(worktreeId), limit: ACTIVE_AGENT_TERMINAL_LIST_LIMIT }, + { timeoutMs } + ) + return ( + terminals.find( + (terminal) => terminal.tabId === noteTarget.tabId && terminal.leafId === noteTarget.leafId + ) ?? null + ) +} + +function getActivePanePtyId( + state: ActiveTerminalNoteTargetState, + noteTarget: ActiveTerminalNoteTarget +): string | null { + const livePtyIds = state.ptyIdsByTabId?.[noteTarget.tabId] ?? [] + if (livePtyIds.length === 0) { + return null + } + + const ptyIdsByLeafId = state.terminalLayoutsByTabId[noteTarget.tabId]?.ptyIdsByLeafId + if (ptyIdsByLeafId && Object.keys(ptyIdsByLeafId).length > 0) { + const activeLeafPtyId = ptyIdsByLeafId[noteTarget.leafId] + // Why: layout maps can survive sleep/reconnect; ptyIdsByTabId is the live + // PTY source of truth for whether submitting with Enter is currently safe. + return activeLeafPtyId && livePtyIds.includes(activeLeafPtyId) ? activeLeafPtyId : null + } + return livePtyIds[0] ?? null +} + +function hasFocusedPaneAgentHint( + state: ActiveTerminalNoteTargetState, + worktreeId: string, + noteTarget: ActiveTerminalNoteTarget +): boolean { + const tab = (state.tabsByWorktree[worktreeId] ?? []).find( + (entry) => entry.id === noteTarget.tabId + ) + const runtimeTitle = getFocusedRuntimePaneTitle(state, noteTarget) + if (runtimeTitle !== null) { + return isRecognizedAgentTitle(runtimeTitle) + } + if (tab?.launchAgent) { + return true + } + + return tab?.title ? isRecognizedAgentTitle(tab.title) : false +} + +function getFocusedRuntimePaneTitle( + state: ActiveTerminalNoteTargetState, + noteTarget: ActiveTerminalNoteTarget +): string | null { + const paneTitles = state.runtimePaneTitlesByTabId?.[noteTarget.tabId] + if (!paneTitles || Object.keys(paneTitles).length === 0) { + return null + } + + const layout = state.terminalLayoutsByTabId[noteTarget.tabId] + const titleEntries = Object.entries(paneTitles) + if (layout?.root) { + // Why: split-pane title maps can be sparse; a lone background title must not + // enable "send to active agent" for the focused shell pane. + for (const [runtimePaneId, title] of titleEntries) { + if (resolveRuntimePaneTitleLeafId(layout, runtimePaneId) === noteTarget.leafId) { + return title + } + } + return null + } + + if (titleEntries.length === 1) { + return titleEntries[0][1] + } + + for (const [runtimePaneId, title] of titleEntries) { + if (resolveRuntimePaneTitleLeafId(layout, runtimePaneId) === noteTarget.leafId) { + return title + } + } + return null +} + +function isRecognizedAgentTitle(title: string): boolean { + return detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null +} diff --git a/src/renderer/src/components/sidebar/runtime-pane-title-leaf-id.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts similarity index 88% rename from src/renderer/src/components/sidebar/runtime-pane-title-leaf-id.ts rename to src/renderer/src/lib/runtime-pane-title-leaf-id.ts index b1ccde050..6c0b37bd9 100644 --- a/src/renderer/src/components/sidebar/runtime-pane-title-leaf-id.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts @@ -1,6 +1,6 @@ -import { FIRST_PANE_ID } from '../../../../shared/pane-key' -import { isTerminalLeafId } from '../../../../shared/stable-pane-id' -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import { FIRST_PANE_ID } from '../../../shared/pane-key' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../shared/types' function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) @@ -38,7 +38,7 @@ function collectLeafIdsInReplayCreationOrder( } export function resolveRuntimePaneTitleLeafId( - tabLayout: TerminalLayoutSnapshot | undefined, + tabLayout: { root?: TerminalLayoutSnapshot['root'] } | undefined, runtimePaneId: string ): string | null { return resolveRuntimePaneTitleLeafIdFromRoot(tabLayout?.root, runtimePaneId) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index fd3392af3..5018c7310 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,6 +1,6 @@ import { detectAgentStatusFromTitle } from '@/lib/agent-status' import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/components/sidebar/runtime-pane-title-leaf-id' +import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode,