From a4f08912df335dc1e2f4ea972a96fbb85de6a856 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:33:17 -0800 Subject: [PATCH] Improve sidebar activity rollup performance - Refresh only stale descendant slots and scope unread updates to their workspace. - Share agent row projections and index unread markers to reduce repeated work. - Preserve host-specific agent activation and show the latest activity time. --- .../activity/WorktreeCardAgents.tsx | 23 +- .../activity/useWorktreeAgentRows.ts | 16 +- .../worktree-agent-rows-projection.ts | 36 ++- .../surface/worktree-card-secondary-rows.tsx | 1 + .../surface/worktree-card-title-status.tsx | 34 +-- ...ree-card-workspace-unread-selector.test.ts | 117 ++++++++++ ...worktree-card-workspace-unread-selector.ts | 75 +++++++ .../template/card-activity-parts.tsx | 1 + ...ree-card-slot-invalidation.scaling.test.ts | 203 +++++++++++++++++ .../worktree-card-slot-invalidation.ts | 208 +++++++++++++----- .../worktree-card-slot-layout-invalidation.ts | 141 ++++++++++++ .../sidebar/worktree-card-slot-range.test.ts | 152 +++++++++++++ .../sidebar/worktree-card-slot-range.ts | 109 +++++++-- .../worktree-card-slot-rollup.scaling.test.ts | 179 +++++++++++++++ .../sidebar/worktree-card-slot-rollup.ts | 35 +-- .../sidebar/worktree-card-slot-signal.test.ts | 109 +++++++++ .../sidebar/worktree-card-slot-signal.ts | 30 ++- .../worktree-card-slot-stale-set.test.ts | 70 ++++++ .../sidebar/worktree-card-slot-stale-set.ts | 63 ++++++ ...worktree-card-slot-targets.scaling.test.ts | 129 +++++++++++ .../sidebar/worktree-card-slot-targets.ts | 18 +- .../zz-slot-invalidation.bench-tmp.test.ts | 79 +++++++ .../workspace-attention-slice.ts | 4 +- .../workspace-live-markers.ts | 52 ++++- .../workspace-marker-host.ts | 70 ++++-- .../sidebar-list/lineage-range-aggregate.ts | 19 ++ 26 files changed, 1800 insertions(+), 173 deletions(-) create mode 100644 src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.test.ts create mode 100644 src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-invalidation.scaling.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-layout-invalidation.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-range.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-rollup.scaling.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-signal.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-stale-set.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-stale-set.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-targets.scaling.test.ts create mode 100644 src/renderer/src/components/sidebar/zz-slot-invalidation.bench-tmp.test.ts diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx index ada73377803..acc427c37e4 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx @@ -4,6 +4,7 @@ import { useNow } from '@/hooks/use-now' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { cn } from '@/lib/class-names' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { CompactAgentExpansion, CompactAgentSummaryButton } from './worktree-card-compact-agents' import { CompactAgentRow } from './worktree-card-compact-agent-row' import { revealCompactAgentCard } from '../../worktree-list/navigation/worktree-sidebar-reveal' @@ -15,33 +16,49 @@ import { useWorktreeAgentListModel } from './use-worktree-agent-list-model' type Props = { worktreeId: string + /** The card's host; local and remote copies can share a workspace ID, so activation must name it. */ + executionHostId?: ExecutionHostId agents?: DashboardAgentRowData[] className?: string } const WorktreeCardAgents = React.memo(function WorktreeCardAgents({ worktreeId, + executionHostId, agents: precomputedAgents, className }: Props) { useUiLanguageRevision() - const selectedAgents = useWorktreeAgentRows(worktreeId, precomputedAgents === undefined) + const selectedAgents = useWorktreeAgentRows( + worktreeId, + precomputedAgents === undefined, + executionHostId + ) const agents = precomputedAgents ?? selectedAgents if (agents.length === 0) { return null } // Only non-empty bodies mount the 30s clock, avoiding timer cost for idle worktrees. - return + return ( + + ) }) type BodyProps = { worktreeId: string + executionHostId?: ExecutionHostId agents: DashboardAgentRowData[] className?: string } const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ worktreeId, + executionHostId, agents, className }: BodyProps) { @@ -67,7 +84,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ toggleIdleChildGroup, isLineageExpanded, isFocusedOrHidesFocus - } = useWorktreeAgentListModel(worktreeId, agents) + } = useWorktreeAgentListModel(worktreeId, agents, executionHostId) const compactAgentListRootRef = useRef(null) const now = useNow(30_000) diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts index 2adac5b7517..e006d6e1694 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/useWorktreeAgentRows.ts @@ -1,15 +1,25 @@ import { useCallback, useMemo, useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' import { useAppStateView } from '@/store/retained-views/app-state-view' import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import { createWorktreeAgentRowsSelector, EMPTY_AGENT_ROWS } from './worktree-agent-rows-projection' +import { + createWorktreeAgentRowsSelector, + EMPTY_AGENT_ROWS, + getSharedWorktreeAgentRowsSelector +} from './worktree-agent-rows-projection' const subscribeToNothing = (): (() => void) => () => {} export function useWorktreeAgentRows(worktreeId: string, active = true, hostId?: ExecutionHostId) { const store = useAppStateView() + // Live reads share the rollup's selector; a retained view's frozen state keeps its own memo. + const live = store === useAppStore const selectRows = useMemo( - () => createWorktreeAgentRowsSelector(worktreeId, hostId), - [worktreeId, hostId] + () => + live + ? getSharedWorktreeAgentRowsSelector(worktreeId, hostId) + : createWorktreeAgentRowsSelector(worktreeId, hostId), + [live, worktreeId, hostId] ) const getRows = useCallback( () => (active ? selectRows(store.getState()) : EMPTY_AGENT_ROWS), diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts index c446bd0068e..bac3c53e6ba 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts @@ -113,8 +113,31 @@ export function createWorktreeAgentRowsSelector(worktreeId: string, hostId?: Exe } } -/** Structural changes can alter agent ownership; live status and acknowledgements invalidate only their owners. */ -export function worktreeAgentStructureInputs(state: AppState): readonly unknown[] { +const sharedSelectors = new Map>() +const SHARED_SELECTOR_LIMIT = 2048 + +/** + * One live-store row selector per workspace and host, shared by the card's own rows and the + * lineage rollup so a status change projects the rows once. Least recently used is evicted, so + * workspaces still being read keep their memo above the cap. + */ +export function getSharedWorktreeAgentRowsSelector(worktreeId: string, hostId?: ExecutionHostId) { + const key = `${hostId ?? ''}\n${worktreeId}` + let selector = sharedSelectors.get(key) + if (selector) { + sharedSelectors.delete(key) + } else { + selector = createWorktreeAgentRowsSelector(worktreeId, hostId) + if (sharedSelectors.size >= SHARED_SELECTOR_LIMIT) { + sharedSelectors.delete(sharedSelectors.keys().next().value!) + } + } + sharedSelectors.set(key, selector) + return selector +} + +/** Tab and pane layout inputs; a change here can move any agent between workspaces. */ +export function worktreeAgentLayoutInputs(state: AppState): readonly unknown[] { return [ state.tabsByWorktree, state.unifiedTabsByWorktree, @@ -123,7 +146,14 @@ export function worktreeAgentStructureInputs(state: AppState): readonly unknown[ state.ptyIdsByTabId, state.terminalLayoutsByTabId, state.runtimeAgentOrchestrationByPaneKey, - state.paneForegroundAgentByPaneKey, + state.paneForegroundAgentByPaneKey + ] +} + +/** Structural changes can alter agent ownership; live status and acknowledgements invalidate only their owners. */ +export function worktreeAgentStructureInputs(state: AppState): readonly unknown[] { + return [ + ...worktreeAgentLayoutInputs(state), selectWorktreesByRepo(state), ...structuredAgentSessionOwnerInputs(state) ] diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-secondary-rows.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-secondary-rows.tsx index 26c7f4531bf..374002987c6 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-secondary-rows.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-secondary-rows.tsx @@ -62,6 +62,7 @@ export function WorktreeCardSecondaryRows({ {showInlineAgentList && ( diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx index 4095877b8be..5c8255c8c7f 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx @@ -10,9 +10,6 @@ import { composeWorktreeHostIdentity, type HostScopedWorktreeKey } from '../../../../../../shared/worktree/host-qualified-identity' -import type { AppState } from '@/store/types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { worktreeAgentProjectionInputs } from '../activity/worktree-agent-rows-projection' import { selectAgentRowsUnread } from '../activity/worktree-card-agent-ack-inputs' import { selectSendTargetControlInputs } from '../activity/worktree-card-send-target-inputs' import { useWorktreeAgentExpansionState } from '../activity/worktree-card-agents-expansion-state' @@ -26,7 +23,7 @@ import { buildSlotAgentSignals, type SlotAgentSignals } from '../../worktree-card-slot-agent-signals' -import { titleRowSlot, type SlotException } from '../../worktree-card-slot-signal' +import { titleRowSlot, titleRowUnread, type SlotException } from '../../worktree-card-slot-signal' import { expandWorkspaceUnreadTargets, slotTargetsFromAgentLines, @@ -35,6 +32,7 @@ import { workspaceUnreadTargetsOutsideAgentRows, type SlotTarget } from '../../worktree-card-slot-targets' +import { createWorkspaceUnreadSelector } from './worktree-card-workspace-unread-selector' import { WorktreeCardSlot, WorktreeCardTitlePills, @@ -64,31 +62,6 @@ function useSlotAgentSignals(agents: DashboardAgentRow[]): SlotAgentSignals { return useMemo(() => buildSlotAgentSignals(agents, unread), [agents, unread]) } -function createWorkspaceUnreadSelector(worktree: Worktree, visibleAgents: readonly SlotTarget[]) { - const target = workspaceUnreadTarget( - worktree.id, - worktree.lastActivityAt || null, - worktree.hostId - ) - let inputs: readonly unknown[] = [] - let unread = false - return (state: AppState) => { - const next = [ - ...worktreeAgentProjectionInputs(state), - state.unreadTerminalTabs, - state.unreadAgentCompletionPanes, - state.workspacesMarkedUnreadByUser - ] - if (!next.every((value, index) => value === inputs[index])) { - inputs = next - unread = - worktree.isUnread && - workspaceUnreadTargetsOutsideAgentRows(state, target, visibleAgents).length > 0 - } - return unread - } -} - /** * Computes the card's rolled-up status once, for the status lane, the title and the title's * right-hand slot: rows mount by the hundreds, so the three must not each subscribe. @@ -121,7 +94,6 @@ export function WorktreeCardTitleStatusProvider({ const hiddenDescendants = lineageCollapsed ? descendants : NO_DESCENDANTS const collapsedLineage = useCollapsedLineageSlots(hiddenDescendants) const lineageLatestAt = useLineageLatestAt(descendants) - const agentUnread = signals.allSignals.some((signal) => signal.kind === 'unread') const lastActivityAt = worktree.lastActivityAt > 0 ? worktree.lastActivityAt : null const visibleAgentTargets = useMemo( () => (linesShown ? slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId) : []), @@ -176,7 +148,7 @@ export function WorktreeCardTitleStatusProvider({ const at = slot.kind === 'status' ? slot.at : null // Why: a collapsed parent rolls up its children's unread agent lines, so each child must show // the same dot even when the line is visible, or expanding hides where the unread came from. - const unread = agentUnread || exceptions.some((exception) => exception.kind === 'unread') + const unread = titleRowUnread({ lines: signals.allSignals, collapsedLineage, workspaceUnread }) const lineageStatus = lineageLaneStatus(collapsedLineage) const value = useMemo( () => ({ offline, exceptions, unread, lineageStatus, at, resolveTargets }), diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.test.ts b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.test.ts new file mode 100644 index 00000000000..2d481cb478b --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type * as SlotTargetsModule from '../../worktree-card-slot-targets' +import { makeTab } from '@/store/terminals/terminal-tab.test-fixture' +import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture' +import { createWorkspaceUnreadSelector } from './worktree-card-workspace-unread-selector' + +const resolve = vi.hoisted(() => ({ calls: 0 })) +vi.mock('../../worktree-card-slot-targets', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + workspaceUnreadTargetsOutsideAgentRows: ( + ...args: Parameters + ) => { + resolve.calls++ + return actual.workspaceUnreadTargetsOutsideAgentRows(...args) + } + } +}) + +const MARKER = { at: 1, kind: 'agent-completion' } as never +const ENTRY = (state: string) => ({ state, worktreeId: 'x' }) as never + +function baseState(): AppState { + return { + workspacesMarkedUnreadByUser: {}, + unreadTerminalTabs: {}, + unreadAgentCompletionPanes: { 'a1:leaf': MARKER, 'b1:leaf': MARKER }, + tabsByWorktree: { + A: [makeTab({ id: 'a1', worktreeId: 'A', ptyId: 'pty-a1' })], + B: [makeTab({ id: 'b1', worktreeId: 'B', ptyId: 'pty-b1' })] + }, + unifiedTabsByWorktree: {}, + terminalLayoutsByTabId: { + a1: { root: null, activeLeafId: 'leaf', expandedLeafId: null }, + b1: { root: null, activeLeafId: 'leaf', expandedLeafId: null } + }, + ptyIdsByTabId: {}, + agentStatusByPaneKey: { 'a1:leaf': ENTRY('done'), 'b1:leaf': ENTRY('working') }, + agentStatusEpoch: 0, + acknowledgedAgentsByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + activeWorktreeId: null + } as unknown as AppState +} + +beforeEach(() => { + resolve.calls = 0 +}) + +describe('createWorkspaceUnreadSelector', () => { + const worktree = makeWorktree({ id: 'A', repoId: 'r', hostId: 'local', isUnread: true }) + + it('ignores agent, marker, layout and selection changes in other workspaces', () => { + const select = createWorkspaceUnreadSelector(worktree, []) + let state = baseState() + expect(select(state)).toBe(true) + expect(resolve.calls).toBe(1) + + state = { + ...state, + agentStatusByPaneKey: { ...state.agentStatusByPaneKey, 'b1:leaf': ENTRY('done') }, + agentStatusEpoch: 1, + acknowledgedAgentsByPaneKey: { 'b1:leaf': 1 } as never, + runtimePaneTitlesByTabId: { b1: 'title' } as never, + unreadAgentCompletionPanes: { ...state.unreadAgentCompletionPanes, 'b1:other': MARKER }, + terminalLayoutsByTabId: { + ...state.terminalLayoutsByTabId, + b1: { root: null, activeLeafId: 'other', expandedLeafId: null } + }, + activeWorktreeId: 'B' + } as AppState + expect(select(state)).toBe(true) + expect(resolve.calls).toBe(1) + }) + + it('re-resolves when this workspace’s marker, status, or tabs change', () => { + const select = createWorkspaceUnreadSelector(worktree, []) + let state = baseState() + select(state) + + state = { + ...state, + agentStatusByPaneKey: { ...state.agentStatusByPaneKey, 'a1:leaf': ENTRY('working') } + } as AppState + select(state) + expect(resolve.calls).toBe(2) + + state = { + ...state, + unreadAgentCompletionPanes: { 'b1:leaf': MARKER } + } as AppState + select(state) + expect(resolve.calls).toBe(3) + + state = { + ...state, + unreadTerminalTabs: { a1: MARKER } + } as AppState + select(state) + expect(resolve.calls).toBe(4) + + state = { + ...state, + tabsByWorktree: { ...state.tabsByWorktree, A: [...state.tabsByWorktree.A!] } + } as AppState + select(state) + expect(resolve.calls).toBe(5) + }) + + it('does no work for a card that is not unread', () => { + const select = createWorkspaceUnreadSelector({ ...worktree, isUnread: false }, []) + expect(select(baseState())).toBe(false) + expect(resolve.calls).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.ts b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.ts new file mode 100644 index 00000000000..1a1ae03f640 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-workspace-unread-selector.ts @@ -0,0 +1,75 @@ +import type { AppState } from '@/store/types' +import type { Worktree } from '../../../../../../shared/worktree/types' +import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' +import { structuredAgentSessionOwnerInputs } from '@/runtime/native-chat/session-ownership/structured-agent-session-owner' +import { + completionPaneKeysByTabId, + workspaceMarkerTabs +} from '@/store/slices/workspace-attention/workspace-live-markers' +import { markerTabPaneKey } from '@/store/slices/workspace-attention/workspace-marker-host' +import { + workspaceUnreadTarget, + workspaceUnreadTargetsOutsideAgentRows, + type SlotTarget +} from '../../worktree-card-slot-targets' + +/** + * Exactly what `workspaceUnreadTargetsOutsideAgentRows` reads for this workspace, so an agent + * update, marker or layout change in another workspace does not re-resolve this card. O(tabs + * in this workspace); completion markers come from the shared per-snapshot tab index. + */ +export function workspaceUnreadInputs(state: AppState, worktree: Worktree): unknown[] { + const identity = composeWorktreeHostIdentity(worktree.hostId ?? 'local', worktree.id) + const inputs: unknown[] = [ + state.workspacesMarkedUnreadByUser[identity], + state.tabsByWorktree[worktree.id], + state.unifiedTabsByWorktree[worktree.id] + ] + const completions = completionPaneKeysByTabId(state.unreadAgentCompletionPanes) + let hasStructuredTab = false + for (const tab of workspaceMarkerTabs(state, worktree.id)) { + hasStructuredTab ||= tab.sessionId !== null + inputs.push( + state.unreadTerminalTabs[tab.id], + completions.get(tab.id), + // Host resolution for this tab: its layout, pty ids, and status entry. + state.terminalLayoutsByTabId[tab.id], + state.ptyIdsByTabId[tab.id], + state.agentStatusByPaneKey[markerTabPaneKey(state, tab)] + ) + } + if (hasStructuredTab) { + // Chat ownership can fall back to workspace and runtime state when a tab has no stamped host. + inputs.push(...structuredAgentSessionOwnerInputs(state)) + } + return inputs +} + +/** Whether the card's workspace unread has a source its visible agent rows don't already show. */ +export function createWorkspaceUnreadSelector( + worktree: Worktree, + visibleAgents: readonly SlotTarget[] +) { + const target = workspaceUnreadTarget( + worktree.id, + worktree.lastActivityAt || null, + worktree.hostId + ) + let inputs: readonly unknown[] | null = null + let unread = false + return (state: AppState) => { + if (!worktree.isUnread) { + return false + } + const next = workspaceUnreadInputs(state, worktree) + if ( + inputs === null || + next.length !== inputs.length || + !next.every((value, index) => value === inputs![index]) + ) { + inputs = next + unread = workspaceUnreadTargetsOutsideAgentRows(state, target, visibleAgents).length > 0 + } + return unread + } +} diff --git a/src/renderer/src/components/sidebar/workspace-card/template/card-activity-parts.tsx b/src/renderer/src/components/sidebar/workspace-card/template/card-activity-parts.tsx index 8677f4cecb6..faa1fe8e319 100644 --- a/src/renderer/src/components/sidebar/workspace-card/template/card-activity-parts.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/template/card-activity-parts.tsx @@ -53,6 +53,7 @@ export const CARD_ACTIVITY_PARTS = { Agents: part(['tight'], (props, _children, card) => ( ({ reads: [] as string[] })) +vi.mock('./workspace-card/activity/worktree-agent-rows-projection', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSharedWorktreeAgentRowsSelector: ( + ...args: Parameters + ) => { + const select = actual.getSharedWorktreeAgentRowsSelector(...args) + return (state: Parameters[0]) => { + counts.reads.push(args[0]) + return select(state) + } + } + } +}) + +const original = useAppStore.getState() +const LEAF = '11111111-1111-4111-8111-111111111111' + +function tab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function entry(paneKey: string, worktreeId: string): AgentStatusEntry { + return { + paneKey, + worktreeId, + state: 'done', + agentType: 'claude', + prompt: '', + updatedAt: 2_000_000, + stateStartedAt: 1_900_000, + stateHistory: [] + } +} + +/** A parent with `size` descendants, each owning one tab, then one unrelated root after them. */ +function seedLineage(size: number): { range: WorktreeLineageRange; rows: Worktree[] } { + const rows = Array.from({ length: size + 2 }, (_, i) => + makeWorktree({ id: `${TEST_REPO.id}::/l${size}-w${i}`, repoId: TEST_REPO.id, hostId: 'local' }) + ) + useAppStore.setState({ + worktreesByRepo: { [TEST_REPO.id]: rows }, + tabsByWorktree: Object.fromEntries(rows.map((row, i) => [row.id, [tab(`t${i}`, row.id)]])) + }) + const tree = { + identities: rows.map(getWorktreeHostIdentity), + worktrees: rows, + ends: rows.map((_, i) => (i === 0 ? size + 1 : i + 1)) + } + return { range: { tree, index: 0, length: size }, rows } +} + +function readParent(range: WorktreeLineageRange): void { + const state = useAppStore.getState() + selectCollapsedLineageSlots(state, range) + selectLineageLatestAt(state, range) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(2_000_000) + useAppStore.setState({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, + agentStatusEpoch: 1, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + repos: [TEST_REPO], + activeWorktreeId: null + }) + counts.reads = [] +}) +afterEach(() => { + useAppStore.setState(original, true) + vi.useRealTimers() +}) + +describe.each([10, 100, 1000])('layout invalidation under a parent of %i', (size) => { + it('re-checks only the owner when one tab’s ptys or layout change', () => { + const { range, rows } = seedLineage(size) + readParent(range) + counts.reads = [] + useAppStore.setState({ ptyIdsByTabId: { t3: ['pty-t3'] } }) + readParent(range) + expect(counts.reads).toEqual([rows[3]!.id]) + + counts.reads = [] + useAppStore.setState({ + terminalLayoutsByTabId: { + t4: { root: null, activeLeafId: null, expandedLeafId: null } + } + }) + readParent(range) + expect(counts.reads).toEqual([rows[4]!.id]) + }) + + it('skips the lineage entirely for a change outside it', () => { + const { range, rows } = seedLineage(size) + readParent(range) + counts.reads = [] + const outside = rows.length - 1 + useAppStore.setState({ ptyIdsByTabId: { [`t${outside}`]: ['pty-x'] } }) + readParent(range) + expect(counts.reads).toEqual([]) + }) + + it('re-checks only the owner when a tab is replaced in place', () => { + const { range, rows } = seedLineage(size) + readParent(range) + counts.reads = [] + const tabs = useAppStore.getState().tabsByWorktree + useAppStore.setState({ + tabsByWorktree: { ...tabs, [rows[2]!.id]: [{ ...tab('t2', rows[2]!.id), title: 'renamed' }] } + }) + readParent(range) + expect(counts.reads).toEqual([rows[2]!.id]) + }) + + it('re-checks the old and new owner when a retained agent changes', () => { + const { range, rows } = seedLineage(size) + const paneKey = makePaneKey('gone-tab', LEAF) + const retained = (worktreeId: string) => ({ + entry: entry(paneKey, worktreeId), + worktreeId, + tab: tab('gone-tab', worktreeId), + agentType: 'claude' as const, + startedAt: 1_900_000 + }) + useAppStore.setState({ retainedAgentsByPaneKey: { [paneKey]: retained(rows[1]!.id) } }) + readParent(range) + counts.reads = [] + useAppStore.setState({ retainedAgentsByPaneKey: { [paneKey]: retained(rows[5]!.id) } }) + readParent(range) + expect(new Set(counts.reads)).toEqual(new Set([rows[1]!.id, rows[5]!.id])) + }) + + it('still re-checks every descendant when a tab moves between workspaces', () => { + const { range, rows } = seedLineage(size) + readParent(range) + counts.reads = [] + const tabs = useAppStore.getState().tabsByWorktree + useAppStore.setState({ + tabsByWorktree: { + ...tabs, + [rows[2]!.id]: [], + [rows[3]!.id]: [tab('t3', rows[3]!.id), tab('t2', rows[3]!.id)] + } + }) + readParent(range) + expect(counts.reads).toHaveLength(size) + }) +}) + +describe('orchestration invalidation', () => { + it('re-checks the parent pane’s workspace, not only the child’s', () => { + const { range, rows } = seedLineage(10) + const child = makePaneKey('t6', LEAF) + const parent = makePaneKey('t2', LEAF) + useAppStore.setState({ agentStatusByPaneKey: { [child]: entry(child, rows[6]!.id) } }) + readParent(range) + counts.reads = [] + useAppStore.setState({ + runtimeAgentOrchestrationByPaneKey: { + [child]: { taskId: 'task', dispatchId: 'dispatch', parentPaneKey: parent } + } + }) + readParent(range) + expect(new Set(counts.reads)).toEqual(new Set([rows[2]!.id, rows[6]!.id])) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts index 58eaeb20ea6..1954bfc71d9 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts @@ -6,14 +6,27 @@ import { import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs' import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import { getTabIdToWorktreeId } from './workspace-card/activity/worktree-agent-row-selectors' +import { selectWorktreeAgentOrchestrationIndex } from './workspace-card/activity/worktree-agent-orchestration-index' +import { worktreeAgentProjectionInputs } from './workspace-card/activity/worktree-agent-rows-projection' +import { structuredAgentSessionOwnerInputs } from '@/runtime/native-chat/session-ownership/structured-agent-session-owner' +import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' import { - worktreeAgentProjectionInputs, - worktreeAgentStructureInputs -} from './workspace-card/activity/worktree-agent-rows-projection' + addChangedLayoutWorkspaces, + forEachChangedKey, + type SlotLayout +} from './worktree-card-slot-layout-invalidation' export type SlotSnapshot = { inputs: readonly unknown[] - structure: readonly unknown[] + layout: SlotLayout + /** + * Orchestration links reach across workspaces (a child pane shows on its parent's workspace, + * host aliasing reads tab and pty provenance), so it is diffed by its per-workspace output. + */ + orchestration: ReturnType + worktreesByRepo: AppState['worktreesByRepo'] + /** Workspace selection, settings and catalog: only consulted for chats with no stamped host. */ + owner: readonly unknown[] live: AppState['agentStatusByPaneKey'] acknowledgements: AppState['acknowledgedAgentsByPaneKey'] titles: AppState['runtimePaneTitlesByTabId'] @@ -26,7 +39,19 @@ export function slotSnapshot(state: AppState): SlotSnapshot { if (!current || !inputs.every((value, index) => value === current!.inputs[index])) { current = { inputs, - structure: worktreeAgentStructureInputs(state), + layout: { + tabsByWorktree: state.tabsByWorktree, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, + migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + ptyIdsByTabId: state.ptyIdsByTabId, + terminalLayoutsByTabId: state.terminalLayoutsByTabId, + runtimeAgentOrchestrationByPaneKey: state.runtimeAgentOrchestrationByPaneKey, + paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey + }, + orchestration: selectWorktreeAgentOrchestrationIndex(state), + worktreesByRepo: selectWorktreesByRepo(state), + owner: structuredAgentSessionOwnerInputs(state), live: state.agentStatusByPaneKey, acknowledgements: state.acknowledgedAgentsByPaneKey, titles: state.runtimePaneTitlesByTabId, @@ -36,6 +61,72 @@ export function slotSnapshot(state: AppState): SlotSnapshot { } return current } +function sameInputs(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +/** Workspaces whose catalog rows were added, removed or replaced; untouched repos are skipped. */ +function addChangedCatalogWorkspaces( + result: Set, + before: AppState['worktreesByRepo'], + after: AppState['worktreesByRepo'] +): void { + forEachChangedKey(before, after, (repoId) => { + const previous = before[repoId] ?? [] + const next = after[repoId] ?? [] + const previousRows = new Set(previous) + const nextRows = new Set(next) + for (const row of next) { + if (!previousRows.has(row)) { + result.add(row.id) + } + } + for (const row of previous) { + if (!nextRows.has(row)) { + result.add(row.id) + } + } + }) +} + +/** + * Chats without a stamped host resolve their owner from the workspace catalog and selection + * (to disambiguate same-ID twins); only their workspaces can change when those inputs do. + */ +function addUnstampedChatWorkspaces(result: Set, state: AppState): void { + for (const tab of getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).values()) { + if (!tab.executionHostId) { + result.add(tab.worktreeId) + } + } +} + +function addChangedOrchestrationWorkspaces( + result: Set, + before: SlotSnapshot['orchestration'], + after: SlotSnapshot['orchestration'] +): void { + if (before === after) { + return + } + // The index reuses an unchanged workspace's record, so identity marks what moved. + for (const [worktreeId, record] of after) { + if (before.get(worktreeId) !== record) { + result.add(worktreeId) + } + } + for (const worktreeId of before.keys()) { + if (!after.has(worktreeId)) { + result.add(worktreeId) + } + } +} + +/** + * Adds the workspaces a layout change can touch. Returns false when the change moved panes between + * workspaces (a tab or chat added, removed or moved, or a pane in a tab no workspace owns): only + * then must every slot be re-checked. + */ const changes = new WeakMap | null>>() export function changedSlotWorkspaces( @@ -47,68 +138,71 @@ export function changedSlotWorkspaces( if (cached !== undefined) { return cached } - let result: Set | null = null - if (after.structure.every((value, index) => value === before.structure[index])) { - result = new Set() - const tabOwners = getTabIdToWorktreeId(state.tabsByWorktree) - if (before.titles !== after.titles) { - for (const tabId of new Set([...Object.keys(before.titles), ...Object.keys(after.titles)])) { - if (before.titles[tabId] !== after.titles[tabId]) { - const owner = tabOwners.get(tabId) - if (owner) { - result.add(owner) - } - } + let result: Set | null = new Set() + const tabOwners = getTabIdToWorktreeId(state.tabsByWorktree) + const represented = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree) + const includePane = (paneKey: string): void => { + const parsed = parsePaneKey(paneKey) + const tabOwner = parsed && tabOwners.get(parsed.tabId) + if (tabOwner) { + result!.add(tabOwner) + } + // Structured sessions may have no backing terminal tab. + const chat = represented.get(paneKey) + if (chat) { + result!.add(chat.worktreeId) + } + for (const entry of [ + before.live[paneKey], + after.live[paneKey], + before.layout.retainedAgentsByPaneKey[paneKey]?.entry, + after.layout.retainedAgentsByPaneKey[paneKey]?.entry + ]) { + if (entry?.worktreeId) { + result!.add(entry.worktreeId) } } - const includePane = (paneKey: string): void => { - const parsed = parsePaneKey(paneKey) - const tabOwner = parsed && tabOwners.get(parsed.tabId) - const represented = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).get( - paneKey - ) - if (tabOwner) { - result!.add(tabOwner) - } - // Structured sessions may have no backing terminal tab. - if (represented) { - result!.add(represented.worktreeId) - } - for (const entry of [ - before.live[paneKey], - after.live[paneKey], - state.retainedAgentsByPaneKey[paneKey]?.entry - ]) { - if (entry?.worktreeId) { - result!.add(entry.worktreeId) - } + for (const retained of [ + before.layout.retainedAgentsByPaneKey[paneKey], + after.layout.retainedAgentsByPaneKey[paneKey] + ]) { + if (retained?.worktreeId) { + result!.add(retained.worktreeId) } } - if (before.live !== after.live || before.epoch !== after.epoch) { - for (const paneKey of new Set([...Object.keys(before.live), ...Object.keys(after.live)])) { - const previous = before.live[paneKey] - const next = after.live[paneKey] + } + if (addChangedLayoutWorkspaces(result, before.layout, after.layout, tabOwners, includePane)) { + addChangedOrchestrationWorkspaces(result, before.orchestration, after.orchestration) + if (before.worktreesByRepo !== after.worktreesByRepo) { + addChangedCatalogWorkspaces(result, before.worktreesByRepo, after.worktreesByRepo) + } + if (!sameInputs(after.owner, before.owner)) { + addUnstampedChatWorkspaces(result, state) + } + forEachChangedKey(before.titles, after.titles, (tabId) => { + const owner = tabOwners.get(tabId) + if (owner) { + result!.add(owner) + } + }) + forEachChangedKey(before.live, after.live, includePane) + if (before.epoch !== after.epoch) { + // A new epoch moves the clock: entries that kept their identity may still go stale. + for (const paneKey in after.live) { + const entry = after.live[paneKey] if ( - previous !== next || - (next && - before.epoch !== after.epoch && - isAgentStatusFresh(next, before.at, AGENT_STATUS_STALE_AFTER_MS) !== - isAgentStatusFresh(next, after.at, AGENT_STATUS_STALE_AFTER_MS)) + entry && + before.live[paneKey] === entry && + isAgentStatusFresh(entry, before.at, AGENT_STATUS_STALE_AFTER_MS) !== + isAgentStatusFresh(entry, after.at, AGENT_STATUS_STALE_AFTER_MS) ) { includePane(paneKey) } } } - if (before.acknowledgements !== after.acknowledgements) { - for (const paneKey of new Set([ - ...Object.keys(before.acknowledgements), - ...Object.keys(after.acknowledgements) - ])) { - if (before.acknowledgements[paneKey] !== after.acknowledgements[paneKey]) { - includePane(paneKey) - } - } - } + forEachChangedKey(before.acknowledgements, after.acknowledgements, includePane) + } else { + result = null } let next = changes.get(before) if (!next) { diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-layout-invalidation.ts b/src/renderer/src/components/sidebar/worktree-card-slot-layout-invalidation.ts new file mode 100644 index 00000000000..15e4a2a6d09 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-layout-invalidation.ts @@ -0,0 +1,141 @@ +import type { AppState } from '@/store/types' +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs' +import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { readRecordChanges } from '@/store/keyed-record-change-reads' + +export type SlotLayout = Pick< + AppState, + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + | 'migrationUnsupportedByPtyId' + | 'retainedAgentsByPaneKey' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' + | 'runtimeAgentOrchestrationByPaneKey' + | 'paneForegroundAgentByPaneKey' +> + +type KeyedRecord = Readonly> +const EMPTY_RECORD: KeyedRecord = Object.freeze({}) + +/** Visits every key whose value differs, using keyed-record journal entries when available. */ +export function forEachChangedKey( + before: KeyedRecord | undefined, + after: KeyedRecord | undefined, + visit: (key: string) => void +): void { + const previous = before ?? EMPTY_RECORD + const next = after ?? EMPTY_RECORD + if (previous === next) { + return + } + const changes = readRecordChanges(next, previous) + if (changes?.kind === 'addressed') { + changes.keys.forEach(visit) + return + } + for (const key in next) { + if (Object.hasOwn(next, key) && previous[key] !== next[key]) { + visit(key) + } + } + for (const key in previous) { + if (Object.hasOwn(previous, key) && !Object.hasOwn(next, key)) { + visit(key) + } + } +} + +function sameTabIds( + previous: readonly { id: string }[] | undefined, + next: readonly { id: string }[] | undefined +): boolean { + const previousIds = new Set((previous ?? []).map((tab) => tab.id)) + const nextIds = new Set((next ?? []).map((tab) => tab.id)) + return previousIds.size === nextIds.size && [...nextIds].every((id) => previousIds.has(id)) +} + +/** + * Adds the workspaces a layout change can touch. Returns false when the change moved panes between + * workspaces (a tab or chat added, removed or moved, or a pane in a tab no workspace owns): only + * then must every slot be re-checked. + */ +export function addChangedLayoutWorkspaces( + result: Set, + before: SlotLayout, + after: SlotLayout, + tabOwners: ReadonlyMap, + includePane: (paneKey: string) => void +): boolean { + let scoped = true + forEachChangedKey(before.tabsByWorktree, after.tabsByWorktree, (worktreeId) => { + if (!sameTabIds(before.tabsByWorktree[worktreeId], after.tabsByWorktree[worktreeId])) { + scoped = false + } + result.add(worktreeId) + }) + if (!scoped) { + return false + } + forEachChangedKey(before.unifiedTabsByWorktree, after.unifiedTabsByWorktree, (worktreeId) => { + const previous = getStructuredAgentSessionTabsByPaneKey( + before.unifiedTabsByWorktree?.[worktreeId] + ) + const next = getStructuredAgentSessionTabsByPaneKey(after.unifiedTabsByWorktree?.[worktreeId]) + if (previous === next) { + return + } + if (previous.size !== next.size) { + scoped = false + return + } + for (const [paneKey, tab] of next) { + if (!previous.has(paneKey)) { + scoped = false + return + } + if (previous.get(paneKey) !== tab) { + result.add(worktreeId) + } + } + }) + if (!scoped) { + return false + } + const addTabOwner = (tabId: string): void => { + const owner = tabOwners.get(tabId) + if (owner) { + result.add(owner) + } else { + scoped = false + } + } + forEachChangedKey(before.ptyIdsByTabId, after.ptyIdsByTabId, addTabOwner) + forEachChangedKey(before.terminalLayoutsByTabId, after.terminalLayoutsByTabId, addTabOwner) + if (!scoped) { + return false + } + forEachChangedKey( + before.migrationUnsupportedByPtyId, + after.migrationUnsupportedByPtyId, + (ptyId) => { + for (const entry of [ + before.migrationUnsupportedByPtyId[ptyId], + after.migrationUnsupportedByPtyId[ptyId] + ]) { + const parsed = entry?.paneKey ? parsePaneKey(entry.paneKey) : null + const owner = parsed && tabOwners.get(parsed.tabId) + if (owner) { + result.add(owner) + } + } + } + ) + forEachChangedKey(before.retainedAgentsByPaneKey, after.retainedAgentsByPaneKey, includePane) + forEachChangedKey( + before.paneForegroundAgentByPaneKey, + after.paneForegroundAgentByPaneKey, + includePane + ) + return true +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-range.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-range.test.ts new file mode 100644 index 00000000000..b8f6cc3dfd1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-range.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { HostScopedWorktreeKey } from '../../../../shared/worktree/host-qualified-identity' +import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import type { WorktreeLineageTree } from '../../../../shared/sidebar-list/worktree-lineage-range' +import { toLineageSlotRecord } from './worktree-card-slot-summary' +import { createLineageSlotReader, type WorkspaceSlot } from './worktree-card-slot-range' + +// The reader's contract with invalidation: a snapshot per state, and either the changed workspace +// ids or null for a structural change. Tests drive both directly. +const invalidation = vi.hoisted(() => ({ changed: null as Set | null })) +vi.mock('./worktree-card-slot-invalidation', () => ({ + slotSnapshot: (state: { snapshot: object }) => state.snapshot, + changedSlotWorkspaces: () => invalidation.changed +})) + +type FakeState = AppState & { snapshot: object; latest: Map } + +function makeState(latest: Map): FakeState { + return { snapshot: {}, latest } as unknown as FakeState +} + +/** Root with one child at 0..1, then `siblings` leaf roots: a large section, a tiny lineage. */ +function makeTree(siblings: number): WorktreeLineageTree { + const size = 2 + siblings + const identities = Array.from({ length: size }, (_, i) => + composeWorktreeHostIdentity('local', `w${i}`) + ) + const ends = Array.from({ length: size }, (_, i) => (i === 0 ? 2 : i + 1)) + return { identities, worktrees: [] as Worktree[], ends } +} + +function makeReader() { + const calls: HostScopedWorktreeKey[] = [] + const reader = createLineageSlotReader((state, identity): WorkspaceSlot => { + calls.push(identity) + const at = (state as FakeState).latest.get(identity) ?? null + return { targets: [], record: toLineageSlotRecord([]), latestAt: at } + }) + return { reader, calls } +} + +describe('createLineageSlotReader', () => { + it.each([100, 1000])( + 'projects only the queried range after a structural change (section of %i)', + (siblings) => { + const tree = makeTree(siblings) + const { reader, calls } = makeReader() + const child = tree.identities[1]! + const state = makeState(new Map([[child, 5]])) + expect(reader.descendantSlot(state, tree, 0).latestAt).toBe(5) + expect(calls).toEqual([child]) + + calls.length = 0 + invalidation.changed = null + const next = makeState(new Map([[child, 9]])) + expect(reader.descendantSlot(next, tree, 0).latestAt).toBe(9) + expect(calls).toEqual([child]) + } + ) + + it('does not project again for an unchanged snapshot', () => { + const tree = makeTree(50) + const { reader, calls } = makeReader() + const state = makeState(new Map()) + reader.descendantSlot(state, tree, 0) + calls.length = 0 + reader.descendantSlot(state, tree, 0) + reader.descendantSlots(state, tree, 0) + expect(calls).toEqual([]) + }) + + it('re-projects only changed workspaces on a localized change', () => { + const tree: WorktreeLineageTree = { + identities: ['a', 'b', 'c', 'd'].map((id) => composeWorktreeHostIdentity('local', id)), + worktrees: [], + ends: [4, 4, 4, 4] + } + const { reader, calls } = makeReader() + const [, b, c, d] = tree.identities + reader.descendantSlot( + makeState( + new Map([ + [b!, 1], + [c!, 2], + [d!, 3] + ]) + ), + tree, + 0 + ) + calls.length = 0 + invalidation.changed = new Set(['c']) + const next = makeState( + new Map([ + [b!, 1], + [c!, 7], + [d!, 3] + ]) + ) + expect(reader.descendantSlot(next, tree, 0).latestAt).toBe(7) + expect(calls).toEqual([c]) + expect(reader.descendantSlots(next, tree, 0).map((slot) => slot.latestAt)).toEqual([1, 7, 3]) + }) + + it('re-projects one changed descendant in a 10,000-descendant range', () => { + const size = 10_001 + const tree: WorktreeLineageTree = { + identities: Array.from({ length: size }, (_, i) => + composeWorktreeHostIdentity('local', `n${i}`) + ), + worktrees: [], + ends: Array.from({ length: size }, (_, i) => (i === 0 ? size : i + 1)) + } + const { reader, calls } = makeReader() + const changed = tree.identities[4321]! + reader.descendantSlot(makeState(new Map()), tree, 0) + calls.length = 0 + invalidation.changed = new Set(['n4321']) + expect(reader.descendantSlot(makeState(new Map([[changed, 42]])), tree, 0).latestAt).toBe(42) + expect(calls).toEqual([changed]) + }) + + it('aggregates fresh and lazily refreshed slots across nested ranges', () => { + // a ─ b ─ c, then d: reading b's range first must not leave a's aggregate stale. + const tree: WorktreeLineageTree = { + identities: ['a', 'b', 'c', 'd'].map((id) => composeWorktreeHostIdentity('local', id)), + worktrees: [], + ends: [3, 3, 3, 4] + } + const { reader } = makeReader() + const [, b, c] = tree.identities + invalidation.changed = null + const state = makeState( + new Map([ + [b!, 4], + [c!, 6] + ]) + ) + expect(reader.descendantSlot(state, tree, 1).latestAt).toBe(6) + expect(reader.descendantSlot(state, tree, 0).latestAt).toBe(6) + const next = makeState( + new Map([ + [b!, 8], + [c!, 6] + ]) + ) + expect(reader.descendantSlot(next, tree, 0).latestAt).toBe(8) + expect(reader.descendantSlot(next, tree, 1).latestAt).toBe(6) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts index 223254982bf..06215d1b1c1 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts @@ -18,20 +18,28 @@ import { changedSlotWorkspaces, type SlotSnapshot } from './worktree-card-slot-invalidation' +import { createStaleSlotSet, type StaleSlotSet } from './worktree-card-slot-stale-set' export type WorkspaceSlot = { targets: SlotTarget[] record: LineageSlotRecord latestAt: number | null } +/** + * Lineage rollups read one parent's preorder descendant range. Slots refresh lazily: a structural + * change only marks the tree stale, and each read projects just the stale slots inside the range + * it asks for, so a parent with one child costs one projection even in a thousand-row section. + */ export function createLineageSlotReader( ownSlot: (state: AppState, identity: HostScopedWorktreeKey, fallback?: Worktree) => WorkspaceSlot ) { type SlotAggregate = { record: LineageSlotRecord; latestAt: number | null } type TreeSlots = { snapshot: SlotSnapshot - own: WorkspaceSlot[] + own: (WorkspaceSlot | undefined)[] aggregate: LineageRangeAggregate + /** Slots to re-project before reading; reads find them without scanning their range. */ + stale: StaleSlotSet indicesById: Map } const treeSlots = new WeakMap() @@ -41,34 +49,31 @@ export function createLineageSlotReader( mergeRecord(record, right.record) return { record, latestAt: Math.max(left.latestAt ?? 0, right.latestAt ?? 0) || null } } - function updateTreeSlot(slots: TreeSlots, index: number, own: WorkspaceSlot): void { - slots.own[index] = own - slots.aggregate.update(index, own) - } - function slotsForTree(state: AppState, tree: WorktreeLineageTree): TreeSlots { + + /** Brings the tree's bookkeeping up to the current snapshot without projecting any slot. */ + function syncTree(state: AppState, tree: WorktreeLineageTree): TreeSlots { const snapshot = slotSnapshot(state) const cached = treeSlots.get(tree) if (cached?.snapshot === snapshot) { return cached } - const changed = cached && changedSlotWorkspaces(state, cached.snapshot, snapshot) - if (cached && changed) { - for (const id of changed) { - for (const index of cached.indicesById.get(id) ?? []) { - updateTreeSlot( - cached, - index, - ownSlot(state, tree.identities[index]!, tree.worktrees[index]) - ) + if (cached) { + const changed = changedSlotWorkspaces(state, cached.snapshot, snapshot) + if (changed) { + for (const id of changed) { + for (const index of cached.indicesById.get(id) ?? []) { + cached.stale.mark(index) + } } + } else { + cached.stale.markAll() } cached.snapshot = snapshot return cached } - const own = tree.identities.map((identity, index) => - ownSlot(state, identity, tree.worktrees[index]) - ) - const aggregate = createLineageRangeAggregate(own, emptyAggregate, combineSlots) + const size = tree.identities.length + const stale = createStaleSlotSet(size) + stale.markAll() const indicesById = new Map() tree.identities.forEach((identity, index) => { const id = getWorktreeIdFromHostIdentity(identity) @@ -76,11 +81,71 @@ export function createLineageSlotReader( indices.push(index) indicesById.set(id, indices) }) - const result = { snapshot, own, aggregate, indicesById } + const result: TreeSlots = { + snapshot, + own: Array.from({ length: size }), + aggregate: createLineageRangeAggregate( + Array.from({ length: size }, emptyAggregate), + emptyAggregate, + combineSlots + ), + stale, + indicesById + } treeSlots.set(tree, result) return result } + /** + * Re-projects only the stale slots in [start, end), batching each contiguous run into one + * aggregate update. Finding them costs O(k log N) for k stale slots, independent of range size. + */ + function refreshRange( + state: AppState, + tree: WorktreeLineageTree, + start: number, + end: number + ): TreeSlots { + const slots = syncTree(state, tree) + const staleIndices: number[] = [] + slots.stale.forEachStale(start, end, (index) => staleIndices.push(index)) + let runStart = -1 + let run: WorkspaceSlot[] = [] + const flush = (): void => { + if (run.length > 0) { + slots.aggregate.updateRun(runStart, run) + run = [] + } + } + for (const index of staleIndices) { + if (run.length > 0 && index !== runStart + run.length) { + flush() + } + if (run.length === 0) { + runStart = index + } + const own = ownSlot(state, tree.identities[index]!, tree.worktrees[index]) + slots.own[index] = own + slots.stale.clear(index) + run.push(own) + } + flush() + return slots + } + + /** The descendants' own slots, fresh for the current snapshot. */ + function descendantSlots( + state: AppState, + tree: WorktreeLineageTree, + index: number + ): WorkspaceSlot[] { + const end = tree.ends[index]! + if (end <= index + 1) { + return [] + } + return refreshRange(state, tree, index + 1, end).own.slice(index + 1, end) as WorkspaceSlot[] + } + /** Range queries and localized updates merge O(log N) records, without reprojecting siblings. */ function descendantSlot( state: AppState, @@ -91,8 +156,8 @@ export function createLineageSlotReader( if (end <= index + 1) { return emptyAggregate() } - return slotsForTree(state, tree).aggregate.read(index + 1, end) + return refreshRange(state, tree, index + 1, end).aggregate.read(index + 1, end) } - return { slotsForTree, descendantSlot } + return { descendantSlots, descendantSlot } } diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.scaling.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.scaling.test.ts new file mode 100644 index 00000000000..5585c1a3dd4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.scaling.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type * as ProjectionModule from './workspace-card/activity/worktree-agent-rows-projection' +import type * as RowsModule from './workspace-card/activity/worktree-agent-rows' +import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture' +import { TEST_REPO } from '@/store/repos/repo.test-fixture' +import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import type { WorktreeLineageRange } from '../../../../shared/sidebar-list/worktree-lineage-range' +import type { Worktree } from '../../../../shared/worktree/types' +import { selectCollapsedLineageSlots, selectLineageLatestAt } from './worktree-card-slot-rollup' +import { getSharedWorktreeAgentRowsSelector } from './workspace-card/activity/worktree-agent-rows-projection' + +const counts = vi.hoisted(() => ({ + selectorKeys: new Set(), + selectorReads: 0, + builds: 0 +})) +vi.mock('./workspace-card/activity/worktree-agent-rows-projection', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSharedWorktreeAgentRowsSelector: ( + ...args: Parameters + ) => { + counts.selectorKeys.add(`${args[1]}\n${args[0]}`) + const select = actual.getSharedWorktreeAgentRowsSelector(...args) + return (state: Parameters[0]) => { + counts.selectorReads++ + return select(state) + } + } + } +}) +vi.mock('./workspace-card/activity/worktree-agent-rows', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + buildWorktreeAgentRows: (...args: Parameters) => { + counts.builds++ + return actual.buildWorktreeAgentRows(...args) + } + } +}) + +const original = useAppStore.getState() +const LEAF = '11111111-1111-4111-8111-111111111111' + +function resetCounts(): void { + counts.selectorKeys.clear() + counts.selectorReads = 0 + counts.builds = 0 +} + +/** One parent with one child, then `siblings` unrelated roots in the same section. */ +function seedSection(siblings: number): { range: WorktreeLineageRange; rows: Worktree[] } { + const rows = Array.from({ length: siblings + 2 }, (_, i) => + makeWorktree({ + id: `${TEST_REPO.id}::/s${siblings}-w${i}`, + repoId: TEST_REPO.id, + hostId: 'local' + }) + ) + useAppStore.setState({ worktreesByRepo: { [TEST_REPO.id]: rows } }) + const tree = { + identities: rows.map(getWorktreeHostIdentity), + worktrees: rows, + ends: rows.map((_, i) => (i === 0 ? 2 : i + 1)) + } + return { range: { tree, index: 0, length: 1 }, rows } +} + +function readParent(range: WorktreeLineageRange): void { + const state = useAppStore.getState() + selectCollapsedLineageSlots(state, range) + selectLineageLatestAt(state, range) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(2_000_000) + useAppStore.setState({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, + agentStatusEpoch: 1, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + repos: [TEST_REPO], + activeWorktreeId: null + }) + resetCounts() +}) +afterEach(() => { + useAppStore.setState(original, true) + vi.useRealTimers() +}) + +describe.each([10, 100])('lineage rollup in a section with %i unrelated siblings', (siblings) => { + it('projects only the nested workspace on mount', () => { + const { range } = seedSection(siblings) + readParent(range) + expect(counts.selectorKeys.size).toBe(1) + expect(counts.builds).toBe(1) + }) + + it('does no per-workspace work when the active workspace changes', () => { + const { range, rows } = seedSection(siblings) + readParent(range) + resetCounts() + useAppStore.setState({ activeWorktreeId: rows[5]!.id, activeWorkspaceExecutionHostId: 'local' }) + readParent(range) + expect(counts.selectorReads).toBe(0) + }) + + it('re-projects only the child when a catalog row changes', () => { + const { range, rows } = seedSection(siblings) + readParent(range) + resetCounts() + const sibling = { ...rows[3]!, lastActivityAt: 5 } + useAppStore.setState({ + worktreesByRepo: { [TEST_REPO.id]: rows.map((row, i) => (i === 3 ? sibling : row)) } + }) + readParent(range) + expect(counts.selectorReads).toBe(0) + + const latest = useAppStore.getState().worktreesByRepo[TEST_REPO.id]! + useAppStore.setState({ + worktreesByRepo: { + [TEST_REPO.id]: latest.map((row, i) => + i === 1 ? { ...row, lastActivityAt: 1_500_000 } : row + ) + } + }) + expect(selectLineageLatestAt(useAppStore.getState(), range)).toBe(1_500_000) + expect(counts.selectorReads).toBe(1) + }) + + it('shares the child card’s row projection with the rollup', () => { + const { range, rows } = seedSection(siblings) + readParent(range) + resetCounts() + // What the mounted child card's useWorktreeAgentRows reads on the live store. + const ownRows = getSharedWorktreeAgentRowsSelector(rows[1]!.id, 'local') + expect(ownRows(useAppStore.getState())).toEqual([]) + expect(counts.builds).toBe(0) + }) + + it('ignores agent activity in sibling workspaces', () => { + const { range, rows } = seedSection(siblings) + readParent(range) + resetCounts() + const paneKey = makePaneKey('sibling-tab', LEAF) + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + worktreeId: rows[4]!.id, + state: 'working', + agentType: 'claude', + prompt: '', + updatedAt: 2_000_000, + stateStartedAt: 1_900_000, + stateHistory: [] + } + } + }) + readParent(range) + expect(counts.selectorReads).toBe(0) + expect(counts.builds).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts index f10d2c35f8b..bc3f813bb7b 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts @@ -24,7 +24,7 @@ import { type WorktreeLineageSource } from '../../../../shared/sidebar-list/worktree-lineage-range' import { - createWorktreeAgentRowsSelector, + getSharedWorktreeAgentRowsSelector, worktreeAgentProjectionInputs } from './workspace-card/activity/worktree-agent-rows-projection' import { selectAgentRowsUnread } from './workspace-card/activity/worktree-card-agent-ack-inputs' @@ -46,11 +46,6 @@ function worktreeForIdentity( ).find((row) => getWorktreeHostIdentity(row) === identity) } -const ownSelectors = new Map< - HostScopedWorktreeKey, - ReturnType ->() - const ownSlots = new Map< HostScopedWorktreeKey, { @@ -68,18 +63,10 @@ function ownSlot( ): WorkspaceSlot { const cached = ownSlots.get(identity) const worktree = worktreeForIdentity(state, identity) ?? fallback ?? cached?.worktree - let selector = ownSelectors.get(identity) - if (!selector) { - selector = createWorktreeAgentRowsSelector( - getWorktreeIdFromHostIdentity(identity), - getExecutionHostIdFromWorktreeHostIdentity(identity) - ) - ownSelectors.set(identity, selector) - if (ownSelectors.size > 2048) { - ownSelectors.delete(ownSelectors.keys().next().value!) - } - } - const agents = selector(state) + const agents = getSharedWorktreeAgentRowsSelector( + getWorktreeIdFromHostIdentity(identity), + getExecutionHostIdFromWorktreeHostIdentity(identity) + )(state) const unread = selectAgentRowsUnread(state, agents) if ( cached && @@ -88,6 +75,9 @@ function ownSlot( unread.length === cached.unread.length && unread.every((value, index) => value === cached.unread[index]) ) { + // Refresh recency so the cap evicts least recently used, not oldest inserted. + ownSlots.delete(identity) + ownSlots.set(identity, cached) return cached.slot } const signals = buildSlotAgentSignals(agents, unread) @@ -118,7 +108,7 @@ function ownSlot( return result } -const { slotsForTree, descendantSlot } = createLineageSlotReader(ownSlot) +const { descendantSlots, descendantSlot } = createLineageSlotReader(ownSlot) /** What the card shows as unread: its flag, or an agent result the user hasn't read. */ export function selectWorkspaceShowsUnread(state: AppState, worktree: Worktree): boolean { @@ -138,12 +128,7 @@ export function collectLineageSlotTargets( if (source.length === 0) { return [] } - const slots = slotsForTree(state, source.tree) - const result: SlotTarget[] = [] - for (let i = source.index + 1; i < source.tree.ends[source.index]!; i++) { - result.push(...slots.own[i]!.targets) - } - return result + return descendantSlots(state, source.tree, source.index).flatMap((slot) => slot.targets) } return source.flatMap((identity) => ownSlot(state, identity).targets) } diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-signal.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-signal.test.ts new file mode 100644 index 00000000000..3ccff767efe --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-signal.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { titleRowSlot, titleRowUnread } from './worktree-card-slot-signal' + +const base = { + offline: false, + collapsedLineage: [], + workspaceUnread: null, + ownLinesShown: true +} + +describe('titleRowSlot quiet timestamp', () => { + it('prefers newer workspace activity over an older acknowledged agent line', () => { + const slot = titleRowSlot({ + ...base, + lines: [{ kind: 'seen', at: 1_000 }], + lastActivityAt: 5_000 + }) + expect(slot).toEqual({ kind: 'status', exceptions: [], at: 5_000 }) + }) + + it('keeps a newer agent line over older workspace activity', () => { + const slot = titleRowSlot({ + ...base, + lines: [{ kind: 'seen', at: 9_000 }], + lastActivityAt: 5_000 + }) + expect(slot).toMatchObject({ at: 9_000 }) + }) + + it('takes newer lineage activity over its own', () => { + const slot = titleRowSlot({ + ...base, + lines: [{ kind: 'idle', at: 1_000 }], + lastActivityAt: 2_000, + lineageLatestAt: 3_000 + }) + expect(slot).toMatchObject({ at: 3_000 }) + }) + + it('keeps the working start time even when workspace activity is newer', () => { + const slot = titleRowSlot({ + ...base, + ownLinesShown: false, + lines: [{ kind: 'working', at: 1_000 }], + lastActivityAt: 5_000 + }) + expect(slot).toMatchObject({ at: 1_000 }) + }) + + it('returns null when nothing has a time', () => { + expect(titleRowSlot({ ...base, lines: [], lastActivityAt: null })).toMatchObject({ at: null }) + }) +}) + +describe('titleRowUnread', () => { + const unreadChild = [{ kind: 'unread' as const, at: 1_000, manual: false }] + + it('keeps a collapsed child’s unread while the parent is offline', () => { + // The offline slot drops every exception; the dot must not depend on it. + expect( + titleRowSlot({ + ...base, + offline: true, + collapsedLineage: unreadChild, + lines: [], + lastActivityAt: null + }) + ).toEqual({ kind: 'offline' }) + expect( + titleRowUnread({ lines: [], collapsedLineage: unreadChild, workspaceUnread: false }) + ).toBe(true) + }) + + it('agrees with the online slot’s unread exception', () => { + for (const lines of [[], [{ kind: 'unread' as const, at: 1 }]]) { + for (const collapsedLineage of [[], unreadChild]) { + for (const workspaceUnread of [false, true]) { + for (const ownLinesShown of [false, true]) { + const slot = titleRowSlot({ + ...base, + ownLinesShown, + lines, + collapsedLineage, + workspaceUnread: workspaceUnread ? { manual: false } : null, + lastActivityAt: null + }) + const fromSlot = + slot.kind === 'status' && slot.exceptions.some((e) => e.kind === 'unread') + // Shown lines are left out of the slot but still mark the card unread. + const shownLineUnread = ownLinesShown && lines.length > 0 + expect(titleRowUnread({ lines, collapsedLineage, workspaceUnread })).toBe( + fromSlot || shownLineUnread + ) + } + } + } + } + }) + + it('is quiet with nothing unread', () => { + expect( + titleRowUnread({ + lines: [{ kind: 'seen', at: 1 }], + collapsedLineage: [], + workspaceUnread: false + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts index 4519a460c51..67853956586 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts @@ -66,6 +66,13 @@ function latest(lines: readonly SlotLineSignal[]): number | null { return at } +function maxAt(a: number | null, b: number | null): number | null { + if (a === null) { + return b + } + return b === null || a >= b ? a : b +} + export type SlotException = Extract< SlotSignal, { kind: 'needs' } | { kind: 'failed' } | { kind: 'unread' } | { kind: 'working' } @@ -164,11 +171,28 @@ export function titleRowSlot(args: { return { kind: 'status', exceptions, at: exception.at } } } - const ownAt = latest(args.lines) ?? args.lastActivityAt - const lineageAt = args.lineageLatestAt ?? null + // Quiet fallback: the newest of agent lines, workspace activity, and lineage, so a card + // never shows an older time than its own workspace (or its rollup parent) reports. return { kind: 'status', exceptions, - at: ownAt === null || (lineageAt !== null && lineageAt > ownAt) ? lineageAt : ownAt + at: maxAt(maxAt(latest(args.lines), args.lastActivityAt), args.lineageLatestAt ?? null) } } + +/** + * Whether the card shows the unread dot: any of its agent lines (shown or hidden), the workspace's + * own unread, or a collapsed child's. Read from the sources rather than the title slot, because an + * offline slot carries no exceptions and a disconnected parent must still surface an unread child. + */ +export function titleRowUnread(args: { + lines: readonly SlotLineSignal[] + collapsedLineage: readonly SlotException[] + workspaceUnread: boolean +}): boolean { + return ( + args.workspaceUnread || + args.lines.some((line) => line.kind === 'unread') || + args.collapsedLineage.some((exception) => exception.kind === 'unread') + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.test.ts new file mode 100644 index 00000000000..ab0d6947ca2 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { createStaleSlotSet } from './worktree-card-slot-stale-set' + +function staleIn(set: ReturnType, start: number, end: number) { + const found: number[] = [] + const visited = set.forEachStale(start, end, (index) => found.push(index)) + return { found, visited } +} + +describe('createStaleSlotSet', () => { + it.each([100, 1000, 10_000])( + 'finds one stale descendant among %i without scanning the range', + (descendants) => { + // Parent at 0, descendants at 1..n: the shape a mounted parent card reads. + const set = createStaleSlotSet(descendants + 1) + set.markAll() + for (let i = 0; i <= descendants; i++) { + set.clear(i) + } + const changed = Math.floor(descendants / 2) + set.mark(changed) + const { found, visited } = staleIn(set, 1, descendants + 1) + expect(found).toEqual([changed]) + // Two boundary paths plus the path to the stale leaf: O(log N), not O(N). + expect(visited).toBeLessThanOrEqual(6 * Math.ceil(Math.log2(descendants + 1)) + 4) + } + ) + + it('visits O(log N) nodes for a fully fresh range even when slots elsewhere are stale', () => { + const set = createStaleSlotSet(10_000) + set.markAll() + for (let i = 2000; i < 9000; i++) { + set.clear(i) + } + const { found, visited } = staleIn(set, 2000, 9000) + expect(found).toEqual([]) + expect(visited).toBeLessThanOrEqual(6 * 14 + 4) + }) + + it('matches a brute-force scan for random marks and ranges', () => { + let seed = 7 + const random = (limit: number) => { + seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31 + return seed % limit + } + for (const length of [1, 2, 3, 17, 64, 100]) { + const set = createStaleSlotSet(length) + const truth = Array.from({ length }, () => true) + set.markAll() + for (let step = 0; step < 200; step++) { + const index = random(length) + if (random(2) === 0) { + set.mark(index) + truth[index] = true + } else { + set.clear(index) + truth[index] = false + } + const start = random(length + 1) + const end = start + random(length - start + 1) + const expected = truth + .map((stale, i) => (stale ? i : -1)) + .filter((i) => i >= start && i < end) + expect(staleIn(set, start, end).found).toEqual(expected) + } + set.markAll() + expect(staleIn(set, 0, length).found).toEqual(Array.from({ length }, (_, i) => i)) + } + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.ts b/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.ts new file mode 100644 index 00000000000..93058636c1e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-stale-set.ts @@ -0,0 +1,63 @@ +/** + * Which lineage slots must be re-projected before they are read. A counting segment tree: each + * node holds how many stale slots its subtree covers, so a read finds the k stale slots in its + * range in O(k log N) by skipping fresh subtrees, never by scanning the range. + */ +export type StaleSlotSet = { + /** Every slot stale (a structural change): O(N) integer writes, no projections. */ + markAll: () => void + mark: (index: number) => void + clear: (index: number) => void + /** Stale slots in [start, end), ascending. Returns how many tree nodes it visited. */ + forEachStale: (start: number, end: number, visit: (index: number) => void) => number +} + +export function createStaleSlotSet(length: number): StaleSlotSet { + const size = 2 ** Math.ceil(Math.log2(Math.max(length, 1))) + const counts = new Int32Array(size * 2) + + function setLeaf(index: number, value: 0 | 1): void { + let node = size + index + const delta = value - counts[node]! + if (delta === 0) { + return + } + while (node >= 1) { + counts[node]! += delta + node = Math.floor(node / 2) + } + } + + return { + markAll() { + counts.fill(0) + counts.fill(1, size, size + length) + for (let node = size - 1; node > 0; node--) { + counts[node] = counts[node * 2]! + counts[node * 2 + 1]! + } + }, + mark: (index) => setLeaf(index, 1), + clear: (index) => setLeaf(index, 0), + forEachStale(start, end, visit) { + let visited = 0 + // Explicit stack, left child pushed last so leaves come out in ascending order. + const stack: number[] = [1, 0, size] + while (stack.length > 0) { + const high = stack.pop()! + const low = stack.pop()! + const node = stack.pop()! + visited++ + if (counts[node] === 0 || high <= start || low >= end) { + continue + } + if (node >= size) { + visit(node - size) + continue + } + const middle = (low + high) / 2 + stack.push(node * 2 + 1, middle, high, node * 2, low, middle) + } + return visited + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-targets.scaling.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-targets.scaling.test.ts new file mode 100644 index 00000000000..8933e38dce3 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-targets.scaling.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { makeTab } from '@/store/terminals/terminal-tab.test-fixture' +import { makeUnifiedTab } from '@/store/slices/tabs/tab-group.test-fixture' +import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection' +import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import { expandWorkspaceUnreadTargets, workspaceUnreadTarget } from './worktree-card-slot-targets' + +const WS = 'ws-scale' +const REMOTE = 'runtime:host-B' as const +const MARKER = { at: 1, kind: 'agent-completion' } as never + +type Counter = { visits: number } + +/** Count element reads so a per-tab `Array.find` shows up as quadratic visits. */ +function countingArray(items: T[], counter: Counter): T[] { + return new Proxy(items, { + get(target, prop, receiver) { + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + counter.visits++ + } + return Reflect.get(target, prop, receiver) + } + }) +} + +/** + * T marked terminal tabs alternating local/remote, one structured chat whose old session also + * left a completion, and C unrelated completion markers in other workspaces. + */ +function buildState(tabCount: number, unrelatedCount: number, counter: Counter): AppState { + const terminals = Array.from({ length: tabCount }, (_, i) => + makeTab({ + id: `t${i}`, + worktreeId: WS, + ptyId: i % 2 === 0 ? `remote:host-B@@pty-${i}` : `pty-${i}` + }) + ) + const chat = makeUnifiedTab({ + id: 'chat', + worktreeId: WS, + groupId: 'g', + contentType: 'agent-session', + entityId: 'session-2', + executionHostId: REMOTE, + agentSessionAgent: 'claude' + }) + const unified = [ + ...terminals.map((tab) => + makeUnifiedTab({ + id: `u-${tab.id}`, + worktreeId: WS, + groupId: 'g', + entityId: tab.id + }) + ), + chat + ] + const completions: Record = {} + for (const tab of terminals) { + completions[`${tab.id}:leaf`] = MARKER + } + completions[structuredAgentSessionPaneKey('chat', 'session-1')] = MARKER + completions[structuredAgentSessionPaneKey('chat', 'session-2')] = MARKER + for (let j = 0; j < unrelatedCount; j++) { + completions[`other${j}:leaf`] = MARKER + } + return { + workspacesMarkedUnreadByUser: {}, + unreadTerminalTabs: {}, + unreadAgentCompletionPanes: completions, + tabsByWorktree: { [WS]: countingArray(terminals, counter) }, + unifiedTabsByWorktree: { [WS]: countingArray(unified, counter) }, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + agentStatusByPaneKey: {} + } as unknown as AppState +} + +/** Visits = tab element reads + pane-key parses (paneKeyTabId uses indexOf). */ +function measure(tabCount: number, unrelatedCount: number): number { + const counter: Counter = { visits: 0 } + const state = buildState(tabCount, unrelatedCount, counter) + const indexOf = vi.spyOn(String.prototype, 'indexOf') + expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, REMOTE)]) + expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, 'local')]) + const parses = indexOf.mock.calls.length + indexOf.mockRestore() + return counter.visits + parses +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('expandWorkspaceUnreadTargets scaling', () => { + it('grows linearly with marked tabs and unrelated completion markers', () => { + const small = measure(20, 200) + const large = measure(200, 2000) + // Linear work scales ~10x; the former T×C and per-tab find scans scaled ~100x. + expect(large / small).toBeLessThan(15) + }) + + it('keeps host ownership and structured-session filtering', () => { + const state = buildState(4, 3, { visits: 0 }) + const remote = expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, REMOTE)]) + expect(remote.map((target) => [target.tabId, target.unreadPaneKeys])).toEqual([ + ['t0', ['t0:leaf']], + ['t2', ['t2:leaf']], + ['chat', [structuredAgentSessionPaneKey('chat', 'session-2')]] + ]) + const local = expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, 'local')]) + expect(local.map((target) => [target.tabId, target.unreadPaneKeys])).toEqual([ + ['t1', ['t1:leaf']], + ['t3', ['t3:leaf']] + ]) + }) + + it('keeps a manual mark as its own target alongside tab targets', () => { + const state = buildState(2, 0, { visits: 0 }) + state.workspacesMarkedUnreadByUser = { + [composeWorktreeHostIdentity('local', WS)]: true + } as never + const local = expandWorkspaceUnreadTargets(state, [workspaceUnreadTarget(WS, 1, 'local')]) + expect( + local.map((target) => target.tabId ?? (target.markedByUser ? 'manual' : 'workspace')) + ).toEqual(['t1', 'manual']) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts index 65c8bbcba85..8d7d35fd6b1 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts @@ -13,6 +13,7 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { workspaceMarkerTabIdsForHost } from '@/store/slices/workspace-attention/workspace-marker-host' import { + completionPaneKeysByTabId, paneKeyTabId, workspaceLiveMarkerTabIds } from '@/store/slices/workspace-attention/workspace-live-markers' @@ -102,7 +103,7 @@ export function expandWorkspaceUnreadTargets( ] ) const ownedTabs = workspaceMarkerTabIdsForHost(state, target.worktreeId, target.executionHostId) - const completionKeys = Object.keys(state.unreadAgentCompletionPanes) + const completionKeysByTab = completionPaneKeysByTabId(state.unreadAgentCompletionPanes) const representedTabs = getStructuredAgentSessionTabsByPaneKey( state.unifiedTabsByWorktree[target.worktreeId] ) @@ -113,10 +114,9 @@ export function expandWorkspaceUnreadTargets( ...target, tabId, terminalBell: state.unreadTerminalTabs[tabId] === 'terminal-bell', - unreadPaneKeys: completionKeys.filter( - (key) => - paneKeyTabId(key) === tabId && - (!structuredKeysByTabId.has(tabId) || structuredKeysByTabId.get(tabId) === key) + unreadPaneKeys: unreadPaneKeysForTab( + completionKeysByTab.get(tabId) ?? [], + structuredKeysByTabId.get(tabId) ) })) return tabTargets.length === 0 || markedByUser @@ -125,6 +125,14 @@ export function expandWorkspaceUnreadTargets( }) } +/** A structured chat tab owns only its current session's key; a terminal tab owns all of its panes. */ +function unreadPaneKeysForTab( + tabKeys: readonly string[], + structuredKey: string | undefined +): readonly string[] { + return structuredKey === undefined ? tabKeys : tabKeys.filter((key) => key === structuredKey) +} + function workspaceKey(target: SlotTarget) { return composeWorktreeHostIdentity(target.executionHostId, target.worktreeId) } diff --git a/src/renderer/src/components/sidebar/zz-slot-invalidation.bench-tmp.test.ts b/src/renderer/src/components/sidebar/zz-slot-invalidation.bench-tmp.test.ts new file mode 100644 index 00000000000..b951e6dbfff --- /dev/null +++ b/src/renderer/src/components/sidebar/zz-slot-invalidation.bench-tmp.test.ts @@ -0,0 +1,79 @@ +import { it } from 'vitest' +import { appendFileSync } from 'node:fs' +import type { AppState } from '@/store/types' +import { changedSlotWorkspaces, type SlotSnapshot } from './worktree-card-slot-invalidation' + +function snap( + live: Record, + acks: Record, + epoch: number +): SlotSnapshot { + const layout = { + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + paneForegroundAgentByPaneKey: {} + } + return { + inputs: [], + layout, + orchestration: new Map(), + worktreesByRepo: {}, + owner: [], + live, + acknowledgements: acks, + titles: {}, + epoch, + at: 2_000_000 + } as unknown as SlotSnapshot +} + +it.each([100, 1000, 10000])('live diff cost with %i panes', (n) => { + const live: Record = {} + const acks: Record = {} + for (let i = 0; i < n; i++) { + live[`tab${i}:leaf`] = { worktreeId: `w${i}`, updatedAt: 2_000_000, state: 'working' } + acks[`tab${i}:leaf`] = { at: 1 } + } + const state = { tabsByWorktree: {}, unifiedTabsByWorktree: {} } as unknown as AppState + const results: string[] = [] + for (const [label, mk] of [ + [ + 'one live entry', + (prev: SlotSnapshot) => + snap( + { + ...prev.live, + 'tab0:leaf': { worktreeId: 'w0', updatedAt: 2_000_001, state: 'working' } + }, + prev.acknowledgements, + prev.epoch + ) + ], + ['epoch bump', (prev: SlotSnapshot) => snap(prev.live, prev.acknowledgements, prev.epoch + 1)], + [ + 'one ack', + (prev: SlotSnapshot) => + snap(prev.live, { ...prev.acknowledgements, 'tab0:leaf': { at: 2 } }, prev.epoch) + ] + ] as const) { + const runs: number[] = [] + for (let r = 0; r < 50; r++) { + const before = snap(live, acks, 1) + const after = mk(before) + const t = performance.now() + changedSlotWorkspaces(state, before, after) + runs.push(performance.now() - t) + } + runs.sort((a, b) => a - b) + results.push(`${label}: median ${runs[25]!.toFixed(3)}ms p95 ${runs[47]!.toFixed(3)}ms`) + } + appendFileSync( + '/private/tmp/claude-501/-Users-jinjingliang-Documents-projects-orca-new-worktree-sidebar/891ff09f-c478-44b2-94a1-7e1df9183934/scratchpad/bench.txt', + `n=${n}\n ${results.join('\n ')}\n` + ) +}) diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts index 6f9b5c5e6d0..426f041b8f2 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts @@ -180,11 +180,13 @@ export const createWorkspaceAttentionSlice: StateCreator< ...workspaceAgentPaneKeys(get(), worktreeId) ]) const state = get() + // One index per action: a lookup per pane is O(tabs × panes) for a workspace with many tabs. + const tabsById = new Map((state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])) const hostPaneKeys = [...paneKeys].filter((paneKey) => { const retained = state.retainedAgentsByPaneKey[paneKey] const entry = state.agentStatusByPaneKey[paneKey] ?? retained?.entry const tabId = paneKeyTabId(paneKey) - const tab = state.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) + const tab = tabId ? tabsById.get(tabId) : undefined return ( getAgentPaneExecutionHost(state, paneKey, entry, tab?.ptyId ?? retained?.tab.ptyId) === executionHostId diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts b/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts index 96dec70ae32..42e559d7a90 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts @@ -25,6 +25,54 @@ export function paneKeyTabId(paneKey: string): string | null { return separator > 0 ? paneKey.slice(0, separator) : null } +const completionKeysByTabCache = new WeakMap>() +let previousCompletionKeysByTab: ReadonlyMap = new Map() + +function sameKeys(left: readonly string[], right: readonly string[] | undefined): boolean { + return ( + right !== undefined && + left.length === right.length && + left.every((key, index) => key === right[index]) + ) +} + +/** + * Completion pane keys grouped by owning tab. Cached per record snapshot (store updates replace the + * record) so every mounted card shares one O(C) pass instead of rescanning all markers per tab. + * A tab whose keys did not change keeps its previous array, so per-workspace readers can compare + * by identity and ignore completions elsewhere. + */ +export function completionPaneKeysByTabId( + completions: MarkerRecord +): ReadonlyMap { + const cached = completionKeysByTabCache.get(completions) + if (cached) { + return cached + } + const byTab = new Map() + for (const key of Object.keys(completions)) { + const tabId = paneKeyTabId(key) + if (tabId === null) { + continue + } + const keys = byTab.get(tabId) + if (keys) { + keys.push(key) + } else { + byTab.set(tabId, [key]) + } + } + for (const [tabId, keys] of byTab) { + const previous = previousCompletionKeysByTab.get(tabId) + if (previous !== keys && sameKeys(keys, previous)) { + byTab.set(tabId, previous as string[]) + } + } + completionKeysByTabCache.set(completions, byTab) + previousCompletionKeysByTab = byTab + return byTab +} + /** The tabs of a workspace that can own a marker. */ export function workspaceMarkerTabs(sources: LiveMarkerSources, workspaceId: string): MarkerTab[] { const tabs: MarkerTab[] = (sources.tabsByWorktree?.[workspaceId] ?? []).map((tab) => ({ @@ -51,9 +99,7 @@ export function workspaceLiveMarkerTabIds( sources: LiveMarkerSources & { unreadAgentCompletionPanes: MarkerRecord }, workspaceId: string ): string[] { - const completionTabIds = new Set( - Object.keys(sources.unreadAgentCompletionPanes).map(paneKeyTabId) - ) + const completionTabIds = completionPaneKeysByTabId(sources.unreadAgentCompletionPanes) return workspaceMarkerTabs(sources, workspaceId) .filter( (tab) => diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts b/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts index 2522fdb0d84..062d066aee3 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts @@ -4,22 +4,59 @@ import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-sessi import { getAgentPaneExecutionHost } from '../agent-status/agent-pane-execution-host' import { workspaceMarkerTabs, type MarkerTab } from './workspace-live-markers' -function markerTabHost(state: AppState, workspaceId: string, tab: MarkerTab) { - const terminal = state.tabsByWorktree[workspaceId]?.find((candidate) => candidate.id === tab.id) +type WorkspaceTabIndex = { + terminalPtyById: ReadonlyMap + stampedHostByTerminalId: ReadonlyMap +} + +/** Index the workspace's tab arrays once so resolving T marker tabs is O(T), not O(T²). */ +function indexWorkspaceTabs(state: AppState, workspaceId: string): WorkspaceTabIndex { + const terminalPtyById = new Map() + for (const terminal of state.tabsByWorktree[workspaceId] ?? []) { + if (!terminalPtyById.has(terminal.id)) { + terminalPtyById.set(terminal.id, terminal.ptyId) + } + } + const stampedHostByTerminalId = new Map() + for (const tab of state.unifiedTabsByWorktree[workspaceId] ?? []) { + // First match wins, matching the previous Array.find semantics. + if (tab.contentType === 'terminal' && !stampedHostByTerminalId.has(tab.entityId)) { + stampedHostByTerminalId.set(tab.entityId, tab.executionHostId) + } + } + return { terminalPtyById, stampedHostByTerminalId } +} + +const structuredPaneKeys = new Map() + +/** The pane whose status entry decides a marker tab's host: its active leaf, or its chat session. */ +export function markerTabPaneKey(state: Pick, tab: MarkerTab) { + if (tab.sessionId === null) { + return `${tab.id}:${state.terminalLayoutsByTabId[tab.id]?.activeLeafId ?? ''}` + } + // Why memoized: the structured key hashes the session id, and selectors call this per update. + const cacheKey = `${tab.id}\n${tab.sessionId}` + let paneKey = structuredPaneKeys.get(cacheKey) + if (paneKey === undefined) { + paneKey = structuredAgentSessionPaneKey(tab.id, tab.sessionId) + structuredPaneKeys.set(cacheKey, paneKey) + if (structuredPaneKeys.size > 4096) { + structuredPaneKeys.delete(structuredPaneKeys.keys().next().value!) + } + } + return paneKey +} + +function markerTabHost(state: AppState, index: WorkspaceTabIndex, tab: MarkerTab) { const layout = state.terminalLayoutsByTabId[tab.id] - const paneKey = - tab.sessionId === null - ? `${tab.id}:${layout?.activeLeafId ?? ''}` - : structuredAgentSessionPaneKey(tab.id, tab.sessionId) + const paneKey = markerTabPaneKey(state, tab) const entry = state.agentStatusByPaneKey[paneKey] const ptyId = (layout?.activeLeafId ? layout.ptyIdsByLeafId?.[layout.activeLeafId] : undefined) ?? - terminal?.ptyId ?? + index.terminalPtyById.get(tab.id) ?? state.ptyIdsByTabId[tab.id]?.[0] if (tab.sessionId === null && !entry && !ptyId) { - const stampedHost = state.unifiedTabsByWorktree[workspaceId]?.find( - (candidate) => candidate.contentType === 'terminal' && candidate.entityId === tab.id - )?.executionHostId + const stampedHost = index.stampedHostByTerminalId.get(tab.id) if (stampedHost) { return stampedHost } @@ -33,13 +70,12 @@ export function workspaceMarkerTabIdsForHost( workspaceId: string, executionHostId: ExecutionHostId | undefined ): Set { + const tabs = workspaceMarkerTabs(state, workspaceId) + if (executionHostId === undefined) { + return new Set(tabs.map((tab) => tab.id)) + } + const index = indexWorkspaceTabs(state, workspaceId) return new Set( - workspaceMarkerTabs(state, workspaceId) - .filter( - (tab) => - executionHostId === undefined || - markerTabHost(state, workspaceId, tab) === executionHostId - ) - .map((tab) => tab.id) + tabs.filter((tab) => markerTabHost(state, index, tab) === executionHostId).map((tab) => tab.id) ) } diff --git a/src/shared/sidebar-list/lineage-range-aggregate.ts b/src/shared/sidebar-list/lineage-range-aggregate.ts index fb8d0c9e6a7..3dbb2c03ba2 100644 --- a/src/shared/sidebar-list/lineage-range-aggregate.ts +++ b/src/shared/sidebar-list/lineage-range-aggregate.ts @@ -1,5 +1,7 @@ export type LineageRangeAggregate = { update: (index: number, value: T) => void + /** Replace a contiguous run of leaves, recombining each shared ancestor once: O(k + log N). */ + updateRun: (start: number, values: readonly T[]) => void read: (start: number, end: number) => T } @@ -26,6 +28,23 @@ export function createLineageRangeAggregate( segments[node] = combine(segments[node * 2]!, segments[node * 2 + 1]!) } }, + updateRun(start, values) { + if (values.length === 0) { + return + } + let low = size + start + let high = low + values.length - 1 + values.forEach((value, offset) => { + segments[low + offset] = value + }) + while (low > 1) { + low = Math.floor(low / 2) + high = Math.floor(high / 2) + for (let node = low; node <= high; node++) { + segments[node] = combine(segments[node * 2]!, segments[node * 2 + 1]!) + } + } + }, read(start, end) { start += size end += size