diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 83977dee7fe..2c12d5ec41c 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -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, 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 043bb9b78c6..e1b4f8c6b0f 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 @@ -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) => { + // 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 } diff --git a/src/renderer/src/components/native-chat/native-chat-owner-selectors.test.tsx b/src/renderer/src/components/native-chat/native-chat-owner-selectors.test.tsx new file mode 100644 index 00000000000..2bee35ac548 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-owner-selectors.test.tsx @@ -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[0] + +vi.mock('@/store', async () => { + const { create } = await import('zustand') + const { getDefaultSettings } = await import('../../../../shared/constants') + return { + useAppStore: create(() => ({ + 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 { + 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 = (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')) + } + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-owner-selector.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-owner-selector.test.ts new file mode 100644 index 00000000000..8e476421bba --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-owner-selector.test.ts @@ -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')) + } + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts b/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts index 02aaae1a6f3..a85739ae36c 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts @@ -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 & 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 + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts b/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts index d3073115b5a..51ae6382794 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts @@ -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)) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts b/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts index 8ec04deac07..1539547a36c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts @@ -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) }