From a2cbb3d66421b779ee49ee6dae45fe28f3e39c44 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 10:57:08 -0700 Subject: [PATCH] Send review notes to active agent session --- src/main/runtime/rpc/methods/terminal.ts | 7 + src/main/runtime/rpc/terminal-send.test.ts | 20 ++ src/main/runtime/runtime-rpc.ts | 2 + .../components/editor/NotesSendMenu.test.tsx | 35 ++- .../src/components/editor/NotesSendMenu.tsx | 9 +- .../editor/ReviewNotesSendMenuContent.tsx | 80 ++++++ .../src/lib/active-agent-note-send.test.ts | 250 ++++++++++++++++++ .../src/lib/active-agent-note-send.ts | 181 +++++++++++++ 8 files changed, 576 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx create mode 100644 src/renderer/src/lib/active-agent-note-send.test.ts create mode 100644 src/renderer/src/lib/active-agent-note-send.ts diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index a21689f71ef..d035dfb9fa3 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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, diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 37e4c503135..1950a211682 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -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' }), diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 29d49ac41cf..eadc4b780e6 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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', diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index 224bbfad3b3..d412bfee659 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -74,9 +74,15 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuContent: function DropdownMenuContent(props: Record) { return { type: 'DropdownMenuContent', props } }, + DropdownMenuItem: function DropdownMenuItem(props: Record) { + return { type: 'DropdownMenuItem', props } + }, DropdownMenuLabel: function DropdownMenuLabel(props: Record) { return { type: 'DropdownMenuLabel', props } }, + DropdownMenuSeparator: function DropdownMenuSeparator(props: Record) { + return { type: 'DropdownMenuSeparator', props } + }, DropdownMenuSub: function DropdownMenuSub(props: Record) { 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', diff --git a/src/renderer/src/components/editor/NotesSendMenu.tsx b/src/renderer/src/components/editor/NotesSendMenu.tsx index 8d0b5c107c2..7e983a5e03c 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.tsx @@ -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({ - ({ ))} ) : ( - 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 ( + <> + Send notes to + + + Active agent session + + + New agent + + + ) +} diff --git a/src/renderer/src/lib/active-agent-note-send.test.ts b/src/renderer/src/lib/active-agent-note-send.test.ts new file mode 100644 index 00000000000..8ec3c9cb34d --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send.test.ts @@ -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 + tabsByWorktree: Record + terminalLayoutsByTabId: Record + settings: Record + }, + 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() + }) +}) diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts new file mode 100644 index 00000000000..923bada66ef --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -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 + terminalLayoutsByTabId: 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) => getActiveTerminalNoteTarget(state, worktreeId) !== null) +} + +export async function sendNotesToActiveAgentSession({ + worktreeId, + prompt, + timeoutMs = ACTIVE_AGENT_SEND_TIMEOUT_MS +}: { + worktreeId: string + prompt: string + timeoutMs?: number +}): Promise { + 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, + worktreeId: string, + noteTarget: ActiveTerminalNoteTarget +): Promise { + const { terminals } = await callRuntimeRpc( + 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') + ) +}