Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,30 @@ describe('buildDashboardSnapshot rows cache', () => {
expect(cached).toEqual(buildDashboardSnapshot(titleWrite, NOW + 500))
})

it('recomputes only the worktree whose pane foreground process changed', () => {
const cache = createWorktreeAgentRowsCache()
const state: DashboardSnapshotState = {
...baseState(),
agentStatusByPaneKey: { [PANE_1]: entry(PANE_1, 'tab1', 'w1') },
runtimePaneTitlesByTabId: { tab1: { 0: 'shell' }, tab2: { 0: 'demo-repo' } }
}
buildDashboardSnapshot(state, NOW, { rowsCache: cache, rowsGeneration: 1 })

const codexStarted: DashboardSnapshotState = {
...state,
paneForegroundAgentByPaneKey: {
[PANE_2]: { agent: 'codex', agentEvidence: 'process-read', shellForeground: false }
}
}
const cached = buildDashboardSnapshot(codexStarted, NOW + 500, {
rowsCache: cache,
rowsGeneration: 1
})
expect(cache.lastComputedWorktreeIds).toEqual(['w2'])
expect(cached.cards.find((card) => card.paneKey === PANE_2)?.agentType).toBe('codex')
expect(cached).toEqual(buildDashboardSnapshot(codexStarted, NOW + 500))
})

it('keeps card-level fields fresh (acks, workspace statuses) without recomputing rows', () => {
const cache = createWorktreeAgentRowsCache()
const state = baseState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('@/store', () => ({ useAppStore: () => undefined }))
const STORE_WRITES = 2_000

function storeState(): AppState {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only the slices listed here; the rest of AppState is never touched in this test.
return {
repos: [],
worktreesByRepo: {},
Expand All @@ -23,6 +24,7 @@ function storeState(): AppState {
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
paneForegroundAgentByPaneKey: {},
folderWorkspaces: [],
acknowledgedAgentsByPaneKey: {},
agentStatusEpoch: 0,
Expand All @@ -32,7 +34,7 @@ function storeState(): AppState {
}

// What `useShallow` did before the gate, unwrapped from the hook so it can be
// driven directly: allocate the 14-key object, then `shallow()` it against the
// driven directly: allocate the 15-key object, then `shallow()` it against the
// previous one. Kept here as the comparison baseline.
let previousShallow: Record<string, unknown> | null = null
const shallowInputs = (s: AppState): Record<string, unknown> => ({
Expand All @@ -47,6 +49,7 @@ const shallowInputs = (s: AppState): Record<string, unknown> => ({
terminalLayoutsByTabId: s.terminalLayoutsByTabId,
ptyIdsByTabId: s.ptyIdsByTabId,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
paneForegroundAgentByPaneKey: s.paneForegroundAgentByPaneKey,
folderWorkspaces: s.folderWorkspaces,
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
agentStatusEpoch: s.agentStatusEpoch
Expand Down Expand Up @@ -92,7 +95,7 @@ afterEach(() => {
})

describe('agent bucket count input gate', () => {
it('allocates nothing on a store write that leaves all fourteen slices alone', () => {
it('allocates nothing on a store write that leaves all fifteen slices alone', () => {
const state = storeState()
// Prime the gate, then replay the writes an unrelated slice would trigger.
selectAgentBucketCountState(state)
Expand Down Expand Up @@ -149,6 +152,7 @@ describe('agent bucket count input gate', () => {
'terminalLayoutsByTabId',
'ptyIdsByTabId',
'runtimePaneTitlesByTabId',
'paneForegroundAgentByPaneKey',
'folderWorkspaces',
'acknowledgedAgentsByPaneKey',
'agentStatusEpoch'
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/src/components/dashboard/useAgentBucketCounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type AgentBucketCountState = Pick<
| 'terminalLayoutsByTabId'
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
| 'paneForegroundAgentByPaneKey'
| 'folderWorkspaces'
| 'acknowledgedAgentsByPaneKey'
| 'agentStatusEpoch'
Expand All @@ -34,8 +35,8 @@ export type AgentBucketCountState = Pick<

// Why module scope rather than useShallow: zustand runs this selector on every
// store write, and shallow() on a plain object takes the compareEntries path —
// two Object.entries arrays, 28 tuples and two Maps allocated per write just to
// conclude nothing moved. Fourteen `===` against the previous slices allocates
// two Object.entries arrays, 30 tuples and two Maps allocated per write just to
// conclude nothing moved. Fifteen `===` against the previous slices allocates
// nothing on the unchanged path, and the result is a pure function of the state
// so one gate can serve every mounted consumer.
let previousState: AgentBucketCountState | null = null
Expand All @@ -60,6 +61,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState
previous.terminalLayoutsByTabId === s.terminalLayoutsByTabId &&
previous.ptyIdsByTabId === s.ptyIdsByTabId &&
previous.runtimePaneTitlesByTabId === s.runtimePaneTitlesByTabId &&
previous.paneForegroundAgentByPaneKey === s.paneForegroundAgentByPaneKey &&
previous.folderWorkspaces === s.folderWorkspaces &&
previous.acknowledgedAgentsByPaneKey === s.acknowledgedAgentsByPaneKey &&
previous.agentStatusEpoch === s.agentStatusEpoch
Expand All @@ -78,6 +80,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState
terminalLayoutsByTabId: s.terminalLayoutsByTabId,
ptyIdsByTabId: s.ptyIdsByTabId,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
paneForegroundAgentByPaneKey: s.paneForegroundAgentByPaneKey,
folderWorkspaces: s.folderWorkspaces,
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
agentStatusEpoch: s.agentStatusEpoch,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import {
} from '../sidebar/worktree-agent-row-selectors'
import {
selectLivePtyIdsForWorktree,
selectPaneForegroundAgentsForWorktree,
selectRuntimePaneTitlesForWorktree
} from '../sidebar/worktree-card-status-inputs'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'

type WorktreeAgentRowsCacheEntry = {
/** Caller-provided invalidation token for time-based freshness (agentStatusEpoch). */
Expand All @@ -31,6 +33,7 @@ type WorktreeAgentRowsCacheEntry = {
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
paneTitlesByTabId: Record<string, Record<number, string>>
ptyIdsByTabId: Record<string, string[]>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
rows: DashboardAgentRowWithLineage[]
}

Expand Down Expand Up @@ -88,7 +91,7 @@ export type WorktreeAgentRowsState = Pick<
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
> &
Partial<Pick<AppState, 'unifiedTabsByWorktree'>>
Partial<Pick<AppState, 'unifiedTabsByWorktree' | 'paneForegroundAgentByPaneKey'>>

// Why not identity: the per-worktree layout/title/ptyId selectors build a fresh top-level
// record per call while preserving per-tab value references, so shallow equality is the
Expand Down Expand Up @@ -122,6 +125,7 @@ export function selectWorktreeAgentRowsCached(args: {
const terminalLayoutsByTabId = selectTerminalLayoutsForWorktree(state, worktreeId)
const paneTitlesByTabId = selectRuntimePaneTitlesForWorktree(state, worktreeId)
const ptyIdsByTabId = selectLivePtyIdsForWorktree(state, worktreeId)
const paneForegroundAgentByPaneKey = selectPaneForegroundAgentsForWorktree(state, worktreeId)

const cached = cache?.byWorktree.get(worktreeId)
if (
Expand All @@ -134,7 +138,8 @@ export function selectWorktreeAgentRowsCached(args: {
cached.orchestration === orchestration &&
shallowRecordEqual(cached.terminalLayoutsByTabId, terminalLayoutsByTabId) &&
shallowRecordEqual(cached.paneTitlesByTabId, paneTitlesByTabId) &&
shallowRecordEqual(cached.ptyIdsByTabId, ptyIdsByTabId)
shallowRecordEqual(cached.ptyIdsByTabId, ptyIdsByTabId) &&
shallowRecordEqual(cached.paneForegroundAgentByPaneKey, paneForegroundAgentByPaneKey)
) {
return cached.rows
}
Expand All @@ -158,6 +163,7 @@ export function selectWorktreeAgentRowsCached(args: {
ptyIdsByTabId,
terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey: orchestration,
paneForegroundAgentByPaneKey,
now
})
)
Expand All @@ -174,6 +180,7 @@ export function selectWorktreeAgentRowsCached(args: {
terminalLayoutsByTabId,
paneTitlesByTabId,
ptyIdsByTabId,
paneForegroundAgentByPaneKey,
rows
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { titleShowsNoAgent } from '../../../../shared/agent-detection'
import type { AgentType } from '../../../../shared/agent-status-types'
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'

const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
'Claude Code': 'claude',
OpenClaude: 'openclaude',
Codex: 'codex',
'Gemini CLI': 'gemini',
'GitHub Copilot': 'copilot',
Grok: 'grok',
Devin: 'devin',
Antigravity: 'antigravity',
OpenCode: 'opencode',
Aider: 'aider',
Cursor: 'cursor',
Droid: 'droid',
Hermes: 'hermes',
Pi: 'pi',
OMP: 'omp'
}

const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i

export function resolveTitleDerivedAgentType(
title: string,
label: string,
ownerAgentType?: AgentType | null
): AgentType | null {
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
if (agentType !== 'claude') {
return agentType
}
// Why: Claude's task-title spinner heuristic has no provider identity. In
// split panes it can match arbitrary terminal spinners, so sidebar rows only
// accept Claude when the title itself names Claude.
if (!CLAUDE_AGENT_TOKEN_RE.test(title)) {
return null
}
// Why: a "claude" word inside another agent's task text is a mention, not identity.
// Only a title that PRESENTS Claude may take a pane away from its known owner (#8940).
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
return null
}
return agentType
}

/** The pane's foreground-process read as the tracker publishes it; routing fields are omitted. */
export type TitleDerivedPaneForeground = Pick<
PaneForegroundAgentEntry,
'agent' | 'agentEvidence' | 'shellForeground'
>

// Why: Codex clears its title on exit (OSC 0 with no text) and the tab then shows its default title.
function titleRetiresProcessRead(args: { title: string; defaultTitle?: string }): boolean {
return args.title.trim().length === 0 || titleShowsNoAgent(args.title, args.defaultTitle)
}

/**
* Which agent a hook-less pane runs: a live process read (unless the title is a shell's), then the
* title, then the agent Orca launched while the title shows activity. Sidebar-only; the tab icon
* orders its signals differently. Null means the pane shows no agent row.
*
* Only the process read may keep a row whose title shows no agent activity (Codex retitles itself
* to the project name, #23767): the mounted pane's tracker re-derives it at command boundaries,
* which a pane without shell command marks never emits, so exit titles must still retire it. The
* launch record is a tab-scoped latch with no run id, so it stays a fallback for titles that show
* activity, and ranks below a title that names a different agent (pane reuse).
*/
export function resolveTitleDerivedPaneAgent(args: {
title: string
defaultTitle?: string
titleShowsActivity: boolean
titleAgentType: AgentType | null
launchAgentType: AgentType | null
foreground: TitleDerivedPaneForeground | undefined
}): AgentType | null {
// Why: a shell/default title is exit evidence the process read may not have caught up with; a
// reattach's launch record is not a read at all and can name an agent that already exited.
const processAgent =
args.foreground?.agentEvidence !== 'process-read' || titleRetiresProcessRead(args)
? null
: args.foreground.agent
// Why: OMP's nested pi process must not take an OMP-launched pane from its owner.
const ownedProcessAgent = processAgent
? (resolveCompatibleAgentTypeForOwner(processAgent, args.launchAgentType) ?? processAgent)
: null
return (
ownedProcessAgent ??
args.titleAgentType ??
(args.titleShowsActivity ? args.launchAgentType : null)
)
}
9 changes: 9 additions & 0 deletions src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor
import { useAppStore } from '@/store'
import {
EMPTY_LIVE_PTY_IDS,
EMPTY_PANE_FOREGROUND_AGENTS,
EMPTY_RUNTIME_PANE_TITLES,
selectLivePtyIdsForWorktree,
selectPaneForegroundAgentsForWorktree,
selectRuntimePaneTitlesForWorktree
} from './worktree-card-status-inputs'
import { buildWorktreeAgentRows } from './worktree-agent-rows'
Expand Down Expand Up @@ -101,6 +103,11 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
: EMPTY_WORKTREE_AGENT_ORCHESTRATION
)
)
const paneForegroundAgentByPaneKey = useAppStore(
useShallow((s) =>
active ? selectPaneForegroundAgentsForWorktree(s, worktreeId) : EMPTY_PANE_FOREGROUND_AGENTS
)
)
const agentFreshnessSignature = useAppStore((s) =>
active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
)
Expand Down Expand Up @@ -131,6 +138,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
ptyIdsByTabId,
terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey,
now
})
)
Expand All @@ -145,6 +153,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
ptyIdsByTabId,
terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey,
agentFreshnessSignature
])
}
2 changes: 2 additions & 0 deletions src/renderer/src/components/sidebar/worktree-agent-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows'
import type { TitleDerivedPaneForeground } from './title-derived-pane-agent-identity'
import { buildSubagentChildRows } from './worktree-subagent-child-rows'
import { compareWorktreeAgentRows } from './worktree-agent-row-order'
import {
Expand Down Expand Up @@ -146,6 +147,7 @@ export function buildWorktreeAgentRows(args: {
ptyIdsByTabId?: Record<string, string[]>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
paneForegroundAgentByPaneKey?: Record<string, TitleDerivedPaneForeground>
now: number
}): DashboardAgentRow[] {
const rows: DashboardAgentRow[] = []
Expand Down
Loading
Loading