Merge remote-tracking branch 'origin/main' into Jinwoo-H/rc78-terminal-stack-on-main

This commit is contained in:
Jinwoo-H
2026-06-18 12:48:16 -07:00
22 changed files with 1976 additions and 333 deletions
+1 -3
View File
@@ -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'")
})
})
@@ -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<string, unknown>) {
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<string, unknown>) {
return { type: 'AgentStateDot', props }
},
agentStateLabel: (state: string) => state
}))
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: function AgentIcon(props: Record<string, unknown>) {
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',
@@ -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<string, unknown>
}
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<string, unknown>,
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<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
useCallback<T extends (...args: never[]) => unknown>(callback: T): T {
return callback
},
useMemo<T>(factory: () => T): T {
return factory()
},
useEffect(effect: () => void | (() => void)): void {
const cleanup = effect()
if (typeof cleanup === 'function') {
hookRuntime.cleanups.push(cleanup)
}
},
useState<T>(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<string, unknown>) => 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<string, unknown>) {
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<string, unknown>) {
return { type: 'AgentIcon', props }
}
}))
vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({
QuickLaunchAgentMenuItems: function QuickLaunchAgentMenuItems(props: Record<string, unknown>) {
return { type: 'QuickLaunchAgentMenuItems', props }
}
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuItem: function DropdownMenuItem(props: Record<string, unknown>) {
return { type: 'DropdownMenuItem', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: Record<string, unknown>) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSeparator: function DropdownMenuSeparator(props: Record<string, unknown>) {
return { type: 'DropdownMenuSeparator', props }
}
}))
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<string, unknown> = {}) {
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<string, unknown> = {}): 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<Record<string, unknown>>
if (typeof element.type === 'function') {
const Component = element.type as (props: Record<string, unknown>) => 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<string, unknown> = {}): unknown {
hookRuntime.index = 0
return expand(
<ReviewNotesSendMenuContent worktreeId="wt-1" groupId="group-1" prompt="my notes" {...props} />
)
}
async function flushMicrotasks(): Promise<void> {
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'
})
})
})
@@ -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<ActiveAgentNotesSendResult>, 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 (
<>
<DropdownMenuLabel>
{translate('auto.components.editor.ReviewNotesSendMenuContent.03378aea75', 'Send notes to')}
</DropdownMenuLabel>
<DropdownMenuItem
disabled={!hasPrompt || !canSendToActiveAgent}
onSelect={sendToActiveAgent}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<SquareTerminal className="size-3.5" />
{translate(
'auto.components.editor.ReviewNotesSendMenuContent.e84705f223',
'Active agent session'
)}
</DropdownMenuItem>
{orderedSendTargets.map(({ target, agent }) => (
<AgentTargetMenuItem
key={target.paneKey}
target={target}
agent={agent}
now={now}
disabled={!hasPrompt || target.status !== 'eligible'}
onSend={sendToAgentTarget}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{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 (
<DropdownMenuItem
disabled={disabled}
onSelect={() => 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"
>
<AgentStateDot state={state} size="sm" className="shrink-0" />
<AgentIcon agent={agentTypeToIconAgent(target.agentType ?? agent?.agentType)} size={14} />
<span className="grid min-w-0 flex-1 text-left">
<span className="truncate">
{formatAgentTypeLabel(target.agentType ?? agent?.agentType)}
</span>
<span className="truncate text-[11px] font-normal text-muted-foreground">
{secondaryParts.join(' · ')}
</span>
</span>
</DropdownMenuItem>
)
}
function orderSendTargetsByWorktreeAgentRows(
sendTargets: NotesSendAgentTarget[],
agentRows: DashboardAgentRowData[],
terminalLayoutsByTabId: Record<string, { ptyIdsByLeafId?: Record<string, string> } | undefined>,
ptyIdsByTabId: Record<string, string[]>
): OrderedSendTarget[] {
const targetsByPaneKey = new Map(sendTargets.map((target) => [target.paneKey, target]))
const usedPaneKeys = new Set<string>()
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<string, { ptyIdsByLeafId?: Record<string, string> } | undefined>,
ptyIdsByTabId: Record<string, string[]>
): 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`
}
@@ -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<string, unknown> {
'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(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('data-agent-send-target="eligible"')
expect(markup).toContain('data-agent-send-target="disabled"')
expect(markup).toContain('title="Agent is working"')
})
})
@@ -191,7 +191,7 @@ describe('WorktreeCardAgents', () => {
expect(markup).toContain('data-testid="agent-row"')
expect(markup).not.toContain('<button')
expect(markup).not.toContain('aria-expanded')
})
}, 10_000)
it('uses compact mode when the display preference is absent', async () => {
mockAgents = [mockAgent({ agentType: 'codex', startedAt: 1000, prompt: 'Run tests' })]
@@ -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<HTMLDivElement | null>(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 (
@@ -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
@@ -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<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
onToggleChildAgents?.()
},
[onToggleChildAgents]
)
const rowBody = (
<>
{hasChildDisclosure ? (
<button
type="button"
className="compact-agent-child-disclosure-button flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
aria-label={translate(
'auto.components.sidebar.worktree.card.compact.agents.a128d7006b',
'{{value0}} {{value1}} child {{value2}}',
{
value0: childAgentsExpanded ? 'Hide' : 'Show',
value1: childAgentCount,
value2: childAgentCount === 1 ? 'agent' : 'agents'
}
)}
aria-expanded={childAgentsExpanded}
onClick={handleToggleChildren}
onKeyDown={stopActivationKeyPropagation}
>
<ChevronRight
className={cn(
'size-3 transition-transform duration-150',
childAgentsExpanded && 'rotate-90'
)}
aria-hidden
/>
</button>
) : reserveDisclosureGutter ? (
<span className="size-4 shrink-0" aria-hidden />
) : null}
<AgentStateDot state={dotState} size="sm" />
{!hideIdentityIcon && (
<span className="inline-flex shrink-0" title={formatAgentTypeLabel(agent.agentType)}>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={13} />
</span>
)}
<span className="min-w-0 flex-1 truncate">
{/* Why: the selected-row fill is strong enough to wash out the dimmed
prompt/secondary text, so lift both toward full foreground when focused. */}
<span className={isFocusedPane ? 'text-foreground' : 'text-foreground/85'}>{primary}</span>
{secondary && (
<span className={isFocusedPane ? 'text-foreground/70' : 'text-muted-foreground/75'}>
{' '}
- {secondary}
</span>
)}
</span>
{hasChildDisclosure && !childAgentsExpanded && (
<span
className={cn(
'shrink-0 text-[10px] tabular-nums',
isFocusedPane ? 'text-foreground/70' : 'text-muted-foreground/70'
)}
>
+{childAgentCount}
</span>
)}
{shortTime && (
<span
className={cn(
'shrink-0 text-[10px] tabular-nums',
// Why: the muted timestamp drops out against the selected-row fill.
isFocusedPane ? 'text-foreground/70' : 'text-muted-foreground/60'
)}
>
{shortTime}
</span>
)}
</>
)
return (
<div
draggable={false}
className={cn(
'compact-agent-row group/compact-agent-row min-w-0 cursor-pointer rounded-sm px-1 text-[11px] leading-none',
'text-muted-foreground worktree-agent-row-hover',
hasChildDisclosure && 'worktree-agent-lineage-parent-row',
isLineageChild && 'worktree-agent-lineage-child-row',
hasAssistantImage ? 'flex flex-col py-0.5' : 'flex h-6 items-center gap-1',
isFocusedPane && 'bg-worktree-sidebar-accent',
sendTargetStatus === 'sending' && 'cursor-progress opacity-75',
sendTargetStatus === 'disabled' && 'cursor-default opacity-60'
)}
onClickCapture={handleSendTargetClickCapture}
onClick={handleActivate}
onMouseDown={(e) => 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 ? (
<>
<div className="flex h-6 min-w-0 items-center gap-1">{rowBody}</div>
<CommentMarkdown
content={assistantMessage}
className="ml-5 max-h-36 max-w-full overflow-hidden text-[10px] leading-snug text-muted-foreground/80 [&_.comment-md-p]:block [&_.comment-md-p+.comment-md-p]:mt-1"
/>
</>
) : (
rowBody
)}
</div>
)
})
@@ -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({
</button>
)
}
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<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
onToggleChildAgents?.()
},
[onToggleChildAgents]
)
const rowBody = (
<>
{hasChildDisclosure ? (
<button
type="button"
className="compact-agent-child-disclosure-button flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
aria-label={translate(
'auto.components.sidebar.worktree.card.compact.agents.a128d7006b',
'{{value0}} {{value1}} child {{value2}}',
{
value0: childAgentsExpanded ? 'Hide' : 'Show',
value1: childAgentCount,
value2: childAgentCount === 1 ? 'agent' : 'agents'
}
)}
aria-expanded={childAgentsExpanded}
onClick={handleToggleChildren}
onKeyDown={stopActivationKeyPropagation}
>
<ChevronRight
className={cn(
'size-3 transition-transform duration-150',
childAgentsExpanded && 'rotate-90'
)}
aria-hidden
/>
</button>
) : reserveDisclosureGutter ? (
<span className="size-4 shrink-0" aria-hidden />
) : null}
<AgentStateDot state={dotState} size="sm" />
{!hideIdentityIcon && (
<span className="inline-flex shrink-0" title={formatAgentTypeLabel(agent.agentType)}>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={13} />
</span>
)}
<span className="min-w-0 flex-1 truncate">
{/* Why: the selected-row fill is strong enough to wash out the dimmed
prompt/secondary text, so lift both toward full foreground when focused. */}
<span className={isFocusedPane ? 'text-foreground' : 'text-foreground/85'}>{primary}</span>
{secondary && (
<span className={isFocusedPane ? 'text-foreground/70' : 'text-muted-foreground/75'}>
{' '}
- {secondary}
</span>
)}
</span>
{hasChildDisclosure && !childAgentsExpanded && (
<span
className={cn(
'shrink-0 text-[10px] tabular-nums',
isFocusedPane ? 'text-foreground/70' : 'text-muted-foreground/70'
)}
>
+{childAgentCount}
</span>
)}
{shortTime && (
<span
className={cn(
'shrink-0 text-[10px] tabular-nums',
// Why: the muted timestamp drops out against the selected-row fill.
isFocusedPane ? 'text-foreground/70' : 'text-muted-foreground/60'
)}
>
{shortTime}
</span>
)}
</>
)
return (
<div
draggable={false}
className={cn(
'compact-agent-row group/compact-agent-row min-w-0 cursor-pointer rounded-sm px-1 text-[11px] leading-none',
'text-muted-foreground worktree-agent-row-hover',
hasChildDisclosure && 'worktree-agent-lineage-parent-row',
isLineageChild && 'worktree-agent-lineage-child-row',
hasAssistantImage ? 'flex flex-col py-0.5' : 'flex h-6 items-center gap-1',
isFocusedPane && 'bg-worktree-sidebar-accent'
)}
onClick={handleActivate}
onMouseDown={(e) => 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 ? (
<>
<div className="flex h-6 min-w-0 items-center gap-1">{rowBody}</div>
<CommentMarkdown
content={assistantMessage}
className="ml-5 max-h-36 max-w-full overflow-hidden text-[10px] leading-snug text-muted-foreground/80 [&_.comment-md-p]:block [&_.comment-md-p+.comment-md-p]:mt-1"
/>
</>
) : (
rowBody
)}
</div>
)
})
@@ -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(
+12 -2
View File
@@ -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<ActiveAgentNotesSendResult> {
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' }
}
@@ -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 {
+10
View File
@@ -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.
@@ -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> = {}): 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<string, string>
): 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<string, AgentStatusEntry>
tabsByWorktree: Record<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
ptyIdsByTabId: Record<string, string[]>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
}> = {}
): NotesSendAgentTargetState {
const terminalLayoutsByTabId = overrides.terminalLayoutsByTabId ?? {}
return {
agentStatusByPaneKey: {},
tabsByWorktree: { [WORKTREE_ID]: [] },
terminalLayoutsByTabId,
ptyIdsByTabId: deriveLivePtyIdsByTabId(terminalLayoutsByTabId),
runtimePaneTitlesByTabId: {},
...overrides
} as NotesSendAgentTargetState
}
function deriveLivePtyIdsByTabId(
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
): Record<string, string[]> {
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([])
})
})
@@ -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<AppState, 'runtimePaneTitlesByTabId'>
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
}
@@ -52,18 +52,37 @@ function entry(
function state(
overrides: Partial<
Pick<AppState, 'agentStatusByPaneKey' | 'tabsByWorktree' | 'terminalLayoutsByTabId'>
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<AppState, 'agentStatusByPaneKey' | 'tabsByWorktree' | 'terminalLayoutsByTabId'>
} 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)
@@ -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)) {
@@ -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<TerminalLayoutSnapshot, 'root'> = {
root: { type: 'leaf', leafId: LEAF_A }
}
const splitLayout: Pick<TerminalLayoutSnapshot, 'root'> = {
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')
})
})
@@ -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<number, string> | 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
+10 -1
View File
@@ -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]: []
}
})
+2 -8
View File
@@ -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<UISlic
)
}
function agentKindForTarget(agentType: Parameters<typeof agentTypeToIconAgent>[0]) {
const tuiAgent = agentTypeToIconAgent(agentType)
return tuiAgent ? tuiAgentToAgentKind(tuiAgent) : 'other'
}
let agentSendTargetModeInstanceCounter = 0
function createAgentSendTargetModeInstanceId(): string {
@@ -1009,7 +1003,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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'
})