diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 063f4124..202883f1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -95,23 +95,19 @@ function App(): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const activeTabId = useAppStore((s) => s.activeTabId) - const activeAgentCount = useAppStore((s) => - countWorkingAgents({ - tabsByWorktree: s.tabsByWorktree, - runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId - }) - ) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const agentInputs = useAppStore( useShallow((s) => ({ tabsByWorktree: s.tabsByWorktree, - runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + worktreesByRepo: s.worktreesByRepo })) ) + const activeAgentCount = useMemo(() => countWorkingAgents(agentInputs), [agentInputs]) const workingAgentsPerWorktree = useMemo( () => getWorkingAgentsPerWorktree(agentInputs), [agentInputs] ) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId) const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) diff --git a/src/renderer/src/lib/agent-status-count.test.ts b/src/renderer/src/lib/agent-status-count.test.ts index f90ae33a..b13bed84 100644 --- a/src/renderer/src/lib/agent-status-count.test.ts +++ b/src/renderer/src/lib/agent-status-count.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { TerminalTab } from '../../../shared/types' +import type { TerminalTab, Worktree } from '../../../shared/types' import { countWorkingAgents, getWorkingAgentsPerWorktree } from './agent-status' function makeTab(overrides: Partial = {}): TerminalTab { @@ -16,6 +16,32 @@ function makeTab(overrides: Partial = {}): TerminalTab { } } +function worktrees(...ids: string[]): Record { + return { + repo: ids.map( + (id) => + ({ + id, + repoId: 'repo', + path: `/path/${id}`, + head: '', + branch: '', + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + }) satisfies Worktree + ) + } +} + describe('countWorkingAgents', () => { it('counts each live working tab when pane-level titles are unavailable', () => { expect( @@ -27,7 +53,8 @@ describe('countWorkingAgents', () => { ], 'wt-2': [makeTab({ id: 'tab-3', worktreeId: 'wt-2', title: '⠋ Codex is thinking' })] }, - runtimePaneTitlesByTabId: {} + runtimePaneTitlesByTabId: {}, + worktreesByRepo: worktrees('wt-1', 'wt-2') }) ).toBe(3) }) @@ -44,7 +71,8 @@ describe('countWorkingAgents', () => { 2: '✦ Gemini CLI', 3: '✳ Claude Code' } - } + }, + worktreesByRepo: worktrees('wt-1') }) ).toBe(2) }) @@ -60,7 +88,8 @@ describe('countWorkingAgents', () => { makeTab({ id: 'tab-4', title: '⠂ Claude Code', ptyId: null }) ] }, - runtimePaneTitlesByTabId: {} + runtimePaneTitlesByTabId: {}, + worktreesByRepo: worktrees('wt-1') }) ).toBe(0) }) @@ -76,10 +105,24 @@ describe('countWorkingAgents', () => { 1: '✳ Claude Code', 2: 'bash' } - } + }, + worktreesByRepo: worktrees('wt-1') }) ).toBe(0) }) + + it('excludes orphaned worktrees not in worktreesByRepo', () => { + expect( + countWorkingAgents({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', title: '⠂ Claude Code' })], + 'wt-deleted': [makeTab({ id: 'tab-2', worktreeId: 'wt-deleted', title: '✦ Gemini CLI' })] + }, + runtimePaneTitlesByTabId: {}, + worktreesByRepo: worktrees('wt-1') + }) + ).toBe(1) + }) }) describe('getWorkingAgentsPerWorktree', () => { @@ -95,7 +138,8 @@ describe('getWorkingAgentsPerWorktree', () => { 2: '✦ Gemini CLI', 3: '✳ Claude Code' } - } + }, + worktreesByRepo: worktrees('wt-1') }) ).toEqual({ 'wt-1': { @@ -106,4 +150,21 @@ describe('getWorkingAgentsPerWorktree', () => { } }) }) + + it('excludes orphaned worktrees not in worktreesByRepo', () => { + expect( + getWorkingAgentsPerWorktree({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', title: '⠂ Claude Code' })], + 'wt-deleted': [makeTab({ id: 'tab-2', worktreeId: 'wt-deleted', title: '✦ Gemini CLI' })] + }, + runtimePaneTitlesByTabId: {}, + worktreesByRepo: worktrees('wt-1') + }) + ).toEqual({ + 'wt-1': { + agents: [{ label: 'Claude Code', status: 'working', tabId: 'tab-1', paneId: null }] + } + }) + }) }) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index e5ca887a..77cee3cc 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../shared/types' +import type { TerminalTab, Worktree } from '../../../shared/types' // 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 @@ -19,9 +19,10 @@ import { getAgentLabel } from '../../../shared/agent-detection' -type CountWorkingAgentsArgs = { +type AgentQueryArgs = { tabsByWorktree: Record runtimePaneTitlesByTabId: Record> + worktreesByRepo: Record } export type WorkingAgentEntry = { @@ -37,11 +38,20 @@ export type WorktreeAgents = { export function getWorkingAgentsPerWorktree({ tabsByWorktree, - runtimePaneTitlesByTabId -}: CountWorkingAgentsArgs): Record { + runtimePaneTitlesByTabId, + worktreesByRepo +}: AgentQueryArgs): Record { + const validIds = collectWorktreeIds(worktreesByRepo) const result: Record = {} for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + // Why: tabsByWorktree can retain orphaned entries for worktrees that no + // longer exist in git (e.g. deleted worktrees whose tab cleanup didn't + // complete, or worktrees removed outside Orca). worktreesByRepo is the + // source of truth — only include worktrees that still exist. + if (!validIds.has(worktreeId)) { + continue + } const agents: WorkingAgentEntry[] = [] for (const tab of tabs) { @@ -78,11 +88,16 @@ export function getWorkingAgentsPerWorktree({ export function countWorkingAgents({ tabsByWorktree, - runtimePaneTitlesByTabId -}: CountWorkingAgentsArgs): number { + runtimePaneTitlesByTabId, + worktreesByRepo +}: AgentQueryArgs): number { + const validIds = collectWorktreeIds(worktreesByRepo) let count = 0 - for (const tabs of Object.values(tabsByWorktree)) { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + if (!validIds.has(worktreeId)) { + continue + } for (const tab of tabs) { count += countWorkingAgentsForTab(tab, runtimePaneTitlesByTabId) } @@ -91,6 +106,16 @@ export function countWorkingAgents({ return count } +function collectWorktreeIds(worktreesByRepo: Record): Set { + const ids = new Set() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const wt of worktrees) { + ids.add(wt.id) + } + } + return ids +} + function countWorkingAgentsForTab( tab: TerminalTab, runtimePaneTitlesByTabId: Record>