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.
This commit is contained in:
parent
b42b286879
commit
bd0533cfe5
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, string | null>
|
||||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
tabsByWorktree: Record<string, { id: string; launchAgent?: string }[]>
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{ activeLeafId: string | null; ptyIdsByLeafId?: Record<string, string | undefined> }
|
||||
{
|
||||
activeLeafId: string | null
|
||||
root?: TerminalLayoutSnapshot['root']
|
||||
ptyIdsByLeafId?: Record<string, string | undefined>
|
||||
}
|
||||
>
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>
|
||||
settings: Record<string, unknown>
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<string, readonly { id: string }[] | undefined>
|
||||
ptyIdsByTabId?: Record<string, readonly string[] | undefined>
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{ activeLeafId: string | null; ptyIdsByLeafId?: Record<string, string | undefined> } | undefined
|
||||
>
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry | undefined>
|
||||
}
|
||||
|
||||
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<typeof getActiveRuntimeTarget>,
|
||||
worktreeId: string,
|
||||
noteTarget: ActiveTerminalNoteTarget
|
||||
): Promise<RuntimeTerminalListResult['terminals'][number] | null> {
|
||||
const { terminals } = await callRuntimeRpc<RuntimeTerminalListResult>(
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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<string, readonly string[] | undefined>
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
| {
|
||||
activeLeafId: string | null
|
||||
root?: TerminalLayoutSnapshot['root']
|
||||
ptyIdsByLeafId?: Record<string, string | undefined>
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
runtimePaneTitlesByTabId?: Record<string, Record<number, string> | undefined>
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry | undefined>
|
||||
settings: Parameters<typeof getActiveRuntimeTarget>[0]
|
||||
}
|
||||
|
||||
type ActiveAgentRuntimeProbeDescriptor = {
|
||||
key: string
|
||||
worktreeId: string
|
||||
runtimeTarget: ReturnType<typeof getActiveRuntimeTarget>
|
||||
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<boolean> {
|
||||
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<typeof getActiveRuntimeTarget>,
|
||||
worktreeId: string,
|
||||
noteTarget: ActiveTerminalNoteTarget,
|
||||
timeoutMs: number
|
||||
): Promise<RuntimeTerminalListResult['terminals'][number] | null> {
|
||||
const { terminals } = await callRuntimeRpc<RuntimeTerminalListResult>(
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue