Refresh sidebar activity when a native chat tab arrives after its status (#37151)

* fix(sidebar): invalidate activity summaries on every source snapshot

* test(sidebar): cover status pings and title writes in the activity summary perf contract

* test(sidebar): drop the double cast in the activity summary perf contract

* test(sidebar): type the perf contract counter without a broad object

* test(sidebar): key perf contract rows by worktree id
This commit is contained in:
Brennan Benson
2026-11-01 15:51:47 -08:00
committed by GitHub
parent 4d4ca42519
commit 147a6ab414
3 changed files with 237 additions and 8 deletions
@@ -0,0 +1,184 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection'
import { useWorktreeActivityStatus } from './use-worktree-activity-status'
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
type Work = { statuses: number; tabs: number }
describe('workspace activity summary subscriptions', () => {
it('keeps rows quiet on status pings and title writes and refreshes only the changed chat at 10 and 100 workspaces', () => {
const original = useAppStore.getState()
const now = vi.spyOn(Date, 'now').mockReturnValue(2_000)
const operations: Record<string, Work[]> = {}
try {
for (const count of [10, 100]) {
const renders = Array.from({ length: count }, () => 0)
const worktreeIds = renders.map((_, index) => `folder-${index}`)
const work: Work = { statuses: 0, tabs: 0 }
let counting = false
// Counts each read of a status entry or chat tab, whichever code scans it.
const countReads = (
record: Readonly<Record<string, unknown>> | readonly unknown[],
key: keyof Work
) => {
for (const property of Object.keys(record)) {
const descriptor = Object.getOwnPropertyDescriptor(record, property)
let value = descriptor?.get ? descriptor.get.call(record) : descriptor?.value
Object.defineProperty(record, property, {
enumerable: true,
configurable: true,
get() {
if (counting) {
work[key]++
}
return value
},
set(next) {
value = next
}
})
}
}
const countSnapshot = () => {
const wasCounting = counting
counting = false
const state = useAppStore.getState()
countReads(state.agentStatusByPaneKey, 'statuses')
for (const tabs of Object.values(state.unifiedTabsByWorktree)) {
countReads(tabs, 'tabs')
}
counting = wasCounting
}
const measure = (trigger: string, update: () => void) => {
countSnapshot()
work.statuses = 0
work.tabs = 0
renders.fill(0)
counting = true
try {
act(update)
} finally {
counting = false
}
expect(work.statuses).toBeLessThanOrEqual(count * 4)
expect(work.tabs).toBeLessThanOrEqual(count * 4)
;(operations[trigger] ??= []).push({ ...work })
}
const paneKey = (index: number) =>
structuredAgentSessionPaneKey(`restored:chat-${index}`, `session-${index}`)
const report = (index: number, state: 'working' | 'blocked', updatedAt: number) =>
useAppStore
.getState()
.setAgentStatus(
paneKey(index),
{ state, agentType: 'claude', prompt: `prompt ${index}` },
undefined,
{ updatedAt },
{ worktreeId: `folder-${index}` }
)
const createChat = (index: number) =>
useAppStore.getState().createUnifiedTab(`folder-${index}`, 'agent-session', {
id: `restored:chat-${index}`,
entityId: `session-${index}`,
agentSessionAgent: 'claude',
activate: false,
recordInteraction: false
})
useAppStore.setState({
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
tabsByWorktree: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
activeGroupIdByWorktree: {},
runtimeAgentOrchestrationByPaneKey: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {}
})
// Every workspace has a working chat; folder-0's tab arrives after its status.
for (let index = 0; index < count; index++) {
if (index > 0) {
createChat(index)
}
report(index, 'working', 1_000)
}
function Row({ index }: { index: number }) {
const status = useWorktreeActivityStatus(`folder-${index}`)
renders[index]++
return <span data-row={index}>{status}</span>
}
const container = document.createElement('div')
const root = createRoot(container)
const text = (index: number) =>
container.querySelector(`[data-row="${index}"]`)?.textContent
const quiet = Array(count).fill(0)
// Why: writers copy records, so wrap each published snapshot before the rows read it.
const unsubscribe = useAppStore.subscribe(() => {
if (counting) {
countSnapshot()
}
})
try {
act(() =>
root.render(
worktreeIds.map((worktreeId, index) => <Row key={worktreeId} index={index} />)
)
)
expect(text(1)).toBe('working')
measure('unrelated', () =>
useAppStore.setState({ uiZoomLevel: original.uiZoomLevel + 1 })
)
expect(renders).toEqual(quiet)
expect(work).toEqual({ statuses: 0, tabs: 0 })
let before = useAppStore.getState()
measure('late tab', () => createChat(0))
let after = useAppStore.getState()
expect(after.agentStatusEpoch).toBe(before.agentStatusEpoch)
expect(after.tabsByWorktree).toBe(before.tabsByWorktree)
expect(after.agentStatusByPaneKey).toBe(before.agentStatusByPaneKey)
expect(text(0)).toBe('working')
expect(renders).toEqual([1, ...quiet.slice(1)])
expect(work.statuses).toBeGreaterThan(0)
expect(work.tabs).toBeGreaterThan(0)
before = useAppStore.getState()
measure('same-state ping', () => report(1, 'working', 1_500))
after = useAppStore.getState()
expect(after.agentStatusByPaneKey).not.toBe(before.agentStatusByPaneKey)
expect(after.agentStatusByPaneKey[paneKey(1)]?.updatedAt).toBe(1_500)
expect(after.agentStatusEpoch).toBe(before.agentStatusEpoch)
expect(renders).toEqual(quiet)
before = useAppStore.getState()
measure('title', () =>
useAppStore.getState().setTabLabel('restored:chat-1', 'Renamed chat')
)
after = useAppStore.getState()
expect(after.unifiedTabsByWorktree).not.toBe(before.unifiedTabsByWorktree)
expect(renders).toEqual(quiet)
measure('state change', () => report(1, 'blocked', 1_600))
expect(text(1)).toBe('permission')
expect(renders).toEqual([0, 1, ...quiet.slice(2)])
} finally {
unsubscribe()
act(() => root.unmount())
}
}
for (const [, [small, large]] of Object.entries(operations)) {
expect(large.statuses).toBeLessThanOrEqual(small.statuses * 10)
expect(large.tabs).toBeLessThanOrEqual(small.tabs * 10)
}
} finally {
useAppStore.setState(original, true)
now.mockRestore()
}
})
})
@@ -5,6 +5,8 @@ import {
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection'
import type { Tab } from '../../../../shared/tab-types'
import { resolveWorktreeStatus } from '@/lib/worktree-status'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import {
@@ -64,6 +66,44 @@ describe('selectWorktreeAgentActivitySummary', () => {
vi.restoreAllMocks()
})
it('updates when a represented chat arrives after its status without changing other inputs', () => {
vi.spyOn(Date, 'now').mockReturnValue(2_000)
const worktreeId = 'repo::/wt-1'
const tab: Tab = {
id: 'restored:chat',
entityId: 'session-1',
worktreeId,
groupId: 'group-1',
contentType: 'agent-session',
agentSessionAgent: 'claude',
label: 'Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId)
const state: AgentActivityInput = {
tabsByWorktree: {},
unifiedTabsByWorktree: {},
agentStatusEpoch: 0,
agentStatusByPaneKey: {
[paneKey]: makeAgentStatusEntry({ paneKey, worktreeId, state: 'working' })
},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
runtimeAgentOrchestrationByPaneKey: {}
}
expect(selectWorktreeAgentActivitySummary(state, worktreeId).hasLiveWorking).toBe(false)
const represented = { ...state, unifiedTabsByWorktree: { [worktreeId]: [tab] } }
expect(selectWorktreeAgentActivitySummary(represented, worktreeId).hasLiveWorking).toBe(true)
expect(
selectWorktreeAgentActivitySummary({ ...represented, unifiedTabsByWorktree: {} }, worktreeId)
.hasLiveWorking
).toBe(false)
})
it('attributes migration-blocked UUID and legacy terminal panes without affecting other worktrees', () => {
for (const paneKey of [makePaneKey('tab-1', LEAF_ID), 'tab-1:0', undefined, 'malformed']) {
const state: AgentActivityInput = {
@@ -128,7 +168,7 @@ describe('selectWorktreeAgentActivitySummary', () => {
expect(nowSpy).toHaveBeenCalledTimes(1)
})
it('reuses the cached summary when same-state agent pings only clone the status map', () => {
it('reuses equal summaries when same-state agent pings clone the status map', () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000)
const paneKey = makePaneKey('tab-1', LEAF_ID)
const tabsByWorktree = {
@@ -158,16 +198,15 @@ describe('selectWorktreeAgentActivitySummary', () => {
}
}
expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')).toMatchObject({
const before = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')
expect(before).toMatchObject({
hasLiveWorking: true
})
expect(selectWorktreeAgentActivitySummary(sameStatePing, 'repo::/wt-1')).toMatchObject({
hasLiveWorking: true
})
expect(nowSpy).toHaveBeenCalledTimes(1)
expect(selectWorktreeAgentActivitySummary(sameStatePing, 'repo::/wt-1')).toBe(before)
expect(nowSpy).toHaveBeenCalledTimes(2)
})
it('rebuilds the summary when the agent status epoch changes', () => {
it.each([0, 1])('rebuilds for a changed status snapshot with epoch %i', (epoch) => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000)
const paneKey = makePaneKey('tab-1', LEAF_ID)
const tabsByWorktree = {
@@ -187,7 +226,7 @@ describe('selectWorktreeAgentActivitySummary', () => {
}
const changedState = {
...state,
agentStatusEpoch: 1,
agentStatusEpoch: epoch,
agentStatusByPaneKey: {
[paneKey]: makeAgentStatusEntry({ paneKey, state: 'done' })
}
@@ -69,6 +69,8 @@ export type AgentActivityInput = Pick<
type AgentActivityCache = {
tabsByWorktree: AgentActivityTabsByWorktree
unifiedTabsByWorktree: AgentActivityInput['unifiedTabsByWorktree']
agentStatusByPaneKey: AgentActivityInput['agentStatusByPaneKey']
agentStatusEpoch: number
migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId']
retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey']
@@ -92,6 +94,8 @@ function getWorktreeAgentActivitySummaries(
if (
agentActivityCache &&
agentActivityCache.tabsByWorktree === state.tabsByWorktree &&
agentActivityCache.unifiedTabsByWorktree === state.unifiedTabsByWorktree &&
agentActivityCache.agentStatusByPaneKey === state.agentStatusByPaneKey &&
agentActivityCache.agentStatusEpoch === state.agentStatusEpoch &&
agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId &&
agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey &&
@@ -194,6 +198,8 @@ function getWorktreeAgentActivitySummaries(
agentActivityCache = {
tabsByWorktree: state.tabsByWorktree,
unifiedTabsByWorktree: state.unifiedTabsByWorktree,
agentStatusByPaneKey: state.agentStatusByPaneKey,
agentStatusEpoch: state.agentStatusEpoch,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,