mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 08:03:08 +00:00
Send review notes to active agent session
This commit is contained in:
@@ -506,6 +506,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
process: await runtime.inspectTerminalProcess(params.terminal)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.isRunningAgent',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => ({
|
||||
isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.rename',
|
||||
params: TerminalRename,
|
||||
|
||||
@@ -16,6 +16,26 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
}
|
||||
|
||||
describe('terminal send RPC', () => {
|
||||
it('reports whether a terminal handle is running a recognized agent', async () => {
|
||||
const runtime = stubRuntime({
|
||||
isTerminalRunningAgent: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.isRunningAgent', {
|
||||
terminal: 'terminal-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
expect(response.result).toEqual({ isRunningAgent: true })
|
||||
expect(runtime.isTerminalRunningAgent).toHaveBeenCalledWith('terminal-1')
|
||||
})
|
||||
|
||||
it('drops desktop input while a mobile client owns the terminal floor', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
|
||||
@@ -278,6 +278,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'terminal.create',
|
||||
'terminal.focus',
|
||||
'terminal.getAutoRestoreFit',
|
||||
'terminal.isRunningAgent',
|
||||
'terminal.list',
|
||||
'terminal.multiplex',
|
||||
'terminal.read',
|
||||
@@ -288,6 +289,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'terminal.subscribe',
|
||||
'terminal.unsubscribe',
|
||||
'terminal.updateViewport',
|
||||
'terminal.wait',
|
||||
'ui.get',
|
||||
'ui.recordFeatureInteraction',
|
||||
'ui.set',
|
||||
|
||||
@@ -74,9 +74,15 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuContent: function DropdownMenuContent(props: Record<string, unknown>) {
|
||||
return { type: 'DropdownMenuContent', props }
|
||||
},
|
||||
DropdownMenuItem: function DropdownMenuItem(props: Record<string, unknown>) {
|
||||
return { type: 'DropdownMenuItem', props }
|
||||
},
|
||||
DropdownMenuLabel: function DropdownMenuLabel(props: Record<string, unknown>) {
|
||||
return { type: 'DropdownMenuLabel', props }
|
||||
},
|
||||
DropdownMenuSeparator: function DropdownMenuSeparator(props: Record<string, unknown>) {
|
||||
return { type: 'DropdownMenuSeparator', props }
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: Record<string, unknown>) {
|
||||
return { type: 'DropdownMenuSub', props }
|
||||
},
|
||||
@@ -109,8 +115,20 @@ vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
|
||||
focusTerminalTabSurface: vi.fn()
|
||||
vi.mock('@/lib/active-agent-note-send', () => ({
|
||||
activeAgentNotesSendFailureMessage: (status: string) => status,
|
||||
sendNotesToActiveAgentSession: vi.fn(),
|
||||
useCanSendNotesToActiveTerminal: () => false
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
dismiss: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
message: vi.fn(),
|
||||
success: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function resetHookRuntime(): void {
|
||||
@@ -291,6 +309,19 @@ describe('NotesSendMenu', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('offers the active session action alongside new agent launchers', () => {
|
||||
const tree = renderMenu()
|
||||
|
||||
expect(findByType(tree, 'DropdownMenuItem').props.disabled).toBe(true)
|
||||
expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
prompt: 'prompt-all',
|
||||
promptDelivery: 'submit-after-ready',
|
||||
launchSource: 'notes_send'
|
||||
})
|
||||
})
|
||||
|
||||
it('switches running-agent target mode when a different scope is focused', () => {
|
||||
const tree = renderMenu({
|
||||
defaultScopeId: 'file',
|
||||
|
||||
@@ -12,9 +12,8 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent'
|
||||
|
||||
const ENABLED_SEND_TOOLTIP = 'Send notes to an agent'
|
||||
|
||||
@@ -195,10 +194,9 @@ export function NotesSendMenu<TNote>({
|
||||
<NoteScopeMenuRow label={scope.label} count={scope.notes.length} />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
<QuickLaunchAgentMenuItems
|
||||
<ReviewNotesSendMenuContent
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
onFocusTerminal={focusTerminalTabSurface}
|
||||
prompt={scope.prompt}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
@@ -209,10 +207,9 @@ export function NotesSendMenu<TNote>({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<QuickLaunchAgentMenuItems
|
||||
<ReviewNotesSendMenuContent
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
onFocusTerminal={focusTerminalTabSurface}
|
||||
prompt={defaultScope?.prompt ?? ''}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { SquareTerminal } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import {
|
||||
activeAgentNotesSendFailureMessage,
|
||||
sendNotesToActiveAgentSession,
|
||||
useCanSendNotesToActiveTerminal
|
||||
} from '@/lib/active-agent-note-send'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
|
||||
export function ReviewNotesSendMenuContent({
|
||||
worktreeId,
|
||||
groupId,
|
||||
prompt,
|
||||
promptDelivery = 'submit-after-ready',
|
||||
launchSource = 'notes_send',
|
||||
onPromptDelivered
|
||||
}: {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
prompt: string
|
||||
promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready'
|
||||
launchSource?: LaunchSource
|
||||
onPromptDelivered?: () => void
|
||||
}): React.JSX.Element {
|
||||
const hasPrompt = prompt.trim().length > 0
|
||||
const canSendToActiveTerminal = useCanSendNotesToActiveTerminal(worktreeId)
|
||||
|
||||
const sendToActiveAgent = useCallback(() => {
|
||||
const pending = toast.loading('Sending notes to active agent...')
|
||||
void sendNotesToActiveAgentSession({ worktreeId, prompt })
|
||||
.then((result) => {
|
||||
if (result.status === 'sent') {
|
||||
onPromptDelivered?.()
|
||||
toast.success('Notes sent to active agent.')
|
||||
return
|
||||
}
|
||||
toast.message(activeAgentNotesSendFailureMessage(result.status))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to send notes to active agent:', error)
|
||||
toast.error('Could not send notes to the active agent.')
|
||||
})
|
||||
.finally(() => {
|
||||
toast.dismiss(pending)
|
||||
})
|
||||
}, [worktreeId, prompt, onPromptDelivered])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel>Send notes to</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasPrompt || !canSendToActiveTerminal}
|
||||
onSelect={sendToActiveAgent}
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
|
||||
>
|
||||
<SquareTerminal className="size-3.5" />
|
||||
Active agent session
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>New agent</DropdownMenuLabel>
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
onFocusTerminal={focusTerminalTabSurface}
|
||||
prompt={prompt}
|
||||
promptDelivery={promptDelivery}
|
||||
launchSource={launchSource}
|
||||
onPromptDelivered={onPromptDelivered}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getActiveTerminalNoteTarget,
|
||||
sendNotesToActiveAgentSession
|
||||
} from './active-agent-note-send'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
appState: {
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabType: 'terminal',
|
||||
activeTabId: 'tab-1',
|
||||
activeTabIdByWorktree: {},
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': { activeLeafId: 'leaf-1' }
|
||||
},
|
||||
settings: {}
|
||||
} as {
|
||||
activeWorktreeId: string | null
|
||||
activeTabType: 'terminal' | 'editor'
|
||||
activeTabId: string | null
|
||||
activeTabIdByWorktree: Record<string, string | null>
|
||||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
terminalLayoutsByTabId: Record<string, { activeLeafId: string | null }>
|
||||
settings: Record<string, unknown>
|
||||
},
|
||||
callRuntimeRpc: vi.fn(),
|
||||
getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' }))
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: typeof testState.appState) => unknown) => selector(testState.appState),
|
||||
{
|
||||
getState: () => testState.appState
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: testState.callRuntimeRpc,
|
||||
getActiveRuntimeTarget: testState.getActiveRuntimeTarget
|
||||
}))
|
||||
|
||||
describe('active agent note send', () => {
|
||||
beforeEach(() => {
|
||||
testState.appState = {
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabType: 'terminal',
|
||||
activeTabId: 'tab-1',
|
||||
activeTabIdByWorktree: {},
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1' }]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': { activeLeafId: 'leaf-1' }
|
||||
},
|
||||
settings: {}
|
||||
}
|
||||
testState.callRuntimeRpc.mockReset()
|
||||
testState.getActiveRuntimeTarget.mockClear()
|
||||
testState.getActiveRuntimeTarget.mockReturnValue({ kind: 'local' })
|
||||
})
|
||||
|
||||
it('resolves the current worktree terminal pane from renderer state', () => {
|
||||
expect(getActiveTerminalNoteTarget(testState.appState, 'wt-1')).toEqual({
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the per-worktree active tab fallback', () => {
|
||||
testState.appState.activeTabId = null
|
||||
testState.appState.activeTabIdByWorktree = { 'wt-1': 'tab-1' }
|
||||
|
||||
expect(getActiveTerminalNoteTarget(testState.appState, 'wt-1')).toEqual({
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the last active terminal tab while the user is viewing editor notes', () => {
|
||||
testState.appState.activeTabType = 'editor'
|
||||
testState.appState.activeTabIdByWorktree = { 'wt-1': 'tab-1' }
|
||||
|
||||
expect(getActiveTerminalNoteTarget(testState.appState, 'wt-1')).toEqual({
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('sends notes only after the active terminal is verified as an idle agent', async () => {
|
||||
testState.callRuntimeRpc.mockImplementation(async (_target, method, params) => {
|
||||
if (method === 'terminal.list') {
|
||||
return {
|
||||
terminals: [
|
||||
{
|
||||
handle: 'term-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
branch: 'main',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Codex',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: 1,
|
||||
preview: ''
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
if (method === 'terminal.isRunningAgent') {
|
||||
return { isRunningAgent: true }
|
||||
}
|
||||
if (method === 'terminal.wait') {
|
||||
return {
|
||||
wait: {
|
||||
handle: 'term-1',
|
||||
condition: 'tui-idle',
|
||||
satisfied: true,
|
||||
status: 'running',
|
||||
exitCode: null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'terminal.send') {
|
||||
return { send: { handle: 'term-1', accepted: true, bytesWritten: params.text.length } }
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'File: src/app.ts' })
|
||||
).resolves.toEqual({ status: 'sent' })
|
||||
|
||||
expect(testState.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'terminal.list',
|
||||
{ worktree: 'id:wt-1', limit: 200 },
|
||||
{ timeoutMs: 15000 }
|
||||
)
|
||||
expect(testState.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'terminal.send',
|
||||
{ terminal: 'term-1', text: 'File: src/app.ts', enter: true },
|
||||
{ timeoutMs: 15000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not write notes when the active terminal is not an agent', 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-1',
|
||||
title: 'zsh',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: 1,
|
||||
preview: ''
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
if (method === 'terminal.isRunningAgent') {
|
||||
return { isRunningAgent: false }
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' })
|
||||
).resolves.toEqual({ status: 'no-agent' })
|
||||
|
||||
expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'terminal.send',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not write notes when the active agent is not ready', 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-1',
|
||||
title: 'Codex',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: 1,
|
||||
preview: ''
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
if (method === 'terminal.isRunningAgent') {
|
||||
return { isRunningAgent: true }
|
||||
}
|
||||
if (method === 'terminal.wait') {
|
||||
throw new Error('timeout')
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' })
|
||||
).resolves.toEqual({ status: 'not-ready' })
|
||||
|
||||
expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'terminal.send',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call runtime when no terminal pane is known for the worktree', async () => {
|
||||
testState.appState.activeTabType = 'editor'
|
||||
testState.appState.activeTabIdByWorktree = {}
|
||||
|
||||
await expect(
|
||||
sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' })
|
||||
).resolves.toEqual({ status: 'no-active-terminal' })
|
||||
|
||||
expect(testState.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalSend,
|
||||
RuntimeTerminalWait
|
||||
} from '../../../shared/runtime-types'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
|
||||
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'
|
||||
| 'empty'
|
||||
| 'no-active-terminal'
|
||||
| 'no-agent'
|
||||
| 'not-ready'
|
||||
| 'not-writable'
|
||||
|
||||
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>
|
||||
terminalLayoutsByTabId: Record<string, { activeLeafId: string | null } | 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) => getActiveTerminalNoteTarget(state, worktreeId) !== null)
|
||||
}
|
||||
|
||||
export async function sendNotesToActiveAgentSession({
|
||||
worktreeId,
|
||||
prompt,
|
||||
timeoutMs = ACTIVE_AGENT_SEND_TIMEOUT_MS
|
||||
}: {
|
||||
worktreeId: string
|
||||
prompt: string
|
||||
timeoutMs?: number
|
||||
}): Promise<ActiveAgentNotesSendResult> {
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
return { status: 'empty' }
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const noteTarget = getActiveTerminalNoteTarget(state, worktreeId)
|
||||
if (!noteTarget) {
|
||||
return { status: 'no-active-terminal' }
|
||||
}
|
||||
|
||||
const runtimeTarget = getActiveRuntimeTarget(state.settings)
|
||||
const terminal = await findActiveRuntimeTerminal(runtimeTarget, worktreeId, noteTarget)
|
||||
if (!terminal) {
|
||||
return { status: 'no-active-terminal' }
|
||||
}
|
||||
|
||||
// Why: sending notes submits with Enter, so only the runtime's agent/idle
|
||||
// checks can authorize it; tab labels and renderer state are not enough.
|
||||
const agentCheck = await callRuntimeRpc<{ isRunningAgent: boolean }>(
|
||||
runtimeTarget,
|
||||
'terminal.isRunningAgent',
|
||||
{ terminal: terminal.handle },
|
||||
{ timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!agentCheck.isRunningAgent) {
|
||||
return { status: 'no-agent' }
|
||||
}
|
||||
|
||||
try {
|
||||
const { wait } = await callRuntimeRpc<{ wait: RuntimeTerminalWait }>(
|
||||
runtimeTarget,
|
||||
'terminal.wait',
|
||||
{ terminal: terminal.handle, for: 'tui-idle', timeoutMs },
|
||||
{ timeoutMs: timeoutMs + 5000 }
|
||||
)
|
||||
if (!wait.satisfied) {
|
||||
return { status: 'not-ready' }
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRuntimeTerminalUnavailable(error)) {
|
||||
return { status: 'no-active-terminal' }
|
||||
}
|
||||
if (isRuntimeTimeout(error)) {
|
||||
return { status: 'not-ready' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>(
|
||||
runtimeTarget,
|
||||
'terminal.send',
|
||||
{ terminal: terminal.handle, text: trimmedPrompt, enter: true },
|
||||
{ timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS }
|
||||
)
|
||||
return send.accepted ? { status: 'sent' } : { status: 'not-writable' }
|
||||
}
|
||||
|
||||
export function activeAgentNotesSendFailureMessage(status: ActiveAgentNotesSendStatus): string {
|
||||
switch (status) {
|
||||
case 'empty':
|
||||
return 'No notes to send.'
|
||||
case 'no-active-terminal':
|
||||
return 'Open the agent terminal in this worktree, then send the notes again.'
|
||||
case 'no-agent':
|
||||
return 'The active terminal is not a recognized agent session.'
|
||||
case 'not-ready':
|
||||
return 'The active agent was not ready for input yet.'
|
||||
case 'not-writable':
|
||||
return 'The active terminal did not accept the notes.'
|
||||
case 'sent':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
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; the runtime selector
|
||||
// accepts raw values, but the id: prefix keeps the lookup unambiguous.
|
||||
{ worktree: `id:${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 isRuntimeTimeout(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.includes('timeout')
|
||||
}
|
||||
|
||||
function isRuntimeTerminalUnavailable(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return (
|
||||
message.includes('terminal_handle_stale') ||
|
||||
message.includes('terminal_exited') ||
|
||||
message.includes('terminal_gone') ||
|
||||
message.includes('no_active_terminal')
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user