From 147a6ab414c4408a5ac46ecb61efcdfbc8bbc353 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:51:47 -0800 Subject: [PATCH] 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 --- ...ent-activity-summary-subscription.test.tsx | 184 ++++++++++++++++++ .../worktree-agent-activity-summary.test.ts | 55 +++++- .../worktree-agent-activity-summary.ts | 6 + 3 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-agent-activity-summary-subscription.test.tsx diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary-subscription.test.tsx b/src/renderer/src/components/sidebar/worktree-agent-activity-summary-subscription.test.tsx new file mode 100644 index 00000000000..0ac615cb64a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary-subscription.test.tsx @@ -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 = {} + 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> | 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 {status} + } + 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) => ) + ) + ) + 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() + } + }) +}) 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 37c65e59981..ef74fea3153 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 @@ -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' }) } 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 c09caeb7fab..f60a49df662 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -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,