mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge remote-tracking branch 'origin/main' into Jinwoo-H/rc78-terminal-stack-on-main
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]: []
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user