diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx index cc340ff59a6..2dd9009177a 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx @@ -311,7 +311,9 @@ describe('StructuredAgentSessionStatusBridge pinned launch directory', () => { ) expect(statusWork).toEqual({ members: 0, groups: hostCount }) expect(reads.reduce((sum, read) => sum + read.mock.calls.length, 0)).toBe(perHost) - expect(snapshots.reduce((sum, read) => sum + read.mock.calls.length, 0)).toBe(2 * perHost) + // One capture read per member; only the changed session reruns owner checks and companions. + expect(snapshots.reduce((sum, read) => sum + read.mock.calls.length, 0)).toBe(perHost + 1) + expect(owners).toHaveBeenCalledTimes(0) expect(commits).toBe(1) expect(projects).toHaveBeenCalledOnce() expect(mocks.applyStatus).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index c4bbb704f48..bb7a2c1608c 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { projectStatus } from './structured-agent-session-status-projection' import { @@ -56,6 +57,7 @@ type Member = { hostKey: string | null group: HostGroup | null status?: ProjectionInput + captured?: ProjectionInput } const ownerInputs: readonly (keyof WorktreeRuntimeOwnerState)[] = [ 'repos', @@ -86,6 +88,41 @@ function ownerInputChanged( ) } +function ownerInputToken(state: AppState, key: keyof WorktreeRuntimeOwnerState): unknown { + return key === 'worktreesByRepo' + ? worktreeOwnerRowsTokenOf(selectWorktreesByRepo(state)) + : state[key] +} + +/** Owners only change with owner inputs, so feed ticks and tab-map edits reuse each tab's resolution. */ +function createTabOwnerCache(): (state: AppState, tab: StructuredTab) => ExecutionHostId | null { + let inputs: unknown[] | null = null + let owners = new WeakMap() + return (state, tab) => { + const next = ownerInputs.map((key) => ownerInputToken(state, key)) + if (!inputs || next.some((input, index) => input !== inputs?.[index])) { + inputs = next + owners = new WeakMap() + } + if (!owners.has(tab)) { + owners.set(tab, structuredAgentSessionOwnerForTab(state, tab)) + } + return owners.get(tab) ?? null + } +} + +/** Unchanged feed facts for an unchanged tab cannot change its projection or companions. */ +function sameCapturedInput(previous: ProjectionInput | undefined, next: ProjectionInput): boolean { + return ( + previous?.tab === next.tab && + previous.summary === next.summary && + previous.observation === next.observation && + previous.failedAt === next.failedAt && + previous.sleptAt === next.sleptAt && + previous.directory === next.directory + ) +} + function sameStatusInput(previous: ProjectionInput | undefined, next: ProjectionInput): boolean { return ( previous?.tab === next.tab && @@ -101,6 +138,7 @@ function installStatusProjection(): () => void { const retired = new Set() const groups = new Map() const queue: (() => void)[] = [] + const ownerForTab = createTabOwnerCache() let disposed = false let draining = false const report = (error: unknown, context: unknown): void => { @@ -138,10 +176,9 @@ function installStatusProjection(): () => void { if (disposed || members.get(input.member.paneKey) !== input.member) { return false } - const state = useAppStore.getState() const tab = input.member.tab const target = - tab && structuredAgentSessionTargetForHost(structuredAgentSessionOwnerForTab(state, tab)) + tab && structuredAgentSessionTargetForHost(ownerForTab(useAppStore.getState(), tab)) return ( tab === input.tab && (target ? structuredAgentSessionStatusFeedKey(target) : null) === input.member.hostKey @@ -224,6 +261,10 @@ function installStatusProjection(): () => void { } else { clean() } + // Mark consumed only after a normal return: a throwing commit leaves the input for the next emission to retry. + for (const input of inputs) { + input.member.captured = input + } } const activateGroups = (): void => { for (const [hostKey, group] of groups) { @@ -238,10 +279,11 @@ function installStatusProjection(): () => void { } } const capture = (selected: Iterable): void => { - const inputs = [...selected].map((member): ProjectionInput => { + const inputs: ProjectionInput[] = [] + for (const member of selected) { const { tab, group } = member const summary = group?.feed.getSnapshot().get(tab.entityId) ?? null - return { + const input: ProjectionInput = { member, tab, summary, @@ -254,7 +296,11 @@ function installStatusProjection(): () => void { sleptAt: group ? group.feed.getSessionSleepState(tab.entityId) : null, directory: member.hostKey === 'local' ? summary?.launchDirectory : undefined } - }) + // A host feed tick names one session; skip the hundreds of unchanged members it also carries. + if (!sameCapturedInput(member.captured, input)) { + inputs.push(input) + } + } enqueue( () => { activateGroups() @@ -268,9 +314,7 @@ function installStatusProjection(): () => void { const next = new Map() for (const tab of getStructuredAgentSessionTabs(state.unifiedTabsByWorktree)) { const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) - const target = structuredAgentSessionTargetForHost( - structuredAgentSessionOwnerForTab(state, tab) - ) + const target = structuredAgentSessionTargetForHost(ownerForTab(state, tab)) const hostKey = target ? structuredAgentSessionStatusFeedKey(target) : null let group = hostKey ? groups.get(hostKey) : undefined if (!group && target && hostKey) { diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index d9cd348e174..56101563f42 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -107,12 +107,13 @@ const FILE_LINK_CONTEXT_INPUT_KEYS = [ 'runtimeEnvironments', 'runtimeEnvironmentCatalogHydrated', 'removedRuntimeEnvironmentIds', - 'floatingWorkspacePath', - 'structuredSessionLaunchDirectoryByTabId' + 'floatingWorkspacePath' ] as const satisfies readonly (keyof FileLinkContextInputState)[] type UnlistedInput = Exclude< keyof FileLinkContextInputState, - (typeof FILE_LINK_CONTEXT_INPUT_KEYS)[number] | 'unifiedTabsByWorktree' + | (typeof FILE_LINK_CONTEXT_INPUT_KEYS)[number] + | 'unifiedTabsByWorktree' + | 'structuredSessionLaunchDirectoryByTabId' > // Fails to compile when either state type gains a key not listed above; deeper resolver types are not checked. const allInputsListed: [UnlistedInput] extends [never] ? true : UnlistedInput = true @@ -128,6 +129,8 @@ export function createNativeChatFileLinkContextSelector(tabId: string) { const next = [ owner, state.unifiedTabsByWorktree, + // Why: only this tab's pin; the bridge publishes one pin per write across every chat. + state.structuredSessionLaunchDirectoryByTabId?.[tabId], ...FILE_LINK_CONTEXT_INPUT_KEYS.map((key) => state[key]) ] if (!inputs || next.some((input, index) => input !== inputs?.[index])) { diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts index a39527d7605..e649299e580 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts @@ -11,6 +11,7 @@ import type { Tab } from '../../../../shared/tab-types' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { createNativeChatImageOwnerSelector, + createNativeChatImageRuntimeContextSelector, resolveNativeChatImageRuntimeContext, resolveNativeChatImageRuntimeContextForOwner, selectNativeChatImageOwnerState, @@ -94,6 +95,30 @@ describe('resolveNativeChatImageRuntimeContext', () => { expect(shallow(second, first)).toBe(true) }) + it('keeps the selected context across rank and catalog-timestamp writes until its path moves', () => { + const storeState = state() + const select = createNativeChatImageRuntimeContextSelector('tab-1') + const first = select(storeState) + const worktree = storeState.worktreesByRepo.repo[0] + const ranked = { + ...storeState, + worktreesByRepo: { repo: [{ ...worktree, sortOrder: 9, isUnread: true }] }, + runtimeEnvironments: [...storeState.runtimeEnvironments] + } + + expect(first?.worktreePath).toBe('/repo/worktree') + expect(select(ranked)).toBe(first) + + const moved = worktreeFixture('wt-1', '/repo/moved', { hostId: 'local' }) + expect( + select({ + ...ranked, + getKnownWorktreeById: () => moved, + worktreesByRepo: { repo: [moved] } + })?.worktreePath + ).toBe('/repo/moved') + }) + it('reuses derived settings when owner inputs are unchanged', () => { const storeState = state() const first = resolveNativeChatImageRuntimeContext(storeState, 'tab-1') diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts index 788b5fd729d..ef05f4f209b 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts @@ -17,7 +17,7 @@ import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shar import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' import { resolveNativeChatTabDirectory } from './native-chat-tab-directory' import { useMemo } from 'react' -import { useShallow } from 'zustand/react/shallow' +import { shallow } from 'zustand/shallow' import { selectDetectedWorktreesByRepo, selectFolderWorkspaces, @@ -49,7 +49,10 @@ type OwnerState = Pick< | 'unifiedTabsByWorktree' > -type RouteState = Omit +type RouteState = Omit< + OwnerState, + 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'structuredSessionLaunchDirectoryByTabId' +> // Subscribe only to ownership inputs; derive context after Zustand filters unrelated updates. function selectNativeChatImageRouteState(state: AppState): RouteState { @@ -60,7 +63,6 @@ function selectNativeChatImageRouteState(state: AppState): RouteState { detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state), folderWorkspaces: selectFolderWorkspaces(state), floatingWorkspacePath: state.floatingWorkspacePath, - structuredSessionLaunchDirectoryByTabId: state.structuredSessionLaunchDirectoryByTabId, projectGroups: state.projectGroups, runtimeEnvironments: state.runtimeEnvironments, runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, @@ -76,6 +78,7 @@ function selectNativeChatImageRouteState(state: AppState): RouteState { export function selectNativeChatImageOwnerState(state: AppState): OwnerState { return { ...selectNativeChatImageRouteState(state), + structuredSessionLaunchDirectoryByTabId: state.structuredSessionLaunchDirectoryByTabId, tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree } @@ -115,7 +118,8 @@ export function resolveNativeChatImageRuntimeContext( } export function resolveNativeChatImageRuntimeContextForOwner( - state: RouteState & Pick, + state: RouteState & + Pick, tabId: string, owner: string | null ): NativeChatImageRuntimeContext { @@ -172,7 +176,7 @@ export function resolveNativeChatImageRuntimeContextForOwner( return context } -/** Ordinary workspace directories need only the selected owner; floating also needs its tab list. */ +/** Ordinary workspace directories need only the selected owner; floating also needs its tab list and pin. */ export function createNativeChatImageOwnerSelector(tabId: string) { const selectOwner = createNativeChatTabOwnerSelector(tabId) return (state: AppState) => { @@ -183,27 +187,41 @@ export function createNativeChatImageOwnerSelector(tabId: string) { floatingTabs: owner && isFloatingWorkspaceId(owner) && Object.hasOwn(state.unifiedTabsByWorktree, owner) ? state.unifiedTabsByWorktree[owner] - : undefined + : undefined, + // Why: the bridge publishes one pin per write, so the shared map would re-resolve every chat per pin. + launchDirectory: state.structuredSessionLaunchDirectoryByTabId[tabId] } } } -export function useNativeChatImageRuntimeContext(tabId: string): NativeChatImageRuntimeContext { - const selectOwnerState = useMemo(() => createNativeChatImageOwnerSelector(tabId), [tabId]) - const ownerState = useAppStore(useShallow(selectOwnerState)) - return useMemo(() => { - // This view serves only the existing floating-directory predicate, never an owner index. +/** Resolve inside the selector and keep an equal result's identity, so catalog/rank writes cannot rerender retained chats. */ +export function createNativeChatImageRuntimeContextSelector(tabId: string) { + const selectOwnerState = createNativeChatImageOwnerSelector(tabId) + let ownerState: ReturnType | null = null + let context: NativeChatImageRuntimeContext = null + return (state: AppState): NativeChatImageRuntimeContext => { + const next = selectOwnerState(state) + if (ownerState && shallow(ownerState, next)) { + return context + } + ownerState = next + // These views serve only the existing floating-directory predicate, never an owner index. const unifiedTabsByWorktree = - ownerState.owner && ownerState.floatingTabs - ? { [ownerState.owner]: ownerState.floatingTabs } - : {} - return resolveNativeChatImageRuntimeContextForOwner( - { - ...ownerState, - unifiedTabsByWorktree - }, + next.owner && next.floatingTabs ? { [next.owner]: next.floatingTabs } : {} + const structuredSessionLaunchDirectoryByTabId = next.launchDirectory + ? { [tabId]: next.launchDirectory } + : {} + const resolved = resolveNativeChatImageRuntimeContextForOwner( + { ...next, unifiedTabsByWorktree, structuredSessionLaunchDirectoryByTabId }, tabId, - ownerState.owner + next.owner ) - }, [ownerState, tabId]) + context = shallow(context, resolved) ? context : resolved + return context + } +} + +export function useNativeChatImageRuntimeContext(tabId: string): NativeChatImageRuntimeContext { + const selectContext = useMemo(() => createNativeChatImageRuntimeContextSelector(tabId), [tabId]) + return useAppStore(selectContext) } diff --git a/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx b/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx index 9fe278addac..a01b0eca5de 100644 --- a/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx @@ -15,7 +15,8 @@ import { nativeChatFanoutWorkspace as workspace, terminalTabFixture } from './native-chat-workspace-test-fixtures' -import { getDefaultSettings } from '../../../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' +import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import type * as StructuredClient from '@/runtime/structured-agent-session-client' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' @@ -259,6 +260,15 @@ function measureImageSubscriptions(scale: number) { expect(data.work).toEqual(emptyWork()) expect(renders - baseline).toBe(0) + // Rank patches and host timestamp refreshes replace owner inputs without moving any chat. + act(() => + useAppStore.setState({ + worktreesByRepo: { unrelated: [] }, + runtimeEnvironments: [...useAppStore.getState().runtimeEnvironments] + }) + ) + expect(renders - baseline).toBe(0) + const destination = data.moveLastChat() expect(contexts.get(`chat-${data.mounted - 1}`)).toMatchObject({ worktreeId: destination, @@ -501,6 +511,64 @@ describe('native chat store subscription performance', () => { console.info('native-chat-subscription-work', JSON.stringify(results)) }) + it('resolves only the pinned chat per write when the bridge publishes every floating pin at N and 10N', () => { + const results: { mounted: number; rowReads: number; bound: number }[] = [] + for (const scale of [1, 10]) { + const mounted = 57 * scale + const work = { rows: 0 } + const chatIds = Array.from({ length: mounted }, (_, index) => `floating-chat-${index}`) + const rows = chatIds.map((chatId, index) => + countRow( + agentSessionTabFixture(chatId, FLOATING_TERMINAL_WORKTREE_ID, { + entityId: `session-${index}` + }), + () => work.rows++ + ) + ) + useAppStore.setState({ + ...useAppStore.getInitialState(), + settings: getDefaultSettings('/test-home'), + floatingWorkspacePath: '/floating-setting', + runtimeEnvironmentCatalogHydrated: true, + tabsByWorktree: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: rows }, + structuredSessionLaunchDirectoryByTabId: {} + }) + const paths = new Map() + let renders = 0 + function Probe({ chatId }: { chatId: string }) { + paths.set(chatId, [ + useNativeChatImageRuntimeContext(chatId)?.worktreePath, + useNativeChatFileLinkContext(chatId)?.worktreePath + ]) + renders += 1 + return null + } + const view = render(chatIds.map((chatId) => )) + expect([...paths.values()].every(([image, link]) => !image && !link)).toBe(true) + const baseline = renders + work.rows = 0 + // One status snapshot publishes each pin as its own store write before React renders. + act(() => { + const { setStructuredSessionLaunchDirectory } = useAppStore.getState() + chatIds.forEach((chatId, index) => + setStructuredSessionLaunchDirectory(chatId, `session-${index}`, `/pinned/${index}`) + ) + }) + chatIds.forEach((chatId, index) => + expect(paths.get(chatId)).toEqual([`/pinned/${index}`, `/pinned/${index}`]) + ) + expect(renders - baseline).toBe(mounted) + // Each write may scan the floating list up to its own chat three times (image resolves the + // directory twice, file links once); a shared-map dependency rescans every chat per write: N³. + const bound = (3 * mounted * (mounted + 1)) / 2 + expect(work.rows).toBeLessThanOrEqual(bound) + results.push({ mounted, rowReads: work.rows, bound }) + view.unmount() + } + console.info('native-chat-floating-pin-work', JSON.stringify(results)) + }) + it('bounds dense ordinary-workspace image work on mount and own-list rename/replace at N and 10N', () => { const results: { mounted: number diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx index 20914393aeb..abcafdf2e4a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx @@ -175,6 +175,41 @@ describe('native chat transcript virtualizer contract', () => { expect(virtualizerMock.options.current?.getItemKey).toBe(getItemKey) }) + it('skips the per-render scroll-margin offset walk while the pane is hidden', () => { + const container = document.createElement('div') + const sizer = document.createElement('div') + let offsetReads = 0 + Object.defineProperty(sizer, 'offsetTop', { + get: () => { + offsetReads += 1 + return 24 + } + }) + Object.defineProperty(sizer, 'offsetParent', { get: () => container }) + const scrollRef = { current: container } + const { result, rerender } = renderHook( + ({ isVisible }) => + useNativeChatTranscriptWindow({ + scrollRef, + slots: [slot('a')], + isVisible, + revealIndex: -1 + }), + { initialProps: { isVisible: false } } + ) + result.current.sizerRef(sizer) + offsetReads = 0 + + for (let render = 0; render < 3; render++) { + rerender({ isVisible: false }) + } + expect(offsetReads).toBe(0) + + rerender({ isVisible: true }) + expect(offsetReads).toBeGreaterThan(0) + expect(result.current.scrollMargin).toBe(24) + }) + it('attributes a clamped fallback landing after content grows before its echo', () => { const scrollElement = document.createElement('div') let scrollHeight = 1_000 diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts index ca6d97bcf8f..cdeadbeb3d4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts @@ -139,7 +139,12 @@ export function useNativeChatTranscriptWindow({ setScrollMargin((current) => (current === offset ? current : offset)) } }, [scrollRef]) - useLayoutEffect(readScrollMargin) + // A hidden pane is display:none: its offset walk would only force layout on every retained chat's render. + useLayoutEffect(() => { + if (isVisible) { + readScrollMargin() + } + }) useLayoutEffect(() => { const container = scrollRef.current diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts index f8174f4927f..f20fc92d64d 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { shallow } from 'zustand/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { mulberry32 } from '../../../../shared/agent-tui-ansi-fuzz-stream' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' import { @@ -66,7 +67,9 @@ describe('selectTabAgentTypesByTabId', () => { expect(selectTabAgentTypesByTabId(agentFirst, layouts)['tab-1']).toBe('codex') expect(selectTabAgentTypesByTabId(activeFirst, layouts)['tab-1']).toBe('codex') - expect(selectNativeChatTabWideFallbackUnsafeTabsById(layouts)).toEqual({ 'tab-1': true }) + expect(selectNativeChatTabWideFallbackUnsafeTabsById(layouts)).toEqual({ + 'tab-1': true + }) }) it('does not inherit a supported sibling when the active split leaf is a shell', () => { @@ -240,10 +243,14 @@ describe('selectTabAgentTypesByTabId', () => { it('reuses outputs and invalidates only the projection whose input changed', () => { const onStatusEntryVisited = vi.fn() + const onStatusComparisonVisited = vi.fn() + const onLayoutComparisonVisited = vi.fn() const onAgentTypeLayoutVisited = vi.fn() const onUnsafeLayoutVisited = vi.fn() const select = createTabBarAgentProjectionSelector({ onStatusEntryVisited, + onStatusComparisonVisited, + onLayoutComparisonVisited, onAgentTypeLayoutVisited, onUnsafeLayoutVisited }) @@ -269,8 +276,11 @@ describe('selectTabAgentTypesByTabId', () => { }) expect(afterStatus).toBe(first) - expect(onStatusEntryVisited).toHaveBeenCalledTimes(4) - expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(2) + // Same-key pings that keep each agent cost one status comparison and no projection walks. + expect(onStatusComparisonVisited).toHaveBeenCalledTimes(2) + expect(onLayoutComparisonVisited).toHaveBeenCalledTimes(1) + expect(onStatusEntryVisited).toHaveBeenCalledTimes(2) + expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(1) expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(1) const singleLeaf = { @@ -292,11 +302,135 @@ describe('selectTabAgentTypesByTabId', () => { expect(afterLayout.nativeChatTabWideFallbackUnsafeTabsById).not.toBe( first.nativeChatTabWideFallbackUnsafeTabsById ) - expect(onStatusEntryVisited).toHaveBeenCalledTimes(6) - expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(3) + // Only the edited tab is re-resolved; its layout claims it, so no first-pane status walk. + expect(onStatusComparisonVisited).toHaveBeenCalledTimes(2) + expect(onLayoutComparisonVisited).toHaveBeenCalledTimes(2) + expect(onStatusEntryVisited).toHaveBeenCalledTimes(2) + expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(2) expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(2) }) + it('shares one layout diff across both projections and walks previous layouts only on removal', () => { + const onLayoutComparisonVisited = vi.fn() + const onAgentTypeLayoutVisited = vi.fn() + const onUnsafeLayoutVisited = vi.fn() + const select = createTabBarAgentProjectionSelector({ + onLayoutComparisonVisited, + onAgentTypeLayoutVisited, + onUnsafeLayoutVisited + }) + const statuses = { 'tab-0:leaf-a': entry({ agentType: 'claude' }) } + const tabCount = 100 + let layouts: Record = {} + for (let index = 0; index < tabCount; index++) { + layouts[`tab-${index}`] = splitLayout('leaf-a') + } + select({ agentStatusByPaneKey: statuses, terminalLayoutsByTabId: layouts }) + onLayoutComparisonVisited.mockClear() + onAgentTypeLayoutVisited.mockClear() + onUnsafeLayoutVisited.mockClear() + + layouts = { ...layouts, 'tab-7': splitLayout('leaf-b') } + select({ agentStatusByPaneKey: statuses, terminalLayoutsByTabId: layouts }) + + expect(onLayoutComparisonVisited).toHaveBeenCalledTimes(tabCount) + expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(1) + expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(1) + + onLayoutComparisonVisited.mockClear() + layouts = { ...layouts, 'tab-new': splitLayout('leaf-a') } + select({ agentStatusByPaneKey: statuses, terminalLayoutsByTabId: layouts }) + + expect(onLayoutComparisonVisited).toHaveBeenCalledTimes(tabCount + 1) + + onLayoutComparisonVisited.mockClear() + const { 'tab-7': _removed, ...withoutTab7 } = layouts + layouts = withoutTab7 + const afterRemoval = select({ + agentStatusByPaneKey: statuses, + terminalLayoutsByTabId: layouts + }) + + // A removal is the one case that needs the second walk to find the dropped key. + expect(onLayoutComparisonVisited).toHaveBeenCalledTimes(tabCount + tabCount + 1) + expect(afterRemoval.nativeChatTabWideFallbackUnsafeTabsById['tab-7']).toBeUndefined() + }) + + it('patches incrementally to the same result as a full projection', () => { + // Why: an LCG multiply past 2^53 loses low bits and collapses the picks onto a few cases. + const nextRandom = mulberry32(7) + const random = (limit: number): number => Math.floor(nextRandom() * limit) + const tabCount = 8 + const agents = ['claude', 'codex', 'grok', undefined] as const + const leaves = ['leaf-a', 'leaf-b', 'leaf-c'] as const + const layoutChoices: TerminalLayoutSnapshot[] = [ + splitLayout('leaf-a'), + splitLayout('leaf-b'), + splitLayout(null), + splitLayout('leaf-c'), + { + root: { type: 'leaf', leafId: 'leaf-b' }, + activeLeafId: 'leaf-b', + expandedLeafId: null + }, + { root: null, activeLeafId: 'leaf-a', expandedLeafId: null }, + { root: null, activeLeafId: null, expandedLeafId: null } + ] + const select = createTabBarAgentProjectionSelector() + let statuses: Record = {} + let layouts: Record = {} + const seenTabIds = new Set() + const seenAgents = new Set<(typeof agents)[number]>() + const seenLayouts = new Set() + const appliedOperations = new Set() + for (let step = 0; step < 2_000; step++) { + const tabId = `tab-${random(tabCount)}` + const paneKey = `${tabId}:${leaves[random(leaves.length)]}` + const operation = random(7) + seenTabIds.add(tabId) + // A status ping for a missing pane falls through, like operation 6, to reselect unchanged inputs. + if (operation !== 1 || statuses[paneKey]) { + appliedOperations.add(operation) + } + if (operation === 0) { + const agentType = agents[random(agents.length)] + seenAgents.add(agentType) + statuses = { ...statuses, [paneKey]: entry({ agentType }) } + } else if (operation === 1 && statuses[paneKey]) { + statuses = { + ...statuses, + [paneKey]: { ...statuses[paneKey], state: 'done' } + } + } else if (operation === 2) { + const { [paneKey]: _removed, ...rest } = statuses + statuses = rest + } else if (operation === 3) { + // Same keys and entries in a new insertion order. + statuses = Object.fromEntries(Object.entries(statuses).toReversed()) + } else if (operation === 4) { + const layout = layoutChoices[random(layoutChoices.length)]! + seenLayouts.add(layout) + layouts = { ...layouts, [tabId]: layout } + } else if (operation === 5) { + const { [tabId]: _removed, ...rest } = layouts + layouts = rest + } + const result = select({ + agentStatusByPaneKey: statuses, + terminalLayoutsByTabId: layouts + }) + expect(result.tabAgentTypesByTabId).toEqual(selectTabAgentTypesByTabId(statuses, layouts)) + expect(result.nativeChatTabWideFallbackUnsafeTabsById).toEqual( + selectNativeChatTabWideFallbackUnsafeTabsById(layouts) + ) + } + // Why: a degenerate generator silently narrows the differential check, so pin what it exercised. + expect(seenTabIds.size).toBe(tabCount) + expect(seenAgents).toEqual(new Set(agents)) + expect(seenLayouts).toEqual(new Set(layoutChoices)) + expect(appliedOperations).toEqual(new Set([0, 1, 2, 3, 4, 5, 6])) + }) + it('normalizes missing maps to shared empty inputs', () => { const select = createTabBarAgentProjectionSelector() @@ -321,7 +455,10 @@ describe('selectTabAgentTypesByTabId', () => { } const first = select(state) - const disabled = select({ ...state, settings: { experimentalNativeChat: false } }) + const disabled = select({ + ...state, + settings: { experimentalNativeChat: false } + }) const afterReenable = select(state) expect(disabled.tabAgentTypesByTabId).toEqual({ 'tab-1': 'claude' }) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts index 5dc55319bd2..99b4078622e 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -7,6 +7,8 @@ import { type TabBarAgentProjectionSelectorDependencies = { onStatusEntryVisited?: (paneKey: string) => void + onStatusComparisonVisited?: (paneKey: string) => void + onLayoutComparisonVisited?: (tabId: string) => void onAgentTypeLayoutVisited?: (tabId: string) => void onUnsafeLayoutVisited?: (tabId: string) => void } @@ -41,28 +43,28 @@ function reuseRecordIfEqual( return nextKeys.every((key) => previous[key] === next[key]) ? previous : next } -function projectTabAgentTypesByTabId( +function layoutClaimsTab(layout: TerminalLayoutSnapshot | undefined): boolean { + return Boolean(layout && (layout.root || layout.activeLeafId)) +} + +function activeLeafAgentType( + tabId: string, + layout: TerminalLayoutSnapshot, + agentStatusByPaneKey: Record +): AgentType | undefined { + const activeLeafId = resolveNativeChatActiveLayoutLeafId(layout) + return activeLeafId + ? (agentStatusByPaneKey[`${tabId}:${activeLeafId}`]?.agentType ?? undefined) + : undefined +} + +/** Before layout hydration a tab takes its first pane's agent in pane-map insertion order. */ +function firstPaneAgentTypes( agentStatusByPaneKey: Record, - terminalLayoutsByTabId: Record, + isUnclaimedTab: (tabId: string) => boolean, dependencies?: TabBarAgentProjectionSelectorDependencies -): Record { - const byTabId: Record = {} - const claimed = new Set() - for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { - dependencies?.onAgentTypeLayoutVisited?.(tabId) - if (!layout.root && !layout.activeLeafId) { - continue - } - claimed.add(tabId) - const activeLeafId = resolveNativeChatActiveLayoutLeafId(layout) - if (!activeLeafId) { - continue - } - const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`] - if (entry?.agentType != null) { - byTabId[tabId] = entry.agentType - } - } +): Map { + const byTabId = new Map() for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { dependencies?.onStatusEntryVisited?.(paneKey) const colon = paneKey.indexOf(':') @@ -70,31 +72,175 @@ function projectTabAgentTypesByTabId( continue } const tabId = paneKey.slice(0, colon) - if (claimed.has(tabId)) { - continue - } - claimed.add(tabId) - if (entry.agentType != null) { - byTabId[tabId] = entry.agentType + if (!byTabId.has(tabId) && isUnclaimedTab(tabId)) { + byTabId.set(tabId, entry.agentType ?? undefined) } } return byTabId } -function projectNativeChatTabWideFallbackUnsafeTabsById( +function projectTabAgentTypesByTabId( + agentStatusByPaneKey: Record, terminalLayoutsByTabId: Record, dependencies?: TabBarAgentProjectionSelectorDependencies +): Record { + const byTabId: Record = {} + for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { + dependencies?.onAgentTypeLayoutVisited?.(tabId) + if (!layoutClaimsTab(layout)) { + continue + } + const agentType = activeLeafAgentType(tabId, layout, agentStatusByPaneKey) + if (agentType != null) { + byTabId[tabId] = agentType + } + } + const firstPanes = firstPaneAgentTypes( + agentStatusByPaneKey, + (tabId) => !layoutClaimsTab(terminalLayoutsByTabId[tabId]), + dependencies + ) + for (const [tabId, agentType] of firstPanes) { + if (agentType != null) { + byTabId[tabId] = agentType + } + } + return byTabId +} + +/** True when the projection cannot differ: same pane keys in the same order, and every changed entry keeps its agent. */ +function statusChangeKeepsAgentTypes( + previous: Record, + next: Record, + dependencies?: TabBarAgentProjectionSelectorDependencies +): boolean { + const previousKeys = Object.keys(previous) + let index = 0 + for (const paneKey in next) { + dependencies?.onStatusComparisonVisited?.(paneKey) + if (previousKeys[index] !== paneKey) { + return false + } + index += 1 + const before = previous[paneKey] + const entry = next[paneKey] + if (before !== entry && before?.agentType !== entry?.agentType) { + return false + } + } + return index === previousKeys.length +} + +/** Tab ids whose layout entry was added, replaced or removed. */ +function changedLayoutTabIds( + previous: Record, + next: Record, + dependencies?: TabBarAgentProjectionSelectorDependencies +): string[] { + const changed: string[] = [] + let sharedCount = 0 + for (const tabId in next) { + dependencies?.onLayoutComparisonVisited?.(tabId) + sharedCount += tabId in previous ? 1 : 0 + if (previous[tabId] !== next[tabId]) { + changed.push(tabId) + } + } + // Why: only removals leave previous keys unmatched, so replacements skip the second walk. + if (sharedCount !== Object.keys(previous).length) { + for (const tabId in previous) { + dependencies?.onLayoutComparisonVisited?.(tabId) + if (!(tabId in next)) { + changed.push(tabId) + } + } + } + return changed +} + +function patchRecord( + previous: Record, + patches: Iterable<[string, T | undefined]> +): Record { + let next: Record | null = null + for (const [key, value] of patches) { + if (previous[key] === value) { + continue + } + next ??= { ...previous } + if (value === undefined) { + delete next[key] + } else { + next[key] = value + } + } + return next ?? previous +} + +function patchTabAgentTypesForLayouts( + previous: Record, + changedTabIds: readonly string[], + agentStatusByPaneKey: Record, + terminalLayoutsByTabId: Record, + dependencies?: TabBarAgentProjectionSelectorDependencies +): Record { + const patches: [string, AgentType | undefined][] = [] + const unclaimed = new Set() + for (const tabId of changedTabIds) { + dependencies?.onAgentTypeLayoutVisited?.(tabId) + const layout = terminalLayoutsByTabId[tabId] + if (layout && layoutClaimsTab(layout)) { + patches.push([tabId, activeLeafAgentType(tabId, layout, agentStatusByPaneKey)]) + } else { + unclaimed.add(tabId) + } + } + if (unclaimed.size > 0) { + const firstPanes = firstPaneAgentTypes( + agentStatusByPaneKey, + (tabId) => unclaimed.has(tabId), + dependencies + ) + for (const tabId of unclaimed) { + patches.push([tabId, firstPanes.get(tabId)]) + } + } + return patchRecord(previous, patches) +} + +function nativeChatTabWideFallbackUnsafe( + layout: TerminalLayoutSnapshot | undefined +): true | undefined { + return layout && !isNativeChatTabWideFallbackSafe(layout) ? true : undefined +} + +function projectNativeChatTabWideFallbackUnsafeTabsById( + terminalLayoutsByTabId: Record ): Record { const unsafeTabs: Record = {} for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { - dependencies?.onUnsafeLayoutVisited?.(tabId) - if (!isNativeChatTabWideFallbackSafe(layout)) { + if (nativeChatTabWideFallbackUnsafe(layout)) { unsafeTabs[tabId] = true } } return unsafeTabs } +function patchNativeChatTabWideFallbackUnsafeTabsById( + previous: Record, + changedTabIds: readonly string[], + terminalLayoutsByTabId: Record, + dependencies?: TabBarAgentProjectionSelectorDependencies +): Record { + return patchRecord( + previous, + changedTabIds.map((tabId): [string, true | undefined] => { + dependencies?.onUnsafeLayoutVisited?.(tabId) + return [tabId, nativeChatTabWideFallbackUnsafe(terminalLayoutsByTabId[tabId])] + }) + ) +} + /** Project stable agent identity to avoid status-driven strip rerenders; active leaves win, with first-pane fallback before layout hydration. */ export function selectTabAgentTypesByTabId( agentStatusByPaneKey: Record, @@ -113,9 +259,9 @@ export function createTabBarAgentProjectionSelector( dependencies?: TabBarAgentProjectionSelectorDependencies ): (state: TabBarAgentProjectionState) => TabBarAgentProjections { let cachedAgentStatusByPaneKey: Record | null = null - let cachedAgentTypeLayoutsByTabId: Record | null = null + // Why: starting from the empty map lets the first call patch every tab in through the shared diff. + let cachedLayoutsByTabId = EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID let cachedAgentTypesByTabId = EMPTY_TAB_AGENT_TYPES_BY_TAB_ID - let cachedUnsafeLayoutsByTabId: Record | null = null let cachedUnsafeTabsById = EMPTY_UNSAFE_TABS_BY_ID let cachedEnabledResult: TabBarAgentProjections | null = null @@ -123,20 +269,41 @@ export function createTabBarAgentProjectionSelector( const nativeChatEnabled = state.settings?.experimentalNativeChat === true const statuses = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY const layouts = state.terminalLayoutsByTabId ?? EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID - if (statuses !== cachedAgentStatusByPaneKey || layouts !== cachedAgentTypeLayoutsByTabId) { - cachedAgentTypesByTabId = reuseRecordIfEqual( - cachedAgentTypesByTabId, - projectTabAgentTypesByTabId(statuses, layouts, dependencies) - ) + // Status pings and single-tab layout edits patch affected tabs instead of rescanning thousands of layouts. + if (statuses !== cachedAgentStatusByPaneKey || layouts !== cachedLayoutsByTabId) { + // Why: both projections patch from one diff so a layout edit walks the layout map once. + const changedTabIds = + layouts === cachedLayoutsByTabId + ? null + : changedLayoutTabIds(cachedLayoutsByTabId, layouts, dependencies) + const statusesKeepAgents = + cachedAgentStatusByPaneKey !== null && + (statuses === cachedAgentStatusByPaneKey || + statusChangeKeepsAgentTypes(cachedAgentStatusByPaneKey, statuses, dependencies)) + cachedAgentTypesByTabId = !statusesKeepAgents + ? reuseRecordIfEqual( + cachedAgentTypesByTabId, + projectTabAgentTypesByTabId(statuses, layouts, dependencies) + ) + : changedTabIds + ? patchTabAgentTypesForLayouts( + cachedAgentTypesByTabId, + changedTabIds, + statuses, + layouts, + dependencies + ) + : cachedAgentTypesByTabId + if (changedTabIds) { + cachedUnsafeTabsById = patchNativeChatTabWideFallbackUnsafeTabsById( + cachedUnsafeTabsById, + changedTabIds, + layouts, + dependencies + ) + } cachedAgentStatusByPaneKey = statuses - cachedAgentTypeLayoutsByTabId = layouts - } - if (layouts !== cachedUnsafeLayoutsByTabId) { - cachedUnsafeTabsById = reuseRecordIfEqual( - cachedUnsafeTabsById, - projectNativeChatTabWideFallbackUnsafeTabsById(layouts, dependencies) - ) - cachedUnsafeLayoutsByTabId = layouts + cachedLayoutsByTabId = layouts } if ( cachedEnabledResult?.nativeChatEnabled === nativeChatEnabled && diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts index 98fcdeb8817..46d10e65d4c 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts @@ -91,6 +91,16 @@ function replaceProvider( return { ...state, [collection]: { ...state[collection], [paneKey]: next } } } +async function collectGarbage(): Promise { + if (typeof globalThis.gc !== 'function') { + throw new Error('Run with the repository Vitest --expose-gc config') + } + for (let round = 0; round < 3; round++) { + await new Promise((resolve) => setImmediate(resolve)) + globalThis.gc() + } +} + describe('AI Vault title subscription inputs', () => { it('does not enumerate unchanged retained and sleeping maps during live status writes', () => { const state = makeState(500) @@ -214,6 +224,72 @@ describe('AI Vault title subscription inputs', () => { } }) + it('skips owner rescans for rank-only rows but reports a conversation workspace moving host', () => { + // No active selection, so the row's host decides ownership. + const state = { + ...makeState(200), + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null + } + const row = state.worktreesByRepo.fixture[0] + const tabs = Object.values(state.tabsByWorktree).flat() + const tabValues = vi.spyOn(state.tabsByWorktree, 'wt-title', 'get').mockReturnValue(tabs) + // A rank/unread patch replaces the row map without changing who owns any row. + const ranked: AppState = { + ...state, + worktreesByRepo: { fixture: [{ ...row, sortOrder: 5, isUnread: true }] } + } + expect(aiVaultTitleSyncInputsChanged(ranked, state)).toBe(false) + expect(tabValues).not.toHaveBeenCalled() + + const settingsOnly: AppState = { ...ranked, settings: structuredClone(ranked.settings) } + expect(aiVaultTitleSyncInputsChanged(settingsOnly, ranked)).toBe(false) + expect(aiVaultTitleSyncInputsChanged({ ...settingsOnly }, settingsOnly)).toBe(false) + // Membership was collected once and reused across owner-only updates. + expect(tabValues.mock.calls.length).toBeLessThanOrEqual(1) + + const moved: AppState = { + ...settingsOnly, + worktreesByRepo: { + fixture: [{ ...row, hostId: 'runtime:server-1', runtimeOwnerEnvironmentId: 'server-1' }] + } + } + expect(aiVaultTitleSyncInputsChanged(moved, settingsOnly)).toBe(true) + }) + + it("releases a closed tab's status and tab maps after an update that skips the owner comparison", async () => { + function cacheThenCloseTab() { + const open = makeState(3) + // An owner-only update caches request membership for the open tabs. + const ownerOnly: AppState = { ...open, settings: structuredClone(open.settings) } + expect(aiVaultTitleSyncInputsChanged(ownerOnly, open)).toBe(false) + // Closing a tab replaces membership maps and returns before any owner comparison. + const [closedPane] = Object.keys(open.agentStatusByPaneKey) + const { [closedPane]: _closed, ...agentStatusByPaneKey } = open.agentStatusByPaneKey + const closed: AppState = { + ...ownerOnly, + agentStatusByPaneKey, + tabsByWorktree: { 'wt-title': open.tabsByWorktree['wt-title'].slice(1) } + } + expect(aiVaultTitleSyncInputsChanged(closed, ownerOnly)).toBe(true) + return { + closed, + retired: [new WeakRef(open.agentStatusByPaneKey), new WeakRef(open.tabsByWorktree)] + } + } + + const { closed, retired } = cacheThenCloseTab() + await collectGarbage() + + expect(retired.map((ref) => ref.deref())).toEqual([undefined, undefined]) + expect( + aiVaultTitleSyncInputsChanged( + { ...closed, settings: structuredClone(closed.settings) }, + closed + ) + ).toBe(false) + }) + it('still checks active panes and stored titles after unchanged record collections', () => { const state = makeState() const agentStatusByPaneKey = { ...state.agentStatusByPaneKey } diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts index eadbecbacdc..c5d84f932ce 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts @@ -4,6 +4,7 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' import { collectAiVaultTitleRequests } from './ai-vault-tab-title-requests' +import { worktreeOwnerRowsTokenOf } from './worktree-owner-rows-token' import { selectDetectedWorktreesByRepo, selectFolderWorkspaces, @@ -151,11 +152,52 @@ function activePanesEqual(current: AppState, previous: AppState): boolean { ) } +type RequestWorktreeIdsInputs = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'retainedAgentsByPaneKey' + | 'sleepingAgentSessionsByPaneKey' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' +> +let cachedRequestWorktreeIds: { inputs: RequestWorktreeIdsInputs; worktreeIds: string[] } | null = + null + +function requestInputsMatch(inputs: RequestWorktreeIdsInputs, state: AppState): boolean { + return ( + inputs.agentStatusByPaneKey === state.agentStatusByPaneKey && + inputs.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey && + inputs.sleepingAgentSessionsByPaneKey === state.sleepingAgentSessionsByPaneKey && + inputs.tabsByWorktree === state.tabsByWorktree && + inputs.terminalLayoutsByTabId === state.terminalLayoutsByTabId + ) +} + +// Request membership reads only these maps, so owner-only updates reuse it instead of re-collecting every tab. +function requestWorktreeIds(state: AppState): string[] { + const cached = cachedRequestWorktreeIds + if (cached && requestInputsMatch(cached.inputs, state)) { + return cached.worktreeIds + } + const worktreeIds = [ + ...new Set(collectAiVaultTitleRequests(state).map((request) => request.worktreeId)) + ] + cachedRequestWorktreeIds = { + inputs: { + agentStatusByPaneKey: state.agentStatusByPaneKey, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, + tabsByWorktree: state.tabsByWorktree, + terminalLayoutsByTabId: state.terminalLayoutsByTabId + }, + worktreeIds + } + return worktreeIds +} + function requestOwnersEqual(current: AppState, previous: AppState): boolean { - const worktreeIds = new Set([ - ...collectAiVaultTitleRequests(current).map((request) => request.worktreeId), - ...collectAiVaultTitleRequests(previous).map((request) => request.worktreeId) - ]) + // Previous first, so the single cache entry ends on current: the next update's previous. + const worktreeIds = new Set([...requestWorktreeIds(previous), ...requestWorktreeIds(current)]) for (const worktreeId of worktreeIds) { const currentHost = getExecutionHostIdForWorktree(current, worktreeId) const previousHost = getExecutionHostIdForWorktree(previous, worktreeId) @@ -167,6 +209,16 @@ function requestOwnersEqual(current: AppState, previous: AppState): boolean { } export function aiVaultTitleSyncInputsChanged(current: AppState, previous: AppState): boolean { + const changed = syncInputsChanged(current, previous) + // Why: later updates only move forward from current, so a snapshot it replaced can never hit again; + // keeping it would pin closed tabs' status text and scrollback until the next owner change. + if (cachedRequestWorktreeIds && !requestInputsMatch(cachedRequestWorktreeIds.inputs, current)) { + cachedRequestWorktreeIds = null + } + return changed +} + +function syncInputsChanged(current: AppState, previous: AppState): boolean { const agentRecordsChanged = current.agentStatusByPaneKey !== previous.agentStatusByPaneKey || current.retainedAgentsByPaneKey !== previous.retainedAgentsByPaneKey || @@ -186,7 +238,8 @@ export function aiVaultTitleSyncInputsChanged(current: AppState, previous: AppSt const ownerInputsChanged = current.repos !== previous.repos || - selectWorktreesByRepo(current) !== selectWorktreesByRepo(previous) || + worktreeOwnerRowsTokenOf(selectWorktreesByRepo(current)) !== + worktreeOwnerRowsTokenOf(selectWorktreesByRepo(previous)) || selectDetectedWorktreesByRepo(current) !== selectDetectedWorktreesByRepo(previous) || selectFolderWorkspaces(current) !== selectFolderWorkspaces(previous) || current.projectGroups !== previous.projectGroups ||