perf(renderer): share retained TabBar projections (#10094)
This commit is contained in:
parent
09756dfaff
commit
c445f26541
|
|
@ -330,6 +330,14 @@ describe('TabBar context menu wiring', () => {
|
|||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('wires the shared agent projection selector into the production TabBar', async () => {
|
||||
const { selectTabBarAgentProjections } = await import('./tab-agent-types-by-tab-id')
|
||||
|
||||
await renderTabBar({ tabs: [], editorFiles: [], browserTabs: [], tabBarOrder: [] })
|
||||
|
||||
expect(useAppStoreMock).toHaveBeenCalledWith(selectTabBarAgentProjections)
|
||||
})
|
||||
|
||||
it('counts every tab kind for SortableTab.tabCount', async () => {
|
||||
// Why: Close Others used to pass tabCount=tabs.length, where tabs is just the
|
||||
// terminal list. With one terminal + any number of editor/browser tabs, the
|
||||
|
|
|
|||
|
|
@ -78,10 +78,7 @@ import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll'
|
|||
import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility'
|
||||
import { canToggleNativeChat } from '../native-chat/native-chat-availability'
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
import {
|
||||
selectNativeChatTabWideFallbackUnsafeTabsById,
|
||||
selectTabAgentTypesByTabId
|
||||
} from './tab-agent-types-by-tab-id'
|
||||
import { selectTabBarAgentProjections } from './tab-agent-types-by-tab-id'
|
||||
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
|
||||
|
||||
const isWindows = navigator.userAgent.includes('Windows')
|
||||
|
|
@ -419,16 +416,9 @@ function TabBarInner({
|
|||
|
||||
// Why: tab-wide launch/title hints are safe only before split; gate the view-mode toggle to the active leaf's agent.
|
||||
const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode)
|
||||
// Why: agentStatusByPaneKey churns on every status flip; project {tabId:agentType} to re-render only on agent identity change.
|
||||
const tabAgentTypesByTabId = useAppStore(
|
||||
useShallow((s) =>
|
||||
selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {}, s.terminalLayoutsByTabId)
|
||||
)
|
||||
)
|
||||
const nativeChatTabWideFallbackUnsafeTabsById = useAppStore(
|
||||
useShallow((s) => selectNativeChatTabWideFallbackUnsafeTabsById(s.terminalLayoutsByTabId))
|
||||
)
|
||||
const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true)
|
||||
// Why: every retained TabBar observes the same hot maps; one feature-gated selector shares their projections.
|
||||
const { nativeChatEnabled, tabAgentTypesByTabId, nativeChatTabWideFallbackUnsafeTabsById } =
|
||||
useAppStore(useShallow(selectTabBarAgentProjections))
|
||||
const nativeChatTranscriptIsLocalReadable = useAppStore((s) =>
|
||||
isNativeChatTranscriptLocalReadable(getConnectionIdFromState(s, worktreeId))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry'
|
||||
import {
|
||||
createTabBarAgentProjectionSelector,
|
||||
selectTabBarAgentProjections,
|
||||
selectNativeChatTabWideFallbackUnsafeTabsById,
|
||||
selectTabAgentTypesByTabId
|
||||
} from './tab-agent-types-by-tab-id'
|
||||
|
|
@ -205,4 +207,174 @@ describe('selectTabAgentTypesByTabId', () => {
|
|||
it('ignores malformed pane keys with no tab id', () => {
|
||||
expect(selectTabAgentTypesByTabId({ ':leaf-a': entry({ agentType: 'claude' }) })).toEqual({})
|
||||
})
|
||||
|
||||
it('shares one pair of global scans across retained TabBar consumers', () => {
|
||||
const onStatusEntryVisited = vi.fn()
|
||||
const onAgentTypeLayoutVisited = vi.fn()
|
||||
const onUnsafeLayoutVisited = vi.fn()
|
||||
const select = createTabBarAgentProjectionSelector({
|
||||
onStatusEntryVisited,
|
||||
onAgentTypeLayoutVisited,
|
||||
onUnsafeLayoutVisited
|
||||
})
|
||||
const statuses = {
|
||||
'tab-1:leaf-a': entry({ agentType: 'claude' }),
|
||||
'tab-2:leaf-a': entry({ agentType: 'codex' }),
|
||||
'tab-3:leaf-a': entry({ agentType: 'grok' })
|
||||
}
|
||||
const layouts = {
|
||||
'tab-1': splitLayout('leaf-a'),
|
||||
'tab-2': splitLayout('leaf-a')
|
||||
}
|
||||
|
||||
for (let consumer = 0; consumer < 100; consumer++) {
|
||||
select({
|
||||
settings: { experimentalNativeChat: true },
|
||||
agentStatusByPaneKey: statuses,
|
||||
terminalLayoutsByTabId: layouts
|
||||
})
|
||||
}
|
||||
|
||||
expect(onStatusEntryVisited).toHaveBeenCalledTimes(3)
|
||||
expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(2)
|
||||
expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reuses outputs and invalidates only the projection whose input changed', () => {
|
||||
const onStatusEntryVisited = vi.fn()
|
||||
const onAgentTypeLayoutVisited = vi.fn()
|
||||
const onUnsafeLayoutVisited = vi.fn()
|
||||
const select = createTabBarAgentProjectionSelector({
|
||||
onStatusEntryVisited,
|
||||
onAgentTypeLayoutVisited,
|
||||
onUnsafeLayoutVisited
|
||||
})
|
||||
const split = { 'tab-1': splitLayout('leaf-a') }
|
||||
const working = {
|
||||
'tab-1:leaf-a': entry({ agentType: 'claude', state: 'working' }),
|
||||
'tab-1:leaf-b': entry({ agentType: 'codex', state: 'working' })
|
||||
}
|
||||
|
||||
const first = select({
|
||||
settings: { experimentalNativeChat: true },
|
||||
agentStatusByPaneKey: working,
|
||||
terminalLayoutsByTabId: split
|
||||
})
|
||||
const done = {
|
||||
'tab-1:leaf-a': entry({ agentType: 'claude', state: 'done' }),
|
||||
'tab-1:leaf-b': entry({ agentType: 'codex', state: 'done' })
|
||||
}
|
||||
const afterStatus = select({
|
||||
settings: { experimentalNativeChat: true },
|
||||
agentStatusByPaneKey: done,
|
||||
terminalLayoutsByTabId: split
|
||||
})
|
||||
|
||||
expect(afterStatus).toBe(first)
|
||||
expect(onStatusEntryVisited).toHaveBeenCalledTimes(4)
|
||||
expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(2)
|
||||
expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(1)
|
||||
|
||||
const singleLeaf = {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf' as const, leafId: 'leaf-b' },
|
||||
activeLeafId: 'leaf-b',
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
const afterLayout = select({
|
||||
settings: { experimentalNativeChat: true },
|
||||
agentStatusByPaneKey: done,
|
||||
terminalLayoutsByTabId: singleLeaf
|
||||
})
|
||||
|
||||
expect(afterLayout.tabAgentTypesByTabId).toEqual({ 'tab-1': 'codex' })
|
||||
expect(afterLayout.tabAgentTypesByTabId).not.toBe(first.tabAgentTypesByTabId)
|
||||
expect(afterLayout.nativeChatTabWideFallbackUnsafeTabsById).toEqual({})
|
||||
expect(afterLayout.nativeChatTabWideFallbackUnsafeTabsById).not.toBe(
|
||||
first.nativeChatTabWideFallbackUnsafeTabsById
|
||||
)
|
||||
expect(onStatusEntryVisited).toHaveBeenCalledTimes(6)
|
||||
expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(3)
|
||||
expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('normalizes missing maps to shared empty inputs', () => {
|
||||
const select = createTabBarAgentProjectionSelector()
|
||||
|
||||
const first = select({ settings: { experimentalNativeChat: true } })
|
||||
|
||||
expect(select({ settings: { experimentalNativeChat: true } })).toBe(first)
|
||||
})
|
||||
|
||||
it('releases enabled inputs on disable and rescans them after re-enabling', () => {
|
||||
const onStatusEntryVisited = vi.fn()
|
||||
const onAgentTypeLayoutVisited = vi.fn()
|
||||
const onUnsafeLayoutVisited = vi.fn()
|
||||
const select = createTabBarAgentProjectionSelector({
|
||||
onStatusEntryVisited,
|
||||
onAgentTypeLayoutVisited,
|
||||
onUnsafeLayoutVisited
|
||||
})
|
||||
const state = {
|
||||
settings: { experimentalNativeChat: true },
|
||||
agentStatusByPaneKey: { 'tab-1:leaf-a': entry({ agentType: 'claude' }) },
|
||||
terminalLayoutsByTabId: { 'tab-1': splitLayout('leaf-a') }
|
||||
}
|
||||
|
||||
const first = select(state)
|
||||
select({ ...state, settings: { experimentalNativeChat: false } })
|
||||
const afterReenable = select(state)
|
||||
|
||||
expect(afterReenable).not.toBe(first)
|
||||
expect(onStatusEntryVisited).toHaveBeenCalledTimes(2)
|
||||
expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(2)
|
||||
expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('production selector skips all map scans while native chat is disabled', () => {
|
||||
let statusEnumerations = 0
|
||||
let layoutEnumerations = 0
|
||||
const statuses = new Proxy(
|
||||
{ 'tab-1:leaf-a': entry({ agentType: 'claude' }) },
|
||||
{
|
||||
ownKeys(target) {
|
||||
statusEnumerations++
|
||||
return Reflect.ownKeys(target)
|
||||
}
|
||||
}
|
||||
)
|
||||
const layouts = new Proxy(
|
||||
{ 'tab-1': splitLayout('leaf-a') },
|
||||
{
|
||||
ownKeys(target) {
|
||||
layoutEnumerations++
|
||||
return Reflect.ownKeys(target)
|
||||
}
|
||||
}
|
||||
)
|
||||
const disabledState = {
|
||||
settings: { experimentalNativeChat: false },
|
||||
agentStatusByPaneKey: statuses,
|
||||
terminalLayoutsByTabId: layouts
|
||||
}
|
||||
|
||||
const disabled = selectTabBarAgentProjections(disabledState)
|
||||
for (let consumer = 0; consumer < 100; consumer++) {
|
||||
expect(selectTabBarAgentProjections(disabledState)).toBe(disabled)
|
||||
}
|
||||
expect(statusEnumerations).toBe(0)
|
||||
expect(layoutEnumerations).toBe(0)
|
||||
|
||||
const enabledState = {
|
||||
...disabledState,
|
||||
settings: { experimentalNativeChat: true }
|
||||
}
|
||||
const enabled = selectTabBarAgentProjections(enabledState)
|
||||
for (let consumer = 0; consumer < 100; consumer++) {
|
||||
expect(selectTabBarAgentProjections(enabledState)).toBe(enabled)
|
||||
}
|
||||
expect(statusEnumerations).toBe(1)
|
||||
expect(layoutEnumerations).toBe(2)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,102 @@ import {
|
|||
resolveNativeChatActiveLayoutLeafId
|
||||
} from '../native-chat/native-chat-leaf-routing'
|
||||
|
||||
type TabBarAgentProjectionSelectorDependencies = {
|
||||
onStatusEntryVisited?: (paneKey: string) => void
|
||||
onAgentTypeLayoutVisited?: (tabId: string) => void
|
||||
onUnsafeLayoutVisited?: (tabId: string) => void
|
||||
}
|
||||
|
||||
export type TabBarAgentProjectionState = {
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
|
||||
settings?: { experimentalNativeChat?: boolean } | null
|
||||
}
|
||||
|
||||
export type TabBarAgentProjections = {
|
||||
nativeChatEnabled: boolean
|
||||
tabAgentTypesByTabId: Record<string, AgentType>
|
||||
nativeChatTabWideFallbackUnsafeTabsById: Record<string, true>
|
||||
}
|
||||
|
||||
const EMPTY_AGENT_STATUS_BY_PANE_KEY: Record<string, AgentStatusEntry> = Object.freeze({})
|
||||
const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: Record<string, TerminalLayoutSnapshot> = Object.freeze({})
|
||||
const EMPTY_TAB_AGENT_TYPES_BY_TAB_ID: Record<string, AgentType> = Object.freeze({})
|
||||
const EMPTY_UNSAFE_TABS_BY_ID: Record<string, true> = Object.freeze({})
|
||||
const DISABLED_TAB_BAR_AGENT_PROJECTIONS: TabBarAgentProjections = Object.freeze({
|
||||
nativeChatEnabled: false,
|
||||
tabAgentTypesByTabId: EMPTY_TAB_AGENT_TYPES_BY_TAB_ID,
|
||||
nativeChatTabWideFallbackUnsafeTabsById: EMPTY_UNSAFE_TABS_BY_ID
|
||||
})
|
||||
|
||||
function reuseRecordIfEqual<T>(
|
||||
previous: Record<string, T> | undefined,
|
||||
next: Record<string, T>
|
||||
): Record<string, T> {
|
||||
if (!previous) {
|
||||
return next
|
||||
}
|
||||
const nextKeys = Object.keys(next)
|
||||
if (Object.keys(previous).length !== nextKeys.length) {
|
||||
return next
|
||||
}
|
||||
return nextKeys.every((key) => previous[key] === next[key]) ? previous : next
|
||||
}
|
||||
|
||||
function projectTabAgentTypesByTabId(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>,
|
||||
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
|
||||
}
|
||||
}
|
||||
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
|
||||
dependencies?.onStatusEntryVisited?.(paneKey)
|
||||
const colon = paneKey.indexOf(':')
|
||||
if (colon <= 0) {
|
||||
continue
|
||||
}
|
||||
const tabId = paneKey.slice(0, colon)
|
||||
if (claimed.has(tabId)) {
|
||||
continue
|
||||
}
|
||||
claimed.add(tabId)
|
||||
if (entry.agentType != null) {
|
||||
byTabId[tabId] = entry.agentType
|
||||
}
|
||||
}
|
||||
return byTabId
|
||||
}
|
||||
|
||||
function projectNativeChatTabWideFallbackUnsafeTabsById(
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>,
|
||||
dependencies?: TabBarAgentProjectionSelectorDependencies
|
||||
): Record<string, true> {
|
||||
const unsafeTabs: Record<string, true> = {}
|
||||
for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) {
|
||||
dependencies?.onUnsafeLayoutVisited?.(tabId)
|
||||
if (!isNativeChatTabWideFallbackSafe(layout)) {
|
||||
unsafeTabs[tabId] = true
|
||||
}
|
||||
}
|
||||
return unsafeTabs
|
||||
}
|
||||
|
||||
/**
|
||||
* Project `agentStatusByPaneKey` down to the stable `{ terminalTabId: agentType }`
|
||||
* the tab strip actually reads (to gate the native-chat view-mode toggle).
|
||||
|
|
@ -26,53 +122,69 @@ export function selectTabAgentTypesByTabId(
|
|||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
|
||||
): Record<string, AgentType> {
|
||||
const byTabId: Record<string, AgentType> = {}
|
||||
const claimed = new Set<string>()
|
||||
// Why: the tab action opens chat on the active split leaf, so that leaf's
|
||||
// identity must outrank object insertion order from unrelated siblings.
|
||||
for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) {
|
||||
// A rootless snapshot with no active leaf is hydration absence, not a
|
||||
// topology decision; preserve the legacy tab lookup until a leaf exists.
|
||||
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
|
||||
}
|
||||
}
|
||||
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
|
||||
const colon = paneKey.indexOf(':')
|
||||
if (colon <= 0) {
|
||||
continue
|
||||
}
|
||||
const tabId = paneKey.slice(0, colon)
|
||||
if (claimed.has(tabId)) {
|
||||
continue
|
||||
}
|
||||
claimed.add(tabId)
|
||||
if (entry.agentType != null) {
|
||||
byTabId[tabId] = entry.agentType
|
||||
}
|
||||
}
|
||||
return byTabId
|
||||
return projectTabAgentTypesByTabId(agentStatusByPaneKey, terminalLayoutsByTabId)
|
||||
}
|
||||
|
||||
export function selectNativeChatTabWideFallbackUnsafeTabsById(
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
|
||||
): Record<string, true> {
|
||||
// Why: legacy and hydrating store shapes may not expose layout state yet;
|
||||
// absence carries no unsafe split evidence and must not crash tab rendering.
|
||||
const unsafeTabs: Record<string, true> = {}
|
||||
for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) {
|
||||
if (!isNativeChatTabWideFallbackSafe(layout)) {
|
||||
unsafeTabs[tabId] = true
|
||||
}
|
||||
}
|
||||
return unsafeTabs
|
||||
return projectNativeChatTabWideFallbackUnsafeTabsById(terminalLayoutsByTabId)
|
||||
}
|
||||
|
||||
export function createTabBarAgentProjectionSelector(
|
||||
dependencies?: TabBarAgentProjectionSelectorDependencies
|
||||
): (state: TabBarAgentProjectionState) => TabBarAgentProjections {
|
||||
let cachedAgentStatusByPaneKey: Record<string, AgentStatusEntry> | null = null
|
||||
let cachedAgentTypeLayoutsByTabId: Record<string, TerminalLayoutSnapshot> | null = null
|
||||
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
|
||||
|
||||
return (state) => {
|
||||
if (state.settings?.experimentalNativeChat !== true) {
|
||||
if (cachedEnabledResult) {
|
||||
cachedAgentStatusByPaneKey = null
|
||||
cachedAgentTypeLayoutsByTabId = null
|
||||
cachedAgentTypesByTabId = EMPTY_TAB_AGENT_TYPES_BY_TAB_ID
|
||||
cachedUnsafeLayoutsByTabId = null
|
||||
cachedUnsafeTabsById = EMPTY_UNSAFE_TABS_BY_ID
|
||||
cachedEnabledResult = null
|
||||
}
|
||||
return DISABLED_TAB_BAR_AGENT_PROJECTIONS
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
cachedAgentStatusByPaneKey = statuses
|
||||
cachedAgentTypeLayoutsByTabId = layouts
|
||||
}
|
||||
if (layouts !== cachedUnsafeLayoutsByTabId) {
|
||||
cachedUnsafeTabsById = reuseRecordIfEqual(
|
||||
cachedUnsafeTabsById,
|
||||
projectNativeChatTabWideFallbackUnsafeTabsById(layouts, dependencies)
|
||||
)
|
||||
cachedUnsafeLayoutsByTabId = layouts
|
||||
}
|
||||
if (
|
||||
cachedEnabledResult?.tabAgentTypesByTabId === cachedAgentTypesByTabId &&
|
||||
cachedEnabledResult.nativeChatTabWideFallbackUnsafeTabsById === cachedUnsafeTabsById
|
||||
) {
|
||||
return cachedEnabledResult
|
||||
}
|
||||
cachedEnabledResult = {
|
||||
nativeChatEnabled: true,
|
||||
tabAgentTypesByTabId: cachedAgentTypesByTabId,
|
||||
nativeChatTabWideFallbackUnsafeTabsById: cachedUnsafeTabsById
|
||||
}
|
||||
return cachedEnabledResult
|
||||
}
|
||||
}
|
||||
|
||||
// Why: every retained TabBar requests the same global projection tuple.
|
||||
export const selectTabBarAgentProjections = createTabBarAgentProjectionSelector()
|
||||
|
|
|
|||
Loading…
Reference in New Issue