mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
perf: reuse chat tab ownership across unrelated app updates (#26150)
This commit is contained in:
@@ -49,7 +49,7 @@ import {
|
||||
emptyNativeChatContextMenuActions,
|
||||
useNativeChatContextMenu
|
||||
} from './use-native-chat-context-menu'
|
||||
import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner'
|
||||
import { createNativeChatRuntimeSelector } from './native-chat-runtime-owner'
|
||||
import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
|
||||
import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover'
|
||||
import { useNativeChatLinkActions } from './use-native-chat-link-actions'
|
||||
@@ -77,9 +77,8 @@ export function NativeChatResolvedView({
|
||||
}: NativeChatResolvedViewProps): React.JSX.Element {
|
||||
// Primitive owner selection (no useShallow): routes the pane's read/subscribe to
|
||||
// the remote runtime host for a runtime-owned pane; null keeps the local path.
|
||||
const runtimeEnvironmentId = useAppStore((s) =>
|
||||
selectNativeChatRuntimeEnvironmentId(s, terminalTabId)
|
||||
)
|
||||
const selectOwner = useMemo(() => createNativeChatRuntimeSelector(terminalTabId), [terminalTabId])
|
||||
const runtimeEnvironmentId = useAppStore(selectOwner)
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
const session = useNativeChatRetainedSession({
|
||||
paneKey,
|
||||
|
||||
@@ -71,11 +71,43 @@ export function findNativeChatTabOwnerWorktreeId(
|
||||
)
|
||||
}
|
||||
|
||||
export function createNativeChatTabOwnerSelector(tabId: string) {
|
||||
let terminalTabs: NativeChatFileLinkState['tabsByWorktree'] | null = null
|
||||
let unifiedTabs: NativeChatFileLinkState['unifiedTabsByWorktree']
|
||||
let owner: string | null = null
|
||||
return (state: Pick<NativeChatFileLinkState, 'tabsByWorktree' | 'unifiedTabsByWorktree'>) => {
|
||||
// Hidden chats still subscribe; ownership changes only with the immutable tab maps.
|
||||
if (terminalTabs !== state.tabsByWorktree || unifiedTabs !== state.unifiedTabsByWorktree) {
|
||||
owner = findNativeChatTabOwnerWorktreeId(state, tabId)
|
||||
terminalTabs = state.tabsByWorktree
|
||||
unifiedTabs = state.unifiedTabsByWorktree
|
||||
}
|
||||
return owner
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveNativeChatFileLinkContext(
|
||||
state: NativeChatFileLinkState,
|
||||
terminalTabId: string
|
||||
): NativeChatFileLinkContext | null {
|
||||
const worktreeId = findNativeChatTabOwnerWorktreeId(state, terminalTabId)
|
||||
return resolveNativeChatFileLinkContextForOwner(
|
||||
state,
|
||||
terminalTabId,
|
||||
findNativeChatTabOwnerWorktreeId(state, terminalTabId)
|
||||
)
|
||||
}
|
||||
|
||||
export function createNativeChatFileLinkContextSelector(tabId: string) {
|
||||
const selectOwner = createNativeChatTabOwnerSelector(tabId)
|
||||
return (state: NativeChatFileLinkState) =>
|
||||
resolveNativeChatFileLinkContextForOwner(state, tabId, selectOwner(state))
|
||||
}
|
||||
|
||||
function resolveNativeChatFileLinkContextForOwner(
|
||||
state: NativeChatFileLinkState,
|
||||
terminalTabId: string,
|
||||
worktreeId: string | null
|
||||
): NativeChatFileLinkContext | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
createNativeChatFileLinkContextSelector,
|
||||
createNativeChatTabOwnerSelector,
|
||||
findNativeChatTabOwnerWorktreeId,
|
||||
resolveNativeChatFileLinkContext
|
||||
} from './native-chat-file-link'
|
||||
import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner'
|
||||
import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
type ContextState = Parameters<typeof resolveNativeChatFileLinkContext>[0]
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const { create } = await import('zustand')
|
||||
const { getDefaultSettings } = await import('../../../../shared/constants')
|
||||
return {
|
||||
useAppStore: create<ContextState>(() => ({
|
||||
settings: getDefaultSettings('/home/test'),
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
folderWorkspaces: [],
|
||||
worktreesByRepo: {},
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
getKnownWorktreeById: () => undefined
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function terminal(id: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId: 'terminal-owner',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function structured(id: string, contentType: Tab['contentType'] = 'agent-session'): Tab {
|
||||
return {
|
||||
id,
|
||||
entityId: `session-${id}`,
|
||||
worktreeId: 'chat-owner',
|
||||
groupId: 'group',
|
||||
contentType,
|
||||
agentSessionAgent: 'codex',
|
||||
label: 'Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function worktree(id: string, path = '/repo'): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo',
|
||||
path,
|
||||
displayName: id,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
head: '',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
}
|
||||
|
||||
function contextState(overrides: Partial<ContextState> = {}): ContextState {
|
||||
return {
|
||||
settings: getDefaultSettings('/home/test'),
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
folderWorkspaces: [],
|
||||
worktreesByRepo: {},
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
getKnownWorktreeById: () => undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('native chat ownership selectors', () => {
|
||||
it.each(['chat', 'missing'])('does no ownership scans on unrelated hook updates for %s', (id) => {
|
||||
let reads = 0
|
||||
const counted = <T extends { id: string }>(tab: T): T => {
|
||||
const tabId = tab.id
|
||||
Object.defineProperty(tab, 'id', { enumerable: true, get: () => (reads++, tabId) })
|
||||
return tab
|
||||
}
|
||||
const state = contextState({
|
||||
tabsByWorktree: Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_, index) => [
|
||||
`terminal-owner-${index}`,
|
||||
[counted(terminal(`terminal-${index}`))]
|
||||
])
|
||||
),
|
||||
unifiedTabsByWorktree: { 'chat-owner': [counted(structured('chat'))] },
|
||||
getKnownWorktreeById: (owner) => worktree(owner)
|
||||
})
|
||||
useAppStore.setState(state)
|
||||
const { result } = renderHook(() => ({
|
||||
owner: useNativeChatTabOwnerWorktreeId(id),
|
||||
context: useNativeChatFileLinkContext(id)
|
||||
}))
|
||||
expect(result.current.owner).toBe(id === 'chat' ? 'chat-owner' : null)
|
||||
expect(reads).toBeGreaterThan(0)
|
||||
reads = 0
|
||||
act(() => {
|
||||
for (let index = 0; index < 100; index++) {
|
||||
useAppStore.setState({ lastTerminalInputAtByPaneKey: { pane: index } })
|
||||
}
|
||||
})
|
||||
expect(reads).toBe(0)
|
||||
expect(result.current.context?.worktreePath ?? null).toBe(id === 'chat' ? '/repo' : null)
|
||||
})
|
||||
|
||||
it('refreshes both hooks when the queried tab changes, moves, closes, or returns', () => {
|
||||
const state = contextState({
|
||||
unifiedTabsByWorktree: { first: [structured('one')], second: [structured('two')] },
|
||||
getKnownWorktreeById: (owner) => worktree(owner, `/${owner}`)
|
||||
})
|
||||
useAppStore.setState(state)
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ id }) => ({
|
||||
owner: useNativeChatTabOwnerWorktreeId(id),
|
||||
context: useNativeChatFileLinkContext(id)
|
||||
}),
|
||||
{ initialProps: { id: 'one' } }
|
||||
)
|
||||
expect(result.current.context?.worktreePath).toBe('/first')
|
||||
rerender({ id: 'two' })
|
||||
expect(result.current.context?.worktreePath).toBe('/second')
|
||||
act(() => useAppStore.setState({ unifiedTabsByWorktree: { first: [structured('two')] } }))
|
||||
expect(result.current.owner).toBe('first')
|
||||
act(() => useAppStore.setState({ unifiedTabsByWorktree: {} }))
|
||||
expect(result.current).toEqual({ owner: null, context: null })
|
||||
act(() => useAppStore.setState({ tabsByWorktree: { returned: [terminal('two')] } }))
|
||||
expect(result.current.context?.worktreePath).toBe('/returned')
|
||||
unmount()
|
||||
const reopened = renderHook(() => useNativeChatTabOwnerWorktreeId('two'))
|
||||
expect(reopened.result.current).toBe('returned')
|
||||
})
|
||||
|
||||
it('keeps first-owner collisions, terminal precedence, and the structured content gate', () => {
|
||||
const select = createNativeChatTabOwnerSelector('same')
|
||||
const states = [
|
||||
contextState({
|
||||
tabsByWorktree: { first: [terminal('same')], second: [terminal('same')] },
|
||||
unifiedTabsByWorktree: { structured: [structured('same')] }
|
||||
}),
|
||||
contextState({
|
||||
unifiedTabsByWorktree: {
|
||||
editor: [structured('same', 'editor')],
|
||||
first: [structured('same')],
|
||||
second: [structured('same')]
|
||||
}
|
||||
}),
|
||||
contextState({ unifiedTabsByWorktree: { editor: [structured('same', 'editor')] } }),
|
||||
contextState()
|
||||
]
|
||||
expect(states.map((state) => select(state))).toEqual(['first', 'first', null, null])
|
||||
for (const state of states) {
|
||||
expect(select(state)).toBe(findNativeChatTabOwnerWorktreeId(state, 'same'))
|
||||
}
|
||||
})
|
||||
|
||||
it('updates directory and runtime projections without replacing the ownership maps', () => {
|
||||
let path = '/before'
|
||||
const initial = contextState({
|
||||
unifiedTabsByWorktree: { 'chat-owner': [structured('chat')] },
|
||||
getKnownWorktreeById: (owner) => worktree(owner, path),
|
||||
worktreesByRepo: { repo: [worktree('chat-owner')] }
|
||||
})
|
||||
const select = createNativeChatFileLinkContextSelector('chat')
|
||||
expect(select(initial)?.worktreePath).toBe('/before')
|
||||
path = '/after'
|
||||
const current = {
|
||||
...initial,
|
||||
settings: { ...getDefaultSettings('/home/test'), activeRuntimeEnvironmentId: 'focused' },
|
||||
worktreesByRepo: {
|
||||
repo: [{ ...worktree('chat-owner'), runtimeOwnerEnvironmentId: 'owning-runtime' }]
|
||||
}
|
||||
}
|
||||
expect(select(current)).toEqual(resolveNativeChatFileLinkContext(current, 'chat'))
|
||||
expect(select(current)).toEqual({
|
||||
worktreeId: 'chat-owner',
|
||||
worktreePath: '/after',
|
||||
runtimeEnvironmentId: 'owning-runtime'
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for a floating chat pin and follows pin changes without an ownership change', () => {
|
||||
const initial = contextState({
|
||||
floatingWorkspacePath: '/setting',
|
||||
unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [structured('chat')] }
|
||||
})
|
||||
const select = createNativeChatFileLinkContextSelector('chat')
|
||||
expect(select(initial)).toBeNull()
|
||||
for (const launchDirectory of ['/pinned', '/updated']) {
|
||||
const current = {
|
||||
...initial,
|
||||
structuredSessionLaunchDirectoryByTabId: {
|
||||
chat: { sessionId: 'session-chat', launchDirectory }
|
||||
}
|
||||
}
|
||||
expect(select(current)?.worktreePath).toBe(launchDirectory)
|
||||
expect(select(current)).toEqual(resolveNativeChatFileLinkContext(current, 'chat'))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import {
|
||||
createNativeChatRuntimeSelector,
|
||||
selectNativeChatRuntimeEnvironmentId,
|
||||
type NativeChatRuntimeOwnerState
|
||||
} from './native-chat-runtime-owner'
|
||||
|
||||
function tab(id: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId: 'owner',
|
||||
title: id,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function state(): NativeChatRuntimeOwnerState {
|
||||
return {
|
||||
tabsByWorktree: { owner: [tab('target')] },
|
||||
worktreesByRepo: { repo: [{ id: 'owner', repoId: 'repo', hostId: 'runtime:first' }] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('createNativeChatRuntimeSelector', () => {
|
||||
it('does not inspect terminal rows during unrelated publications and keeps runtime routing fresh', () => {
|
||||
const initial = state()
|
||||
const target = initial.tabsByWorktree.owner?.[0]
|
||||
if (!target) {
|
||||
throw new Error('Expected the target tab fixture')
|
||||
}
|
||||
let reads = 0
|
||||
Object.defineProperty(target, 'id', {
|
||||
get: () => {
|
||||
reads++
|
||||
return 'target'
|
||||
}
|
||||
})
|
||||
const select = createNativeChatRuntimeSelector('target')
|
||||
expect(select(initial)).toBe('first')
|
||||
expect(reads).toBe(1)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const next: NativeChatRuntimeOwnerState = {
|
||||
...initial,
|
||||
worktreesByRepo: { repo: [{ id: 'owner', repoId: 'repo', hostId: `runtime:next-${i}` }] }
|
||||
}
|
||||
expect(select(next)).toBe(`next-${i}`)
|
||||
}
|
||||
expect(reads).toBe(1)
|
||||
expect(select({ ...initial, tabsByWorktree: { ...initial.tabsByWorktree } })).toBe('first')
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves first terminal owner, move, close, and independent tab identities', () => {
|
||||
const initial = state()
|
||||
const withCollision: NativeChatRuntimeOwnerState = {
|
||||
...initial,
|
||||
tabsByWorktree: { ...initial.tabsByWorktree, later: [tab('target'), tab('second')] },
|
||||
worktreesByRepo: {
|
||||
repo: [
|
||||
...(initial.worktreesByRepo?.repo ?? []),
|
||||
{ id: 'later', repoId: 'repo', hostId: 'runtime:later' }
|
||||
]
|
||||
}
|
||||
}
|
||||
const select = createNativeChatRuntimeSelector('target')
|
||||
const selectSecond = createNativeChatRuntimeSelector('second')
|
||||
for (const next of [
|
||||
initial,
|
||||
withCollision,
|
||||
{ ...withCollision, tabsByWorktree: { later: [tab('target'), tab('second')] } },
|
||||
{ ...withCollision, tabsByWorktree: {} }
|
||||
]) {
|
||||
expect(select(next)).toBe(selectNativeChatRuntimeEnvironmentId(next, 'target'))
|
||||
expect(selectSecond(next)).toBe(selectNativeChatRuntimeEnvironmentId(next, 'second'))
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores structured-only tabs and refreshes active, detected, folder, and repo host projections', () => {
|
||||
const select = createNativeChatRuntimeSelector('target')
|
||||
const initial = state()
|
||||
const missing = {
|
||||
...initial,
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: { owner: [{ id: 'target', contentType: 'agent-session' }] }
|
||||
}
|
||||
expect(select(missing)).toBeNull()
|
||||
const projections: NativeChatRuntimeOwnerState[] = [
|
||||
initial,
|
||||
{
|
||||
...initial,
|
||||
activeWorktreeId: 'owner',
|
||||
activeWorkspaceExecutionHostId: 'runtime:active' as const
|
||||
},
|
||||
{
|
||||
...initial,
|
||||
detectedWorktreesByRepo: {
|
||||
repo: { worktrees: [{ id: 'owner', repoId: 'repo', hostId: 'runtime:detected' }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
...initial,
|
||||
worktreesByRepo: { repo: [{ id: 'owner', repoId: 'repo' }] },
|
||||
repos: [{ id: 'repo', executionHostId: 'ssh:remote' as const }]
|
||||
},
|
||||
{
|
||||
...initial,
|
||||
tabsByWorktree: { 'folder:scope': [tab('target')] },
|
||||
folderWorkspaces: [
|
||||
{ id: 'scope', projectGroupId: 'group', executionHostId: 'runtime:folder' as const }
|
||||
]
|
||||
}
|
||||
]
|
||||
for (const next of projections) {
|
||||
expect(select(next)).toBe(selectNativeChatRuntimeEnvironmentId(next, 'target'))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
type WorktreeRuntimeOwnerState
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { findTerminalTabWorktreeId } from './native-chat-file-link'
|
||||
import {
|
||||
createNativeChatTabOwnerSelector,
|
||||
findTerminalTabWorktreeId
|
||||
} from './native-chat-file-link'
|
||||
|
||||
export type NativeChatRuntimeOwnerState = Pick<AppState, 'tabsByWorktree'> &
|
||||
WorktreeRuntimeOwnerState
|
||||
@@ -28,3 +31,11 @@ export function selectNativeChatRuntimeEnvironmentId(
|
||||
const worktreeId = findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId)
|
||||
return worktreeId ? getRuntimeEnvironmentIdForWorktree(state, worktreeId) : null
|
||||
}
|
||||
|
||||
export function createNativeChatRuntimeSelector(terminalTabId: string) {
|
||||
const selectOwner = createNativeChatTabOwnerSelector(terminalTabId)
|
||||
return (state: NativeChatRuntimeOwnerState): string | null => {
|
||||
const worktreeId = selectOwner({ tabsByWorktree: state.tabsByWorktree })
|
||||
return worktreeId ? getRuntimeEnvironmentIdForWorktree(state, worktreeId) : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '../../store'
|
||||
import { resolveNativeChatFileLinkContext } from './native-chat-file-link'
|
||||
import { createNativeChatFileLinkContextSelector } from './native-chat-file-link'
|
||||
|
||||
export function useNativeChatFileLinkContext(terminalTabId: string) {
|
||||
return useAppStore(useShallow((state) => resolveNativeChatFileLinkContext(state, terminalTabId)))
|
||||
const selectContext = useMemo(
|
||||
() => createNativeChatFileLinkContextSelector(terminalTabId),
|
||||
[terminalTabId]
|
||||
)
|
||||
return useAppStore(useShallow(selectContext))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { findNativeChatTabOwnerWorktreeId } from './native-chat-file-link'
|
||||
import { createNativeChatTabOwnerSelector } from './native-chat-file-link'
|
||||
|
||||
/** Owning workspace of a chat tab; launch identity must not wait on its directory resolving. */
|
||||
export function useNativeChatTabOwnerWorktreeId(tabId: string): string | null {
|
||||
return useAppStore((state) => findNativeChatTabOwnerWorktreeId(state, tabId))
|
||||
const selectOwner = useMemo(() => createNativeChatTabOwnerSelector(tabId), [tabId])
|
||||
return useAppStore(selectOwner)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user