mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
Stop hidden chats re-rendering on every tab change (#34020)
* perf(native-chat): stop hidden chats re-rendering on every tab change Skill discovery now subscribes to tab and workspace state only while the slash/skill picker is open, and the per-chat image owner subscription reads only its own workspace's tabs. Previously every mounted chat re-rendered and rescanned the tab maps on any tab change anywhere. * Resolve native chat image context from its selected owner * Cover selected chat image owners through preview consumers * Use typed reads in chat subscription performance fixture * Key chat subscription probes by fixture chat identity * Type dense chat subscription measurement results * Name the read-only floating chat resolver view * Exercise document markdown in selected-owner preview test * Check unavailable chat image through its preview dialog
This commit is contained in:
@@ -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(
|
||||
<TooltipProvider>
|
||||
<NativeChatMarkdownImageOwnerProvider tabId="chat-0" isVisible sessionId="session-0">
|
||||
<NativeChatMarkdown content="" variant="document" allowFileUriLinks />
|
||||
</NativeChatMarkdownImageOwnerProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
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'],
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
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[] = []
|
||||
|
||||
@@ -88,8 +88,8 @@ export function createNativeChatFileLinkContextSelector(tabId: string) {
|
||||
resolveNativeChatFileLinkContextForOwner(state, tabId, selectOwner(state))
|
||||
}
|
||||
|
||||
function resolveNativeChatFileLinkContextForOwner(
|
||||
state: NativeChatFileLinkState,
|
||||
export function resolveNativeChatFileLinkContextForOwner(
|
||||
state: Omit<NativeChatFileLinkState, 'tabsByWorktree'>,
|
||||
terminalTabId: string,
|
||||
worktreeId: string | null
|
||||
): NativeChatFileLinkContext | null {
|
||||
|
||||
@@ -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> = {}): 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<NonNullable<NativeChatImageRuntimeContext>> | 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<OwnerState, 'tabsByWorktree' | 'unifiedTabsByWorktree'>
|
||||
|
||||
// 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<OwnerState, 'unifiedTabsByWorktree'>,
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -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<T extends object>(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<string, TerminalTab[]> = {}
|
||||
const unifiedTabsByWorktree: Record<string, Tab[]> = {}
|
||||
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,
|
||||
|
||||
+383
@@ -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<T extends { id: string }>(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<string, NativeChatImageRuntimeContext>()
|
||||
let renders = 0
|
||||
function Probe({ chatId }: { chatId: string }) {
|
||||
contexts.set(chatId, useNativeChatImageRuntimeContext(chatId))
|
||||
renders += 1
|
||||
return null
|
||||
}
|
||||
const view = render(data.chatIds.map((chatId) => <Probe key={chatId} chatId={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<never>()
|
||||
rpc.mockReturnValueOnce(pending.promise)
|
||||
const data = corpus(scale)
|
||||
const lastChatId = `chat-${data.mounted - 1}`
|
||||
const snapshots = new Map<string, NativeChatSkillDiscovery>()
|
||||
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) => (
|
||||
<Probe key={chatId} chatId={chatId} enabled={enabled && chatId === lastChatId} />
|
||||
))
|
||||
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) => <Probe key={chatId} chatId={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))
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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(<Probe enabled={false} />)
|
||||
view.rerender(<Probe enabled={false} />)
|
||||
|
||||
// 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'),
|
||||
|
||||
@@ -58,6 +58,7 @@ const MISSING_CONTEXT_STATE: StoredDiscoveryState = {
|
||||
errorKind: 'unknown',
|
||||
contextKey: null
|
||||
}
|
||||
const selectNoSkillStateInputs = (): null => null
|
||||
const inFlightDiscovery = new Map<string, Promise<SkillDiscoveryResult>>()
|
||||
|
||||
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<StoredDiscoveryState>(IDLE_STATE)
|
||||
|
||||
Reference in New Issue
Block a user