From 1c603669c12d72bc8008278d007850ff0362d885 Mon Sep 17 00:00:00 2001 From: AJ <60591772+devajmeireles@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:42:27 -0300 Subject: [PATCH] Send review notes to any running agent of a worktree (#5654) Co-authored-by: Orca Co-authored-by: brennanb2025 --- src/main/ai-vault/session-scanner.test.ts | 4 +- .../components/editor/NotesSendMenu.test.tsx | 38 +- .../ReviewNotesSendMenuContent.test.tsx | 637 ++++++++++++++++++ .../editor/ReviewNotesSendMenuContent.tsx | 338 ++++++++-- .../WorktreeCardAgents.send-target.test.tsx | 33 +- .../sidebar/WorktreeCardAgents.test.tsx | 2 +- .../components/sidebar/WorktreeCardAgents.tsx | 18 +- .../src/components/sidebar/WorktreeList.tsx | 10 +- .../worktree-card-compact-agent-row.tsx | 272 ++++++++ .../sidebar/worktree-card-compact-agents.tsx | 228 +------ .../src/lib/active-agent-note-send.test.ts | 108 +++ .../src/lib/active-agent-note-send.ts | 14 +- .../src/lib/active-agent-note-target.ts | 35 +- src/renderer/src/lib/agent-status.ts | 10 + .../src/lib/notes-send-agent-targets.test.ts | 276 ++++++++ .../src/lib/notes-send-agent-targets.ts | 116 ++++ .../src/lib/running-agent-targets.test.ts | 53 +- src/renderer/src/lib/running-agent-targets.ts | 8 +- .../lib/runtime-pane-title-leaf-id.test.ts | 47 ++ .../src/lib/runtime-pane-title-leaf-id.ts | 41 ++ src/renderer/src/store/slices/ui.test.ts | 11 +- src/renderer/src/store/slices/ui.ts | 10 +- 22 files changed, 1976 insertions(+), 333 deletions(-) create mode 100644 src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx create mode 100644 src/renderer/src/lib/notes-send-agent-targets.test.ts create mode 100644 src/renderer/src/lib/notes-send-agent-targets.ts create mode 100644 src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index a01430a2453..e055819d121 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -613,9 +613,7 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'" ) expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") - expect(commandByAgent.get('devin')).toBe( - "cd '/tmp/devin' && devin --resume 'devin-session'" - ) + expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") }) }) diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index ddfabfd72c5..8ef06131029 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -63,10 +63,20 @@ vi.mock('@/store', () => ({ selector({ openAgentSendPopoverTargetMode: storeMocks.openAgentSendPopoverTargetMode, closeAgentSendPopoverTargetMode: storeMocks.closeAgentSendPopoverTargetMode, - agentSendPopoverTargetMode: storeMocks.state.agentSendPopoverTargetMode + agentSendPopoverTargetMode: storeMocks.state.agentSendPopoverTargetMode, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {} }) })) +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector +})) + vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: Record) { return { type: 'DropdownMenu', props } @@ -115,8 +125,30 @@ vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({ } })) +vi.mock('@/components/sidebar/useWorktreeAgentRows', () => ({ + useWorktreeAgentRows: () => [] +})) + +vi.mock('@/components/dashboard/useNow', () => ({ + useNow: () => 0 +})) + +vi.mock('@/components/AgentStateDot', () => ({ + AgentStateDot: function AgentStateDot(props: Record) { + return { type: 'AgentStateDot', props } + }, + agentStateLabel: (state: string) => state +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: function AgentIcon(props: Record) { + return { type: 'AgentIcon', props } + } +})) + vi.mock('@/lib/active-agent-note-send', () => ({ activeAgentNotesSendFailureMessage: (status: string) => status, + getActiveTerminalNoteTarget: () => null, sendNotesToActiveAgentSession: vi.fn(), useCanSendNotesToActiveTerminal: () => false })) @@ -309,10 +341,10 @@ describe('NotesSendMenu', () => { ) }) - it('offers the active session action alongside new agent launchers', () => { + it('offers new agent launchers when no existing agent targets are listed', () => { const tree = renderMenu() - expect(findByType(tree, 'DropdownMenuItem').props.disabled).toBe(true) + expect(findAllByType(tree, 'DropdownMenuItem')).toHaveLength(0) expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({ worktreeId: 'wt-1', groupId: 'group-1', diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx new file mode 100644 index 00000000000..95e7ce776ea --- /dev/null +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -0,0 +1,637 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' +import type { TuiAgent } from '../../../../shared/types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent' + +type ReactElementLike = { + type: unknown + props: Record +} + +const TAB_A = 'tab-a' +const TAB_B = 'tab-b' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +const hookRuntime = vi.hoisted(() => ({ + states: [] as unknown[], + index: 0, + cleanups: [] as (() => void)[] +})) + +const harness = vi.hoisted(() => ({ + storeState: {} as Record, + sendNotesToActiveAgentSession: vi.fn(), + track: vi.fn(), + worktreeAgentRows: [] as DashboardAgentRowData[], + noteTargets: [] as { + paneKey: string + tabId: string + leafId: string + agentType: TuiAgent + tabTitle: string + status: 'eligible' | 'disabled' + disabledReason?: string + }[], + now: 600_000 +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + useCallback unknown>(callback: T): T { + return callback + }, + useMemo(factory: () => T): T { + return factory() + }, + useEffect(effect: () => void | (() => void)): void { + const cleanup = effect() + if (typeof cleanup === 'function') { + hookRuntime.cleanups.push(cleanup) + } + }, + useState(initial: T | (() => T)) { + const stateIndex = hookRuntime.index++ + if (!(stateIndex in hookRuntime.states)) { + hookRuntime.states[stateIndex] = + typeof initial === 'function' ? (initial as () => T)() : initial + } + const setState = (next: T | ((previous: T) => T)): void => { + hookRuntime.states[stateIndex] = + typeof next === 'function' + ? (next as (previous: T) => T)(hookRuntime.states[stateIndex] as T) + : next + } + return [hookRuntime.states[stateIndex] as T, setState] as const + } + } +}) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector(harness.storeState) +})) + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector +})) + +vi.mock('@/lib/active-agent-note-send', () => ({ + activeAgentNotesSendFailureMessage: (status: string) => status, + sendNotesToActiveAgentSession: harness.sendNotesToActiveAgentSession +})) + +vi.mock('@/lib/notes-send-agent-targets', () => ({ + deriveNotesSendAgentTargets: () => harness.noteTargets +})) + +vi.mock('@/lib/telemetry', () => ({ + track: harness.track +})) + +vi.mock('@/components/dashboard/useNow', () => ({ + useNow: () => harness.now +})) + +vi.mock('@/components/sidebar/useWorktreeAgentRows', () => ({ + useWorktreeAgentRows: () => harness.worktreeAgentRows +})) + +vi.mock('@/components/AgentStateDot', () => ({ + AgentStateDot: function AgentStateDot(props: Record) { + return { type: 'AgentStateDot', props } + }, + agentStateLabel: (state: string) => { + switch (state) { + case 'working': + return 'Working' + case 'blocked': + return 'Blocked' + case 'waiting': + return 'Waiting for input' + case 'done': + return 'Done' + case 'idle': + return 'Idle' + default: + return state + } + } +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: function AgentIcon(props: Record) { + return { type: 'AgentIcon', props } + } +})) + +vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({ + QuickLaunchAgentMenuItems: function QuickLaunchAgentMenuItems(props: Record) { + return { type: 'QuickLaunchAgentMenuItems', props } + } +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + 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 } + } +})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + loading: vi.fn(() => 'toast-id'), + message: vi.fn(), + success: vi.fn() + } +})) + +function agentEntry( + paneKey: string, + agentType: TuiAgent, + state: AgentStatusState = 'done', + stateStartedAt = harness.now +): AgentStatusEntry { + return { + paneKey, + state, + prompt: '', + updatedAt: stateStartedAt, + stateStartedAt, + agentType, + stateHistory: [] + } +} + +function agentRow({ + paneKey, + tabId, + title, + agentType, + state = 'done', + startedAt = harness.now +}: { + paneKey: string + tabId: string + title: string + agentType: TuiAgent + state?: AgentStatusState | 'idle' + startedAt?: number +}): DashboardAgentRowData { + const entryState: AgentStatusState = state === 'idle' ? 'working' : state + return { + paneKey, + entry: agentEntry(paneKey, agentType, entryState, startedAt), + tab: tab(tabId, { title }) as DashboardAgentRowData['tab'], + agentType, + state, + startedAt + } +} + +function tab(id: string, overrides: Record = {}) { + return { + id, + worktreeId: 'wt-1', + ptyId: null, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function leafLayout(leafId: string, ptyId: string) { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +function setStore(overrides: Record = {}): void { + harness.storeState = { + activeWorktreeId: 'wt-1', + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + tabsByWorktree: { 'wt-1': [] }, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + ...overrides + } +} + +function expand(node: unknown): unknown { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return node + } + if (Array.isArray(node)) { + return node.map((entry) => expand(entry)) + } + if (!React.isValidElement(node)) { + if (typeof node === 'object' && 'props' in node) { + const element = node as ReactElementLike + return { ...element, props: { ...element.props, children: expand(element.props.children) } } + } + return node + } + const element = node as React.ReactElement> + if (typeof element.type === 'function') { + const Component = element.type as (props: Record) => unknown + return expand(Component(element.props)) + } + return { + type: element.type, + props: { ...element.props, children: expand(element.props.children) } + } +} + +function visit(node: unknown, cb: (node: ReactElementLike) => void): void { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return + } + if (Array.isArray(node)) { + node.forEach((entry) => visit(entry, cb)) + return + } + const element = node as ReactElementLike + cb(element) + if (element.props?.children) { + visit(element.props.children, cb) + } +} + +function findAllByType(node: unknown, type: unknown): ReactElementLike[] { + const found: ReactElementLike[] = [] + visit(node, (entry) => { + if (entry.type === type) { + found.push(entry) + } + }) + return found +} + +function findByType(node: unknown, type: unknown): ReactElementLike { + const found = findAllByType(node, type)[0] + if (!found) { + throw new Error(`element not found: ${String(type)}`) + } + return found +} + +function collectText(node: unknown): string { + if (node == null || typeof node === 'boolean') { + return '' + } + if (typeof node === 'string') { + return node + } + if (typeof node === 'number') { + return String(node) + } + if (Array.isArray(node)) { + return node.map(collectText).join('') + } + const element = node as ReactElementLike + return collectText(element.props?.children) +} + +function render(props: Record = {}): unknown { + hookRuntime.index = 0 + return expand( + + ) +} + +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('ReviewNotesSendMenuContent', () => { + beforeEach(() => { + hookRuntime.states = [] + hookRuntime.index = 0 + hookRuntime.cleanups = [] + harness.sendNotesToActiveAgentSession.mockReset() + harness.sendNotesToActiveAgentSession.mockResolvedValue({ status: 'sent' }) + harness.track.mockReset() + harness.worktreeAgentRows = [] + harness.noteTargets = [] + harness.now = 600_000 + setStore() + }) + + it('enumerates each running agent of the worktree as a send target', () => { + const statusPaneKey = makePaneKey(TAB_A, LEAF_A) + setStore({ + tabsByWorktree: { + 'wt-1': [ + tab(TAB_A, { title: 'Terminal 1' }), + tab(TAB_B, { title: 'Codex', launchAgent: 'codex' }) + ] + }, + terminalLayoutsByTabId: { + [TAB_A]: leafLayout(LEAF_A, 'pty-a'), + [TAB_B]: leafLayout(LEAF_B, 'pty-b') + } + }) + harness.noteTargets = [ + { + paneKey: statusPaneKey, + tabId: TAB_A, + leafId: LEAF_A, + agentType: 'claude', + tabTitle: 'Terminal 1', + status: 'eligible' + }, + { + paneKey: makePaneKey(TAB_B, LEAF_B), + tabId: TAB_B, + leafId: LEAF_B, + agentType: 'codex', + tabTitle: 'Codex', + status: 'eligible' + } + ] + + const tree = render() + const items = findAllByType(tree, 'DropdownMenuItem') + + expect(items).toHaveLength(2) + expect(items.every((item) => item.props.disabled === false)).toBe(true) + expect(collectText(items[0])).toContain('Claude') + expect(collectText(items[1])).toContain('Codex') + }) + + it('orders send targets by the current worktree agent rows and shows status timing', () => { + const paneKeyA = makePaneKey(TAB_A, LEAF_A) + const paneKeyB = makePaneKey(TAB_B, LEAF_B) + harness.worktreeAgentRows = [ + agentRow({ + paneKey: paneKeyB, + tabId: TAB_B, + title: 'Second session', + agentType: 'codex', + startedAt: harness.now - 120_000 + }), + agentRow({ + paneKey: paneKeyA, + tabId: TAB_A, + title: 'First session', + agentType: 'claude', + startedAt: harness.now - 60_000 + }) + ] + setStore({ + tabsByWorktree: { + 'wt-1': [tab(TAB_A, { title: 'First session' }), tab(TAB_B, { title: 'Second session' })] + }, + terminalLayoutsByTabId: { + [TAB_A]: leafLayout(LEAF_A, 'pty-a'), + [TAB_B]: leafLayout(LEAF_B, 'pty-b') + } + }) + harness.noteTargets = [ + { + paneKey: paneKeyA, + tabId: TAB_A, + leafId: LEAF_A, + agentType: 'claude', + tabTitle: 'First session', + status: 'eligible' + }, + { + paneKey: paneKeyB, + tabId: TAB_B, + leafId: LEAF_B, + agentType: 'codex', + tabTitle: 'Second session', + status: 'eligible' + } + ] + + const tree = render() + const items = findAllByType(tree, 'DropdownMenuItem') + + expect(items).toHaveLength(2) + expect(collectText(items[0])).toContain('Codex') + expect(collectText(items[0])).toContain('Done') + expect(collectText(items[0])).toContain('2m ago') + expect(collectText(items[0])).toContain('Second session') + expect(collectText(items[1])).toContain('Claude') + }) + + it('can target a title-detected agent row that has not reported hook status yet', async () => { + const paneKey = makePaneKey(TAB_B, LEAF_B) + harness.worktreeAgentRows = [ + agentRow({ + paneKey, + tabId: TAB_B, + title: 'Codex', + agentType: 'codex', + state: 'idle', + startedAt: harness.now + }) + ] + setStore({ + tabsByWorktree: { 'wt-1': [tab(TAB_B, { title: 'Codex' })] }, + terminalLayoutsByTabId: { [TAB_B]: leafLayout(LEAF_B, 'pty-b') }, + ptyIdsByTabId: { [TAB_B]: ['pty-b'] } + }) + + const tree = render() + const item = findByType(tree, 'DropdownMenuItem') + ;(item.props.onSelect as () => void)() + await flushMicrotasks() + + expect(collectText(item)).toContain('Codex') + expect(collectText(item)).toContain('Idle') + expect(harness.sendNotesToActiveAgentSession).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + prompt: 'my notes', + noteTarget: { tabId: TAB_B, leafId: LEAF_B } + }) + }) + + it('does not offer a title-detected agent row after its live PTY has exited', () => { + const paneKey = makePaneKey(TAB_B, LEAF_B) + harness.worktreeAgentRows = [ + agentRow({ + paneKey, + tabId: TAB_B, + title: 'Codex', + agentType: 'codex', + state: 'done', + startedAt: harness.now - 60_000 + }) + ] + setStore({ + tabsByWorktree: { 'wt-1': [tab(TAB_B, { title: 'Codex' })] }, + terminalLayoutsByTabId: { [TAB_B]: leafLayout(LEAF_B, 'pty-b') }, + ptyIdsByTabId: { [TAB_B]: [] } + }) + + const tree = render() + const items = findAllByType(tree, 'DropdownMenuItem') + + expect(items).toHaveLength(0) + expect(collectText(tree)).not.toContain('Active agent session') + }) + + it('does not render an active agent fallback alongside named targets', () => { + const listedPaneKey = makePaneKey(TAB_A, LEAF_A) + setStore({ + tabsByWorktree: { 'wt-1': [tab(TAB_A), tab(TAB_B)] }, + terminalLayoutsByTabId: { + [TAB_A]: leafLayout(LEAF_A, 'pty-a'), + [TAB_B]: leafLayout(LEAF_B, 'pty-b') + } + }) + harness.noteTargets = [ + { + paneKey: listedPaneKey, + tabId: TAB_A, + leafId: LEAF_A, + agentType: 'claude', + tabTitle: 'Terminal 1', + status: 'eligible' + } + ] + + const tree = render() + const items = findAllByType(tree, 'DropdownMenuItem') + + expect(items).toHaveLength(1) + expect(collectText(items[0])).toContain('Claude') + expect(collectText(tree)).not.toContain('Active agent session') + expect(harness.sendNotesToActiveAgentSession).not.toHaveBeenCalled() + }) + + it('does not render an active agent fallback when the matching derived row is disabled', () => { + const paneKey = makePaneKey(TAB_A, LEAF_A) + setStore({ + tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Codex' })] }, + terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } + }) + harness.noteTargets = [ + { + paneKey, + tabId: TAB_A, + leafId: LEAF_A, + agentType: 'codex', + tabTitle: 'Codex', + status: 'disabled', + disabledReason: 'Agent status is stale' + } + ] + + const tree = render() + const items = findAllByType(tree, 'DropdownMenuItem') + + expect(items).toHaveLength(1) + expect(items[0].props.disabled).toBe(true) + expect(collectText(tree)).not.toContain('Active agent session') + }) + + it('sends notes to the chosen agent and tracks the send once it succeeds', async () => { + const statusPaneKey = makePaneKey(TAB_A, LEAF_A) + const onPromptDelivered = vi.fn() + setStore({ + tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] }, + terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } + }) + harness.noteTargets = [ + { + paneKey: statusPaneKey, + tabId: TAB_A, + leafId: LEAF_A, + agentType: 'claude', + tabTitle: 'Terminal 1', + status: 'eligible' + } + ] + + const tree = render({ onPromptDelivered }) + ;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)() + await flushMicrotasks() + + expect(harness.sendNotesToActiveAgentSession).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + prompt: 'my notes', + noteTarget: { tabId: TAB_A, leafId: LEAF_A } + }) + expect(onPromptDelivered).toHaveBeenCalledTimes(1) + expect(harness.track).toHaveBeenCalledWith('agent_prompt_sent', { + agent_kind: 'claude-code', + launch_source: 'notes_send', + request_kind: 'followup' + }) + }) + + it('disables a working agent and never sends to it', () => { + const statusPaneKey = makePaneKey(TAB_A, LEAF_A) + setStore({ + tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] }, + terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } + }) + harness.noteTargets = [ + { + paneKey: statusPaneKey, + tabId: TAB_A, + leafId: LEAF_A, + agentType: 'claude', + tabTitle: 'Terminal 1', + status: 'disabled', + disabledReason: 'Agent is working' + } + ] + + const tree = render() + const item = findByType(tree, 'DropdownMenuItem') + + expect(item.props.disabled).toBe(true) + expect(item.props.title).toBe('Agent is working') + ;(item.props.onSelect as () => void)() + expect(harness.sendNotesToActiveAgentSession).not.toHaveBeenCalled() + }) + + it('does not render an active agent fallback when no agents are derived', () => { + const tree = render() + const items = findAllByType(tree, 'DropdownMenuItem') + + expect(items).toHaveLength(0) + expect(collectText(tree)).not.toContain('Active agent session') + expect(harness.sendNotesToActiveAgentSession).not.toHaveBeenCalled() + }) + + it('always offers the new-agent launcher', () => { + const tree = render() + + expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({ + worktreeId: 'wt-1', + groupId: 'group-1', + prompt: 'my notes', + launchSource: 'notes_send' + }) + }) +}) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 572746cda2f..0dce736025e 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -1,7 +1,10 @@ -import React, { useCallback } from 'react' -import { SquareTerminal } from 'lucide-react' +import React, { useCallback, useMemo } from 'react' import { toast } from 'sonner' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton' +import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' +import { AgentIcon } from '@/lib/agent-catalog' import { DropdownMenuItem, DropdownMenuLabel, @@ -11,11 +14,29 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { activeAgentNotesSendFailureMessage, sendNotesToActiveAgentSession, - useCanSendNotesToActiveTerminal + type ActiveAgentNotesSendResult } from '@/lib/active-agent-note-send' +import { + deriveNotesSendAgentTargets, + type NotesSendAgentTarget +} from '@/lib/notes-send-agent-targets' +import { agentKindForAgentType, formatAgentTypeLabel } from '@/lib/agent-status' +import { agentTypeToIconAgent } from '@/lib/agent-status' +import { track } from '@/lib/telemetry' +import { useNow } from '@/components/dashboard/useNow' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { selectLivePtyIdsForWorktree } from '@/components/sidebar/worktree-card-status-inputs' +import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows' +import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { LaunchSource } from '../../../../shared/telemetry-events' +import type { AgentStatusState } from '../../../../shared/agent-status-types' import { translate } from '@/i18n/i18n' +type OrderedSendTarget = { + target: NotesSendAgentTarget + agent: DashboardAgentRowData | null +} + export function ReviewNotesSendMenuContent({ worktreeId, groupId, @@ -32,62 +53,135 @@ export function ReviewNotesSendMenuContent({ onPromptDelivered?: () => void }): React.JSX.Element { const hasPrompt = prompt.trim().length > 0 - const canSendToActiveAgent = useCanSendNotesToActiveTerminal(worktreeId) - const sendToActiveAgent = useCallback(() => { - if (!hasPrompt || !canSendToActiveAgent) { - return - } - const pending = toast.loading( - translate( - 'auto.components.editor.ReviewNotesSendMenuContent.50f7e753ea', - 'Sending notes to active agent...' - ) + // Why: enumerate every running agent of the worktree so the user can target + // any of them — not only the focused pane. Derive from store slices in a memo + // to avoid the new-array identity churn of selecting the function result. + const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) + const ptyIdsByTabId = useAppStore(useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId))) + const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) + const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const agentRows = useWorktreeAgentRows(worktreeId) + const now = useNow(30_000) + const sendTargets = useMemo(() => { + void agentStatusEpoch + return deriveNotesSendAgentTargets( + { + agentStatusByPaneKey, + tabsByWorktree, + terminalLayoutsByTabId, + ptyIdsByTabId, + runtimePaneTitlesByTabId + }, + worktreeId ) - void sendNotesToActiveAgentSession({ worktreeId, prompt }) - .then((result) => { - if (result.status === 'sent') { - onPromptDelivered?.() - toast.success( + }, [ + // Why: stale-boundary timers bump this epoch without replacing the + // status map, so eligibility must derive again when freshness flips. + agentStatusEpoch, + agentStatusByPaneKey, + tabsByWorktree, + terminalLayoutsByTabId, + runtimePaneTitlesByTabId, + ptyIdsByTabId, + worktreeId + ]) + const orderedSendTargets = useMemo( + () => + orderSendTargetsByWorktreeAgentRows( + sendTargets, + agentRows, + terminalLayoutsByTabId, + ptyIdsByTabId + ), + [agentRows, sendTargets, terminalLayoutsByTabId, ptyIdsByTabId] + ) + + const runNotesSend = useCallback( + (send: () => Promise, onSent: () => void) => { + const pending = toast.loading( + translate( + 'auto.components.editor.ReviewNotesSendMenuContent.50f7e753ea', + 'Sending notes...' + ) + ) + + void send() + .then((result) => { + if (result.status === 'sent') { + onSent() + toast.success( + translate( + 'auto.components.editor.ReviewNotesSendMenuContent.bb9c69a0c9', + 'Notes sent.' + ) + ) + return + } + + toast.message(activeAgentNotesSendFailureMessage(result.status)) + }) + .catch((error) => { + console.error('Failed to send notes:', error) + toast.error( translate( - 'auto.components.editor.ReviewNotesSendMenuContent.bb9c69a0c9', - 'Notes sent to active agent.' + 'auto.components.editor.ReviewNotesSendMenuContent.f5096c6e4e', + 'Could not send notes.' ) ) - return + }) + .finally(() => { + toast.dismiss(pending) + }) + }, + [] + ) + + const sendToAgentTarget = useCallback( + (target: NotesSendAgentTarget) => { + if (!hasPrompt || target.status !== 'eligible') { + return + } + + runNotesSend( + () => + sendNotesToActiveAgentSession({ + worktreeId, + prompt, + noteTarget: { tabId: target.tabId, leafId: target.leafId } + }), + () => { + onPromptDelivered?.() + // Why: mirror the sidebar send-target telemetry so dropdown-routed + // follow-up notes show up identically on `agent_prompt_sent`. + track('agent_prompt_sent', { + agent_kind: agentKindForAgentType(target.agentType), + launch_source: launchSource, + request_kind: 'followup' + }) } - toast.message(activeAgentNotesSendFailureMessage(result.status)) - }) - .catch((error) => { - console.error('Failed to send notes to active agent:', error) - toast.error( - translate( - 'auto.components.editor.ReviewNotesSendMenuContent.f5096c6e4e', - 'Could not send notes to the active agent.' - ) - ) - }) - .finally(() => { - toast.dismiss(pending) - }) - }, [canSendToActiveAgent, hasPrompt, worktreeId, prompt, onPromptDelivered]) + ) + }, + [hasPrompt, runNotesSend, worktreeId, prompt, onPromptDelivered, launchSource] + ) return ( <> {translate('auto.components.editor.ReviewNotesSendMenuContent.03378aea75', 'Send notes to')} - - - {translate( - 'auto.components.editor.ReviewNotesSendMenuContent.e84705f223', - 'Active agent session' - )} - + {orderedSendTargets.map(({ target, agent }) => ( + + ))} {translate('auto.components.editor.ReviewNotesSendMenuContent.a49800405b', 'New agent')} @@ -104,3 +198,153 @@ export function ReviewNotesSendMenuContent({ ) } + +function AgentTargetMenuItem({ + target, + agent, + now, + disabled, + onSend +}: { + target: NotesSendAgentTarget + agent: DashboardAgentRowData | null + now: number + disabled: boolean + onSend: (target: NotesSendAgentTarget) => void +}): React.JSX.Element { + const tabTitle = target.tabTitle.trim() + const state = asDotState(agent?.state ?? 'idle') + const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null + const secondaryParts = [ + agentStateLabel(state), + ...(timeAgo ? [timeAgo] : []), + ...(tabTitle ? [tabTitle] : []) + ] + return ( + onSend(target)} + // Why: surface the ineligibility reason (working/stale/no-terminal) as a + // hover tooltip rather than inline text, matching DashboardAgentRow's + // title-attribute treatment of the same disabledReason. + title={target.status === 'disabled' ? target.disabledReason : undefined} + className="min-w-[240px] gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" + > + + + + + {formatAgentTypeLabel(target.agentType ?? agent?.agentType)} + + + {secondaryParts.join(' · ')} + + + + ) +} + +function orderSendTargetsByWorktreeAgentRows( + sendTargets: NotesSendAgentTarget[], + agentRows: DashboardAgentRowData[], + terminalLayoutsByTabId: Record } | undefined>, + ptyIdsByTabId: Record +): OrderedSendTarget[] { + const targetsByPaneKey = new Map(sendTargets.map((target) => [target.paneKey, target])) + const usedPaneKeys = new Set() + const ordered: OrderedSendTarget[] = [] + + for (const agent of agentRows) { + const target = + targetsByPaneKey.get(agent.paneKey) ?? + deriveTitleOnlySendTarget(agent, terminalLayoutsByTabId, ptyIdsByTabId) + if (!target) { + continue + } + ordered.push({ target: { ...target, agentType: agent.agentType }, agent }) + usedPaneKeys.add(target.paneKey) + } + + for (const target of sendTargets) { + if (!usedPaneKeys.has(target.paneKey)) { + ordered.push({ target, agent: null }) + } + } + + return ordered +} + +function deriveTitleOnlySendTarget( + agent: DashboardAgentRowData, + terminalLayoutsByTabId: Record } | undefined>, + ptyIdsByTabId: Record +): NotesSendAgentTarget | null { + const parsed = parsePaneKey(agent.paneKey) + if (!parsed) { + return null + } + const ptyId = terminalLayoutsByTabId[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] ?? null + if (!ptyId || !ptyIdsByTabId[parsed.tabId]?.includes(ptyId)) { + return null + } + const disabledReason = agent.state === 'working' ? 'Agent is working' : undefined + return { + paneKey: agent.paneKey, + tabId: parsed.tabId, + leafId: parsed.leafId, + agentType: agent.agentType, + tabTitle: agent.tab.title, + status: disabledReason ? 'disabled' : 'eligible', + ...(disabledReason ? { disabledReason } : {}) + } +} + +function asDotState(state: AgentStatusState | 'idle'): AgentDotState { + switch (state) { + case 'working': + case 'blocked': + case 'waiting': + case 'done': + case 'idle': + return state + } + return 'idle' +} + +function formatAgentRelativeTime(agent: DashboardAgentRowData, now: number): string | null { + const doneAt = lastEnteredDoneAt(agent) + if (doneAt !== null) { + return `${formatTimeAgo(doneAt, now)}` + } + const startedAt = agent.startedAt > 0 ? agent.startedAt : agent.entry.stateStartedAt + return startedAt > 0 ? `${formatTimeAgo(startedAt, now)}` : null +} + +function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { + const entry = agent.entry + if (entry.state === 'done') { + return entry.stateStartedAt + } + for (let i = entry.stateHistory.length - 1; i >= 0; i--) { + if (entry.stateHistory[i].state === 'done') { + return entry.stateHistory[i].startedAt + } + } + return null +} + +function formatTimeAgo(ts: number, now: number): string { + const delta = now - ts + if (delta < 60_000) { + return 'just now' + } + const minutes = Math.floor(delta / 60_000) + if (minutes < 60) { + return `${minutes}m ago` + } + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return `${hours}h ago` + } + return `${Math.floor(hours / 24)}d ago` +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx index 9f81438f7a4..1d289a0f1e8 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx @@ -12,9 +12,18 @@ const mockSendPromptToSidebarAgentTarget = vi.fn() function agentRow(paneKey: string, state: string, now: number): unknown { return { paneKey, - tab: { id: 'tab-1' }, + tab: { id: 'tab-1', title: 'Terminal' }, state, - entry: { stateStartedAt: now, orchestration: undefined } + startedAt: now, + agentType: 'codex', + entry: { + prompt: state === 'working' ? 'Busy' : 'Ready', + state, + stateStartedAt: now, + stateHistory: [], + lastAssistantMessage: '', + orchestration: undefined + } } } @@ -61,6 +70,9 @@ function targetStoreState(now: number): Record { '22222222-2222-4222-8222-222222222222': 'pty-2' } } + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1', 'pty-2'] } } } @@ -78,6 +90,7 @@ vi.mock('@/store', () => ({ agentStatusEpoch: 0, tabsByWorktree: {}, terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, sendPromptToSidebarAgentTarget: mockSendPromptToSidebarAgentTarget, ...mockStoreState }) @@ -144,7 +157,7 @@ describe('WorktreeCardAgents send targets', () => { expect(markup).toContain('data-disabled-reason="Agent is working"') expect(markup).toContain(`data-pane-key="${WORKING_PANE_KEY}"`) expect(markup).toContain('data-has-send-handler="true"') - }) + }, 10_000) it('leaves other worktree rows in ordinary mode during target selection', async () => { mockStoreState = { @@ -179,4 +192,18 @@ describe('WorktreeCardAgents send targets', () => { expect(markup).toContain('data-disabled-reason="Sending..."') expect(markup).toContain(`data-pane-key="${READY_PANE_KEY}"`) }) + + it('marks compact active-worktree rows as send targets in the default row UI', async () => { + mockStoreState = { + ...mockStoreState, + agentActivityDisplayMode: 'compact' + } + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + const markup = renderToStaticMarkup() + + expect(markup).toContain('data-agent-send-target="eligible"') + expect(markup).toContain('data-agent-send-target="disabled"') + expect(markup).toContain('title="Agent is working"') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index d291d97ff04..2c3cf82729e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -191,7 +191,7 @@ describe('WorktreeCardAgents', () => { expect(markup).toContain('data-testid="agent-row"') expect(markup).not.toContain(' { mockAgents = [mockAgent({ agentType: 'codex', startedAt: 1000, prompt: 'Run tests' })] diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 740ad711ee8..f919af26cf4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -90,6 +90,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) + const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const sendPromptToSidebarAgentTarget = useAppStore((s) => s.sendPromptToSidebarAgentTarget) const focusedAgentPaneKey = useFocusedAgentPaneKey(worktreeId) const compactAgentListRootRef = useRef(null) @@ -130,7 +131,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ return new Map( deriveRunningAgentSendTargets( - { agentStatusByPaneKey, tabsByWorktree, terminalLayoutsByTabId }, + { agentStatusByPaneKey, tabsByWorktree, terminalLayoutsByTabId, ptyIdsByTabId }, worktreeId ).map((target) => [ target.paneKey, @@ -150,6 +151,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ agentSendPopoverTargetMode?.status, agentStatusByPaneKey, isAgentSendTargetModeActive, + ptyIdsByTabId, tabsByWorktree, terminalLayoutsByTabId, worktreeId @@ -355,6 +357,12 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const hasChildAgents = childAgents.length > 0 const isRootAgent = ancestorPaneKeys.size === 0 const expanded = !collapsedLineageParents.has(agent.paneKey) + const sendTarget = isAgentSendTargetModeActive + ? (sendTargetsByPaneKey.get(agent.paneKey) ?? { + status: 'disabled' as const, + disabledReason: 'Agent is not available' + }) + : undefined const descendantAncestorPaneKeys = new Set(ancestorPaneKeys) descendantAncestorPaneKeys.add(agent.paneKey) return ( @@ -365,6 +373,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ onActivate={ agent.rowSource === 'retained' ? handleActivateRetainedAgent : handleActivateAgentTab } + sendTargetStatus={sendTarget?.status} + sendTargetDisabledReason={sendTarget?.disabledReason} + onSendTargetClick={isAgentSendTargetModeActive ? handleSendTargetClick : undefined} childAgentCount={hasChildAgents ? childAgents.length : undefined} childAgentsExpanded={expanded} onToggleChildAgents={ @@ -389,8 +400,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ if (agentActivityDisplayMode === 'compact') { const summaryAgents = hasLineage ? rootAgents : agents // Why: compact worktree cards keep multiple active agents to a single - // predictable status line, even when there are only two agents. - const shouldUseSummaryRow = summaryAgents.length > 1 + // predictable status line, even when there are only two agents. In + // send-target mode, rows are the picker surface, so keep targets visible. + const shouldUseSummaryRow = summaryAgents.length > 1 && !isAgentSendTargetModeActive const subjectLabel = `${hasLineage ? rootAgents.length : agents.length} agents` return ( diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e1beda8775d..7ff256d9c1b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -269,6 +269,7 @@ const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} +const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { @@ -4405,6 +4406,9 @@ const WorktreeList = React.memo(function WorktreeList({ const agentTargetTerminalLayoutsByTabId = useAppStore((s) => agentSendPopoverTargetMode ? s.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID ) + const agentTargetPtyIdsByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID + ) const agentSendTargetWorktreeId = useMemo(() => { void agentTargetStatusEpoch if (!agentSendPopoverTargetMode) { @@ -4414,7 +4418,8 @@ const WorktreeList = React.memo(function WorktreeList({ { agentStatusByPaneKey: agentTargetStatusByPaneKey, tabsByWorktree: agentTargetTabsByWorktree, - terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId + terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, + ptyIdsByTabId: agentTargetPtyIdsByTabId }, agentSendPopoverTargetMode.worktreeId ) @@ -4428,7 +4433,8 @@ const WorktreeList = React.memo(function WorktreeList({ agentSendPopoverTargetMode, agentTargetStatusByPaneKey, agentTargetTabsByWorktree, - agentTargetTerminalLayoutsByTabId + agentTargetTerminalLayoutsByTabId, + agentTargetPtyIdsByTabId ]) // Read tabsByWorktree when needed for filtering or sorting diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx new file mode 100644 index 00000000000..e865b354117 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -0,0 +1,272 @@ +import React, { useCallback } from 'react' +import { ChevronRight } from 'lucide-react' +import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { AgentIcon } from '@/lib/agent-catalog' +import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' +import { cn } from '@/lib/utils' +import CommentMarkdown from './CommentMarkdown' +import { getAgentDotState } from './worktree-card-agent-summary' +import { translate } from '@/i18n/i18n' + +const MARKDOWN_IMAGE_PATTERN = /!\[[^\]\n]*\]\([^)]+\)/ + +function formatShortTimeAgo(ts: number, now: number): string { + const delta = now - ts + if (delta < 60_000) { + return 'now' + } + const minutes = Math.floor(delta / 60_000) + if (minutes < 60) { + return `${minutes}m` + } + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return `${hours}h` + } + return `${Math.floor(hours / 24)}d` +} + +function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { + const entry = agent.entry + if (entry.state === 'done') { + return entry.stateStartedAt + } + for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { + if (entry.stateHistory[i].state === 'done') { + return entry.stateHistory[i].startedAt + } + } + return null +} + +function getCompactAgentPrimary(agent: DashboardAgentRowData): string { + const prompt = agent.entry.prompt?.trim() ?? '' + return prompt || agentStateLabel(getAgentDotState(agent)) +} + +function getCompactAgentSecondary(agent: DashboardAgentRowData): string { + if (agent.entry.interrupted === true) { + return 'Interrupted by user' + } + if (agent.state === 'working') { + const toolName = agent.entry.toolName?.trim() ?? '' + const toolInput = agent.entry.toolInput?.trim() ?? '' + if (toolName && toolInput) { + return `${toolName}: ${toolInput}` + } + if (toolName) { + return toolName + } + } + return agent.entry.lastAssistantMessage?.trim() || formatAgentTypeLabel(agent.agentType) +} + +function getCompactAgentTime(agent: DashboardAgentRowData, now: number): string | null { + const doneAt = lastEnteredDoneAt(agent) + if (doneAt !== null) { + return formatShortTimeAgo(doneAt, now) + } + const startedAt = agent.startedAt > 0 ? agent.startedAt : agent.entry.stateStartedAt + return startedAt > 0 ? formatShortTimeAgo(startedAt, now) : null +} + +function stopActivationKeyPropagation(e: React.KeyboardEvent): void { + // Why: the surrounding worktree list handles Enter/Space as row activation. + // Focused nested buttons need those keys to stay local. + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + } +} + +type CompactAgentRowProps = { + agent: DashboardAgentRowData + now: number + onActivate: (tabId: string, paneKey: string) => void + // Why: send-popover target mode temporarily turns compact sidebar rows into + // the picker surface, matching the full DashboardAgentRow behavior. + sendTargetStatus?: 'eligible' | 'disabled' | 'sending' + sendTargetDisabledReason?: string + onSendTargetClick?: (paneKey: string) => void + childAgentCount?: number + childAgentsExpanded?: boolean + onToggleChildAgents?: () => void + reserveDisclosureGutter?: boolean + isFocusedPane?: boolean + hideIdentityIcon?: boolean +} + +export const CompactAgentRow = React.memo(function CompactAgentRow({ + agent, + now, + onActivate, + sendTargetStatus, + sendTargetDisabledReason, + onSendTargetClick, + childAgentCount, + childAgentsExpanded = false, + onToggleChildAgents, + reserveDisclosureGutter = false, + isFocusedPane = false, + hideIdentityIcon = false +}: CompactAgentRowProps) { + const hasChildDisclosure = + typeof childAgentCount === 'number' && + childAgentCount > 0 && + typeof onToggleChildAgents === 'function' + const dotState = getAgentDotState(agent) + const primary = getCompactAgentPrimary(agent) + const assistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' + const hasAssistantImage = MARKDOWN_IMAGE_PATTERN.test(assistantMessage) + const isLineageChild = agent.lineage?.depth === 1 + const secondary = hasAssistantImage + ? formatAgentTypeLabel(agent.agentType) + : getCompactAgentSecondary(agent) + const shortTime = getCompactAgentTime(agent, now) + + const handleActivate = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onActivate(agent.tab.id, agent.paneKey) + }, + [agent.paneKey, agent.tab.id, onActivate] + ) + const handleSendTargetClickCapture = useCallback( + (e: React.MouseEvent) => { + if (!sendTargetStatus) { + return + } + const target = e.target + if ( + target instanceof Element && + target.closest('button, a, input, textarea, select, [role="button"]') + ) { + return + } + e.preventDefault() + e.stopPropagation() + if (sendTargetStatus === 'eligible') { + onSendTargetClick?.(agent.paneKey) + } + }, + [agent.paneKey, onSendTargetClick, sendTargetStatus] + ) + const handleToggleChildren = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + onToggleChildAgents?.() + }, + [onToggleChildAgents] + ) + + const rowBody = ( + <> + {hasChildDisclosure ? ( + + ) : reserveDisclosureGutter ? ( + + ) : null} + + {!hideIdentityIcon && ( + + + + )} + + {/* Why: the selected-row fill is strong enough to wash out the dimmed + prompt/secondary text, so lift both toward full foreground when focused. */} + {primary} + {secondary && ( + + {' '} + - {secondary} + + )} + + {hasChildDisclosure && !childAgentsExpanded && ( + + +{childAgentCount} + + )} + {shortTime && ( + + {shortTime} + + )} + + ) + + return ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onDragStart={(e) => e.stopPropagation()} + data-focused-agent-pane={isFocusedPane ? 'true' : undefined} + data-agent-send-target={sendTargetStatus} + role={agent.lineage ? 'treeitem' : undefined} + aria-level={agent.lineage ? agent.lineage.depth + 1 : undefined} + aria-expanded={hasChildDisclosure ? childAgentsExpanded : undefined} + title={sendTargetDisabledReason ?? `${primary}${secondary ? ` - ${secondary}` : ''}`} + > + {hasAssistantImage ? ( + <> +
{rowBody}
+ + + ) : ( + rowBody + )} +
+ ) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx index a7cb8c2fdac..3f43b3b62f8 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx @@ -1,81 +1,19 @@ import React, { useCallback, useRef } from 'react' import { ChevronRight } from 'lucide-react' -import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { AgentStateDot } from '@/components/AgentStateDot' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { AgentIcon } from '@/lib/agent-catalog' -import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' +import { agentTypeToIconAgent } from '@/lib/agent-status' import { cn } from '@/lib/utils' -import CommentMarkdown from './CommentMarkdown' import { buildSummaryAgentGroups, - getAgentDotState, selectSummaryGroupIconAgents, summarizeAgentIdentities, summarizeAgents } from './worktree-card-agent-summary' import { translate } from '@/i18n/i18n' -const MARKDOWN_IMAGE_PATTERN = /!\[[^\]\n]*\]\([^)]+\)/ - -function formatShortTimeAgo(ts: number, now: number): string { - const delta = now - ts - if (delta < 60_000) { - return 'now' - } - const minutes = Math.floor(delta / 60_000) - if (minutes < 60) { - return `${minutes}m` - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return `${hours}h` - } - return `${Math.floor(hours / 24)}d` -} - -function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { - const entry = agent.entry - if (entry.state === 'done') { - return entry.stateStartedAt - } - for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - -function getCompactAgentPrimary(agent: DashboardAgentRowData): string { - const prompt = agent.entry.prompt?.trim() ?? '' - return prompt || agentStateLabel(getAgentDotState(agent)) -} - -function getCompactAgentSecondary(agent: DashboardAgentRowData): string { - if (agent.entry.interrupted === true) { - return 'Interrupted by user' - } - if (agent.state === 'working') { - const toolName = agent.entry.toolName?.trim() ?? '' - const toolInput = agent.entry.toolInput?.trim() ?? '' - if (toolName && toolInput) { - return `${toolName}: ${toolInput}` - } - if (toolName) { - return toolName - } - } - return agent.entry.lastAssistantMessage?.trim() || formatAgentTypeLabel(agent.agentType) -} - -function getCompactAgentTime(agent: DashboardAgentRowData, now: number): string | null { - const doneAt = lastEnteredDoneAt(agent) - if (doneAt !== null) { - return formatShortTimeAgo(doneAt, now) - } - const startedAt = agent.startedAt > 0 ? agent.startedAt : agent.entry.stateStartedAt - return startedAt > 0 ? formatShortTimeAgo(startedAt, now) : null -} +export { CompactAgentRow } from './worktree-card-compact-agent-row' function stopActivationKeyPropagation(e: React.KeyboardEvent): void { // Why: the surrounding worktree list handles Enter/Space as row activation. @@ -248,163 +186,3 @@ export function CompactAgentSummaryButton({ ) } - -type CompactAgentRowProps = { - agent: DashboardAgentRowData - now: number - onActivate: (tabId: string, paneKey: string) => void - childAgentCount?: number - childAgentsExpanded?: boolean - onToggleChildAgents?: () => void - reserveDisclosureGutter?: boolean - isFocusedPane?: boolean - hideIdentityIcon?: boolean -} - -export const CompactAgentRow = React.memo(function CompactAgentRow({ - agent, - now, - onActivate, - childAgentCount, - childAgentsExpanded = false, - onToggleChildAgents, - reserveDisclosureGutter = false, - isFocusedPane = false, - hideIdentityIcon = false -}: CompactAgentRowProps) { - const hasChildDisclosure = - typeof childAgentCount === 'number' && - childAgentCount > 0 && - typeof onToggleChildAgents === 'function' - const dotState = getAgentDotState(agent) - const primary = getCompactAgentPrimary(agent) - const assistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - const hasAssistantImage = MARKDOWN_IMAGE_PATTERN.test(assistantMessage) - const isLineageChild = agent.lineage?.depth === 1 - const secondary = hasAssistantImage - ? formatAgentTypeLabel(agent.agentType) - : getCompactAgentSecondary(agent) - const shortTime = getCompactAgentTime(agent, now) - - const handleActivate = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - onActivate(agent.tab.id, agent.paneKey) - }, - [agent.paneKey, agent.tab.id, onActivate] - ) - const handleToggleChildren = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - onToggleChildAgents?.() - }, - [onToggleChildAgents] - ) - - const rowBody = ( - <> - {hasChildDisclosure ? ( - - ) : reserveDisclosureGutter ? ( - - ) : null} - - {!hideIdentityIcon && ( - - - - )} - - {/* Why: the selected-row fill is strong enough to wash out the dimmed - prompt/secondary text, so lift both toward full foreground when focused. */} - {primary} - {secondary && ( - - {' '} - - {secondary} - - )} - - {hasChildDisclosure && !childAgentsExpanded && ( - - +{childAgentCount} - - )} - {shortTime && ( - - {shortTime} - - )} - - ) - - return ( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onDragStart={(e) => e.stopPropagation()} - data-focused-agent-pane={isFocusedPane ? 'true' : undefined} - role={agent.lineage ? 'treeitem' : undefined} - aria-level={agent.lineage ? agent.lineage.depth + 1 : undefined} - aria-expanded={hasChildDisclosure ? childAgentsExpanded : undefined} - title={`${primary}${secondary ? ` - ${secondary}` : ''}`} - > - {hasAssistantImage ? ( - <> -
{rowBody}
- - - ) : ( - rowBody - )} -
- ) -}) 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 f3d68a65d40..1ddc640eecc 100644 --- a/src/renderer/src/lib/active-agent-note-send.test.ts +++ b/src/renderer/src/lib/active-agent-note-send.test.ts @@ -437,6 +437,114 @@ describe('active agent note send', () => { expect(testState.callRuntimeRpc).not.toHaveBeenCalled() }) + + it('sends notes to an explicit note target even when no terminal pane is focused', async () => { + testState.appState.activeTabType = 'editor' + testState.appState.activeTabIdByWorktree = {} + testState.callRuntimeRpc.mockImplementation(async (_target, method, params) => { + if (method === 'terminal.list') { + return { + terminals: [ + { + handle: 'term-2', + worktreeId: 'wt-1', + worktreePath: '/repo', + branch: 'main', + tabId: 'tab-9', + leafId: OTHER_LEAF_ID, + 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-2', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + } + } + } + if (method === 'terminal.send') { + return { send: { handle: 'term-2', accepted: true, bytesWritten: params.text.length } } + } + throw new Error(`unexpected method ${method}`) + }) + + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'notes', + noteTarget: { tabId: 'tab-9', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'sent' }) + + expect(testState.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'terminal.send', + { + terminal: 'term-2', + text: 'notes', + enter: true, + client: { id: 'orca-desktop', type: 'desktop' } + }, + { timeoutMs: 15000 } + ) + }) + + it('returns no-active-terminal when the explicit note target is absent from the runtime list', 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: 'Codex', + connected: true, + writable: true, + lastOutputAt: 1, + preview: '' + } + ], + totalCount: 1, + truncated: false + } + } + throw new Error(`unexpected method ${method}`) + }) + + await expect( + sendNotesToActiveAgentSession({ + worktreeId: 'wt-1', + prompt: 'notes', + noteTarget: { tabId: 'tab-1', leafId: OTHER_LEAF_ID } + }) + ).resolves.toEqual({ status: 'no-active-terminal' }) + + expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith( + expect.anything(), + 'terminal.send', + expect.anything(), + expect.anything() + ) + }) }) function agentStatusEntry( diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index 3307812055e..e103b224bdf 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -2,7 +2,11 @@ import type { RuntimeTerminalSend, RuntimeTerminalWait } from '../../../shared/r import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { findActiveRuntimeTerminal, getActiveTerminalNoteTarget } from './active-agent-note-target' +import { + findActiveRuntimeTerminal, + getActiveTerminalNoteTarget, + type ActiveTerminalNoteTarget +} from './active-agent-note-target' export { getActiveAgentNoteTarget, @@ -31,10 +35,12 @@ export type ActiveAgentNotesSendResult = { export async function sendNotesToActiveAgentSession({ worktreeId, prompt, + noteTarget: explicitNoteTarget, timeoutMs = ACTIVE_AGENT_SEND_TIMEOUT_MS }: { worktreeId: string prompt: string + noteTarget?: ActiveTerminalNoteTarget timeoutMs?: number }): Promise { const trimmedPrompt = prompt.trim() @@ -43,7 +49,11 @@ export async function sendNotesToActiveAgentSession({ } const state = useAppStore.getState() - const noteTarget = getActiveTerminalNoteTarget(state, worktreeId) + // Why: an explicit target lets the notes dropdown address ANY running agent of + // the worktree, not just the focused pane; omitted, fall back to the focused + // active terminal so existing callers keep their behavior. Routing below still + // resolves the worktree's owner host, so explicit targets stay SSH/remote-correct. + const noteTarget = explicitNoteTarget ?? getActiveTerminalNoteTarget(state, worktreeId) if (!noteTarget) { return { status: 'no-active-terminal' } } diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index f1b36ae3078..5bbc1253a4f 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -19,7 +19,7 @@ import { getAgentLabel, isExplicitAgentStatusFresh } from './agent-status' -import { resolveRuntimePaneTitleLeafId } from './runtime-pane-title-leaf-id' +import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' const ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS = 3000 const ACTIVE_AGENT_TERMINAL_LIST_LIMIT = 200 @@ -263,34 +263,11 @@ 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 + return resolveRuntimePaneTitleForLeaf( + state.terminalLayoutsByTabId[noteTarget.tabId], + state.runtimePaneTitlesByTabId?.[noteTarget.tabId], + noteTarget.leafId + ) } function isRecognizedAgentTitle(title: string): boolean { diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 3addb6235a9..518b4c784b0 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -6,6 +6,8 @@ import type { } from '../../../shared/agent-status-types' import { tabHasLivePty } from './tab-has-live-pty' import type { WorktreeStatus } from './worktree-status' +import { tuiAgentToAgentKind } from '../../../shared/agent-kind' +import type { AgentKind } from '../../../shared/telemetry-events' // Re-export from shared module so existing renderer imports continue to work. // Why: the main process now needs the same agent detection logic for stat @@ -197,6 +199,14 @@ export function agentTypeToIconAgent(agentType: AgentType | null | undefined): T : null } +// Why: telemetry's `agent_kind` enum derives from the TuiAgent mapping. Share +// one resolver so the notes-send dropdown and the sidebar send path stamp +// identical agent_kind values on `agent_prompt_sent`. +export function agentKindForAgentType(agentType: AgentType | null | undefined): AgentKind { + const tuiAgent = agentTypeToIconAgent(agentType) + return tuiAgent ? tuiAgentToAgentKind(tuiAgent) : 'other' +} + // Why: explicit agent status entries (from hook-based reports) can go stale if // the agent process exits without sending a final update. This helper lets // callers decide whether to trust the entry based on a configurable TTL. diff --git a/src/renderer/src/lib/notes-send-agent-targets.test.ts b/src/renderer/src/lib/notes-send-agent-targets.test.ts new file mode 100644 index 00000000000..d2b89050fe7 --- /dev/null +++ b/src/renderer/src/lib/notes-send-agent-targets.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { + deriveNotesSendAgentTargets, + type NotesSendAgentTargetState +} from './notes-send-agent-targets' + +const WORKTREE_ID = 'wt-1' +const STATUS_TAB_ID = 'tab-status' +const LAUNCH_TAB_ID = 'tab-launch' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const NOW = 10_000 + +function tab(id: string, overrides: Partial = {}): TerminalTab { + return { + id, + worktreeId: WORKTREE_ID, + ptyId: null, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function entry(paneKey: string, state: AgentStatusState = 'done'): AgentStatusEntry { + return { + paneKey, + state, + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'codex', + stateHistory: [] + } +} + +function leafLayout(leafId: string, ptyId: string | null): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [leafId]: ptyId } : {} + } +} + +function splitLayout( + activeLeafId: string, + ptyIdsByLeafId: Record +): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId, + expandedLeafId: null, + ptyIdsByLeafId + } +} + +function state( + overrides: Partial<{ + agentStatusByPaneKey: Record + tabsByWorktree: Record + terminalLayoutsByTabId: Record + ptyIdsByTabId: Record + runtimePaneTitlesByTabId: Record> + }> = {} +): NotesSendAgentTargetState { + const terminalLayoutsByTabId = overrides.terminalLayoutsByTabId ?? {} + return { + agentStatusByPaneKey: {}, + tabsByWorktree: { [WORKTREE_ID]: [] }, + terminalLayoutsByTabId, + ptyIdsByTabId: deriveLivePtyIdsByTabId(terminalLayoutsByTabId), + runtimePaneTitlesByTabId: {}, + ...overrides + } as NotesSendAgentTargetState +} + +function deriveLivePtyIdsByTabId( + terminalLayoutsByTabId: Record +): Record { + return Object.fromEntries( + Object.entries(terminalLayoutsByTabId).map(([tabId, layout]) => [ + tabId, + Object.values(layout.ptyIdsByLeafId ?? {}) + ]) + ) +} + +describe('notes send agent targets', () => { + it('maps status-backed targets with their agent type and tab title', () => { + const paneKey = makePaneKey(STATUS_TAB_ID, LEAF_A) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { [paneKey]: entry(paneKey, 'done') }, + tabsByWorktree: { [WORKTREE_ID]: [tab(STATUS_TAB_ID, { title: 'Terminal 1' })] }, + terminalLayoutsByTabId: { [STATUS_TAB_ID]: leafLayout(LEAF_A, 'pty-a') } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + { + paneKey, + tabId: STATUS_TAB_ID, + leafId: LEAF_A, + agentType: 'codex', + tabTitle: 'Terminal 1', + status: 'eligible' + } + ]) + }) + + it('lists a launch-agent tab with a recognized pane title before any hook status', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Terminal 2', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + { + paneKey: makePaneKey(LAUNCH_TAB_ID, LEAF_B), + tabId: LAUNCH_TAB_ID, + leafId: LEAF_B, + agentType: 'codex', + tabTitle: 'Terminal 2', + status: 'eligible' + } + ]) + }) + + it('recognizes a launch-agent tab by its OSC tab title when no pane title is set', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') } + }), + WORKTREE_ID, + NOW + ) + + expect(targets.map((target) => target.paneKey)).toEqual([makePaneKey(LAUNCH_TAB_ID, LEAF_B)]) + }) + + it('skips a still-booting launch-agent tab whose title is not yet an agent', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Terminal 2', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'zsh' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([]) + }) + + it('skips a launch-agent tab without a live pty', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, null) }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([]) + }) + + it('skips a launch-agent tab when only stale layout PTY state remains', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + ptyIdsByTabId: { [LAUNCH_TAB_ID]: [] }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([]) + }) + + it('does not emit a launch hint for a tab already covered by a live status entry', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_A) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { [paneKey]: entry(paneKey, 'working') }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { + [LAUNCH_TAB_ID]: splitLayout(LEAF_B, { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' }) + }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 2: 'Codex' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toHaveLength(1) + expect(targets[0]).toMatchObject({ + tabId: LAUNCH_TAB_ID, + leafId: LEAF_A, + status: 'disabled', + disabledReason: 'Agent is working' + }) + }) + + it('skips a launch-agent tab whose active leaf is not a terminal leaf', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { + [LAUNCH_TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_B }, + activeLeafId: 'editor-pane', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([]) + }) + + it('does not list a plain terminal tab without a launch agent or status', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex' })] }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/notes-send-agent-targets.ts b/src/renderer/src/lib/notes-send-agent-targets.ts new file mode 100644 index 00000000000..1fdb3432fe1 --- /dev/null +++ b/src/renderer/src/lib/notes-send-agent-targets.ts @@ -0,0 +1,116 @@ +import type { AgentType } from '../../../shared/agent-status-types' +import type { AppState } from '@/store/types' +import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' +import { detectAgentStatusFromTitle, getAgentLabel } from './agent-status' +import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' +import { + deriveRunningAgentSendTargets, + type RunningAgentTargetState +} from './running-agent-targets' + +export type NotesSendAgentTargetState = RunningAgentTargetState & + Pick + +export type NotesSendAgentTarget = { + paneKey: string + tabId: string + leafId: string + agentType: AgentType | null | undefined + tabTitle: string + status: 'eligible' | 'disabled' + disabledReason?: string +} + +function isRecognizedAgentTitle(title: string | null): boolean { + return ( + title !== null && detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null + ) +} + +function launchAgentPaneLooksRecognized(paneTitle: string | null, tabTitle: string): boolean { + if (isRecognizedAgentTitle(paneTitle)) { + return true + } + // Why: mirror isTerminalRunningAgent — the OSC-enriched tab title only counts + // when the leaf has no runtime pane title of its own yet. + return paneTitle === null && isRecognizedAgentTitle(tabTitle) +} + +/** + * Agents of a worktree the notes dropdown can target. + * + * Why this exists on top of deriveRunningAgentSendTargets: that derivation only + * sees panes with a live status entry, so a freshly launched (still idle) agent + * stays invisible until its first hook event — i.e. until the user talks to it. + * We augment it with launch-agent tabs whose pane still has a live PTY: + * TerminalTab.launchAgent records the harness Orca started and is the same + * pre-hook signal the tab bar already trusts for its provider icon. + * + * The launch hint is gated on a recognized agent title (pane or tab) — the same + * signal isTerminalRunningAgent checks — so a freshly spawned tab is only listed + * once the runtime would actually accept the send. Without that gate, clicking a + * still-booting pane fails with "not a recognized agent session". + */ +export function deriveNotesSendAgentTargets( + state: NotesSendAgentTargetState, + worktreeId: string, + now = Date.now() +): NotesSendAgentTarget[] { + const targets: NotesSendAgentTarget[] = deriveRunningAgentSendTargets(state, worktreeId, now).map( + (target) => ({ + paneKey: target.paneKey, + tabId: target.tabId, + leafId: target.leafId, + agentType: target.entry.agentType, + tabTitle: target.tab.title, + status: target.status, + ...(target.disabledReason ? { disabledReason: target.disabledReason } : {}) + }) + ) + + // Why: dedupe by tab, not pane. A launch-agent tab already surfaced through a + // live status entry must not also emit a hint row — its active leaf may be a + // split shell pane, which would list a second bogus row for the same tab. + const statusBackedTabIds = new Set(targets.map((target) => target.tabId)) + + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + if (!tab.launchAgent || statusBackedTabIds.has(tab.id)) { + continue + } + + const layout = state.terminalLayoutsByTabId[tab.id] + const leafId = layout?.activeLeafId + if (!leafId || !isTerminalLeafId(leafId)) { + continue + } + + const ptyId = layout.ptyIdsByLeafId?.[leafId] ?? null + if (!ptyId || !state.ptyIdsByTabId[tab.id]?.includes(ptyId)) { + continue + } + + const paneTitle = resolveRuntimePaneTitleForLeaf( + layout, + state.runtimePaneTitlesByTabId[tab.id], + leafId + ) + if (!launchAgentPaneLooksRecognized(paneTitle, tab.title)) { + // Why: launchAgent is set the instant Orca spawns the tab, but the runtime + // only accepts a send once the pane reads as an agent. Skipping until the + // title is recognized keeps "listed ⇒ sendable" and avoids the boot-window + // "not a recognized agent session" error. + continue + } + + targets.push({ + paneKey: makePaneKey(tab.id, leafId), + tabId: tab.id, + leafId, + agentType: tab.launchAgent, + tabTitle: tab.title, + status: 'eligible' + }) + } + + return targets +} diff --git a/src/renderer/src/lib/running-agent-targets.test.ts b/src/renderer/src/lib/running-agent-targets.test.ts index e7743355576..89a6be2568d 100644 --- a/src/renderer/src/lib/running-agent-targets.test.ts +++ b/src/renderer/src/lib/running-agent-targets.test.ts @@ -52,18 +52,37 @@ function entry( function state( overrides: Partial< - Pick + Pick< + AppState, + 'agentStatusByPaneKey' | 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'ptyIdsByTabId' + > > = {} ) { + const terminalLayoutsByTabId = overrides.terminalLayoutsByTabId ?? {} return { agentStatusByPaneKey: {}, tabsByWorktree: { [WORKTREE_ID]: [tab(TAB_ID)], [OTHER_WORKTREE_ID]: [tab(OTHER_TAB_ID, OTHER_WORKTREE_ID)] }, - terminalLayoutsByTabId: {}, + terminalLayoutsByTabId, + ptyIdsByTabId: deriveLivePtyIdsByTabId(terminalLayoutsByTabId), ...overrides - } as Pick + } as Pick< + AppState, + 'agentStatusByPaneKey' | 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'ptyIdsByTabId' + > +} + +function deriveLivePtyIdsByTabId( + terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] +): AppState['ptyIdsByTabId'] { + return Object.fromEntries( + Object.entries(terminalLayoutsByTabId).map(([tabId, layout]) => [ + tabId, + Object.values(layout?.ptyIdsByLeafId ?? {}) + ]) + ) } describe('running agent send targets', () => { @@ -207,6 +226,34 @@ describe('running agent send targets', () => { }) }) + it('disables a stale layout PTY after the live PTY map has been cleared', () => { + const paneKey = makePaneKey(TAB_ID, LEFT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { [paneKey]: entry(paneKey, 'done') }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEFT_LEAF_ID }, + activeLeafId: LEFT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT_LEAF_ID]: 'pty-left' } + } + }, + ptyIdsByTabId: { [TAB_ID]: [] } + }), + WORKTREE_ID, + paneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey, + ptyId: null, + status: 'disabled', + disabledReason: 'Terminal is no longer available' + }) + }) + it('keeps other-workspace and remote PTY targets scoped correctly', () => { const localPaneKey = makePaneKey(TAB_ID, LEFT_LEAF_ID) const remotePaneKey = makePaneKey(OTHER_TAB_ID, OTHER_LEAF_ID) diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 2239202b56a..b89e66e9607 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -7,9 +7,9 @@ import type { TerminalTab } from '../../../shared/types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { isExplicitAgentStatusFresh } from './agent-status' -type RunningAgentTargetState = Pick< +export type RunningAgentTargetState = Pick< AppState, - 'agentStatusByPaneKey' | 'tabsByWorktree' | 'terminalLayoutsByTabId' + 'agentStatusByPaneKey' | 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'ptyIdsByTabId' > export type RunningAgentSendTarget = { @@ -46,8 +46,10 @@ export function deriveRunningAgentSendTargets( continue } - const ptyId = + const layoutPtyId = state.terminalLayoutsByTabId[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] ?? null + const ptyId = + layoutPtyId && state.ptyIdsByTabId[parsed.tabId]?.includes(layoutPtyId) ? layoutPtyId : null let disabledReason: string | undefined if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts new file mode 100644 index 00000000000..3ae0b9a49bd --- /dev/null +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../shared/types' +import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +const leafLayout: Pick = { + root: { type: 'leaf', leafId: LEAF_A } +} + +const splitLayout: Pick = { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + } +} + +describe('resolveRuntimePaneTitleForLeaf', () => { + it('returns null when no pane titles are present', () => { + expect(resolveRuntimePaneTitleForLeaf(leafLayout, undefined, LEAF_A)).toBeNull() + expect(resolveRuntimePaneTitleForLeaf(leafLayout, {}, LEAF_A)).toBeNull() + }) + + it('maps a single-leaf pane title to its leaf', () => { + expect(resolveRuntimePaneTitleForLeaf(leafLayout, { 1: 'Codex' }, LEAF_A)).toBe('Codex') + }) + + it('resolves split-pane titles to the matching leaf by replay order', () => { + expect(resolveRuntimePaneTitleForLeaf(splitLayout, { 1: 'zsh', 2: 'Codex' }, LEAF_A)).toBe( + 'zsh' + ) + expect(resolveRuntimePaneTitleForLeaf(splitLayout, { 1: 'zsh', 2: 'Codex' }, LEAF_B)).toBe( + 'Codex' + ) + }) + + it('does not attribute a lone background split title to an unrelated leaf', () => { + expect(resolveRuntimePaneTitleForLeaf(splitLayout, { 2: 'Codex' }, LEAF_A)).toBeNull() + }) + + it('uses a lone title when the tab has no resolved layout root', () => { + expect(resolveRuntimePaneTitleForLeaf(undefined, { 7: 'Codex' }, LEAF_A)).toBe('Codex') + }) +}) diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts index 6c0b37bd992..dd7eb849d03 100644 --- a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts @@ -44,6 +44,47 @@ export function resolveRuntimePaneTitleLeafId( return resolveRuntimePaneTitleLeafIdFromRoot(tabLayout?.root, runtimePaneId) } +/** + * Resolve the runtime-reported pane title for a specific layout leaf. Pane + * title maps are keyed by runtime pane id, which only lines up with the leaf id + * after replay-order resolution — split tabs can carry a sparse title map, so a + * lone background title must not be attributed to an unrelated leaf. + */ +export function resolveRuntimePaneTitleForLeaf( + tabLayout: { root?: TerminalLayoutSnapshot['root'] } | undefined, + paneTitles: Record | undefined, + leafId: string +): string | null { + if (!paneTitles) { + return null + } + + const titleEntries = Object.entries(paneTitles) + if (titleEntries.length === 0) { + return null + } + + if (tabLayout?.root) { + for (const [runtimePaneId, title] of titleEntries) { + if (resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) === leafId) { + return title + } + } + return null + } + + if (titleEntries.length === 1) { + return titleEntries[0][1] + } + + for (const [runtimePaneId, title] of titleEntries) { + if (resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) === leafId) { + return title + } + } + return null +} + export function resolveRuntimePaneTitleLeafIdFromRoot( root: TerminalPaneLayoutNode | null | undefined, runtimePaneId: string diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index d6a7c6dac7c..c632de014a8 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -233,6 +233,9 @@ describe('createUISlice agent send target mode', () => { } } }, + ptyIdsByTabId: { + [tabId]: ['pty-ready', 'pty-working'] + }, agentStatusByPaneKey: { [readyPaneKey]: { state: 'done', @@ -298,8 +301,14 @@ describe('createUISlice agent send target mode', () => { }, activeLeafId: readyLeafId, expandedLeafId: null, - ptyIdsByLeafId: {} + ptyIdsByLeafId: { + [readyLeafId]: 'pty-ready', + [workingLeafId]: 'pty-working' + } } + }, + ptyIdsByTabId: { + [tabId]: [] } }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 30ad221bb38..ac8c67d3790 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -32,7 +32,6 @@ import type { import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import { tuiAgentToAgentKind } from '../../../../shared/agent-kind' import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/types' import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, @@ -102,7 +101,7 @@ import { getNextVisibleContextualTourStepIndex, getPreviousVisibleContextualTourStepIndex } from '../../components/contextual-tours/contextual-tour-gate' -import { agentTypeToIconAgent, formatAgentTypeLabel } from '../../lib/agent-status' +import { agentKindForAgentType, formatAgentTypeLabel } from '../../lib/agent-status' import { deriveRunningAgentSendTargets, resolveRunningAgentSendTarget @@ -446,11 +445,6 @@ function hydratedUIPartialMatchesState(state: AppState, hydrated: Partial[0]) { - const tuiAgent = agentTypeToIconAgent(agentType) - return tuiAgent ? tuiAgentToAgentKind(tuiAgent) : 'other' -} - let agentSendTargetModeInstanceCounter = 0 function createAgentSendTargetModeInstanceId(): string { @@ -1009,7 +1003,7 @@ export const createUISlice: StateCreator = (set, get) mode.onPromptDelivered?.() const [{ toast }, { track }] = await Promise.all([import('sonner'), import('@/lib/telemetry')]) track('agent_prompt_sent', { - agent_kind: agentKindForTarget(target.entry.agentType), + agent_kind: agentKindForAgentType(target.entry.agentType), launch_source: mode.launchSource, request_kind: 'followup' })