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)