perf: index sidebar agent rows by worktree (#2600)
This commit is contained in:
parent
f050fa364b
commit
e5c4b628a5
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue