diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx index 188b43bfb67..38a383b9b0c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx @@ -12,9 +12,17 @@ import { } from '@/components/editor/useLocalImageSrc' import { useAppStore } from '@/store' import { NativeChatMarkdown } from './NativeChatMarkdown' -import { NativeChatMarkdownImageOwnerContext } from './native-chat-markdown-image-owner' +import { + NativeChatMarkdownImageOwnerContext, + NativeChatMarkdownImageOwnerProvider +} from './native-chat-markdown-image-owner' import { useNativeChatLinkActions } from './use-native-chat-link-actions' -import { repoFixture, worktreeFixture } from './native-chat-workspace-test-fixtures' +import { + nativeChatFanoutFixture, + nativeChatFanoutWorkspace, + repoFixture, + worktreeFixture +} from './native-chat-workspace-test-fixtures' import type * as TerminalFileOpenRouting from '@/components/terminal-pane/terminal-file-open-routing' import type * as HttpLinkRouting from '@/lib/http-link-routing' import { getDefaultSettings } from '../../../../shared/constants' @@ -156,6 +164,108 @@ afterEach(() => { }) describe('native chat Markdown image preview', () => { + it('routes image reads and actions through the selected owner provider across owner changes and removal', async () => { + const original = useAppStore.getState() + const fixture = nativeChatFanoutFixture(1, 1, 0) + const workspaceId = nativeChatFanoutWorkspace(0) + const folders = (owner: string, path: string) => + fixture.folderWorkspaces.map((folder) => ({ + ...folder, + executionHostId: `runtime:${owner}` as const, + folderPath: path + })) + useAppStore.setState({ + ...useAppStore.getInitialState(), + ...fixture, + folderWorkspaces: folders('markdown-first', '/remote/first'), + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + runtimeEnvironmentCatalogHydrated: true + }) + const view = render( + + + + + + ) + try { + await screen.findByRole('img', { name: 'Chart' }) + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + target: { kind: 'environment', environmentId: 'markdown-first' }, + worktreeId: workspaceId, + worktreePath: '/remote/first' + }), + '/remote/first/chart.png', + { kind: 'chat-image' } + ) + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' }), { + ctrlKey: true, + metaKey: true + }) + expect(mocks.openDetectedFilePath).toHaveBeenLastCalledWith( + '/remote/first/chart.png', + null, + null, + expect.objectContaining({ worktreeId: workspaceId, runtimeEnvironmentId: 'markdown-first' }) + ) + + act(() => + useAppStore.setState({ folderWorkspaces: folders('markdown-next', '/remote/next') }) + ) + await waitFor(() => + expect(screen.getByRole('img', { name: 'Chart' })).toHaveAttribute('src', 'blob:image-2') + ) + expect(mocks.readRuntimeFilePreview).toHaveBeenLastCalledWith( + expect.objectContaining({ + target: { kind: 'environment', environmentId: 'markdown-next' }, + worktreePath: '/remote/next' + }), + '/remote/next/chart.png', + { kind: 'chat-image' } + ) + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' }), { + ctrlKey: true, + metaKey: true + }) + expect(mocks.openDetectedFilePath).toHaveBeenLastCalledWith( + '/remote/next/chart.png', + null, + null, + expect.objectContaining({ + worktreeId: workspaceId, + worktreePath: '/remote/next', + runtimeEnvironmentId: 'markdown-next' + }) + ) + mocks.openDetectedFilePath.mockClear() + await act(async () => useAppStore.setState({ unifiedTabsByWorktree: {} })) + expect(screen.queryByRole('img', { name: 'Chart' })).toBeNull() + const unavailableTrigger = screen.getByRole('button', { name: 'View image: Chart' }) + expect(unavailableTrigger).toHaveTextContent('Chart') + expect(screen.queryByRole('dialog')).toBeNull() + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledTimes(2) + fireEvent.click(unavailableTrigger, { + ctrlKey: true, + metaKey: true + }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledTimes(2) + fireEvent.click(unavailableTrigger) + const unavailableDialog = screen.getByRole('dialog', { name: 'Chart' }) + expect(within(unavailableDialog).getByRole('status')).toHaveTextContent('Preview unavailable') + expect(within(unavailableDialog).queryByRole('img')).toBeNull() + expect(within(unavailableDialog).queryByRole('button', { name: 'File actions' })).toBeNull() + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledTimes(2) + } finally { + view.unmount() + useAppStore.setState(original, true) + } + }) + it.each([ ['chart%23v2.png', '/repo/worktree/chart#v2.png'], ['chart%3Fv2.png', '/repo/worktree/chart?v2.png'], diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index 8a2fb04278d..8102cbfdb96 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx @@ -11,15 +11,23 @@ import { resetLocalImageSrcStateForTests } from '@/components/editor/useLocalImageSrc' import { chatImageAccess } from '@/lib/local-file-access' +import { useAppStore } from '@/store' import { NativeChatImageAttachments } from './NativeChatTranscriptChrome' +import { useNativeChatImageRuntimeContext } from './native-chat-image-runtime-context' +import { + nativeChatFanoutFixture, + nativeChatFanoutWorkspace +} from './native-chat-workspace-test-fixtures' // Chat images carry their runtime owner without resolving an editor connection. const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), getConnectionIdForFile: vi.fn(() => { throw new Error('Chat image fixtures must not resolve editor file connections') }) })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: mocks.getConnectionIdForFile })) @@ -41,6 +49,11 @@ async function flushPromises(): Promise { } beforeEach(() => { + mocks.callRuntimeRpc.mockReset().mockResolvedValue({ + content: 'AA==', + isBinary: true, + mimeType: 'image/png' + }) resetLocalImageSrcStateForTests() vi.stubGlobal('IntersectionObserver', undefined) let urlSequence = 0 @@ -69,6 +82,72 @@ afterEach(() => { }) describe('NativeChatImageAttachments', () => { + it('reads on the selected folder owner and stops reading when that chat disappears', async () => { + const original = useAppStore.getState() + const fixture = nativeChatFanoutFixture(1, 1, 0) + const workspaceId = nativeChatFanoutWorkspace(0) + useAppStore.setState({ + ...useAppStore.getInitialState(), + ...fixture, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + runtimeEnvironmentCatalogHydrated: true + }) + function OwnedAttachments() { + const context = useNativeChatImageRuntimeContext('chat-0') + return createElement(NativeChatImageAttachments, { + blocks: [{ type: 'image-ref', path: '/repo/0/image.png' }], + runtimeContext: context + }) + } + const container = document.createElement('div') + const root = createRoot(container) + try { + await act(async () => { + root.render(createElement(OwnedAttachments)) + await flushPromises() + }) + expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:owner-1') + expect(window.api.fs.readFile).toHaveBeenCalledExactlyOnceWith({ + filePath: '/repo/0/image.png', + connectionId: undefined, + access: { kind: 'chat-image' } + }) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + await act(async () => { + useAppStore.setState({ + folderWorkspaces: fixture.folderWorkspaces.map((folder) => ({ + ...folder, + executionHostId: 'runtime:attachment-owner' as const + })) + }) + await flushPromises() + }) + expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:owner-2') + expect(mocks.callRuntimeRpc).toHaveBeenCalledExactlyOnceWith( + { kind: 'environment', environmentId: 'attachment-owner' }, + 'files.readPreview', + { worktree: `id:${workspaceId}`, relativePath: 'image.png' }, + expect.objectContaining({ timeoutMs: 15_000 }) + ) + expect(window.api.fs.readFile).toHaveBeenCalledTimes(1) + + await act(async () => { + useAppStore.setState({ unifiedTabsByWorktree: {} }) + await flushPromises() + }) + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('button')).toBeNull() + expect(container.textContent).toContain('image.png') + expect(window.api.fs.readFile).toHaveBeenCalledTimes(1) + expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) + } finally { + await act(async () => root.unmount()) + useAppStore.setState(original, true) + } + }) + it('pools visibility observation across image refs', async () => { class FakeIntersectionObserver { static instances: FakeIntersectionObserver[] = [] 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 0c1444c2c0d..37b3c6fb4c2 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 @@ -88,8 +88,8 @@ export function createNativeChatFileLinkContextSelector(tabId: string) { resolveNativeChatFileLinkContextForOwner(state, tabId, selectOwner(state)) } -function resolveNativeChatFileLinkContextForOwner( - state: NativeChatFileLinkState, +export function resolveNativeChatFileLinkContextForOwner( + state: Omit, terminalTabId: string, worktreeId: string | null ): NativeChatFileLinkContext | null { diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts index 89b26ce8356..a39527d7605 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom + +import { createElement } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import type { AppState } from '@/store/types' import { useAppStore } from '@/store' @@ -6,10 +10,27 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Tab } from '../../../../shared/tab-types' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { + createNativeChatImageOwnerSelector, resolveNativeChatImageRuntimeContext, - selectNativeChatImageOwnerState + resolveNativeChatImageRuntimeContextForOwner, + selectNativeChatImageOwnerState, + useNativeChatImageRuntimeContext, + type NativeChatImageRuntimeContext } from './native-chat-image-runtime-context' -import { repoFixture, worktreeFixture } from './native-chat-workspace-test-fixtures' +import { + agentSessionTabFixture, + nativeChatFanoutFixture, + nativeChatFanoutWorkspace, + repoFixture, + terminalTabFixture, + worktreeFixture +} from './native-chat-workspace-test-fixtures' + +const originalState = useAppStore.getState() +afterEach(() => { + cleanup() + useAppStore.setState(originalState, true) +}) function state(overrides: Partial = {}): AppState { const tab: TerminalTab = { @@ -58,6 +79,21 @@ describe('resolveNativeChatImageRuntimeContext', () => { expect(shallow(second, first)).toBe(true) }) + it("keeps tab changes in other workspaces out of a chat's owner inputs", () => { + const storeState = state() + const select = createNativeChatImageOwnerSelector('tab-1') + const first = select(storeState) + const otherWorkspaceTab = { ...storeState.tabsByWorktree['wt-1'][0], id: 'tab-2' } + const second = select({ + ...storeState, + tabsByWorktree: { ...storeState.tabsByWorktree, 'wt-2': [otherWorkspaceTab] }, + unifiedTabsByWorktree: { 'wt-2': [] } + }) + + expect(first.owner).toBe('wt-1') + expect(shallow(second, first)).toBe(true) + }) + it('reuses derived settings when owner inputs are unchanged', () => { const storeState = state() const first = resolveNativeChatImageRuntimeContext(storeState, 'tab-1') @@ -151,4 +187,271 @@ describe('resolveNativeChatImageRuntimeContext', () => { target: { kind: 'local' as const } }) }) + + it('keeps selected-hook results equivalent to full-state resolution through ownership, pin and host transitions', () => { + const base = state({ + worktreesByRepo: { + repo: [ + worktreeFixture('wt-1', '/repo/worktree', { hostId: 'local' }), + worktreeFixture('wt-2', '/repo/second', { hostId: 'local' }) + ] + } + }) + const duplicate = { + ...base, + tabsByWorktree: { + 'wt-1': [terminalTabFixture('tab-1', 'wt-1')], + 'wt-2': [terminalTabFixture('tab-1', 'wt-2')] + }, + unifiedTabsByWorktree: { + 'wt-2': [agentSessionTabFixture('tab-1', 'wt-2')], + 'wt-1': [agentSessionTabFixture('tab-1', 'wt-1')] + } + } + const fallback = { ...duplicate, tabsByWorktree: {} } + const moved = { + ...fallback, + unifiedTabsByWorktree: { 'wt-1': [agentSessionTabFixture('tab-1', 'wt-1')] } + } + const floatingTab = agentSessionTabFixture('tab-1', FLOATING_TERMINAL_WORKTREE_ID) + const floating = { + ...base, + tabsByWorktree: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] }, + structuredSessionLaunchDirectoryByTabId: {}, + floatingWorkspacePath: '/floating-setting' + } + const pinned = { + ...floating, + structuredSessionLaunchDirectoryByTabId: { + 'tab-1': { sessionId: floatingTab.entityId, launchDirectory: '/pinned' } + } + } + const replaced = { + ...pinned, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [{ ...floatingTab, entityId: 'replacement' }] + } + } + const folder = nativeChatFanoutFixture(0, 1, 0) + const folderState = { + ...base, + ...folder, + unifiedTabsByWorktree: { + [nativeChatFanoutWorkspace(0)]: [ + agentSessionTabFixture('tab-1', nativeChatFanoutWorkspace(0)) + ] + } + } + const environment = (id: string): AppState['runtimeEnvironments'][number] => ({ + id, + name: id, + createdAt: 0, + updatedAt: 0, + lastUsedAt: null, + runtimeId: null, + preferredEndpointId: 'endpoint', + endpoints: [] + }) + const remote = (id: string): AppState => ({ + ...base, + activeWorkspaceExecutionHostId: `runtime:${id}`, + runtimeEnvironments: [environment(id)], + repos: [repoFixture({ executionHostId: `runtime:${id}` })], + worktreesByRepo: { + repo: [worktreeFixture('wt-1', '/remote', { hostId: `runtime:${id}` })] + } + }) + const ssh = (generation?: number): AppState => ({ + ...remote('owner-a'), + activeWorkspaceExecutionHostId: 'ssh:ssh-1', + worktreesByRepo: { + repo: [ + worktreeFixture('wt-1', '/ssh', { + hostId: 'ssh:ssh-1', + runtimeOwnerEnvironmentId: 'owner-a' + }) + ] + }, + sshStateByEnvironment: new Map([ + [ + 'owner-a', + { + targets: [], + targetGenerations: new Map(), + targetLabels: new Map(), + removedTargetLabels: new Map(), + targetsHydrated: true, + connectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: generation + } + ] + ]) + } + ] + ]) + }) + const local = (worktreeId: string, worktreePath: string) => ({ + worktreeId, + worktreePath, + target: { kind: 'local' as const } + }) + const paired = ssh(3) + const cases: [ + name: string, + snapshot: AppState, + expected: Partial> | null + ][] = [ + ['terminal-first and first duplicate', duplicate, local('wt-1', '/repo/worktree')], + ['unified fallback', fallback, local('wt-2', '/repo/second')], + ['move', moved, local('wt-1', '/repo/worktree')], + ['remove', { ...moved, unifiedTabsByWorktree: {} }, null], + ['return', fallback, local('wt-2', '/repo/second')], + ['missing pin', floating, null], + [ + 'wrong pin', + { + ...pinned, + structuredSessionLaunchDirectoryByTabId: { + 'tab-1': { sessionId: 'wrong', launchDirectory: '/wrong' } + } + }, + null + ], + ['correct pin', pinned, local(FLOATING_TERMINAL_WORKTREE_ID, '/pinned')], + ['replaced session', replaced, null], + [ + 'replaced pin', + { + ...replaced, + structuredSessionLaunchDirectoryByTabId: { + 'tab-1': { sessionId: 'replacement', launchDirectory: '/replacement' } + } + }, + local(FLOATING_TERMINAL_WORKTREE_ID, '/replacement') + ], + [ + 'floating directory predicate skips unregistered duplicate', + { + ...pinned, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { ...floatingTab, entityId: 'unregistered', agentSessionAgent: undefined }, + floatingTab + ] + } + }, + local(FLOATING_TERMINAL_WORKTREE_ID, '/pinned') + ], + [ + 'floating terminal fallback', + { + ...floating, + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + terminalTabFixture('tab-1', FLOATING_TERMINAL_WORKTREE_ID) + ] + }, + unifiedTabsByWorktree: {} + }, + local(FLOATING_TERMINAL_WORKTREE_ID, '/floating-setting') + ], + [ + 'floating first registered duplicate wins', + { + ...pinned, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab, { ...floatingTab, entityId: 'second' }] + }, + structuredSessionLaunchDirectoryByTabId: { + 'tab-1': { sessionId: 'second', launchDirectory: '/wrong-second' } + } + }, + null + ], + ['folder', folderState, local(nativeChatFanoutWorkspace(0), '/repo/0')], + [ + 'runtime', + remote('owner-a'), + { worktreePath: '/remote', target: { kind: 'environment', environmentId: 'owner-a' } } + ], + [ + 'runtime replacement', + remote('owner-b'), + { target: { kind: 'environment', environmentId: 'owner-b' } } + ], + [ + 'owner-only runtime hydration', + { + ...remote('owner-a'), + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + worktreesByRepo: { + repo: [ + worktreeFixture('wt-1', '/remote', { + hostId: undefined, + runtimeOwnerEnvironmentId: 'owner-a' + }) + ] + } + }, + { worktreePath: '/remote', target: { kind: 'environment', environmentId: 'owner-a' } } + ], + [ + 'paired SSH', + paired, + { + worktreePath: '/ssh', + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshConnectionGeneration: 3, + target: { kind: 'environment', environmentId: 'owner-a' } + } + ], + [ + 'SSH generation-only change', + { ...paired, sshStateByEnvironment: ssh(4).sshStateByEnvironment }, + { expectedSshConnectionGeneration: 4 } + ], + [ + 'SSH generation absent', + { ...paired, sshStateByEnvironment: ssh().sshStateByEnvironment }, + null + ], + [ + 'unpaired SSH', + { + ...paired, + repos: [repoFixture({ connectionId: 'ssh-1' })], + worktreesByRepo: { + repo: [worktreeFixture('wt-1', '/ssh', { hostId: 'ssh:ssh-1' })] + } + }, + null + ] + ] + let observed: NativeChatImageRuntimeContext = null + function Probe() { + observed = useNativeChatImageRuntimeContext('tab-1') + return null + } + useAppStore.setState(base) + render(createElement(Probe)) + for (const [name, snapshot, expected] of cases) { + act(() => useAppStore.setState(snapshot)) + expect(observed, name).toEqual(resolveNativeChatImageRuntimeContext(snapshot, 'tab-1')) + if (expected === null) { + expect(observed, name).toBeNull() + } else { + expect(observed, name).toMatchObject(expected) + } + } + expect(resolveNativeChatImageRuntimeContextForOwner(base, 'tab-1', null)).toBeNull() + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts index be238eb8f27..788b5fd729d 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts @@ -7,7 +7,11 @@ import { resolveWorktreeOperationRouteResult, type WorktreeOperationRouteResolution } from '@/lib/worktree-operation-route' -import { resolveNativeChatFileLinkContext } from './native-chat-file-link' +import { + createNativeChatTabOwnerSelector, + findNativeChatTabOwnerWorktreeId, + resolveNativeChatFileLinkContextForOwner +} from './native-chat-file-link' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' @@ -45,8 +49,10 @@ type OwnerState = Pick< | 'unifiedTabsByWorktree' > +type RouteState = Omit + // Subscribe only to ownership inputs; derive context after Zustand filters unrelated updates. -export function selectNativeChatImageOwnerState(state: AppState): OwnerState { +function selectNativeChatImageRouteState(state: AppState): RouteState { return { settings: state.settings, repos: state.repos, @@ -63,7 +69,13 @@ export function selectNativeChatImageOwnerState(state: AppState): OwnerState { sshStateByEnvironment: state.sshStateByEnvironment, activeWorktreeId: state.activeWorktreeId, activeWorkspaceExecutionHostId: state.activeWorkspaceExecutionHostId, - restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + } +} + +export function selectNativeChatImageOwnerState(state: AppState): OwnerState { + return { + ...selectNativeChatImageRouteState(state), tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree } @@ -95,7 +107,19 @@ export function resolveNativeChatImageRuntimeContext( state: OwnerState, tabId: string ): NativeChatImageRuntimeContext { - const linkContext = resolveNativeChatFileLinkContext(state, tabId) + return resolveNativeChatImageRuntimeContextForOwner( + state, + tabId, + findNativeChatTabOwnerWorktreeId(state, tabId) + ) +} + +export function resolveNativeChatImageRuntimeContextForOwner( + state: RouteState & Pick, + tabId: string, + owner: string | null +): NativeChatImageRuntimeContext { + const linkContext = resolveNativeChatFileLinkContextForOwner(state, tabId, owner) if (!linkContext) { return null } @@ -148,7 +172,38 @@ export function resolveNativeChatImageRuntimeContext( return context } +/** Ordinary workspace directories need only the selected owner; floating also needs its tab list. */ +export function createNativeChatImageOwnerSelector(tabId: string) { + const selectOwner = createNativeChatTabOwnerSelector(tabId) + return (state: AppState) => { + const owner = selectOwner(state) + return { + ...selectNativeChatImageRouteState(state), + owner, + floatingTabs: + owner && isFloatingWorkspaceId(owner) && Object.hasOwn(state.unifiedTabsByWorktree, owner) + ? state.unifiedTabsByWorktree[owner] + : undefined + } + } +} + export function useNativeChatImageRuntimeContext(tabId: string): NativeChatImageRuntimeContext { - const ownerState = useAppStore(useShallow(selectNativeChatImageOwnerState)) - return useMemo(() => resolveNativeChatImageRuntimeContext(ownerState, tabId), [ownerState, tabId]) + 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. + const unifiedTabsByWorktree = + ownerState.owner && ownerState.floatingTabs + ? { [ownerState.owner]: ownerState.floatingTabs } + : {} + return resolveNativeChatImageRuntimeContextForOwner( + { + ...ownerState, + unifiedTabsByWorktree + }, + tabId, + ownerState.owner + ) + }, [ownerState, tabId]) } diff --git a/src/renderer/src/components/native-chat/native-chat-store-fanout.perf.test.tsx b/src/renderer/src/components/native-chat/native-chat-store-fanout.perf.test.tsx index 32cb2f731f5..ccfb86bea28 100644 --- a/src/renderer/src/components/native-chat/native-chat-store-fanout.perf.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-store-fanout.perf.test.tsx @@ -6,8 +6,10 @@ import { useAppStore } from '@/store' import { resolveNativeChatDraftOwner } from '@/lib/native-chat-draft-owner' import type { Tab } from '../../../../shared/tab-types' import { testOrcaSessionId } from '../../../../shared/orca-session-address-test-fixture' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import { + nativeChatFanoutFixture, + nativeChatFanoutWorkspace as workspace +} from './native-chat-workspace-test-fixtures' import { useAgentMessageSenderLabel } from './native-chat-agent-message-sender-label' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { useRestoredNativeChatComposerDraftImageCheck } from './native-chat-composer-draft-image-check' @@ -19,7 +21,6 @@ const initial = useAppStore.getInitialState() const originalApi = window.api const reads = { terminal: 0, unified: 0 } let renders = 0 -const workspace = (index: number) => `folder:workspace-${index}` const scope = (index: number) => `agent-session:session-${index}` function counted(map: T, kind: keyof typeof reads): T { return new Proxy(map, { ownKeys: (target) => (reads[kind]++, Reflect.ownKeys(target)) }) @@ -40,42 +41,11 @@ function tab(index: number, workspaces: number): Tab { } } function seed(chats: number, workspaces: number, terminalsPerWorkspace: number) { - const tabsByWorktree: Record = {} - const unifiedTabsByWorktree: Record = {} - const folderWorkspaces: FolderWorkspace[] = [] - for (let index = 0; index < workspaces; index++) { - const id = workspace(index) - tabsByWorktree[id] = Array.from({ length: terminalsPerWorkspace }, (_, slot) => ({ - id: `terminal-${index}-${slot}`, - worktreeId: id, - ptyId: null, - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: slot, - createdAt: 0 - })) - unifiedTabsByWorktree[id] = [] - folderWorkspaces.push({ - id: `workspace-${index}`, - projectGroupId: 'group', - name: id, - folderPath: `/repo/${index}`, - executionHostId: 'local', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: index, - lastActivityAt: 0, - createdAt: 0, - updatedAt: 0 - }) - } - for (let index = 0; index < chats; index++) { - unifiedTabsByWorktree[workspace(index % workspaces)].push(tab(index, workspaces)) - } + const { tabsByWorktree, unifiedTabsByWorktree, folderWorkspaces } = nativeChatFanoutFixture( + chats, + workspaces, + terminalsPerWorkspace + ) useAppStore.setState( { ...initial, diff --git a/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx b/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx new file mode 100644 index 00000000000..45876d084dd --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx @@ -0,0 +1,383 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { NativeChatSkillDiscovery } from './use-native-chat-skills' +import { + useNativeChatImageRuntimeContext, + type NativeChatImageRuntimeContext +} from './native-chat-image-runtime-context' +import { + agentSessionTabFixture, + nativeChatFanoutFixture, + nativeChatFanoutWorkspace as workspace, + terminalTabFixture +} from './native-chat-workspace-test-fixtures' +import { getDefaultSettings } from '../../../../shared/constants' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: rpc })) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatSkillDiscovery: vi.fn() })) + +import { + resetNativeChatSkillDiscoveryCacheForTests, + useNativeChatSkills +} from './use-native-chat-skills' + +type Work = { + terminalRows: number + unifiedRows: number + terminalMapEnumerations: number + unifiedMapEnumerations: number + terminalWorkspaceReads: number + unifiedWorkspaceReads: number +} + +function emptyWork(): Work { + return { + terminalRows: 0, + unifiedRows: 0, + terminalMapEnumerations: 0, + unifiedMapEnumerations: 0, + terminalWorkspaceReads: 0, + unifiedWorkspaceReads: 0 + } +} + +function countRow(row: T, count: () => void): T { + const id = row.id + Object.defineProperty(row, 'id', { + enumerable: true, + get() { + count() + return id + } + }) + return row +} + +function corpus(scale: number, dense = false) { + const mounted = 57 * scale + const workspaces = dense ? 1 : 100 * scale + const work = emptyWork() + const fixture = nativeChatFanoutFixture(mounted, workspaces, 1) + const { tabsByWorktree, unifiedTabsByWorktree } = fixture + const chatIds = Object.values(unifiedTabsByWorktree).flatMap((rows) => rows.map((row) => row.id)) + for (const row of Object.values(tabsByWorktree).flat()) { + countRow(row, () => work.terminalRows++) + } + for (const row of Object.values(unifiedTabsByWorktree).flat()) { + countRow(row, () => work.unifiedRows++) + } + + function terminalMap(map: AppState['tabsByWorktree']) { + return new Proxy(map, { + get(target, key) { + if (typeof key === 'string' && Object.hasOwn(target, key)) { + work.terminalWorkspaceReads += 1 + } + return typeof key === 'string' ? target[key] : undefined + }, + ownKeys(target) { + work.terminalMapEnumerations += 1 + return Reflect.ownKeys(target) + } + }) + } + function unifiedMap(map: AppState['unifiedTabsByWorktree']) { + return new Proxy(map, { + get(target, key) { + if (typeof key === 'string' && Object.hasOwn(target, key)) { + work.unifiedWorkspaceReads += 1 + } + return typeof key === 'string' ? target[key] : undefined + }, + ownKeys(target) { + work.unifiedMapEnumerations += 1 + return Reflect.ownKeys(target) + } + }) + } + + useAppStore.setState({ + ...useAppStore.getInitialState(), + ...fixture, + activeRepoId: null, + activeWorktreeId: workspace(0), + activeWorkspaceExecutionHostId: 'local', + repos: [], + settings: getDefaultSettings('/test-home'), + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + projectGroups: [], + projects: [], + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + tabsByWorktree: terminalMap(tabsByWorktree), + unifiedTabsByWorktree: unifiedMap(unifiedTabsByWorktree) + }) + + return { + mounted, + chatIds, + workspaces, + work, + reset: () => Object.assign(work, emptyWork()), + renameUnrelated(kind: 'terminal' | 'unified', version: number) { + const owner = workspace(workspaces - 1) + const current = useAppStore.getState() + // Prepare the writer's immutable replacement before counting subscriber work. + const patch = + kind === 'terminal' + ? { + tabsByWorktree: terminalMap({ + ...current.tabsByWorktree, + [owner]: [ + countRow( + terminalTabFixture(`terminal-${workspaces - 1}-0`, owner, { + title: `${version}` + }), + () => work.terminalRows++ + ) + ] + }) + } + : { + unifiedTabsByWorktree: unifiedMap({ + ...current.unifiedTabsByWorktree, + [owner]: [ + countRow( + agentSessionTabFixture('unmounted-chat', owner, { + label: `${version}` + }), + () => work.unifiedRows++ + ) + ] + }) + } + Object.assign(work, emptyWork()) + act(() => useAppStore.setState(patch)) + return { ...work } + }, + replaceOwnList(rename: boolean) { + const current = useAppStore.getState() + const list = current.unifiedTabsByWorktree[workspace(0)] + const replacement = unifiedMap({ + ...current.unifiedTabsByWorktree, + [workspace(0)]: rename + ? [countRow({ ...list[0], label: 'Renamed' }, () => work.unifiedRows++), ...list.slice(1)] + : [...list] + }) + Object.assign(work, emptyWork()) + act(() => useAppStore.setState({ unifiedTabsByWorktree: replacement })) + return { ...work } + }, + moveLastChat() { + const source = workspace(mounted - 1) + const destination = workspace(workspaces - 2) + const current = useAppStore.getState() + act(() => + useAppStore.setState({ + unifiedTabsByWorktree: unifiedMap({ + ...current.unifiedTabsByWorktree, + [source]: [], + [destination]: [agentSessionTabFixture(`chat-${mounted - 1}`, destination)] + }) + }) + ) + return destination + } + } +} + +const initialState = useAppStore.getState() +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + resetNativeChatSkillDiscoveryCacheForTests() + rpc.mockReset() +}) + +function measureImageSubscriptions(scale: number) { + const data = corpus(scale) + const contexts = new Map() + let renders = 0 + function Probe({ chatId }: { chatId: string }) { + contexts.set(chatId, useNativeChatImageRuntimeContext(chatId)) + renders += 1 + return null + } + const view = render(data.chatIds.map((chatId) => )) + expect(contexts.size).toBe(data.mounted) + for (let index = 0; index < data.mounted; index += 1) { + expect(contexts.get(`chat-${index}`)?.worktreePath).toBe(`/repo/${index}`) + } + const baseline = renders + const rounds: Work[] = [] + // Separate writes ensure a warm selector cache cannot hide a map invalidation. + for (let version = 0; version < 3; version += 1) { + rounds.push(data.renameUnrelated('terminal', version)) + rounds.push(data.renameUnrelated('unified', version)) + } + expect(renders - baseline).toBe(0) + + data.reset() + act(() => useAppStore.setState({ agentStatusByPaneKey: {} })) + expect(data.work).toEqual(emptyWork()) + expect(renders - baseline).toBe(0) + + const destination = data.moveLastChat() + expect(contexts.get(`chat-${data.mounted - 1}`)).toMatchObject({ + worktreeId: destination, + worktreePath: `/repo/${data.workspaces - 2}`, + target: { kind: 'local' } + }) + expect(renders - baseline).toBe(1) + view.unmount() + return { + mounted: data.mounted, + workspaces: data.workspaces, + canonicalRows: { terminal: data.workspaces, unified: data.mounted + 1 }, + unrelatedRenders: 0, + rounds + } +} + +describe('native chat store subscription performance', () => { + it('does no tab work for closed pickers at N and 10N, then discovers the latest owner on activation', async () => { + rpc.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + for (const scale of [1, 10]) { + const pending = Promise.withResolvers() + rpc.mockReturnValueOnce(pending.promise) + const data = corpus(scale) + const lastChatId = `chat-${data.mounted - 1}` + const snapshots = new Map() + let renders = 0 + function Probe({ chatId, enabled }: { chatId: string; enabled: boolean }) { + snapshots.set(chatId, useNativeChatSkills('codex', chatId, enabled)) + renders += 1 + return null + } + const probes = (enabled: boolean) => + data.chatIds.map((chatId) => ( + + )) + const view = render(probes(false)) + const baseline = renders + for (const kind of ['terminal', 'unified'] as const) { + expect(data.renameUnrelated(kind, 1)).toEqual(emptyWork()) + } + const destination = data.moveLastChat() + expect(renders).toBe(baseline) + expect(rpc).not.toHaveBeenCalled() + + view.rerender(probes(true)) + expect(snapshots.get(lastChatId)?.status).toBe('loading') + expect(rpc).toHaveBeenCalledExactlyOnceWith( + { kind: 'local' }, + 'skills.discover', + expect.objectContaining({ cwd: `/repo/${data.workspaces - 2}`, worktreeId: destination }), + { timeoutMs: 10_000 } + ) + view.rerender(probes(false)) + const current = useAppStore.getState() + act(() => + useAppStore.setState({ + folderWorkspaces: current.folderWorkspaces.map((folder) => + `folder:${folder.id}` === destination + ? { + ...folder, + folderPath: '/latest-owner-path', + executionHostId: 'runtime:latest' as const + } + : folder + ) + }) + ) + expect(snapshots.get(lastChatId)?.status).toBe('idle') + view.rerender(probes(true)) + await waitFor(() => expect(snapshots.get(lastChatId)?.status).toBe('ready')) + expect(rpc).toHaveBeenLastCalledWith( + { kind: 'environment', environmentId: 'latest' }, + 'skills.discover', + { cwd: '/latest-owner-path', worktreeId: destination }, + { timeoutMs: 10_000 } + ) + await act(async () => pending.reject(new Error('Old owner request failed'))) + expect(snapshots.get(lastChatId)?.status).toBe('ready') + view.unmount() + rpc.mockClear() + resetNativeChatSkillDiscoveryCacheForTests() + } + }) + + it('benchmarks image-owner scans separately from renders at N and 10N', () => { + const results = [1, 10].map(measureImageSubscriptions) + for (const result of results) { + for (const [index, work] of result.rounds.entries()) { + // One shared index build per changed map, never a population scan per mounted chat. + expect(work.terminalRows).toBeLessThanOrEqual( + index % 2 === 0 ? result.canonicalRows.terminal : 0 + ) + expect(work.unifiedRows).toBeLessThanOrEqual( + index % 2 === 1 ? result.canonicalRows.unified : 0 + ) + expect(work.terminalMapEnumerations).toBeLessThanOrEqual(index % 2 === 0 ? 1 : 0) + expect(work.unifiedMapEnumerations).toBeLessThanOrEqual(index % 2 === 1 ? 1 : 0) + expect(work.terminalWorkspaceReads).toBeLessThanOrEqual( + index % 2 === 0 ? result.workspaces : 0 + ) + expect(work.unifiedWorkspaceReads).toBeLessThanOrEqual( + index % 2 === 1 ? result.workspaces : 0 + ) + } + } + console.info('native-chat-subscription-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 + mount: Work + updates: Work[] + updateRenders: number + }[] = [] + for (const scale of [1, 10]) { + const data = corpus(scale, true) + let renders = 0 + function Probe({ chatId }: { chatId: string }) { + const context = useNativeChatImageRuntimeContext(chatId) + expect(context?.worktreePath).toBe('/repo/0') + renders += 1 + return null + } + data.reset() + const view = render(data.chatIds.map((chatId) => )) + const mount = { ...data.work } + // All canonical rows may be indexed once. Private per-hook maps would cost N + N². + expect(mount.terminalRows).toBeLessThanOrEqual(1) + expect(mount.unifiedRows).toBeLessThanOrEqual(data.mounted) + expect(mount.terminalMapEnumerations).toBeLessThanOrEqual(1) + expect(mount.unifiedMapEnumerations).toBeLessThanOrEqual(1) + expect(renders).toBe(data.mounted) + const baseline = renders + const updates = [data.replaceOwnList(true), data.replaceOwnList(false)] + for (const work of updates) { + expect(work.terminalRows).toBe(0) + expect(work.unifiedRows).toBeLessThanOrEqual(data.mounted) + expect(work.unifiedMapEnumerations).toBeLessThanOrEqual(1) + expect(work.unifiedWorkspaceReads).toBeLessThanOrEqual(1) + } + expect(renders - baseline).toBe(0) + results.push({ mounted: data.mounted, mount, updates, updateRenders: renders - baseline }) + view.unmount() + } + console.info('native-chat-dense-subscription-work', JSON.stringify(results)) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-workspace-test-fixtures.ts b/src/renderer/src/components/native-chat/native-chat-workspace-test-fixtures.ts index b64ad70ed25..c3f8c45ed8a 100644 --- a/src/renderer/src/components/native-chat/native-chat-workspace-test-fixtures.ts +++ b/src/renderer/src/components/native-chat/native-chat-workspace-test-fixtures.ts @@ -3,6 +3,55 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' import type { AppState } from '@/store/types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' + +export const nativeChatFanoutWorkspace = (index: number) => `folder:workspace-${index}` + +export function nativeChatFanoutFixture( + chats: number, + workspaces: number, + terminalsPerWorkspace: number +) { + const tabsByWorktree: AppState['tabsByWorktree'] = {} + const unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] = {} + const folderWorkspaces: FolderWorkspace[] = [] + for (let index = 0; index < workspaces; index++) { + const id = nativeChatFanoutWorkspace(index) + tabsByWorktree[id] = Array.from({ length: terminalsPerWorkspace }, (_, slot) => + terminalTabFixture(`terminal-${index}-${slot}`, id, { sortOrder: slot }) + ) + unifiedTabsByWorktree[id] = [] + folderWorkspaces.push({ + id: `workspace-${index}`, + projectGroupId: 'group', + name: id, + folderPath: `/repo/${index}`, + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: index, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + }) + } + for (let index = 0; index < chats; index++) { + const id = nativeChatFanoutWorkspace(index % workspaces) + unifiedTabsByWorktree[id].push( + agentSessionTabFixture(`chat-${index}`, id, { + entityId: `session-${index}`, + executionHostId: 'local', + label: `Chat ${index}`, + customLabel: `Name ${index}`, + sortOrder: index + }) + ) + } + return { tabsByWorktree, unifiedTabsByWorktree, folderWorkspaces } +} export function terminalTabFixture( id: string, diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index 79e9e4f74e7..9c93ac0a0b0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -119,6 +119,24 @@ describe('useNativeChatSkills', () => { ) }) + it('reads no tab maps while the picker is closed', () => { + let tabMapReads = 0 + const base = stateForHost('local') + mocks.state = Object.defineProperties( + { ...base }, + { + tabsByWorktree: { get: () => (tabMapReads++, base.tabsByWorktree) }, + unifiedTabsByWorktree: { get: () => (tabMapReads++, base.unifiedTabsByWorktree) } + } + ) + const view = render() + view.rerender() + + // Every mounted chat holds this hook; tab changes must not reach closed pickers. + expect(tabMapReads).toBe(0) + expect(mocks.snapshots.at(-1)?.status).toBe('idle') + }) + it('resolves the catalog for a structured session tab', async () => { mocks.state = { ...stateForHost('local'), diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index 01c11910eb8..b05e681968d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -58,6 +58,7 @@ const MISSING_CONTEXT_STATE: StoredDiscoveryState = { errorKind: 'unknown', contextKey: null } +const selectNoSkillStateInputs = (): null => null const inFlightDiscovery = new Map>() export function isNativeChatSkillForAgent( @@ -88,13 +89,19 @@ export function useNativeChatSkills( terminalTabId: string, enabled = false ): NativeChatSkillDiscovery { - const inputs = useAppStore(useShallow(selectNativeChatSkillStateInputs)) + // Closed is the common state, so the tab-map subscription only runs while the picker is open. + const inputs = useAppStore( + useShallow(enabled ? selectNativeChatSkillStateInputs : selectNoSkillStateInputs) + ) const context = useMemo( - () => resolveNativeChatSkillDiscoveryContext(inputs, terminalTabId), + () => (inputs ? resolveNativeChatSkillDiscoveryContext(inputs, terminalTabId) : null), [inputs, terminalTabId] ) const awaitingContext = useMemo( - () => !context && isNativeChatSkillDiscoveryAwaitingDirectory(inputs, terminalTabId), + () => + inputs !== null && + !context && + isNativeChatSkillDiscoveryAwaitingDirectory(inputs, terminalTabId), [context, inputs, terminalTabId] ) const [state, setState] = useState(IDLE_STATE)