From cb21824bd956ef6fec14afba6dfe65c735ee8b9f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:22:50 -0700 Subject: [PATCH] Optimize hidden chat subscriptions and tab projections (#36047) * perf(tab-bar): patch tab agent projections per changed tab instead of rescanning every layout The shared TabBar agent projection rebuilt agent types and tab-wide chat fallback safety from every terminal layout (~2.5k at the captured scale) and every status row whenever either map's identity changed. Status pings that keep each pane's agent type now reuse the cached projection, and layout edits re-resolve only the added, replaced or removed tabs. Key reorders, agent-type changes and added/removed status rows still take the full projection. * perf(ai-vault): keep rank and owner-only updates from re-collecting every agent conversation The AI Vault title subscription ran two full agent-conversation collections (every terminal tab, retained, sleeping and live record) whenever any owner input changed identity, including worktree rank/unread patches and unrelated settings writes. Compare worktree rows through the existing owner-rows token so rank-only patches skip the check, and reuse the conversation workspace set while the agent/tab maps it reads are unchanged, so owner-only updates compare only those workspaces' hosts. Real host moves still schedule a title rescan. * perf(native-chat): project only changed sessions on structured status feed ticks Each host status feed tick re-captured every structured chat member on that host (hundreds at the captured scale), resolving each tab's owner about three times plus companion pin checks, even though a status event names one session. Members whose feed facts (summary, observation, failure, sleep and launch directory) and tab are unchanged since their last consumed capture are now skipped, and tab owners are cached until an owner input changes, with worktree rows compared through the rank-insensitive owner-rows token. An input is marked consumed only after projection returns normally, so a throwing store commit still retries on the next emission. The bridge remains the only writer of slept-at and launch-directory pins, so skipping unchanged members does not drop a needed re-assertion. * perf(native-chat): skip the per-render scroll-margin offset walk in hidden chats The transcript window re-read its scroll margin through an offsetTop walk in a dependency-free layout effect, so every render of every retained chat forced a synchronous layout read, including display:none panes where the walk cannot produce a margin. The read now runs only while the pane is visible; becoming visible re-renders and measures, and mount/resize measurements are unchanged. * perf(native-chat): stop rank and catalog writes from rerendering every mounted chat useNativeChatImageRuntimeContext shallow-subscribed each chat root to worktree rows, runtime environments, SSH state and other owner maps, then resolved the context in render. A worktree rank patch or a host catalog timestamp refresh replaced one of those maps and re-rendered the whole NativeChatStructuredSession of every mounted chat, hidden ones included (57 of 57 probes per write in the subscription benchmark). The selector now resolves the context itself and keeps the previous identity while the result is shallow-equal, so chats re-render only when their route or path changes. * Optimize hidden chat subscriptions and release closed tab state * Share layout diffs across tab bar projections * Strengthen tab agent projection fuzz coverage - Use a stable PRNG that exercises the full operation space - Assert tab, agent, layout, and operation coverage --- ...sionStatusBridge.launch-directory.test.tsx | 4 +- .../StructuredAgentSessionStatusBridge.tsx | 60 ++++- .../native-chat/native-chat-file-link.ts | 9 +- .../native-chat-image-runtime-context.test.ts | 25 ++ .../native-chat-image-runtime-context.ts | 60 +++-- ...ve-chat-subscriptions.performance.test.tsx | 70 ++++- ...ve-chat-transcript-window.options.test.tsx | 35 +++ .../use-native-chat-transcript-window.ts | 7 +- .../tab-bar/tab-agent-types-by-tab-id.test.ts | 149 +++++++++- .../tab-bar/tab-agent-types-by-tab-id.ts | 255 +++++++++++++++--- .../ai-vault-tab-title-sync-inputs.test.ts | 76 ++++++ .../src/lib/ai-vault-tab-title-sync-inputs.ts | 63 ++++- 12 files changed, 723 insertions(+), 90 deletions(-) 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 ||