diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 8a68b3638..9cd4dea2b 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { type DashboardAgentRow } from './useDashboardData' @@ -23,7 +24,6 @@ type RetainedAgentsSyncInputs = { worktreesByRepo: Record tabsByWorktree: Record agentStatusByPaneKey: Record - agentStatusEpoch?: number } type RetainedAgentsSyncSnapshotInputs = RetainedAgentsSyncInputs & { @@ -66,48 +66,6 @@ function agentStartedAt(entry: AgentStatusEntry): number { return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt } -export function buildRetainedAgentsSyncSignature(args: RetainedAgentsSyncInputs): string { - const { existingWorktreeIds, tabIndex } = buildLiveTabIndex(args) - const worktreeParts = [...existingWorktreeIds].sort() - const tabParts = [...tabIndex.entries()] - .map(([tabId, owner]) => `${owner.worktreeId}:${tabId}`) - .sort() - const agentParts: string[] = [] - - for (const [paneKey, entry] of Object.entries(args.agentStatusByPaneKey)) { - const tabId = paneKeyTabId(paneKey) - if (!tabId) { - continue - } - const owner = tabIndex.get(tabId) - if (!owner) { - continue - } - // Why: working/blocked/waiting pings can update prompt/tool text dozens of - // times per second; retention only cares about identity, state, freshness, - // and final done payloads. - const doneUpdatedAt = entry.state === 'done' ? entry.updatedAt : '' - agentParts.push( - [ - owner.worktreeId, - paneKey, - entry.state, - entry.interrupted === true ? 'interrupted' : '', - agentStartedAt(entry), - doneUpdatedAt - ].join(':') - ) - } - - agentParts.sort() - return [ - `epoch:${args.agentStatusEpoch ?? 0}`, - `worktrees:${worktreeParts.join(',')}`, - `tabs:${tabParts.join(',')}`, - `agents:${agentParts.join(',')}` - ].join('|') -} - export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshotInputs): { currentAgents: RetainedAgentSnapshot existingWorktreeIds: Set @@ -148,14 +106,8 @@ export function useRetainedAgentsSync(): void { const retainAgents = useAppStore((s) => s.retainAgents) const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents) const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys) - const retentionSignature = useAppStore((s) => - buildRetainedAgentsSyncSignature({ - repos: s.repos, - worktreesByRepo: s.worktreesByRepo, - tabsByWorktree: s.tabsByWorktree, - agentStatusByPaneKey: s.agentStatusByPaneKey, - agentStatusEpoch: s.agentStatusEpoch - }) + const [repos, worktreesByRepo, tabsByWorktree, agentStatusEpoch] = useAppStore( + useShallow((s) => [s.repos, s.worktreesByRepo, s.tabsByWorktree, s.agentStatusEpoch] as const) ) const prevAgentsRef = useRef(new Map()) @@ -166,13 +118,12 @@ export function useRetainedAgentsSync(): void { worktreesByRepo: state.worktreesByRepo, tabsByWorktree: state.tabsByWorktree, agentStatusByPaneKey: state.agentStatusByPaneKey, - agentStatusEpoch: state.agentStatusEpoch, now: Date.now() }) - // Why: read retention state via getState() instead of subscribing. This - // effect's driving input is the retention signature — retention decisions - // only need to happen when live identity/state/freshness or worktree + // Why: read retention state via getState() after the cheap ref/epoch gate + // fires. Building the full retention snapshot scans all agents, so do it + // only when live identity/state/freshness/final-done data or worktree // membership changes. Subscribing to retainedAgentsByPaneKey would create // a feedback loop because this effect calls retainAgents. const { retainedAgentsByPaneKey: retainedNow, retentionSuppressedPaneKeys } = state @@ -194,7 +145,15 @@ export function useRetainedAgentsSync(): void { if (consumedSuppressedPaneKeys.length > 0) { clearRetentionSuppressedPaneKeys(consumedSuppressedPaneKeys) } - }, [retentionSignature, retainAgents, pruneRetainedAgents, clearRetentionSuppressedPaneKeys]) + }, [ + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusEpoch, + retainAgents, + pruneRetainedAgents, + clearRetentionSuppressedPaneKeys + ]) } export function collectRetainedAgentsOnDisappear(args: { diff --git a/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts b/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts index c87c4612b..16282a9ff 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts @@ -6,12 +6,8 @@ import { } from '../../../../shared/agent-status-types' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import { - buildRetainedAgentsSyncSignature, - buildRetainedAgentsSyncSnapshot -} from './useRetainedAgents' +import { buildRetainedAgentsSyncSnapshot } from './useRetainedAgents' -const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') const ACTIVE_PANE_KEY = makePaneKey('tab-active', '22222222-2222-4222-8222-222222222222') const ARCHIVED_PANE_KEY = makePaneKey('tab-archived', '33333333-3333-4333-8333-333333333333') @@ -83,87 +79,6 @@ function makeEntry(args: { } } -function makeSyncInputs(entries: Record) { - const repo = makeRepo() - const worktree = makeWorktree() - const tab = makeTab() - return { - repos: [repo], - worktreesByRepo: { [repo.id]: [worktree] }, - tabsByWorktree: { [worktree.id]: [tab] }, - agentStatusByPaneKey: entries, - agentStatusEpoch: 1 - } -} - -describe('buildRetainedAgentsSyncSignature', () => { - it('ignores fresh same-state working ping details but changes on state transitions', () => { - const first = buildRetainedAgentsSyncSignature( - makeSyncInputs({ - [PANE_KEY]: makeEntry({ - paneKey: PANE_KEY, - state: 'working', - updatedAt: 1_000, - stateStartedAt: 1_000, - prompt: 'one', - toolName: 'Read' - }) - }) - ) - const sameState = buildRetainedAgentsSyncSignature( - makeSyncInputs({ - [PANE_KEY]: makeEntry({ - paneKey: PANE_KEY, - state: 'working', - updatedAt: 2_000, - stateStartedAt: 1_000, - prompt: 'two', - toolName: 'Edit' - }) - }) - ) - const done = buildRetainedAgentsSyncSignature( - makeSyncInputs({ - [PANE_KEY]: makeEntry({ - paneKey: PANE_KEY, - state: 'done', - updatedAt: 3_000, - stateStartedAt: 3_000, - prompt: 'two' - }) - }) - ) - - expect(sameState).toBe(first) - expect(done).not.toBe(first) - }) - - it('tracks same-state done updates so retention keeps the final snapshot', () => { - const done = buildRetainedAgentsSyncSignature( - makeSyncInputs({ - [PANE_KEY]: makeEntry({ - paneKey: PANE_KEY, - state: 'done', - updatedAt: 3_000, - stateStartedAt: 3_000 - }) - }) - ) - const updatedDone = buildRetainedAgentsSyncSignature( - makeSyncInputs({ - [PANE_KEY]: makeEntry({ - paneKey: PANE_KEY, - state: 'done', - updatedAt: 4_000, - stateStartedAt: 3_000 - }) - }) - ) - - expect(updatedDone).not.toBe(done) - }) -}) - describe('buildRetainedAgentsSyncSnapshot', () => { it('builds live rows for non-archived worktrees and stale-decays active states', () => { const repo = makeRepo() diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index a9827e3d2..2bd1a120e 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -8,7 +8,9 @@ import type { TerminalTab } from '../../../../shared/types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { buildWorktreeAgentRows, - selectMigrationUnsupportedEntriesForWorktree + selectLiveAgentStatusEntriesForWorktree, + selectMigrationUnsupportedEntriesForWorktree, + selectRetainedAgentEntriesForWorktree } from './useWorktreeAgentRows' import { makePaneKey } from '../../../../shared/stable-pane-id' @@ -140,6 +142,85 @@ describe('selectMigrationUnsupportedEntriesForWorktree', () => { // records preserves element identity for useShallow. expect(first).toEqual([unsupported]) expect(second).toEqual([unsupported]) + expect(first).toBe(second) expect(first[0]).toBe(second[0]) }) }) + +describe('selectLiveAgentStatusEntriesForWorktree', () => { + it('reuses unaffected worktree arrays when another worktree receives a same-state ping', () => { + const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'first' }) + const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'first' }) + const state = { + tabsByWorktree: { + 'wt-1': [makeTab('tab-1')], + 'wt-2': [makeTab('tab-2')] + }, + agentStatusByPaneKey: { + [PANE_KEY_1]: wt1Entry, + [PANE_KEY_2]: wt2Entry + }, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {} + } + + const firstWt1 = selectLiveAgentStatusEntriesForWorktree(state, 'wt-1') + const firstWt2 = selectLiveAgentStatusEntriesForWorktree(state, 'wt-2') + const nextState = { + ...state, + agentStatusByPaneKey: { + [PANE_KEY_1]: wt1Entry, + [PANE_KEY_2]: { + ...wt2Entry, + prompt: 'updated prompt preview', + updatedAt: 1100 + } + } + } + + const secondWt1 = selectLiveAgentStatusEntriesForWorktree(nextState, 'wt-1') + const secondWt2 = selectLiveAgentStatusEntriesForWorktree(nextState, 'wt-2') + + // Why: WorktreeCard mounts one selector per visible card. A same-state + // hook ping for wt-2 must not make wt-1 pay a fresh array/render cost. + expect(secondWt1).toBe(firstWt1) + expect(secondWt2).not.toBe(firstWt2) + expect(secondWt2[0]?.prompt).toBe('updated prompt preview') + }) +}) + +describe('selectRetainedAgentEntriesForWorktree', () => { + it('reuses unaffected worktree arrays when another worktree retained row changes', () => { + const wt1Retained = makeRetained(PANE_KEY_1, 'wt-1', 1000) + const wt2Retained = makeRetained(PANE_KEY_2, 'wt-2', 1000) + const state = { + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: { + [PANE_KEY_1]: wt1Retained, + [PANE_KEY_2]: wt2Retained + } + } + + const firstWt1 = selectRetainedAgentEntriesForWorktree(state, 'wt-1') + const firstWt2 = selectRetainedAgentEntriesForWorktree(state, 'wt-2') + const nextState = { + ...state, + retainedAgentsByPaneKey: { + [PANE_KEY_1]: wt1Retained, + [PANE_KEY_2]: { + ...wt2Retained, + startedAt: 1100 + } + } + } + + const secondWt1 = selectRetainedAgentEntriesForWorktree(nextState, 'wt-1') + const secondWt2 = selectRetainedAgentEntriesForWorktree(nextState, 'wt-2') + + expect(secondWt1).toBe(firstWt1) + expect(secondWt2).not.toBe(firstWt2) + expect(secondWt2[0]?.startedAt).toBe(1100) + }) +}) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 196e6a116..ab3540d85 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -18,7 +18,6 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor // reference when there's nothing for this worktree. Without stable empties, // zustand's shallow equality would see a new `[]` every render and trigger // unnecessary re-renders — defeating the purpose of the narrow selector. -const EMPTY_TABS: TerminalTab[] = [] const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = [] const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = [] const EMPTY_RETAINED: RetainedAgentEntry[] = [] @@ -31,63 +30,188 @@ type WorktreeAgentRowsState = Pick< | 'tabsByWorktree' > -export function selectLiveAgentStatusEntriesForWorktree( - state: WorktreeAgentRowsState, - worktreeId: string -): AgentStatusEntry[] { - const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS - if (wtTabs.length === 0) { - return EMPTY_LIVE_ENTRIES +type TabWorktreeIndexCache = { + tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] + tabIdToWorktreeId: Map +} + +type LiveEntriesByWorktreeCache = { + tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] + agentStatusByPaneKey: WorktreeAgentRowsState['agentStatusByPaneKey'] + entriesByWorktree: Map +} + +type MigrationUnsupportedByWorktreeCache = { + tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] + migrationUnsupportedByPtyId: WorktreeAgentRowsState['migrationUnsupportedByPtyId'] + entriesByWorktree: Map +} + +type RetainedEntriesByWorktreeCache = { + retainedAgentsByPaneKey: WorktreeAgentRowsState['retainedAgentsByPaneKey'] + entriesByWorktree: Map +} + +let tabWorktreeIndexCache: TabWorktreeIndexCache | null = null +let liveEntriesByWorktreeCache: LiveEntriesByWorktreeCache | null = null +let migrationUnsupportedByWorktreeCache: MigrationUnsupportedByWorktreeCache | null = null +let retainedEntriesByWorktreeCache: RetainedEntriesByWorktreeCache | null = null + +function reuseArrayIfEqual(previous: T[] | undefined, next: T[]): T[] { + if (!previous || previous.length !== next.length) { + return next } - const tabIds = new Set(wtTabs.map((t) => t.id)) - const out: AgentStatusEntry[] = [] + for (let i = 0; i < next.length; i += 1) { + if (previous[i] !== next[i]) { + return next + } + } + return previous +} + +function getTabIdToWorktreeId( + tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] +): Map { + if (tabWorktreeIndexCache?.tabsByWorktree === tabsByWorktree) { + return tabWorktreeIndexCache.tabIdToWorktreeId + } + const tabIdToWorktreeId = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + tabIdToWorktreeId.set(tab.id, worktreeId) + } + } + tabWorktreeIndexCache = { tabsByWorktree, tabIdToWorktreeId } + return tabIdToWorktreeId +} + +function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map { + if ( + liveEntriesByWorktreeCache?.tabsByWorktree === state.tabsByWorktree && + liveEntriesByWorktreeCache.agentStatusByPaneKey === state.agentStatusByPaneKey + ) { + return liveEntriesByWorktreeCache.entriesByWorktree + } + + const tabIdToWorktreeId = getTabIdToWorktreeId(state.tabsByWorktree) + const previous = liveEntriesByWorktreeCache?.entriesByWorktree + const entriesByWorktree = new Map() for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { const parsed = parsePaneKey(paneKey) if (!parsed) { continue } - if (!tabIds.has(parsed.tabId)) { + const worktreeId = tabIdToWorktreeId.get(parsed.tabId) + if (!worktreeId) { continue } - out.push(entry) + const bucket = entriesByWorktree.get(worktreeId) + if (bucket) { + bucket.push(entry) + } else { + entriesByWorktree.set(worktreeId, [entry]) + } } - return out.length > 0 ? out : EMPTY_LIVE_ENTRIES + for (const [worktreeId, entries] of entriesByWorktree) { + entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries)) + } + liveEntriesByWorktreeCache = { + tabsByWorktree: state.tabsByWorktree, + agentStatusByPaneKey: state.agentStatusByPaneKey, + entriesByWorktree + } + return entriesByWorktree +} + +function getMigrationUnsupportedByWorktree( + state: WorktreeAgentRowsState +): Map { + if ( + migrationUnsupportedByWorktreeCache?.tabsByWorktree === state.tabsByWorktree && + migrationUnsupportedByWorktreeCache.migrationUnsupportedByPtyId === + state.migrationUnsupportedByPtyId + ) { + return migrationUnsupportedByWorktreeCache.entriesByWorktree + } + + const tabIdToWorktreeId = getTabIdToWorktreeId(state.tabsByWorktree) + const previous = migrationUnsupportedByWorktreeCache?.entriesByWorktree + const entriesByWorktree = new Map() + for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) { + if (!unsupported.paneKey) { + continue + } + const parsed = parsePaneKey(unsupported.paneKey) + const worktreeId = parsed ? tabIdToWorktreeId.get(parsed.tabId) : undefined + if (!worktreeId) { + continue + } + const bucket = entriesByWorktree.get(worktreeId) + if (bucket) { + bucket.push(unsupported) + } else { + entriesByWorktree.set(worktreeId, [unsupported]) + } + } + for (const [worktreeId, entries] of entriesByWorktree) { + entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries)) + } + migrationUnsupportedByWorktreeCache = { + tabsByWorktree: state.tabsByWorktree, + migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, + entriesByWorktree + } + return entriesByWorktree +} + +function getRetainedEntriesByWorktree( + state: WorktreeAgentRowsState +): Map { + if (retainedEntriesByWorktreeCache?.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey) { + return retainedEntriesByWorktreeCache.entriesByWorktree + } + + const previous = retainedEntriesByWorktreeCache?.entriesByWorktree + const entriesByWorktree = new Map() + for (const retained of Object.values(state.retainedAgentsByPaneKey)) { + const bucket = entriesByWorktree.get(retained.worktreeId) + if (bucket) { + bucket.push(retained) + } else { + entriesByWorktree.set(retained.worktreeId, [retained]) + } + } + for (const [worktreeId, entries] of entriesByWorktree) { + entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries)) + } + retainedEntriesByWorktreeCache = { + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + entriesByWorktree + } + return entriesByWorktree +} + +export function selectLiveAgentStatusEntriesForWorktree( + state: WorktreeAgentRowsState, + worktreeId: string +): AgentStatusEntry[] { + return getLiveEntriesByWorktree(state).get(worktreeId) ?? EMPTY_LIVE_ENTRIES } export function selectMigrationUnsupportedEntriesForWorktree( state: WorktreeAgentRowsState, worktreeId: string ): MigrationUnsupportedPtyEntry[] { - const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS - if (wtTabs.length === 0) { - return EMPTY_MIGRATION_UNSUPPORTED_ENTRIES - } - const tabIds = new Set(wtTabs.map((t) => t.id)) - const out: MigrationUnsupportedPtyEntry[] = [] - for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) { - if (!unsupported.paneKey) { - continue - } - const parsed = parsePaneKey(unsupported.paneKey) - if (!parsed || !tabIds.has(parsed.tabId)) { - continue - } - out.push(unsupported) - } - return out.length > 0 ? out : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES + return ( + getMigrationUnsupportedByWorktree(state).get(worktreeId) ?? EMPTY_MIGRATION_UNSUPPORTED_ENTRIES + ) } export function selectRetainedAgentEntriesForWorktree( state: WorktreeAgentRowsState, worktreeId: string ): RetainedAgentEntry[] { - const out: RetainedAgentEntry[] = [] - for (const ra of Object.values(state.retainedAgentsByPaneKey)) { - if (ra.worktreeId === worktreeId) { - out.push(ra) - } - } - return out.length > 0 ? out : EMPTY_RETAINED + return getRetainedEntriesByWorktree(state).get(worktreeId) ?? EMPTY_RETAINED } export function buildWorktreeAgentRows(args: { @@ -155,10 +279,10 @@ export function buildWorktreeAgentRows(args: { * list. Produces live hook-reported agents plus retained "done" snapshots, * stale-decayed to 'idle' when the hook stream has gone quiet. * - * Uses per-worktree selectors rather than reusing useDashboardData's - * cross-worktree aggregate — that pipeline is O(repos × worktrees × agents) - * and would recompute once per sidebar card on every agent-status event. - * Scoped selectors keep the cost O(this-worktree-entries) per card. + * Uses indexed per-worktree selectors rather than reusing useDashboardData's + * cross-worktree aggregate. The index is rebuilt once per relevant immutable + * store slice and then shared by every visible card, avoiding O(cards × agents) + * selector work on high-frequency agent status pings. */ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId]) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 75234b739..f45f3cf84 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -1,7 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary' +import type { TerminalTab } from '../../../../shared/types' +import { + selectWorktreeAgentActivitySummary, + type AgentActivityInput +} from './worktree-agent-activity-summary' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -19,6 +23,19 @@ function makeAgentStatusEntry(args: { } } +function makeTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + describe('selectWorktreeAgentActivitySummary', () => { afterEach(() => { vi.restoreAllMocks() @@ -27,10 +44,11 @@ describe('selectWorktreeAgentActivitySummary', () => { it('builds one cached agent summary index for multiple worktree lookups', () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000) const firstPaneKey = makePaneKey('tab-1', LEAF_ID) - const state = { + const retainedTab = makeTab('tab-2', 'repo::/wt-2') + const state: AgentActivityInput = { tabsByWorktree: { - 'repo::/wt-1': [{ id: 'tab-1' }], - 'repo::/wt-2': [{ id: 'tab-2' }] + 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')], + 'repo::/wt-2': [retainedTab] }, agentStatusEpoch: 0, agentStatusByPaneKey: { @@ -38,18 +56,98 @@ describe('selectWorktreeAgentActivitySummary', () => { }, migrationUnsupportedByPtyId: {}, retainedAgentsByPaneKey: { - 'tab-2:0': { worktreeId: 'repo::/wt-2' } + 'tab-2:0': { + entry: makeAgentStatusEntry({ paneKey: 'tab-2:0', state: 'done' }), + worktreeId: 'repo::/wt-2', + tab: retainedTab, + agentType: 'claude', + startedAt: 1_000 + } } } - expect(selectWorktreeAgentActivitySummary(state as never, 'repo::/wt-1')).toMatchObject({ + expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')).toMatchObject({ hasLiveWorking: true, hasRetainedDone: false }) - expect(selectWorktreeAgentActivitySummary(state as never, 'repo::/wt-2')).toMatchObject({ + expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-2')).toMatchObject({ hasLiveWorking: false, hasRetainedDone: true }) expect(nowSpy).toHaveBeenCalledTimes(1) }) + + it('reuses the cached summary when same-state agent pings only clone the status map', () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const tabsByWorktree = { + 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] + } + const migrationUnsupportedByPtyId = {} + const retainedAgentsByPaneKey = {} + const entry = makeAgentStatusEntry({ paneKey, state: 'working' }) + const state: AgentActivityInput = { + tabsByWorktree, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [paneKey]: entry + }, + migrationUnsupportedByPtyId, + retainedAgentsByPaneKey + } + const sameStatePing = { + ...state, + agentStatusByPaneKey: { + [paneKey]: { + ...entry, + prompt: 'new prompt preview', + updatedAt: 1_500 + } + } + } + + expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')).toMatchObject({ + hasLiveWorking: true + }) + expect(selectWorktreeAgentActivitySummary(sameStatePing, 'repo::/wt-1')).toMatchObject({ + hasLiveWorking: true + }) + expect(nowSpy).toHaveBeenCalledTimes(1) + }) + + it('rebuilds the summary when the agent status epoch changes', () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const tabsByWorktree = { + 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] + } + const migrationUnsupportedByPtyId = {} + const retainedAgentsByPaneKey = {} + const state: AgentActivityInput = { + tabsByWorktree, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ paneKey, state: 'working' }) + }, + migrationUnsupportedByPtyId, + retainedAgentsByPaneKey + } + const changedState = { + ...state, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ paneKey, state: 'done' }) + } + } + + expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')).toMatchObject({ + hasLiveWorking: true, + hasLiveDone: false + }) + expect(selectWorktreeAgentActivitySummary(changedState, 'repo::/wt-1')).toMatchObject({ + hasLiveWorking: false, + hasLiveDone: true + }) + expect(nowSpy).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 52dab2e12..112ea539c 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -21,7 +21,7 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { hasRetainedDone: false } -type AgentActivityInput = Pick< +export type AgentActivityInput = Pick< AppState, | 'tabsByWorktree' | 'agentStatusEpoch' @@ -33,7 +33,6 @@ type AgentActivityInput = Pick< type AgentActivityCache = { tabsByWorktree: AppState['tabsByWorktree'] agentStatusEpoch: number - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId'] retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey'] summaries: Map @@ -55,7 +54,6 @@ function getWorktreeAgentActivitySummaries( agentActivityCache && agentActivityCache.tabsByWorktree === state.tabsByWorktree && agentActivityCache.agentStatusEpoch === state.agentStatusEpoch && - agentActivityCache.agentStatusByPaneKey === state.agentStatusByPaneKey && agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId && agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey ) { @@ -106,7 +104,6 @@ function getWorktreeAgentActivitySummaries( agentActivityCache = { tabsByWorktree: state.tabsByWorktree, agentStatusEpoch: state.agentStatusEpoch, - agentStatusByPaneKey: state.agentStatusByPaneKey, migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, summaries diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 0ebbf1115..190bcf7e1 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -148,7 +148,7 @@ describe('agent status tool + assistant fields', () => { expect(store.getState().agentStatusByPaneKey['tab-1:1'].agentType).toBe('cursor') }) - it('keeps global epochs stable for fresh same-state pings while updating the entry', () => { + it('keeps global epochs stable for fresh same-state working pings while updating the entry', () => { vi.useFakeTimers() const store = createTestStore() store @@ -177,8 +177,8 @@ describe('agent status tool + assistant fields', () => { expect(sameStateEntry.updatedAt).toBe(2_000) // Why: same-state hook pings are high-frequency and already update the // owning row through agentStatusByPaneKey. The global epochs are reserved - // for state/freshness changes that can affect aggregate dashboard/sidebar - // calculations. + // for state/freshness/final-done changes that can affect aggregate + // dashboard/sidebar calculations. expect(store.getState().agentStatusEpoch).toBe(firstEpoch) expect(store.getState().sortEpoch).toBe(firstSortEpoch) @@ -192,6 +192,39 @@ describe('agent status tool + assistant fields', () => { expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) }) + it('bumps the status epoch, not sort epoch, for same-state done updates', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus('tab-1:1', { state: 'done', prompt: 'p1', agentType: 'claude' }, 'claude', { + updatedAt: 1_000, + stateStartedAt: 1_000 + }) + const firstEpoch = store.getState().agentStatusEpoch + const firstSortEpoch = store.getState().sortEpoch + + store.getState().setAgentStatus( + 'tab-1:1', + { + state: 'done', + prompt: 'p1', + agentType: 'claude', + lastAssistantMessage: 'final answer' + }, + 'claude', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + + expect(store.getState().agentStatusByPaneKey['tab-1:1'].lastAssistantMessage).toBe( + 'final answer' + ) + // Why: retained rows need the final done snapshot, but done->done does not + // change smart-sort class, so only the status/retention epoch should tick. + expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1) + expect(store.getState().sortEpoch).toBe(firstSortEpoch) + }) + it('bumps global epochs when a stale same-state entry refreshes', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index d61aa81cc..01e1ea38e 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -259,10 +259,12 @@ export const createAgentStatusSlice: StateCreatorfresh. Same-state - // tool/prompt pings still update agentStatusByPaneKey for the owning - // row, but they must not fan out through dashboard/sidebar aggregate - // work across every card. Sort-relevant inputs are: + // an entry appears, changes state, crosses stale->fresh, or receives + // a same-state `done` update that may carry the final assistant + // message for retained rows. Same-state working prompt/tool pings + // still update agentStatusByPaneKey for the owning row, but they must + // not fan out through dashboard/sidebar aggregate work across every + // card. Sort-relevant inputs are: // 1. `state` transitions — smart-sort class is a function of state. // 2. Freshness transitions (stale → fresh) — `resolveAttention` in // smart-attention.ts filters entries through @@ -276,6 +278,19 @@ export const createAgentStatusSlice: StateCreator