perf: index sidebar agent rows by worktree (#2600)

This commit is contained in:
Neil 2026-05-21 22:21:34 -07:00 committed by GitHub
parent f050fa364b
commit e5c4b628a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 425 additions and 203 deletions

View File

@ -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<string, Worktree[]>
tabsByWorktree: Record<string, TerminalTab[]>
agentStatusByPaneKey: Record<string, AgentStatusEntry>
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<string>
@ -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<RetainedAgentSnapshot>(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: {

View File

@ -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<string, AgentStatusEntry>) {
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()

View File

@ -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)
})
})

View File

@ -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<string, string>
}
type LiveEntriesByWorktreeCache = {
tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree']
agentStatusByPaneKey: WorktreeAgentRowsState['agentStatusByPaneKey']
entriesByWorktree: Map<string, AgentStatusEntry[]>
}
type MigrationUnsupportedByWorktreeCache = {
tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree']
migrationUnsupportedByPtyId: WorktreeAgentRowsState['migrationUnsupportedByPtyId']
entriesByWorktree: Map<string, MigrationUnsupportedPtyEntry[]>
}
type RetainedEntriesByWorktreeCache = {
retainedAgentsByPaneKey: WorktreeAgentRowsState['retainedAgentsByPaneKey']
entriesByWorktree: Map<string, RetainedAgentEntry[]>
}
let tabWorktreeIndexCache: TabWorktreeIndexCache | null = null
let liveEntriesByWorktreeCache: LiveEntriesByWorktreeCache | null = null
let migrationUnsupportedByWorktreeCache: MigrationUnsupportedByWorktreeCache | null = null
let retainedEntriesByWorktreeCache: RetainedEntriesByWorktreeCache | null = null
function reuseArrayIfEqual<T>(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<string, string> {
if (tabWorktreeIndexCache?.tabsByWorktree === tabsByWorktree) {
return tabWorktreeIndexCache.tabIdToWorktreeId
}
const tabIdToWorktreeId = new Map<string, string>()
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<string, AgentStatusEntry[]> {
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<string, AgentStatusEntry[]>()
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<string, MigrationUnsupportedPtyEntry[]> {
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<string, MigrationUnsupportedPtyEntry[]>()
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<string, RetainedAgentEntry[]> {
if (retainedEntriesByWorktreeCache?.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey) {
return retainedEntriesByWorktreeCache.entriesByWorktree
}
const previous = retainedEntriesByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map<string, RetainedAgentEntry[]>()
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])

View File

@ -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)
})
})

View File

@ -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<string, WorktreeAgentActivitySummary>
@ -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

View File

@ -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()

View File

@ -259,10 +259,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
interrupted: payload.interrupted
}
// Why: broad freshness-aware subscribers only need a global tick when
// an entry appears, changes state, or crosses stale->fresh. 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<AppState, [], [], AgentStatusS
const wasFresh =
!!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS)
const sortRelevantChange = !existing || existing.state !== payload.state || !wasFresh
const doneRetentionFieldsChanged =
existing?.state === 'done' &&
entry.state === 'done' &&
(entry.prompt !== existing.prompt ||
entry.updatedAt !== existing.updatedAt ||
entry.stateStartedAt !== existing.stateStartedAt ||
entry.agentType !== existing.agentType ||
entry.terminalTitle !== existing.terminalTitle ||
entry.toolName !== existing.toolName ||
entry.toolInput !== existing.toolInput ||
entry.lastAssistantMessage !== existing.lastAssistantMessage ||
entry.interrupted !== existing.interrupted)
const retentionRelevantChange = sortRelevantChange || doneRetentionFieldsChanged
// Why: a new status event means the agent is live again — lift any
// one-shot retention suppressor so the row can be retained normally
// on its next disappearance. setAgentStatus fires on every PTY status
@ -298,7 +313,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
agentStatusEpoch:
sortRelevantChange || migrationUnsupported.changed
retentionRelevantChange || migrationUnsupported.changed
? s.agentStatusEpoch + 1
: s.agentStatusEpoch,
sortEpoch: