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
This commit is contained in:
Jinjing
2026-10-29 12:22:50 -07:00
committed by GitHub
parent 64c0992d2f
commit cb21824bd9
12 changed files with 723 additions and 90 deletions
@@ -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()
@@ -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<StructuredTab, ExecutionHostId | null>()
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<Member>()
const groups = new Map<string, HostGroup>()
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<Member>): 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<string, Member>()
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) {
@@ -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])) {
@@ -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')
@@ -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<OwnerState, 'tabsByWorktree' | 'unifiedTabsByWorktree'>
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<OwnerState, 'unifiedTabsByWorktree'>,
state: RouteState &
Pick<OwnerState, 'unifiedTabsByWorktree' | 'structuredSessionLaunchDirectoryByTabId'>,
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<typeof selectOwnerState> | 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)
}
@@ -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<string, (string | null | undefined)[]>()
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) => <Probe key={chatId} chatId={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
@@ -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
@@ -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
@@ -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<string, TerminalLayoutSnapshot> = {}
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<string, AgentStatusEntry> = {}
let layouts: Record<string, TerminalLayoutSnapshot> = {}
const seenTabIds = new Set<string>()
const seenAgents = new Set<(typeof agents)[number]>()
const seenLayouts = new Set<TerminalLayoutSnapshot>()
const appliedOperations = new Set<number>()
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' })
@@ -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<T>(
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<string, AgentStatusEntry>
): 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<string, AgentStatusEntry>,
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>,
isUnclaimedTab: (tabId: string) => boolean,
dependencies?: TabBarAgentProjectionSelectorDependencies
): Record<string, AgentType> {
const byTabId: Record<string, AgentType> = {}
const claimed = new Set<string>()
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<string, AgentType | undefined> {
const byTabId = new Map<string, AgentType | undefined>()
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<string, AgentStatusEntry>,
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>,
dependencies?: TabBarAgentProjectionSelectorDependencies
): Record<string, AgentType> {
const byTabId: Record<string, AgentType> = {}
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<string, AgentStatusEntry>,
next: Record<string, AgentStatusEntry>,
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<string, TerminalLayoutSnapshot>,
next: Record<string, TerminalLayoutSnapshot>,
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<T>(
previous: Record<string, T>,
patches: Iterable<[string, T | undefined]>
): Record<string, T> {
let next: Record<string, T> | 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<string, AgentType>,
changedTabIds: readonly string[],
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>,
dependencies?: TabBarAgentProjectionSelectorDependencies
): Record<string, AgentType> {
const patches: [string, AgentType | undefined][] = []
const unclaimed = new Set<string>()
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<string, TerminalLayoutSnapshot>
): Record<string, true> {
const unsafeTabs: Record<string, true> = {}
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<string, true>,
changedTabIds: readonly string[],
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>,
dependencies?: TabBarAgentProjectionSelectorDependencies
): Record<string, true> {
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<string, AgentStatusEntry>,
@@ -113,9 +259,9 @@ export function createTabBarAgentProjectionSelector(
dependencies?: TabBarAgentProjectionSelectorDependencies
): (state: TabBarAgentProjectionState) => TabBarAgentProjections {
let cachedAgentStatusByPaneKey: Record<string, AgentStatusEntry> | null = null
let cachedAgentTypeLayoutsByTabId: Record<string, TerminalLayoutSnapshot> | 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<string, TerminalLayoutSnapshot> | 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 &&
@@ -91,6 +91,16 @@ function replaceProvider(
return { ...state, [collection]: { ...state[collection], [paneKey]: next } }
}
async function collectGarbage(): Promise<void> {
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<void>((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 }
@@ -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 ||