diff --git a/.oxlintrc.json b/.oxlintrc.json index ee88edaa4bbb..79be715ca1ea 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -153,6 +153,14 @@ { "property": "randomUUID", "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'." + }, + { + "property": "endpointProjects", + "message": "Read canonical affiliation through project-host-setup-selector; endpoint facts are reserved for ingestion and concrete operation adapters." + }, + { + "property": "endpointProjectHostSetups", + "message": "Read canonical affiliation through project-host-setup-selector; endpoint facts are reserved for ingestion and concrete operation adapters." } ] } @@ -207,6 +215,39 @@ "rules": { "max-lines": ["error", { "max": 800, "skipBlankLines": true, "skipComments": true }] } + }, + { + "files": [ + "src/renderer/src/store/project-host-setup-selector.ts", + "src/renderer/src/store/project-search-catalog.ts", + "src/renderer/src/store/repos/repo-catalog-identity.ts", + "src/renderer/src/store/repos/repo-catalog-actions.ts", + "src/renderer/src/store/repos/runtime-repo-catalog-actions.ts", + "src/renderer/src/store/repos/all-host-repo-catalog-actions.ts", + "src/renderer/src/store/repos/repo-add-actions.ts", + "src/renderer/src/store/repos/repo-update.ts", + "src/renderer/src/store/repos/repo-removal.ts", + "src/renderer/src/store/projects/project-host-setup-actions.ts", + "src/renderer/src/store/projects/project-update.ts", + "src/renderer/src/store/project-groups/project-group-mutations.ts", + "src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts", + "src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts", + "src/renderer/src/app-shell/use-runtime-graph-sync.ts", + "src/renderer/src/**/*.test.{ts,tsx}", + "src/renderer/src/**/*.spec.{ts,tsx}", + "src/renderer/src/**/*-test-*.ts", + "src/renderer/src/**/*.test-fixture.ts", + "src/renderer/src/components/automations/automations-page-runtime-fixtures.ts" + ], + "rules": { + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'." + } + ] + } } ], "ignorePatterns": [ diff --git a/config/oxlint-js-plugins.json b/config/oxlint-js-plugins.json index 4de864fc0477..a6c8cf584cc3 100644 --- a/config/oxlint-js-plugins.json +++ b/config/oxlint-js-plugins.json @@ -38,6 +38,7 @@ { "files": ["**/src/renderer/src/**/*.{ts,tsx}"], "rules": { + "app-store-performance/no-raw-project-catalog-computed": "error", "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error", "ime-enter-guard/require-ime-guard": "error" } @@ -49,6 +50,32 @@ "renderer-scrollbar-style/require-styled-vertical-scrollbar": "off", "ime-enter-guard/require-ime-guard": "off" } + }, + { + "files": [ + "src/renderer/src/store/project-host-setup-selector.ts", + "src/renderer/src/store/repos/repo-catalog-identity.ts", + "src/renderer/src/store/repos/repo-catalog-actions.ts", + "src/renderer/src/store/repos/runtime-repo-catalog-actions.ts", + "src/renderer/src/store/repos/all-host-repo-catalog-actions.ts", + "src/renderer/src/store/repos/repo-add-actions.ts", + "src/renderer/src/store/repos/repo-update.ts", + "src/renderer/src/store/repos/repo-removal.ts", + "src/renderer/src/store/projects/project-host-setup-actions.ts", + "src/renderer/src/store/projects/project-update.ts", + "src/renderer/src/store/project-groups/project-group-mutations.ts", + "src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts", + "src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts", + "src/renderer/src/app-shell/use-runtime-graph-sync.ts", + "src/renderer/src/**/*.test.{ts,tsx}", + "src/renderer/src/**/*.spec.{ts,tsx}", + "src/renderer/src/**/*-test-*.ts", + "src/renderer/src/**/*.test-fixture.ts", + "src/renderer/src/components/automations/automations-page-runtime-fixtures.ts" + ], + "rules": { + "app-store-performance/no-raw-project-catalog-computed": "off" + } } ], "ignorePatterns": ["**/node_modules", "**/dist", "**/out"] diff --git a/config/oxlint-plugins/app-store-performance.mjs b/config/oxlint-plugins/app-store-performance.mjs index 857d7903dd9d..e59f82f58b7d 100644 --- a/config/oxlint-plugins/app-store-performance.mjs +++ b/config/oxlint-plugins/app-store-performance.mjs @@ -354,12 +354,39 @@ function noNestedFreshUnderShallowRule(sourceCode) { }) } +function noRawProjectCatalogComputedRule(sourceCode) { + return { + MemberExpression(node) { + if (!node.computed || node.property.type !== 'Identifier') { + return + } + const definition = variableFor(node.property, { sourceCode })?.defs[0] + const declaration = definition?.node + if ( + definition?.type === 'Variable' && + declaration.parent.kind === 'const' && + declaration.init?.type === 'Literal' && + ['endpointProjects', 'endpointProjectHostSetups'].includes(declaration.init.value) + ) { + this.report({ + node, + message: 'Read canonical project affiliation through the project catalog selector.' + }) + } + } + } +} + // Skip JS AST deserialization in files that cannot name a store hook. const STORE_HOOK_TEXT = /use[A-Z][A-Za-z0-9]*Store/ function bindContext(createVisitors) { return (context) => { - if (!STORE_HOOK_TEXT.test(context.sourceCode.text) && !context.sourceCode.text.includes('\\')) { + if ( + createVisitors !== noRawProjectCatalogComputedRule && + !STORE_HOOK_TEXT.test(context.sourceCode.text) && + !context.sourceCode.text.includes('\\') + ) { return {} } const visitors = createVisitors(context.sourceCode) @@ -374,6 +401,7 @@ export default { meta: { name: 'app-store-performance' }, rules: { 'require-selector': { create: bindContext(requireSelectorRule) }, + 'no-raw-project-catalog-computed': { create: bindContext(noRawProjectCatalogComputedRule) }, 'no-identity-selector': { create: bindContext(noIdentitySelectorRule) }, 'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) }, 'no-nested-fresh-under-shallow': { create: bindContext(noNestedFreshUnderShallowRule) } diff --git a/config/scripts/project-catalog-read-boundary.test.mjs b/config/scripts/project-catalog-read-boundary.test.mjs new file mode 100644 index 000000000000..1e85163313f3 --- /dev/null +++ b/config/scripts/project-catalog-read-boundary.test.mjs @@ -0,0 +1,102 @@ +import { readFileSync, mkdtempSync, realpathSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve, join, dirname } from 'node:path' +import { spawnSync } from 'node:child_process' +import { expect, it } from 'vitest' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' + +it('enforces five raw catalog forms with the actual config and narrow file exceptions', () => { + const config = JSON.parse(readFileSync(resolve('.oxlintrc.json'), 'utf8')) + const jsPluginConfig = JSON.parse(readFileSync(resolve('config/oxlint-js-plugins.json'), 'utf8')) + expect(config.ignorePatterns.every((pattern) => typeof pattern === 'string')).toBe(true) + const folder = realpathSync(mkdtempSync(join(tmpdir(), 'sta-10541-catalog-rule-'))) + const source = ` + const key = 'projects'; + function read(state) { + state.endpointProjects; + state?.endpointProjectHostSetups; + const { endpointProjects: projects } = state; + state['endpointProjectHostSetups']; + const key = 'endpointProjects'; state[key]; + { const key = 'projects'; state[key]; } + function shadow(key) { return state[key]; } + const unrelated = 'projectHostSetups'; state[unrelated]; + state.projects; + state.projectHostSetups; + return projects; + } + ` + try { + writeFileSync(join(folder, 'config.json'), JSON.stringify(config)) + writeFileSync( + join(folder, 'js-config.json'), + JSON.stringify({ + ...jsPluginConfig, + jsPlugins: jsPluginConfig.jsPlugins.map((plugin) => ({ + ...plugin, + specifier: resolve('config', plugin.specifier) + })) + }) + ) + const lint = (file, content = source) => { + const target = join(folder, file) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, content) + return ['config.json', 'js-config.json'].flatMap((configFile) => { + const invocation = resolveOxcCliInvocation('oxlint', 'oxlint') + const result = spawnSync( + invocation.command, + [ + ...invocation.prefixArgs, + '--config', + join(folder, configFile), + '--format', + 'json', + file + ], + { cwd: folder, encoding: 'utf8' } + ) + expect(result.error).toBeUndefined() + const diagnostics = JSON.parse(result.stdout).diagnostics + expect(result.stderr).toBe('') + expect(JSON.parse(result.stdout).number_of_files, result.stdout).toBe(1) + return diagnostics.filter( + (entry) => + entry.code.includes('no-restricted-properties') || + entry.code.includes('no-raw-project-catalog-computed') + ) + }) + } + for (const file of [ + 'src/renderer/src/hooks/useActiveProjectSkillRuntime.ts', + 'src/renderer/src/store/repos/repo-state.ts' + ]) { + const forbidden = lint(file) + expect(forbidden).toHaveLength(5) + expect( + forbidden.filter((entry) => entry.code.includes('no-restricted-properties')) + ).toHaveLength(4) + expect( + forbidden.filter((entry) => entry.code.includes('no-raw-project-catalog-computed')) + ).toHaveLength(1) + } + const allowed = 'src/renderer/src/store/project-host-setup-selector.ts' + expect(lint(allowed)).toEqual([]) + const uuid = lint(allowed, 'crypto.randomUUID()') + expect(uuid).toHaveLength(1) + expect(uuid[0].message).toContain('randomUUID') + for (const file of [ + 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts', + 'src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts', + 'mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts', + 'tests/e2e/worktree-smart-sort.spec.ts' + ]) { + expect(lint(file, 'crypto.randomUUID()')).toEqual([]) + } + const sharedUuid = lint('src/shared/tmux-status.test-fixture.ts', 'crypto.randomUUID()') + expect(sharedUuid).toHaveLength(1) + expect(sharedUuid[0].help).toContain('src/shared is compiled into the web bundle') + } finally { + rmSync(folder, { recursive: true, force: true }) + } +}) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 8f82f9faa73f..24fdd4ab592d 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { Keyboard } from 'react-native' import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host' -import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' +import { getRepoHostIdentity } from '../../../src/shared/repo-host-identity' import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' @@ -22,6 +22,7 @@ import { type NewWorktreeModalProps } from './new-worktree-modal-types' import { + getNewWorkspaceProjectIds, buildNewWorkspaceProjectOptions, buildNewWorkspaceRunTargetOptions, getNewWorkspaceRunTarget @@ -178,13 +179,26 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { })), [hostRepos] ) - const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos]) - const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null + const projectIds = useMemo(() => getNewWorkspaceProjectIds(repos), [repos]) + const projectPickerItems = useMemo( + () => buildNewWorkspaceProjectOptions(repos, projectIds), + [repos, projectIds] + ) + const selectedProjectId = selectedRepo + ? (projectIds.get(getRepoHostIdentity(selectedRepo)) ?? null) + : null const selectedProject = projectPickerItems.find((project) => project.id === selectedProjectId) ?? null const runTargetPickerItems = useMemo( - () => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform, hostLabelById), - [hostLabelById, hostPlatform, repos, selectedProjectId] + () => + buildNewWorkspaceRunTargetOptions( + repos, + selectedProjectId, + hostPlatform, + hostLabelById, + projectIds + ), + [hostLabelById, hostPlatform, repos, selectedProjectId, projectIds] ) const selectedRunTarget = selectedRepo ? getNewWorkspaceRunTarget(selectedRepo, hostPlatform, hostLabelById) diff --git a/mobile/src/components/new-workspace-project-targets.test.ts b/mobile/src/components/new-workspace-project-targets.test.ts index bfccb682ef95..993a4927aee7 100644 --- a/mobile/src/components/new-workspace-project-targets.test.ts +++ b/mobile/src/components/new-workspace-project-targets.test.ts @@ -75,7 +75,7 @@ describe('new workspace project targets', () => { }) }) - it('shows one target per host when the project has multiple local worktrees', () => { + it('keeps each same-host clone selectable with its concrete target', () => { const upstream = { owner: 'stablyai', repo: 'orca' } const repos = [ { id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream }, @@ -88,7 +88,10 @@ describe('new workspace project targets', () => { upstream } ] - const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null + const projects = buildNewWorkspaceProjectOptions(repos) + expect(projects.map((project) => project.id)).toEqual(['github:stablyai/orca', 'repo:local-b']) + expect(buildNewWorkspaceRunTargetOptions(repos, 'repo:local-b')[0]?.repo).toBe(repos[1]) + const projectId = projects[0]?.id ?? null expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([ expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }), @@ -99,4 +102,13 @@ describe('new workspace project targets', () => { }) ]) }) + it('uses the complete claim catalog before hiding ineligible create targets', () => { + const upstream = { owner: 'acme', repo: 'app' } + const repos = [ + { id: 'a', displayName: 'A', path: '', addedAt: 1, upstream }, + { id: 'b', displayName: 'B', path: '/b', addedAt: 2, upstream } + ] + expect(buildNewWorkspaceProjectOptions(repos).map((project) => project.id)).toEqual(['repo:b']) + expect(buildNewWorkspaceRunTargetOptions(repos, 'repo:b')[0]?.repo).toBe(repos[1]) + }) }) diff --git a/mobile/src/components/new-workspace-project-targets.ts b/mobile/src/components/new-workspace-project-targets.ts index c3f5d436a40f..092f7c2e2635 100644 --- a/mobile/src/components/new-workspace-project-targets.ts +++ b/mobile/src/components/new-workspace-project-targets.ts @@ -1,16 +1,42 @@ import type { Repo } from '../../../src/shared/repo-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' import { - getProjectIdentityKey, + projectHostSetupProjectionFromRepos, getProjectProviderIdentity } from '../../../src/shared/project-host-setup-projection' +import { getRepoHostIdentity } from '../../../src/shared/repo-host-identity' import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels' type WorkspaceRepo = Pick & Partial< - Pick + Pick< + Repo, + | 'connectionId' + | 'executionHostId' + | 'upstream' + | 'repoIcon' + | 'gitRemoteIdentity' + | 'addedAt' + | 'projectHostSetupMethod' + | 'kind' + > > +export function getNewWorkspaceProjectIds( + repos: readonly WorkspaceRepo[] +): ReadonlyMap { + const projection = projectHostSetupProjectionFromRepos( + repos.map((repo) => ({ + badgeColor: '', + addedAt: 0, + ...repo + })) + ) + return new Map( + projection.setups.map((setup, index) => [getRepoHostIdentity(repos[index]), setup.projectId]) + ) +} + export type NewWorkspaceProjectOption = { id: string label: string @@ -27,12 +53,16 @@ export type NewWorkspaceRunTargetOption = { } export function buildNewWorkspaceProjectOptions( - repos: readonly TRepo[] + repos: readonly TRepo[], + projectIds = getNewWorkspaceProjectIds(repos) ): NewWorkspaceProjectOption[] { const options = new Map>() const hostIdsByProject = new Map>() for (const repo of repos) { - const id = getProjectIdentityKey(repo) + if (!repo.path) { + continue + } + const id = projectIds.get(getRepoHostIdentity(repo))! const hostIds = hostIdsByProject.get(id) ?? new Set() hostIds.add(getRepoExecutionHostId(repo)) hostIdsByProject.set(id, hostIds) @@ -80,14 +110,15 @@ export function buildNewWorkspaceRunTargetOptions( repos: readonly TRepo[], projectId: string | null, localPlatform: NodeJS.Platform | null = null, - hostLabelById?: ReadonlyMap + hostLabelById?: ReadonlyMap, + projectIds = getNewWorkspaceProjectIds(repos) ): NewWorkspaceRunTargetOption[] { if (!projectId) { return [] } const options = new Map>() for (const repo of repos) { - if (getProjectIdentityKey(repo) !== projectId) { + if (!repo.path || projectIds.get(getRepoHostIdentity(repo)) !== projectId) { continue } const hostId = getRepoExecutionHostId(repo) diff --git a/mobile/src/components/new-worktree-modal-types.ts b/mobile/src/components/new-worktree-modal-types.ts index b943d06b6080..1bc89181938e 100644 --- a/mobile/src/components/new-worktree-modal-types.ts +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -8,6 +8,8 @@ export type MobileWorkspaceRepo = Pick `${getRepoExecutionHostId(repo)}|${repo.id}` const iconByKey = new Map(desktopRepos.map((repo) => [iconKey(repo), repo.repoIcon])) diff --git a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts index 4f88a9f2f32f..ca0bae1eb1ce 100644 --- a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts +++ b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts @@ -1,8 +1,18 @@ +import { buildMobileProjectSectionCatalog } from '../../../src/shared/mobile-project-sections-contract' +import { resolveProjectHostSetupProjection } from '../../../src/shared/project-catalog-canonical-projection' +import { projectHostSetupProjectionFromRepos } from '../../../src/shared/project-host-setup-projection' +import { + buildProjectGroupingIndex, + getProjectGroupingForRepo +} from '../../../src/shared/project-section-grouping' +import type { Repo } from '../../../src/shared/repo-types' +import { resolveServedProjectSectionClaims } from '../../../src/shared/project-section-catalog-claims' import { NO_WORKSPACE_ROW_FILTERS } from '../../../src/shared/workspace-view/workspace-row-filters' import { describe, expect, it } from 'vitest' import { getRepoExecutionHostId } from '../../../src/shared/execution-host' import { PROJECT_SECTION_PARITY_HEADERS, + OLD_PROJECT_SECTION_PARITY_SERVED, PROJECT_SECTION_PARITY_ROWS, PROJECT_SECTION_PARITY_SERVED } from '../../../src/shared/project-section-parity.test-fixture' @@ -74,6 +84,36 @@ describe('Project grouping on the phone', () => { } ) + it.each(['recent', 'manual'] as const)( + 'normalizes an accepted old %s snapshot without losing independent clones or concrete targets', + (order) => { + const verdict = projectSectionsRead.interpret({ + id: 'old', + ok: true, + result: { catalog: OLD_PROJECT_SECTION_PARITY_SERVED } + }) + expect(verdict.accepted).toBe(true) + if (!verdict.accepted || !verdict.value) { + throw new Error('old catalog refused') + } + const canonical = resolveServedProjectSectionClaims(verdict.value) + expect(resolveServedProjectSectionClaims(canonical)).toBe(canonical) + const catalog = buildProjectSectionCatalog(verdict.value, [], null, order) + expect(headers(catalog)).toEqual(PROJECT_SECTION_PARITY_HEADERS[order]) + expect(catalog.repos.map((repo) => repo.id)).toEqual( + PROJECT_SECTION_PARITY_SERVED.repos.map((repo) => repo.id) + ) + expect( + canonical.setups.map(({ repoId, hostId, path }) => ({ repoId, hostId, path })) + ).toEqual(verdict.value.setups.map(({ repoId, hostId, path }) => ({ repoId, hostId, path }))) + expect( + canonical.setups + .filter((setup) => setup.projectId.startsWith('repo:')) + .map((setup) => setup.repoId) + ).toEqual(['local-assets', 'local-workspace', 'm4-orca-two']) + } + ) + it("groups by repo from an older desktop's own repos when it serves no catalog", () => { const desktopRepos = PROJECT_SECTION_PARITY_SERVED.repos.filter( (repo) => repo.executionHostId === 'local' @@ -144,3 +184,93 @@ describe('readServedProjectSections', () => { expect(readServedProjectSections(null)).toEqual({ kind: 'kept' }) }) }) + +it('keeps published desktop affiliation through phone parsing when a setup has no registered owner', () => { + const repo: Repo = { + id: 'a', + path: '/a', + displayName: 'A', + badgeColor: '', + addedAt: 1, + gitRemoteIdentity: { + canonicalKey: 'github.com/acme/app', + remoteName: 'origin', + remoteUrl: 'https://github.com/acme/app.git' + } + } + const derived = projectHostSetupProjectionFromRepos([repo]) + const firstSetup = derived.setups[0] + if (!firstSetup) { + throw new Error('no derived setup') + } + const orphan = { + ...firstSetup, + id: 'z', + repoId: 'z', + path: '/z', + displayName: 'Z', + createdAt: 0, + updatedAt: 0 + } + const projection = resolveProjectHostSetupProjection([repo], derived.projects, [ + ...derived.setups, + orphan + ]) + const published = buildMobileProjectSectionCatalog([repo], projection, 'manual') + const repoMap = new Map([[repo.id, repo]]) + const desktop = getProjectGroupingForRepo( + 'a', + repoMap, + buildProjectGroupingIndex({ + projects: published.projects, + projectHostSetups: published.setups + }) + ) + expect(desktop.key).toBe(`project:${derived.projects[0].id}`) + for (const extraRepos of [[], [{ id: 'z', displayName: 'Z', path: 42, addedAt: 0 }]]) { + const verdict = projectSectionsRead.interpret({ + id: 'sections', + ok: true, + result: { catalog: { ...published, repos: [...published.repos, ...extraRepos] } } + }) + if (!verdict.accepted || !verdict.value) { + throw new Error('catalog refused') + } + expect(verdict.value.repos.map((row) => row.id)).toEqual(['a']) + expect(verdict.value.setups.map((row) => row.repoId)).toEqual(['a', 'z']) + const phone = buildProjectSectionCatalog(verdict.value, [], null, 'manual') + if (!phone.grouping) { + throw new Error('No served project grouping') + } + expect( + getProjectGroupingForRepo('a', repoMap, buildProjectGroupingIndex(phone.grouping)).key + ).toBe(desktop.key) + expect( + phone.grouping?.projectHostSetups?.map(({ repoId, hostId, path }) => ({ + repoId, + hostId, + path + })) + ).toEqual(verdict.value.setups.map(({ repoId, hostId, path }) => ({ repoId, hostId, path }))) + } + const old = projectSectionsRead.interpret({ + id: 'old', + ok: true, + result: { + catalog: { + ...published, + repos: [...published.repos, { id: 'z', displayName: 'Z', path: '/z' }] + } + } + }) + if (!old.accepted || !old.value) { + throw new Error('old catalog refused') + } + const phone = buildProjectSectionCatalog(old.value, [], null, 'manual') + if (!phone.grouping) { + throw new Error('No served project grouping') + } + expect( + getProjectGroupingForRepo('a', repoMap, buildProjectGroupingIndex(phone.grouping)).key + ).toBe('project:repo:a') +}) diff --git a/src/main/ai-vault-search/session-search-current-owner.test.ts b/src/main/ai-vault-search/session-search-current-owner.test.ts new file mode 100644 index 000000000000..367e74e52610 --- /dev/null +++ b/src/main/ai-vault-search/session-search-current-owner.test.ts @@ -0,0 +1,414 @@ +import { mkdtemp, mkdir, symlink, rm, realpath } from 'node:fs/promises' +import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' +import { AI_VAULT_CANONICAL_PROJECT_SEARCH_CAPABILITY } from '../../shared/ai-vault-runtime-capabilities' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection' +import { + compileSessionSearchProjectOwnership, + sessionSearchProjectOwns +} from './session-search-project-ownership' +import { sessionSearchScopeCatalogFromStore } from './session-search-store-scope-catalog' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from './session-search-scope-catalog' +import { createSessionSearchService } from './session-search-service' +import { createChildSessionSearchService } from './session-search-child-service' +import { searchSessionService, setSessionSearchService } from './session-search-service-registry' +import { + openSessionSearchHarness, + addSyntheticSession, + markFork, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +const a: Repo = { + id: 'a', + displayName: 'app', + path: '/repos/a', + addedAt: 1, + badgeColor: '', + gitRemoteIdentity: { + canonicalKey: 'example.org/team/app', + remoteName: 'origin', + remoteUrl: 'https://example.org/team/app.git' + } +} +const b: Repo = { ...a, id: 'b', path: '/repos/b', addedAt: 2 } +let harness: SessionSearchHarness | undefined +afterEach(async () => { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + await harness?.close() + harness = undefined + vi.restoreAllMocks() +}) + +function store(repos = [a, b], provisioned: readonly string[] = []) { + const projection = projectHostSetupCompatibilityFromRepos(repos) + return { + getRepos: () => repos, + getProjects: () => projection.projects, + getProjectHostSetups: () => + projection.setups.map((setup) => + provisioned.includes(setup.repoId) + ? { ...setup, setupMethod: 'provisioned' as const } + : setup + ), + getAllWorktreeMeta: () => ({ + 'b::/linked/b': { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + projectId: projection.setups[0].projectId, + projectHostSetupId: 'b', + priorWorktreeIds: ['b::/old/b'] + } + }), + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) + } +} + +it('selects current ownership before candidate pressure and fork folding through the store, registry and child boundary', async () => { + expect(RUNTIME_CAPABILITIES).toContain(AI_VAULT_CANONICAL_PROJECT_SEARCH_CAPABILITY) + harness = await openSessionSearchHarness('sta10541-native', { sessionCandidateLimit: 2 }) + for (let id = 1; id <= 10; id++) { + addSyntheticSession(harness.db, { + id, + cwd: a.path, + updatedAt: '2026-10-22', + text: 'ownership needle' + }) + } + addSyntheticSession(harness.db, { + id: 11, + cwd: b.path, + updatedAt: '2026-10-21', + text: 'ownership needle' + }) + addSyntheticSession(harness.db, { + id: 12, + cwd: '/linked/b', + updatedAt: '2026-10-20', + text: 'ownership needle' + }) + markFork(harness.db, [1, 11], 'shared-opening') + const native = createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + setSessionSearchService( + createChildSessionSearchService({ + search: native.search, + reconcile: native.reconcile, + status: native.status + }) + ) + const source = store() + let catalog = sessionSearchScopeCatalogFromStore(source, 'local') + installSessionSearchScopeCatalogSource(() => catalog) + const projectKey = source.getProjects()[0].id + const request = { + query: 'ownership needle', + limit: 1, + within: { kind: 'project', projectKey: `project:${projectKey}`, canonicalProjectKey: 'repo:b' } + } + const first = await searchSessionService(request, 'ipc') + expect(first).toMatchObject({ + kind: 'results', + hits: [{ sessionId: '11' }], + page: { hasMore: true } + }) + if (first.kind !== 'results' || !first.page.cursor) { + throw new Error('missing native continuation') + } + expect( + await searchSessionService({ ...request, cursor: first.page.cursor }, 'ipc') + ).toMatchObject({ kind: 'results', hits: [{ sessionId: '12' }] }) + expect(await searchSessionService({ ...request, query: 'path:repos' }, 'ipc')).toMatchObject({ + kind: 'results', + hits: [{ sessionId: '11' }] + }) + expect( + await searchSessionService( + { ...request, query: 'different needle', cursor: first.page.cursor }, + 'ipc' + ) + ).toMatchObject({ kind: 'malformed-cursor' }) + expect( + await searchSessionService( + { + ...request, + within: { ...request.within, canonicalProjectKey: request.within.projectKey }, + cursor: first.page.cursor + }, + 'ipc' + ) + ).toMatchObject({ kind: 'malformed-cursor' }) + catalog = { + ...catalog, + worktreeMeta: { ...catalog.worktreeMeta, 'a::/linked/b': { projectHostSetupId: 'a' } } + } + expect( + await searchSessionService({ ...request, cursor: first.page.cursor }, 'ipc') + ).toMatchObject({ kind: 'stale-cursor' }) + const legacy = await searchSessionService( + { query: 'ownership needle', within: { kind: 'project', projectKey: `project:${projectKey}` } }, + 'ipc' + ) + expect(legacy).toMatchObject({ kind: 'results', truncated: { candidates: true } }) +}) + +it('uses literal nested regions and source namespaces without changing legacy cwd keys', async () => { + const repos: Repo[] = [ + { ...a, id: 'ubuntu', path: '//wsl.localhost/Ubuntu/home/ada/app' }, + { ...a, id: 'debian', gitRemoteIdentity: undefined, path: '//wsl$/Debian/home/ada/app' }, + { ...b, id: 'nested', path: '//wsl.localhost/Ubuntu/home/ada/app/nested' }, + { + ...a, + id: 'drive', + path: '//wsl.localhost/Ubuntu/mnt/c/Case_%/app' + }, + { ...a, id: 'unicode', path: '/repos/Café_%' }, + { ...a, id: 'native-drive', path: 'C:/Native_%' } + ] + const source = store(repos, ['drive', 'unicode', 'native-drive']) + const catalog = { ...sessionSearchScopeCatalogFromStore(source, 'local'), worktreeMeta: {} } + const project = source.getProjectHostSetups().find((row) => row.repoId === 'ubuntu')!.projectId + const selection = await compileSessionSearchProjectOwnership(catalog, `project:${project}`) + harness = await openSessionSearchHarness('sta10541-native-namespaces') + const cases = [ + ['/home/ada/app', '//wsl$/Ubuntu/home/ada/.claude/session', true], + ['/home/ada/app', '//wsl.localhost/Debian/home/ada/.claude/session', false], + ['/home/ada/app/nested/topic', '//wsl$/Ubuntu/session', false], + ['/home/ada/app-other', '//wsl$/Ubuntu/session', false], + ['/mnt/c/CASE_%/app/topic', '/native/session', true], + ['/mnt/c/CaseXY/app/topic', '/native/session', false], + ['c:/case_%/app/topic', '/native/session', false], + ['/repos/Café_%/topic', '/native/session', true], + ['/repos/café_%/topic', '/native/session', false], + ['/repos/CaféXY/topic', '/native/session', false], + ['c:/native_%/topic', '/native/session', true], + ['C:/NativeXY/topic', '/native/session', false], + ['//wsl$/Ubuntu/home/ada/app/topic', '/native/session', true] + ] as const + for (const [index, [cwd, sessionFilePath]] of cases.entries()) { + addSyntheticSession(harness.db, { id: index + 1, cwd, sessionFilePath }) + } + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => catalog) + for (const query of ['needle', 'path:/']) { + const response = await searchSessionService( + { + query, + within: { + kind: 'project', + projectKey: `project:${project}`, + canonicalProjectKey: `project:${project}` + } + }, + 'ipc' + ) + expect(response.kind === 'results' && response.hits.map((hit) => hit.sessionId).sort()).toEqual( + cases.flatMap((row, index) => (row[2] ? [String(index + 1)] : [])).sort() + ) + } + expect(sessionSearchProjectOwns(selection, '/home/ada/app', '//wsl$/Ubuntu/session')).toBe(true) +}) + +it('keeps layout history canonical-only and absent intent at actual legacy native results', async () => { + harness = await openSessionSearchHarness('sta10541-legacy-layout') + addSyntheticSession(harness.db, { id: 1, cwd: a.path }) + addSyntheticSession(harness.db, { id: 2, cwd: '/new/a/topic' }) + addSyntheticSession(harness.db, { id: 3, cwd: '/old/a/topic' }) + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + const source = store([a]) + const catalog = { + ...sessionSearchScopeCatalogFromStore(source, 'local'), + worktreeMeta: {}, + settings: { + workspaceDir: '/new', + nestWorkspaces: true, + workspaceDirHistory: [{ path: '/old', nestWorkspaces: true }] + } + } + installSessionSearchScopeCatalogSource(() => catalog) + const projectKey = `project:${source.getProjects()[0].id}` + const request = { query: 'needle', within: { kind: 'project', projectKey } } + const legacy = await searchSessionService(request, 'ipc') + expect(legacy.kind === 'results' && legacy.hits.map((hit) => hit.sessionId).sort()).toEqual([ + '1', + '2' + ]) + const canonical = await searchSessionService( + { ...request, within: { ...request.within, canonicalProjectKey: projectKey } }, + 'ipc' + ) + expect(canonical.kind === 'results' && canonical.hits.map((hit) => hit.sessionId).sort()).toEqual( + ['1', '2', '3'] + ) +}) + +it('gives a current workspace its native rows when another owner retains a prior symlink spelling', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'sta10541-reuse-'))) + try { + const actual = join(root, 'current') + const alias = join(root, 'prior') + await mkdir(actual) + await symlink(actual, alias, 'junction') + const repos = [ + { ...a, path: join(root, 'a') }, + { ...b, path: join(root, 'b') } + ] + const source = store(repos) + const template = source.getAllWorktreeMeta()['b::/linked/b'] + const catalog = { + ...sessionSearchScopeCatalogFromStore(source, 'local'), + worktreeMeta: { + [`a::${repos[0].path}/new`]: { + ...template, + projectHostSetupId: 'a', + priorWorktreeIds: [`a::${alias}`] + }, + [`b::${actual}`]: { ...template, projectHostSetupId: 'b', priorWorktreeIds: [] } + } + } + harness = await openSessionSearchHarness('sta10541-current-alias') + addSyntheticSession(harness.db, { id: 1, cwd: `${actual}/topic` }) + addSyntheticSession(harness.db, { id: 2, cwd: `${alias}/topic` }) + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => catalog) + const within = { + kind: 'project', + projectKey: `project:${source.getProjects()[0].id}`, + canonicalProjectKey: 'repo:b' + } + const result = await searchSessionService({ query: 'needle', within }, 'ipc') + expect(result.kind === 'results' && result.hits.map((hit) => hit.sessionId).sort()).toEqual([ + '1', + '2' + ]) + const former = await searchSessionService( + { query: 'needle', within: { ...within, canonicalProjectKey: within.projectKey } }, + 'ipc' + ) + expect(former.kind === 'results' && former.hits).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('answers one complete provisioned Project above 64 roots before pressure and folds its forks once', async () => { + const eligible: Repo[] = Array.from({ length: 80 }, (_, index) => ({ + ...a, + id: `eligible-${index}`, + path: `/roots/eligible-${index}`, + addedAt: index + })) + const source = store( + [...eligible, { ...b, path: '/roots/independent', addedAt: 100 }], + eligible.slice(1).map((repo) => repo.id) + ) + harness = await openSessionSearchHarness('sta10541-whole-union', { sessionCandidateLimit: 81 }) + for (let id = 1; id <= 80; id++) { + addSyntheticSession(harness.db, { + id, + cwd: eligible[id - 1].path, + text: 'ownership needle', + updatedAt: '2026-10-20' + }) + } + for (let id = 81; id <= 240; id++) { + addSyntheticSession(harness.db, { + id, + cwd: '/roots/independent', + text: 'ownership needle', + updatedAt: '2026-10-21' + }) + } + markFork(harness.db, [1, 2, 81], 'union-opening') + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => ({ + ...sessionSearchScopeCatalogFromStore(source, 'local'), + worktreeMeta: {} + })) + const projectKey = `project:${source.getProjects()[0].id}` + for (const query of [ + '"ownership needle"', + 'ownership needle', + 'ownership OR needle', + 'path:roots' + ]) { + const response = await searchSessionService( + { + query, + limit: 100, + within: { kind: 'project', projectKey, canonicalProjectKey: projectKey } + }, + 'ipc' + ) + expect(response.kind).toBe('results') + if (response.kind !== 'results') { + throw new Error('missing native result') + } + expect(response.hits).toHaveLength(79) + expect(response.hits.every((hit) => Number(hit.sessionId) <= 80)).toBe(true) + expect(response.page.hasMore).toBe(false) + expect(response.truncated.candidates).toBe(false) + } +}) diff --git a/src/main/ai-vault-search/session-search-engine-types.ts b/src/main/ai-vault-search/session-search-engine-types.ts index 308a211d4e2e..adac1903b228 100644 --- a/src/main/ai-vault-search/session-search-engine-types.ts +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -1,3 +1,4 @@ +import type { SessionSearchProjectOwnership } from './session-search-project-ownership' import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' @@ -19,6 +20,7 @@ export type SessionSearchScope = 'conversation' | 'all' export type SessionSearchSort = 'relevance' | 'newest' export type SessionSearchFilters = { + ownership?: SessionSearchProjectOwnership agents?: readonly AiVaultAgent[] /** Only sessions whose cwd is that path or inside it. */ scopePaths?: readonly string[] diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts index e64948a66981..e0f11cff198c 100644 --- a/src/main/ai-vault-search/session-search-engine.ts +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -38,6 +38,8 @@ import { import { sessionRowFilter } from './session-search-row-filter' import { ensureSessionSearchQuerySchema } from './session-search-query-schema' import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet' +import { sessionSearchProjectOwns } from './session-search-project-ownership' +import type { SessionSearchProjectOwnership } from './session-search-project-ownership' import { sessionSourcePresence } from './session-search-source-presence' /** Candidate-limit tuning and measurements: docs/reference/agent-session-search-query-tuning.md. */ @@ -60,11 +62,22 @@ export type SessionSearchEngineOptions = { export class SessionSearchEngine { private readonly retrieval: SessionSearchRetrieval private readonly candidateLimit: number + private ownership?: SessionSearchProjectOwnership constructor( private readonly db: SyncDatabase, private readonly options: SessionSearchEngineOptions = {} ) { + this.db.function('canonical_project_owns', { deterministic: true }, (cwd, filePath) => + Number( + Boolean( + this.ownership && + typeof cwd === 'string' && + typeof filePath === 'string' && + sessionSearchProjectOwns(this.ownership, cwd, filePath) + ) + ) + ) this.candidateLimit = options.sessionCandidateLimit ?? SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT // Install tables and generation triggers before preparing retrieval or accepting writes. ensureSessionSearchQuerySchema(this.db) @@ -76,6 +89,7 @@ export class SessionSearchEngine { } search(request: SessionSearchRequest): SessionSearchResponse { + this.ownership = request.filters?.ownership const startedAt = performance.now() ensureSessionSearchQuerySchema(this.db) const generation = readIndexGeneration(this.db) @@ -94,7 +108,13 @@ export class SessionSearchEngine { // Reject invalid cursors before paying for retrieval. const pageKey = sessionSearchPageKey(request) const offset = request.cursor - ? decodeSessionSearchCursor(request.cursor, generation, pageKey, incarnation) + ? decodeSessionSearchCursor( + request.cursor, + generation, + pageKey, + incarnation, + this.ownership?.fingerprint + ) : 0 const plan = planSessionSearchQuery(split.text) @@ -121,7 +141,13 @@ export class SessionSearchEngine { page: { hasMore, cursor: hasMore - ? encodeSessionSearchCursor(generation, offset + limit, pageKey, incarnation) + ? encodeSessionSearchCursor( + generation, + offset + limit, + pageKey, + incarnation, + this.ownership?.fingerprint + ) : null }, truncated: { diff --git a/src/main/ai-vault-search/session-search-ownership-races.test.ts b/src/main/ai-vault-search/session-search-ownership-races.test.ts new file mode 100644 index 000000000000..691ff4999ec8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-ownership-races.test.ts @@ -0,0 +1,131 @@ +import * as fs from 'node:fs/promises' +import { afterEach, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests, + type SessionSearchScopeCatalog +} from './session-search-scope-catalog' +import { createSessionSearchService } from './session-search-service' +import { searchSessionService, setSessionSearchService } from './session-search-service-registry' +import { + openSessionSearchHarness, + addSyntheticSession, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn(actual.realpath) } +}) +let harness: SessionSearchHarness | undefined +afterEach(async () => { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + vi.restoreAllMocks() + await harness?.close() + harness = undefined +}) +function latch() { + let release: () => void = () => undefined + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} + +it('captures after freshness and rejects governing ownership changes across alias and child awaits without new work', async () => { + const repos: Repo[] = ['a', 'b'].map((id, index) => ({ + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' } + })) + const raw = projectHostSetupCompatibilityFromRepos(repos) + let catalog: SessionSearchScopeCatalog = { + repos, + projects: raw.projects, + projectHostSetups: raw.setups, + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + } + installSessionSearchScopeCatalogSource(() => catalog) + harness = await openSessionSearchHarness('sta10541-ownership-await') + addSyntheticSession(harness.db, { id: 1, cwd: '/after-freshness' }) + const fresh = latch() + const reconciling = latch() + const native = createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => { + reconciling.release() + await fresh.promise + } + } + }) + const admission = vi.spyOn(native, 'search') + setSessionSearchService(native) + const request = { + query: 'needle', + within: { + kind: 'project', + projectKey: `project:${raw.projects[0].id}`, + canonicalProjectKey: 'repo:b' + } + } + const first = searchSessionService({ ...request, freshness: 'wait-until-current' }, 'ipc') + await reconciling.promise + catalog = { + ...catalog, + worktreeMeta: { + 'b::/after-freshness': { projectHostSetupId: 'b', projectId: raw.projects[0].id } + } + } + fresh.release() + expect(await first).toMatchObject({ kind: 'results', hits: [{ sessionId: '1' }] }) + expect(admission).toHaveBeenCalledTimes(1) + + const aliasEntered = latch() + const aliasRelease = latch() + const realpath = vi.mocked(fs.realpath) + const delegate = realpath.getMockImplementation()! + realpath.mockImplementationOnce(async (...args) => { + aliasEntered.release() + await aliasRelease.promise + return delegate(...args) + }) + const aliasRace = searchSessionService(request, 'ipc') + await aliasEntered.promise + repos[1].path = '/repos/b-moved' + aliasRelease.release() + expect(await aliasRace).toMatchObject({ kind: 'stale-cursor' }) + expect(admission).toHaveBeenCalledTimes(1) + + const childEntered = latch() + const childRelease = latch() + const child = { + ...native, + search: vi.fn(async (...args: Parameters) => { + const result = await native.search(...args) + childEntered.release() + await childRelease.promise + return result + }) + } + setSessionSearchService(child) + const childRace = searchSessionService(request, 'ipc') + await childEntered.promise + catalog = { + ...catalog, + worktreeMeta: { ...catalog.worktreeMeta, 'a::/after-freshness': { projectHostSetupId: 'a' } } + } + childRelease.release() + expect(await childRace).toMatchObject({ kind: 'stale-cursor' }) + expect(child.search).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/ai-vault-search/session-search-page-cursor.ts b/src/main/ai-vault-search/session-search-page-cursor.ts index 38f02a37f4e9..c6b791a14e2b 100644 --- a/src/main/ai-vault-search/session-search-page-cursor.ts +++ b/src/main/ai-vault-search/session-search-page-cursor.ts @@ -17,6 +17,7 @@ export class SessionSearchCursorError extends Error { type CursorPayload = { /** Database incarnation; changes when the index is rebuilt. */ + p?: string i: string /** Index generation. */ g: number @@ -35,7 +36,8 @@ export function sessionSearchPageKey(request: SessionSearchRequest): string { filters.sort ?? 'relevance', filters.since ?? null, [...(filters.agents ?? [])].sort(), - [...(filters.scopePaths ?? [])].sort() + [...(filters.scopePaths ?? [])].sort(), + ...(filters.ownership ? [filters.ownership.projectKey] : []) ]) return createHash('sha256').update(identity).digest('base64url').slice(0, 16) } @@ -44,9 +46,16 @@ export function encodeSessionSearchCursor( generation: number, offset: number, key: string, - incarnation: string + incarnation: string, + ownership?: string ): string { - const payload: CursorPayload = { i: incarnation, g: generation, o: offset, k: key } + const payload: CursorPayload = { + i: incarnation, + g: generation, + o: offset, + k: key, + ...(ownership ? { p: ownership } : {}) + } return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url') } @@ -55,7 +64,8 @@ export function decodeSessionSearchCursor( cursor: string, generation: number, key: string, - incarnation: string + incarnation: string, + ownership?: string ): number { let payload: CursorPayload try { @@ -84,6 +94,9 @@ export function decodeSessionSearchCursor( if (payload.i !== incarnation) { throw new SessionSearchCursorError('stale-generation', generation, claimed) } + if (payload.p !== ownership) { + throw new SessionSearchCursorError('stale-generation', generation, claimed) + } if (payload.k !== key) { throw new SessionSearchCursorError('different-query', generation, claimed) } diff --git a/src/main/ai-vault-search/session-search-project-ownership.ts b/src/main/ai-vault-search/session-search-project-ownership.ts new file mode 100644 index 000000000000..d0b14363224b --- /dev/null +++ b/src/main/ai-vault-search/session-search-project-ownership.ts @@ -0,0 +1,284 @@ +import { createHash } from 'node:crypto' +import { + normalizeRuntimePathForComparison, + isRuntimePathAbsolute +} from '../../shared/cross-platform-path' +import { getRepoExecutionHostId, getRepoRoutingConnectionId } from '../../shared/execution-host' +import { resolveProjectHostSetupProjection } from '../../shared/project-catalog-canonical-projection' +import { getProjectHostSetupLookup } from '../../shared/project-host-setup-lookup' +import { toAiVaultProjectKey } from '../../shared/ai-vault-project-key' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { sessionFileExecutionNamespace } from '../../shared/ai-vault-scope-cwd-match' +import { resolveAiVaultScopeRealPaths } from '../ai-vault/session-scope-real-paths' +import type { SessionSearchScopeCatalog } from './session-search-scope-catalog' +import { managedWorktreeDirectories } from './session-search-scope-paths' + +// Half-open BINARY intervals, including exact roots separately from descendants. +type Interval = [low: string, high: string, owner: string | null, priority: number] +type Rule = { + path: string + owner: string | null + workspace: boolean + prior?: boolean + namespace?: string +} +export type SessionSearchProjectOwnership = { + projectKey: string + fingerprint: string + planes: Record + ranges: [string, string][] +} +const compare = (a: string, b: string) => Buffer.compare(Buffer.from(a), Buffer.from(b)) +const key = (path: string) => normalizeRuntimePathForComparison(path).toWellFormed() +export function materializeSessionSearchOwnership( + catalog: SessionSearchScopeCatalog | null +): Rule[] | null { + if (!catalog) { + return null + } + const projection = resolveProjectHostSetupProjection( + catalog.repos, + catalog.projects, + catalog.projectHostSetups + ) + const owners = getProjectHostSetupLookup(projection.setups) + const rules: Rule[] = [] + const add = ( + path: string | undefined, + owner: string | null, + workspace = false, + prior = false + ) => { + if (path && isRuntimePathAbsolute(path)) { + rules.push({ path, owner, workspace, prior }) + } + } + const local = (host: string) => host === (catalog.executionHostId ?? 'local') + for (const setup of projection.setups) { + // Nested SSH is a separate filesystem even when its transport belongs to this runtime. + const host = setup.connectionId + ? `ssh:${encodeURIComponent(setup.connectionId)}` + : (setup.executionHostId ?? setup.hostId) + if (local(host)) { + add(setup.path, toAiVaultProjectKey(setup.projectId, setup.repoId)) + } + } + for (const repo of catalog.repos) { + const connectionId = getRepoRoutingConnectionId(repo) + const host = connectionId + ? `ssh:${encodeURIComponent(connectionId)}` + : getRepoExecutionHostId(repo) + if (!local(host)) { + continue + } + const owner = toAiVaultProjectKey(owners.projectId({ repoId: repo.id, hostId: host }), repo.id) + add(repo.path, owner) + for (const path of managedWorktreeDirectories(repo, catalog.settings)) { + add(path, owner) + } + } + const retained: { rule: Rule; prior: readonly string[]; repoId: string }[] = [] + for (const [id, meta] of Object.entries(catalog.worktreeMeta)) { + const parsed = splitWorktreeIdForFilesystem(id) + if (!parsed) { + continue + } + const owner = toAiVaultProjectKey( + owners.projectId({ + ...meta, + repoId: parsed.repoId, + hostId: meta.hostId ?? catalog.executionHostId ?? 'local' + }), + parsed.repoId + ) + const rule = { path: parsed.worktreePath, owner, workspace: true } + rules.push(rule) + retained.push({ rule, prior: meta.priorWorktreeIds ?? [], repoId: parsed.repoId }) + } + for (const { rule, prior, repoId } of retained) { + for (const id of prior) { + const parsed = splitWorktreeIdForFilesystem(id) + if (parsed?.repoId === repoId) { + add(parsed.worktreePath, rule.owner, true, true) + } + } + } + return rules +} + +export async function compileSessionSearchProjectOwnership( + catalog: SessionSearchScopeCatalog, + projectKey: string, + input = materializeSessionSearchOwnership(catalog) ?? [] +): Promise { + const rules = [...input] + const pathByKey = new Map() + for (const rule of rules) { + const normalized = key(rule.path) + if (!pathByKey.has(normalized)) { + pathByKey.set(normalized, rule.path) + } + } + const aliases = await resolveAiVaultScopeRealPaths([...pathByKey.values()]) + const realByPath = new Map(aliases.links.map((link) => [key(link.scopePath), key(link.realPath)])) + const groups = new Map() + for (const rule of rules) { + const root = realByPath.get(key(rule.path)) ?? key(rule.path) + const entries = groups.get(root) ?? [] + entries.push(rule) + groups.set(root, entries) + } + rules.length = 0 + for (const [root, entries] of groups) { + const current = entries.filter((rule) => !rule.prior) + const active = current.length ? current : entries + rules.push(...active) + // Registered spellings share current occupancy; never resolve arbitrary session cwd. + const workspace = active.some((rule) => rule.workspace) + const owners = new Set( + active.filter((rule) => rule.workspace === workspace).map((rule) => rule.owner) + ) + const owner = owners.size === 1 ? owners.values().next().value! : null + for (const path of new Set([root, ...entries.map((rule) => key(rule.path))])) { + rules.push({ path, owner, workspace, namespace: 'native' }) + } + } + const planes = new Map>() + const insert = (plane: string, path: string, rule: Rule, mounted = false) => { + const root = key(path) + const roots = planes.get(plane) ?? new Map() + const priority = + (root.split('/').filter(Boolean).length + (mounted ? 1 : 0)) * 2 + Number(rule.workspace) + const prior = roots.get(root) + if (!prior || prior.priority < priority) { + roots.set(root, { owner: rule.owner, priority }) + } else if (prior.priority === priority && prior.owner !== rule.owner) { + prior.owner = null + } + planes.set(plane, roots) + } + for (const rule of rules) { + insert(rule.namespace ?? '', rule.path, rule) + const wsl = parseWslUncPath(rule.path) + if (!wsl) { + continue + } + if (/^\/mnt\/[A-Za-z](?:\/|$)/.test(wsl.linuxPath)) { + insert('mounted', toWindowsWslPath(wsl.linuxPath, wsl.distro), rule, true) + } else { + insert(`wsl:${wsl.distro.toLowerCase()}`, wsl.linuxPath, rule) + } + } + const compiled = Object.fromEntries( + [...planes].map(([plane, roots]) => [plane, intervals(roots)]) + ) + const ranges: [string, string][] = [] + for (const [plane, entries] of Object.entries(compiled)) { + for (const [low, high, owner] of entries) { + if (owner === projectKey && plane !== 'mounted') { + ranges.push([low, high]) + } + } + if (plane === 'mounted' && entries.some((entry) => entry[2] === projectKey)) { + ranges.push(['/mnt/', '/mnt0']) + } + } + ranges.sort((a, b) => compare(a[0], b[0]) || compare(a[1], b[1])) + const union: [string, string][] = [] + for (const range of ranges) { + const prior = union.at(-1) + if (prior && compare(range[0], prior[1]) <= 0) { + if (compare(range[1], prior[1]) > 0) { + prior[1] = range[1] + } + } else { + union.push([...range]) + } + } + const fingerprint = createHash('sha256') + .update(JSON.stringify([compiled, aliases.links])) + .digest('base64url') + return { projectKey, fingerprint, planes: compiled, ranges: union } +} + +function intervals(roots: Map): Interval[] { + const events: { at: string; end?: string; value?: Interval }[] = [] + for (const [root, { owner, priority }] of roots) { + const prefix = root.endsWith('/') ? root : `${root}/` + for (const [low, high] of [ + [root, `${root}\0`], + [prefix, `${prefix.slice(0, -1)}0`] + ]) { + events.push({ at: low, end: high, value: [low, high, owner, priority] }, { at: high }) + } + } + events.sort( + (a, b) => compare(a.at, b.at) || (a.end && b.end ? -compare(a.end, b.end) : a.end ? 1 : -1) + ) + const stack: Interval[] = [] + const result: Interval[] = [] + let previous = '' + for (const event of events) { + const active = stack.at(-1) + if (active && compare(previous, event.at) < 0) { + result.push([previous, event.at, active[2], active[3]]) + } + while (stack.length && compare(stack.at(-1)![1], event.at) <= 0) { + stack.pop() + } + if (event.value) { + stack.push(event.value) + } + previous = event.at + } + return result +} + +/** Binary searches precompiled ownership, never the catalog, for each indexed candidate. */ +export function sessionSearchProjectOwner( + selection: SessionSearchProjectOwnership, + cwd: string, + filePath: string +): string | null { + cwd = key(cwd) + const find = (plane: string, value: string): Interval | undefined => { + const entries = selection.planes[plane] ?? [] + let low = 0, + high = entries.length + while (low < high) { + const middle = (low + high) >>> 1 + if (compare(entries[middle][0], value) <= 0) { + low = middle + 1 + } else { + high = middle + } + } + const found = entries[low - 1] + return found && compare(value, found[1]) < 0 ? found : undefined + } + const candidates = [find('', cwd), find(sessionFileExecutionNamespace(filePath), cwd)] + if (/^\/mnt\/[A-Za-z](?:\/|$)/.test(cwd)) { + candidates.push(find('mounted', key(toWindowsWslPath(cwd, '')))) + } + let best: Interval | undefined + for (const candidate of candidates) { + if (!candidate) { + continue + } + if (!best || candidate[3] > best[3]) { + best = candidate + } else if (candidate[3] === best[3] && candidate[2] !== best[2]) { + best = [best[0], best[1], null, best[3]] + } + } + return best?.[2] ?? null +} + +export function sessionSearchProjectOwns( + selection: SessionSearchProjectOwnership, + cwd: string, + filePath: string +): boolean { + return sessionSearchProjectOwner(selection, cwd, filePath) === selection.projectKey +} diff --git a/src/main/ai-vault-search/session-search-row-filter.ts b/src/main/ai-vault-search/session-search-row-filter.ts index f3d047d47de7..5f73cf9bc657 100644 --- a/src/main/ai-vault-search/session-search-row-filter.ts +++ b/src/main/ai-vault-search/session-search-row-filter.ts @@ -38,6 +38,14 @@ export function sessionRowFilter( filter.values.push(JSON.stringify(keys.map(scopeRange))) } } + if (filters.ownership) { + filter.conditions.push(`id IN ( + SELECT scoped.id FROM json_each(?) AS scope JOIN sessions AS scoped + ON scoped.cwd_key >= scope.value ->> 0 AND scoped.cwd_key < scope.value ->> 1 + WHERE canonical_project_owns(scoped.cwd_key, scoped.file_path) + )`) + filter.values.push(JSON.stringify(filters.ownership.ranges)) + } return filter } diff --git a/src/main/ai-vault-search/session-search-scope-catalog.ts b/src/main/ai-vault-search/session-search-scope-catalog.ts index b47d407b90d8..9e1d35f7878e 100644 --- a/src/main/ai-vault-search/session-search-scope-catalog.ts +++ b/src/main/ai-vault-search/session-search-scope-catalog.ts @@ -1,20 +1,24 @@ +import type { ExecutionHostId } from '../../shared/execution-host' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Project, ProjectHostSetup } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' +import type { WorkspaceProjectOwner } from '../../shared/project-host-setup-lookup' import type { WorktreeMeta } from '../../shared/worktree/meta-types' // Use the answering host’s persisted registrations to avoid Git scans. export type SessionSearchScopeCatalog = { - repos: readonly Pick< - Repo, - 'id' | 'path' | 'kind' | 'worktreeBasePath' | 'connectionId' | 'executionHostId' - >[] - projects: readonly Pick[] - projectHostSetups: readonly Pick< - ProjectHostSetup, - 'projectId' | 'repoId' | 'path' | 'worktreeBasePath' - >[] - worktreeMeta: Readonly>> + repos: readonly Repo[] + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + executionHostId?: ExecutionHostId + /** Registered workspaces of this host, keyed by worktree id. */ + worktreeMeta: Readonly< + Record< + string, + Pick & + Partial> + > + > settings: Pick } diff --git a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index b877d80b30b6..4cbe4c738969 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -7,12 +7,13 @@ import { OrcaRuntimeService } from '../runtime/orca-runtime' import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault' import { installSessionSearchScopeCatalogSource, - resetSessionSearchScopeCatalogForTests + resetSessionSearchScopeCatalogForTests, + type SessionSearchScopeCatalog } from './session-search-scope-catalog' import { searchSessionService, setSessionSearchService } from './session-search-service-registry' -const CATALOG = { - repos: [{ id: 'repo-1', path: '/work/app' }], +const CATALOG: SessionSearchScopeCatalog = { + repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }], projects: [], projectHostSetups: [], worktreeMeta: {}, diff --git a/src/main/ai-vault-search/session-search-scope-paths.ts b/src/main/ai-vault-search/session-search-scope-paths.ts index 401595c61f79..2698447e6220 100644 --- a/src/main/ai-vault-search/session-search-scope-paths.ts +++ b/src/main/ai-vault-search/session-search-scope-paths.ts @@ -1,16 +1,9 @@ import { - getRuntimePathBasename, isPathInsideOrEqual, isRuntimePathAbsolute, - normalizeRuntimePathForComparison, - resolveRuntimePath + normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { isFolderRepo } from '../../shared/repo-kind' -import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path' -import { buildKnownOrcaWorkspaceLayouts } from '../../shared/worktree/ownership' -import type { SessionSearchScopeCatalog } from './session-search-scope-catalog' - -type ScopeRepo = SessionSearchScopeCatalog['repos'][number] +export { managedWorktreeDirectories } from '../../shared/worktree/ownership' export class ScopePathSet { private readonly seen = new Set() @@ -37,26 +30,3 @@ export class ScopePathSet { ) } } - -/** Flat global roots belong to all projects; only repo-specific directories can narrow scope. */ -export function managedWorktreeDirectories( - repo: ScopeRepo, - settings: SessionSearchScopeCatalog['settings'] -): string[] { - if (isFolderRepo(repo)) { - return [] - } - const configured = new Set( - resolveConfiguredWorktreeBasePaths(repo).map(normalizeRuntimePathForComparison) - ) - const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/, '') - const directories: string[] = [] - for (const layout of buildKnownOrcaWorkspaceLayouts(settings, repo)) { - if (configured.has(normalizeRuntimePathForComparison(layout.path))) { - directories.push(layout.path) - } else if (layout.nestWorkspaces && repoName) { - directories.push(resolveRuntimePath(layout.path, repoName)) - } - } - return directories -} diff --git a/src/main/ai-vault-search/session-search-scope-resolution.test.ts b/src/main/ai-vault-search/session-search-scope-resolution.test.ts index 0a7b7dae2ec9..6a05767deaea 100644 --- a/src/main/ai-vault-search/session-search-scope-resolution.test.ts +++ b/src/main/ai-vault-search/session-search-scope-resolution.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' +import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection' import type { SessionSearchScopeCatalog } from './session-search-scope-catalog' import { resolveSessionSearchScope } from './session-search-scope-resolution' function catalog(overrides: Partial = {}): SessionSearchScopeCatalog { return { - repos: [{ id: 'repo-1', path: '/work/app' }], + repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }], projects: [], projectHostSetups: [], worktreeMeta: {}, @@ -82,7 +83,18 @@ describe('workspace scope', () => { kind: 'workspace', worktreeId: 'folder-1::/work/notes::workspace:11111111-1111-4111-8111-111111111111' }, - catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] }) + catalog({ + repos: [ + { + id: 'folder-1', + path: '/work/notes', + kind: 'folder', + displayName: 'notes', + badgeColor: '', + addedAt: 0 + } + ] + }) ) expect(paths(resolution)).toEqual(['/work/notes']) }) @@ -137,7 +149,7 @@ describe('project scope', () => { it("uses a repo's own worktree base path instead of the global root", () => { const resolution = resolveSessionSearchScope( { kind: 'project', projectKey: 'repo:repo-1' }, - catalog({ repos: [{ id: 'repo-1', path: '/work/app', worktreeBasePath: '/trees/app' }] }) + catalog({ repos: [{ ...catalog().repos[0], worktreeBasePath: '/trees/app' }] }) ) // Include older worktrees created before the repo’s base path was configured. expect(paths(resolution)).toEqual(['/home/me/orca/workspaces/app', '/trees/app', '/work/app']) @@ -188,10 +200,20 @@ describe('project scope', () => { const resolution = resolveSessionSearchScope( { kind: 'project', projectKey: 'project:proj-1' }, catalog({ - repos: [{ id: 'repo-1', path: '/srv/app' }], - projects: [{ id: 'proj-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...catalog().repos[0], path: '/srv/app' }], + projects: [ + { + id: 'proj-1', + sourceRepoIds: ['repo-1'], + displayName: 'app', + badgeColor: '', + createdAt: 0, + updatedAt: 0 + } + ], projectHostSetups: [ { + ...projectHostSetupCompatibilityFromRepos(catalog().repos).setups[0], projectId: 'proj-1', repoId: 'repo-1', path: '/srv/app', @@ -206,7 +228,18 @@ describe('project scope', () => { it('takes a folder workspace project as its folder', () => { const resolution = resolveSessionSearchScope( { kind: 'project', projectKey: 'repo:folder-1' }, - catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] }) + catalog({ + repos: [ + { + id: 'folder-1', + path: '/work/notes', + kind: 'folder', + displayName: 'notes', + badgeColor: '', + addedAt: 0 + } + ] + }) ) expect(paths(resolution)).toEqual(['/work/notes']) }) @@ -221,7 +254,18 @@ describe('project scope', () => { expect( resolveSessionSearchScope( { kind: 'project', projectKey: 'project:proj-1' }, - catalog({ projects: [{ id: 'proj-1', sourceRepoIds: ['repo-elsewhere'] }] }) + catalog({ + projects: [ + { + id: 'proj-1', + sourceRepoIds: ['repo-elsewhere'], + displayName: 'app', + badgeColor: '', + createdAt: 0, + updatedAt: 0 + } + ] + }) ) ).toEqual({ kind: 'unknown' }) }) diff --git a/src/main/ai-vault-search/session-search-scope-service.test.ts b/src/main/ai-vault-search/session-search-scope-service.test.ts index 8a5e6566008f..a7268baed5db 100644 --- a/src/main/ai-vault-search/session-search-scope-service.test.ts +++ b/src/main/ai-vault-search/session-search-scope-service.test.ts @@ -3,12 +3,13 @@ import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contrac import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' import { installSessionSearchScopeCatalogSource, - resetSessionSearchScopeCatalogForTests + resetSessionSearchScopeCatalogForTests, + type SessionSearchScopeCatalog } from './session-search-scope-catalog' import { searchSessionService, setSessionSearchService } from './session-search-service-registry' -const CATALOG = { - repos: [{ id: 'repo-1', path: '/work/app' }], +const CATALOG: SessionSearchScopeCatalog = { + repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }], projects: [], projectHostSetups: [], worktreeMeta: { 'repo-1::/work/app': {} }, diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts index 8e172e5f2c1e..13c9b72fbaa9 100644 --- a/src/main/ai-vault-search/session-search-service-registry.test.ts +++ b/src/main/ai-vault-search/session-search-service-registry.test.ts @@ -4,6 +4,11 @@ import { unavailableSessionSearchStatus, createSessionSearchClient } from '../../shared/ai-vault-search-client' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from './session-search-scope-catalog' +import * as ownership from './session-search-project-ownership' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { setSessionSearchService, @@ -12,6 +17,8 @@ import { } from './session-search-service-registry' afterEach(() => { + resetSessionSearchScopeCatalogForTests() + vi.restoreAllMocks() setSessionSearchService(null) vi.useRealTimers() }) @@ -132,6 +139,33 @@ describe('session search service registry', () => { ) } ) + it('does no canonical preparation for unscoped or legacy requests', async () => { + const source = vi.fn(() => ({ + repos: [], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + })) + installSessionSearchScopeCatalogSource(source) + const materialize = vi.spyOn(ownership, 'materializeSessionSearchOwnership') + const service = fakeSearchService() + setSessionSearchService(service) + for (const reason of ['disabled', 'not-ready'] as const) { + service.search.mockResolvedValue({ kind: 'unavailable', reason }) + expect(await searchSessionService({ query: 'needle' }, 'ipc')).toEqual({ + kind: 'unavailable', + reason + }) + } + expect(source).not.toHaveBeenCalled() + await searchSessionService( + { query: 'needle', within: { kind: 'project', projectKey: 'repo:absent' } }, + 'ipc' + ) + expect(source).toHaveBeenCalledTimes(1) + expect(materialize).not.toHaveBeenCalled() + }) it('waits for reconcile before search, and clears its timeout', async () => { vi.useFakeTimers() const service = fakeSearchService() diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts index 5f78a365cc3c..aad27dc224df 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -6,6 +6,12 @@ import { } from '../../shared/ai-vault-search-contract' import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' import { sessionSearchScopeCatalog } from './session-search-scope-catalog' +import { resolveProjectHostSetupProjection } from '../../shared/project-host-setup-hydration' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { + compileSessionSearchProjectOwnership, + materializeSessionSearchOwnership +} from './session-search-project-ownership' import { resolveSessionSearchScope } from './session-search-scope-resolution' import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types' import { @@ -60,20 +66,60 @@ export async function searchSessionService( agents: compatibleAgents } } - const hostScope = within - ? resolveSessionSearchScope(within, sessionSearchScopeCatalog()) - : undefined - const retrievalScope = - compatibleAgents.length === 0 && hostScope?.kind !== 'unknown' - ? { kind: 'resolved' as const, paths: [''] } - : hostScope const freshness = request.freshness === 'wait-until-current' ? await reconcileWithin(current, freshnessTimeoutMs) : false + const canonical = within?.kind === 'project' && within.canonicalProjectKey + const catalog = within ? sessionSearchScopeCatalog() : null + const input = canonical ? materializeSessionSearchOwnership(catalog) : null + const snapshot = JSON.stringify(input) + const selection = + canonical && catalog && input + ? await compileSessionSearchProjectOwnership(catalog, canonical, input) + : null + const legacy = + catalog && + within && + !canonical && + resolveProjectHostSetupProjection(catalog.repos, catalog.projects, catalog.projectHostSetups) + const legacyCatalog = + catalog && legacy + ? { + ...catalog, + settings: { + workspaceDir: catalog.settings.workspaceDir, + nestWorkspaces: catalog.settings.nestWorkspaces + }, + projects: legacy.projects, + projectHostSetups: legacy.setups.filter( + (setup) => setup.hostId === (catalog.executionHostId ?? 'local') + ), + repos: catalog.repos.filter( + (repo) => getRepoExecutionHostId(repo) === (catalog.executionHostId ?? 'local') + ) + } + : null + const hostScope = selection + ? { kind: 'canonical' as const, selection } + : within + ? resolveSessionSearchScope(within, legacyCatalog) + : undefined + const retrievalScope = + compatibleAgents.length === 0 && hostScope?.kind !== 'unknown' + ? { kind: 'resolved' as const, paths: [''] } + : hostScope + const moved = () => + snapshot !== JSON.stringify(materializeSessionSearchOwnership(sessionSearchScopeCatalog())) + if (canonical && moved()) { + return { kind: 'stale-cursor', generation: 0 } + } const result = AiVaultSearchResponseSchema.parse( await current.search(compatibleRequest, retrievalScope) ) + if (canonical && moved()) { + return { kind: 'stale-cursor', generation: result.kind === 'results' ? result.generation : 0 } + } if (result.kind !== 'results') { return result } diff --git a/src/main/ai-vault-search/session-search-service.ts b/src/main/ai-vault-search/session-search-service.ts index 28f7bdba8dc9..2a860baf4f90 100644 --- a/src/main/ai-vault-search/session-search-service.ts +++ b/src/main/ai-vault-search/session-search-service.ts @@ -1,3 +1,4 @@ +import type { SessionSearchProjectOwnership } from './session-search-project-ownership' import type { AiVaultSearchRequest, AiVaultSearchResponse, @@ -10,6 +11,10 @@ import { SessionSearchCursorError } from './session-search-page-cursor' /** Host scope bypasses the client path cap; absent scope leaves request filters unchanged. */ export type SessionSearchHostScope = | { kind: 'resolved'; paths: readonly string[] } + | { + kind: 'canonical' + selection: SessionSearchProjectOwnership + } | { kind: 'unknown' } export type SessionSearchService = { @@ -42,7 +47,15 @@ export function createSessionSearchService({ try { const result = engine.search( hostScope - ? { ...request, filters: { ...request.filters, scopePaths: hostScope.paths } } + ? { + ...request, + filters: { + ...request.filters, + ...(hostScope.kind === 'canonical' + ? { ownership: hostScope.selection } + : { scopePaths: hostScope.paths }) + } + } : request ) return { diff --git a/src/main/ai-vault-search/session-search-store-scope-catalog.test.ts b/src/main/ai-vault-search/session-search-store-scope-catalog.test.ts index 98bac3f46675..7c213c1d6821 100644 --- a/src/main/ai-vault-search/session-search-store-scope-catalog.test.ts +++ b/src/main/ai-vault-search/session-search-store-scope-catalog.test.ts @@ -1,4 +1,10 @@ import { describe, expect, it } from 'vitest' +import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection' +import type { Repo } from '../../shared/repo-types' +import { + compileSessionSearchProjectOwnership, + sessionSearchProjectOwns +} from './session-search-project-ownership' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { sessionSearchScopeCatalogFromStore } from './session-search-store-scope-catalog' @@ -19,16 +25,22 @@ function meta(hostId: WorktreeMeta['hostId']): WorktreeMeta { } function store() { + const repos: Repo[] = [ + { id: 'local-repo', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }, + { + id: 'ssh-repo', + path: '/srv/app', + connectionId: 'box', + displayName: 'app', + badgeColor: '', + addedAt: 1 + } + ] + const projection = projectHostSetupCompatibilityFromRepos(repos) return { - getRepos: () => [ - { id: 'local-repo', path: '/work/app' }, - { id: 'ssh-repo', path: '/srv/app', connectionId: 'box' } - ], - getProjects: () => [{ id: 'proj-1', sourceRepoIds: ['local-repo'] }], - getProjectHostSetups: () => [ - { projectId: 'proj-1', hostId: 'local' as const, repoId: 'local-repo', path: '/work/app' }, - { projectId: 'proj-1', hostId: 'ssh:box' as const, repoId: 'ssh-repo', path: '/srv/app' } - ], + getRepos: () => repos, + getProjects: () => projection.projects, + getProjectHostSetups: () => projection.setups, getAllWorktreeMeta: () => ({ 'local-repo::/work/one': meta('local'), 'ssh-repo::/srv/one': meta('ssh:box') @@ -38,16 +50,19 @@ function store() { } describe('scope catalog from the profile store', () => { - it('keeps only the rows the addressed execution host owns', () => { + it('captures every Repo/setup before projection and applies filesystem eligibility afterwards', async () => { const local = sessionSearchScopeCatalogFromStore(store(), 'local') - expect(local.repos.map((repo) => repo.id)).toEqual(['local-repo']) - expect(local.projectHostSetups.map((setup) => setup.repoId)).toEqual(['local-repo']) + expect(local.repos.map((repo) => repo.id)).toEqual(['local-repo', 'ssh-repo']) + expect(local.projectHostSetups.map((setup) => setup.repoId)).toEqual(['local-repo', 'ssh-repo']) + const selection = await compileSessionSearchProjectOwnership(local, 'repo:local-repo') + expect(sessionSearchProjectOwns(selection, '/work/app', '/native/session')).toBe(true) + expect(sessionSearchProjectOwns(selection, '/srv/app', '/native/session')).toBe(false) expect(Object.keys(local.worktreeMeta)).toEqual(['local-repo::/work/one']) }) it('answers for an SSH host from that host’s own rows, not the desktop’s', () => { const remote = sessionSearchScopeCatalogFromStore(store(), 'ssh:box') - expect(remote.repos.map((repo) => repo.id)).toEqual(['ssh-repo']) + expect(remote.repos.map((repo) => repo.id)).toEqual(['local-repo', 'ssh-repo']) expect(Object.keys(remote.worktreeMeta)).toEqual(['ssh-repo::/srv/one']) }) diff --git a/src/main/ai-vault-search/session-search-store-scope-catalog.ts b/src/main/ai-vault-search/session-search-store-scope-catalog.ts index 0862148fe993..bfd19e0a6420 100644 --- a/src/main/ai-vault-search/session-search-store-scope-catalog.ts +++ b/src/main/ai-vault-search/session-search-store-scope-catalog.ts @@ -1,8 +1,4 @@ -import { - getRepoExecutionHostId, - normalizeExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' +import type { ExecutionHostId } from '../../shared/execution-host' import type { ProjectHostSetup } from '../../shared/project-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GlobalSettings } from '../../shared/global-settings-types' @@ -20,22 +16,25 @@ export type SessionSearchScopeStore = { })[] getAllWorktreeMeta(): Record getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record - getSettings(): Pick + getSettings(): Pick } -// The store includes other hosts’ paths; expose only the answering host’s registrations. +// Claims need the complete catalog; filesystem eligibility is applied after projection. export function sessionSearchScopeCatalogFromStore( store: SessionSearchScopeStore, executionHostId: ExecutionHostId ): SessionSearchScopeCatalog { const settings = store.getSettings() return { - repos: store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === executionHostId), + repos: store.getRepos(), + executionHostId, projects: store.getProjects(), - projectHostSetups: store - .getProjectHostSetups() - .filter((setup) => normalizeExecutionHostId(setup.hostId) === executionHostId), + projectHostSetups: store.getProjectHostSetups(), worktreeMeta: readAllWorktreeMetaForHost(store, executionHostId), - settings: { workspaceDir: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces } + settings: { + workspaceDir: settings.workspaceDir, + nestWorkspaces: settings.nestWorkspaces, + workspaceDirHistory: settings.workspaceDirHistory + } } } diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 90751e319310..1eb4e482b20a 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,4 +1,9 @@ import { join } from 'node:path' +import type { SessionSearchScopeCatalogSource } from '../ai-vault-search/session-search-scope-catalog' +import { + compileSessionSearchProjectOwnership, + sessionSearchProjectOwner +} from '../ai-vault-search/session-search-project-ownership' import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { clearAiVaultBackgroundRestartCircuit, @@ -47,6 +52,7 @@ export class AiVaultLocalScanError extends Error { // Configure sources through the runtime service; headless serve never runs window-only IPC registration. export type AiVaultSessionSources = { + getProjectOwnershipCatalog?: SessionSearchScopeCatalogSource getAdditionalCodexHomePaths?: () => readonly string[] } @@ -116,12 +122,15 @@ export async function listAiVaultSessions( cachedList.expiresAt > now && aiVaultCachedDepthServes(cachedList.depth, depth, args?.scopePaths) ) { - return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) + return annotateHistoryOwnership( + truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths), + args + ) } // Capture before the coordinator's start microtask so intervening invalidation still rejects the result. const startGeneration = cacheGeneration let progress: LocalAiVaultScanProgressHandle | null = null - return scanCoordinator.run({ + const result = await scanCoordinator.run({ key: scanKey, force: args?.force, signal: options.signal, @@ -169,6 +178,37 @@ export async function listAiVaultSessions( return result } }) + return annotateHistoryOwnership(result, args) +} + +async function annotateHistoryOwnership( + result: AiVaultListResult, + args?: AiVaultListArgs +): Promise { + if (!args?.historyOwnership) { + return result + } + try { + const catalog = sources.getProjectOwnershipCatalog?.() + if (catalog) { + const ownership = await compileSessionSearchProjectOwnership( + catalog, + args.historyOwnership.projectKey ?? '' + ) + result = { + ...result, + sessions: result.sessions.map((session) => ({ + ...session, + projectOwnerKey: session.cwd + ? sessionSearchProjectOwner(ownership, session.cwd, session.filePath) + : null + })) + } + } + } catch (error) { + console.error('[ai-vault] history ownership unavailable:', error) + } + return result } // Subagent transcript validation shares these WSL-aware roots with discovery. diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index cd4ba27203d9..cd8062a7e77f 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -59,6 +59,7 @@ describe('runtime AI Vault session scanner', () => { { limit: 25, force: true, + historyOwnership: {}, scopePaths: ['/srv/app'] }, { timeoutMs: 3000 } @@ -71,6 +72,7 @@ describe('runtime AI Vault session scanner', () => { { limit: 25, force: true, + historyOwnership: {}, scopePaths: ['/srv/app'], executionHostId: 'runtime:env-1' }, diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 933753043754..f82c891be464 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -77,6 +77,7 @@ export async function scanRuntimeAiVaultSessions( environmentId, 'aiVault.listSessions', { + historyOwnership: args.historyOwnership, includeAntigravityIdeSessions: args.includeAntigravityIdeSessions, limit: args.limit, unlimited: args.unlimited, diff --git a/src/main/ai-vault/runtime-session-search-call.test.ts b/src/main/ai-vault/runtime-session-search-call.test.ts index d70135448593..f1e329834223 100644 --- a/src/main/ai-vault/runtime-session-search-call.test.ts +++ b/src/main/ai-vault/runtime-session-search-call.test.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const callRuntimeEnvironment = vi.hoisted(() => vi.fn()) +const { callRuntimeEnvironment, resolveEnvironment } = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) +vi.mock('../../shared/runtime-environment-store', () => ({ resolveEnvironment })) vi.mock('../ipc/runtime-environments/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment })) -import { callRuntimeSessionSearch } from './runtime-session-search-call' +import { callRuntimeSessionSearch, bindRuntimeSessionSearch } from './runtime-session-search-call' beforeEach(() => { callRuntimeEnvironment.mockReset() @@ -37,3 +41,36 @@ describe('runtime session search transport', () => { ).rejects.toMatchObject({ code: 'method_not_found', message: 'unknown method' }) }) }) + +it('captures the selected pairing/runtime before status and reuses it for search and continuation', async () => { + const selected = { id: 'env-1', createdAt: 1, pairingRevision: 7, runtimeId: 'runtime-a' } + resolveEnvironment.mockReturnValue(selected) + const call = bindRuntimeSessionSearch('/user/data', 'env-1') + selected.pairingRevision = 8 + selected.runtimeId = 'runtime-b' + callRuntimeEnvironment.mockResolvedValue({ ok: true, result: {} }) + for (const [method, params] of [ + ['aiVault.searchStatus', {}], + [ + 'aiVault.searchSessions', + { + query: 'needle', + within: { kind: 'project', projectKey: 'project:P', canonicalProjectKey: 'repo:b' } + } + ], + ['aiVault.searchSessions', { query: 'needle', cursor: 'next' }] + ] as const) { + await call(method, params) + expect(callRuntimeEnvironment).toHaveBeenLastCalledWith( + '/user/data', + 'env-1', + method, + params, + undefined, + 7, + undefined, + { expectedEnvironmentRuntimeId: 'runtime-a' } + ) + } + expect(resolveEnvironment).toHaveBeenCalledOnce() +}) diff --git a/src/main/ai-vault/runtime-session-search-call.ts b/src/main/ai-vault/runtime-session-search-call.ts index 4dfe7d66f31a..036d8cad2976 100644 --- a/src/main/ai-vault/runtime-session-search-call.ts +++ b/src/main/ai-vault/runtime-session-search-call.ts @@ -1,3 +1,4 @@ +import { resolveEnvironment } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing' // Preserve RPC refusal codes so search recognizes older hosts that lack the method. @@ -5,11 +6,33 @@ export async function callRuntimeSessionSearch( userDataPath: string, environmentId: string, method: string, - params: Record + params: Record, + expected?: { pairingRevision: number; runtimeId?: string } ): Promise { - const response = await callRuntimeEnvironment(userDataPath, environmentId, method, params) + const response = expected + ? await callRuntimeEnvironment( + userDataPath, + environmentId, + method, + params, + undefined, + expected.pairingRevision, + undefined, + { expectedEnvironmentRuntimeId: expected.runtimeId } + ) + : await callRuntimeEnvironment(userDataPath, environmentId, method, params) if (response.ok === true) { return response.result } throw Object.assign(new Error(response.error.message), { code: response.error.code }) } + +export function bindRuntimeSessionSearch(userDataPath: string, environmentId: string) { + const environment = resolveEnvironment(userDataPath, environmentId) + const expected = { + pairingRevision: environment.pairingRevision ?? environment.createdAt, + runtimeId: environment.runtimeId ?? undefined + } + return (method: string, params: Record) => + callRuntimeSessionSearch(userDataPath, environment.id, method, params, expected) +} diff --git a/src/main/ai-vault/runtime-session-search-lifetime.test.ts b/src/main/ai-vault/runtime-session-search-lifetime.test.ts new file mode 100644 index 000000000000..a54e8df71bc9 --- /dev/null +++ b/src/main/ai-vault/runtime-session-search-lifetime.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto' +import { encodePairingOffer } from '../../shared/pairing' +import { + addEnvironmentFromPairingCode, + markEnvironmentUsed, + updateEnvironmentFromPairingCode +} from '../../shared/runtime-environment-store' +import { enqueueRuntimeCall } from '../ipc/runtime-environments/runtime-environment-call-queue' +import { bindRuntimeSessionSearch } from './runtime-session-search-call' + +const send = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +vi.mock('../ipc/runtime-environments/runtime-environment-abortable-requests', () => ({ + sendRemoteRuntimeConnectionRequestAbortable: send, + sendRemoteRuntimeRequestAbortable: send +})) + +it.each(['pairing', 'runtime'] as const)( + 'refuses a queued search after the selected %s changes before dispatch', + async (change) => { + const root = await mkdtemp(join(tmpdir(), 'sta10541-search-queue-')) + let release: () => void = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + const blockers: Promise[] = [] + try { + send.mockClear() + const pairingCode = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:9', + deviceToken: 'a'.repeat(48), + publicKeyB64: publicKeyToBase64(generateKeyPair().publicKey) + }) + const env = addEnvironmentFromPairingCode(root, { name: 'search-host', pairingCode }) + markEnvironmentUsed(root, env.id, { runtimeId: 'runtime-a' }) + const call = bindRuntimeSessionSearch(root, env.id) + for (let index = 0; index < 8; index++) { + blockers.push(enqueueRuntimeCall(env.id, 'aiVault.searchSessions', () => held)) + } + const result = call('aiVault.searchSessions', { + query: 'needle', + within: { kind: 'project', projectKey: 'project:P', canonicalProjectKey: 'repo:b' } + }).then( + () => { + throw new Error('changed target accepted') + }, + (error: unknown) => error + ) + if (change === 'pairing') { + updateEnvironmentFromPairingCode(root, env.id, { pairingCode }) + } else { + markEnvironmentUsed(root, env.id, { runtimeId: 'runtime-b' }) + } + release() + await Promise.all(blockers) + expect(await result).toMatchObject({ code: 'runtime_environment_changed' }) + expect(send).not.toHaveBeenCalled() + } finally { + release() + await Promise.all(blockers) + await rm(root, { recursive: true, force: true }) + } + } +) diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 9c6b49f5a860..a8f657c65b9c 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { isAiVaultProjectKey } from '../../shared/ai-vault-project-key' import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT, @@ -58,6 +59,7 @@ const aiVaultSessionSchema = z.object({ .max(AI_VAULT_SCOPE_PATHS_MAX_COUNT) .optional() .catch(undefined), + projectOwnerKey: z.string().refine(isAiVaultProjectKey).nullable().optional().catch(undefined), branch: z.string().nullable(), model: z.string().nullable(), filePath: z.string(), diff --git a/src/main/ai-vault/session-scanner-service-search-process.test.ts b/src/main/ai-vault/session-scanner-service-search-process.test.ts new file mode 100644 index 000000000000..343728f0c403 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-search-process.test.ts @@ -0,0 +1,186 @@ +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' +import { once } from 'node:events' +import { createRequire } from 'node:module' +import { mkdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { expect, it } from 'vitest' +import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' +import type { Repo } from '../../shared/repo-types' +import { SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT } from '../ai-vault-search/session-search-engine' +import { + addSyntheticSession, + markFork +} from '../ai-vault-search/session-search-engine-test-fixture' +import { openSessionSearchIndexerHarness } from '../ai-vault-search/session-search-indexer-test-fixture' +import { compileSessionSearchProjectOwnership } from '../ai-vault-search/session-search-project-ownership' +import { openSessionSearchDatabase } from '../ai-vault-search/session-search-schema' +import { AiVaultScannerServiceClient } from './session-scanner-service-client' +import { buildAiVaultServiceEnv } from './session-scanner-service-env' +import { isAiVaultServiceChildMessage } from './session-scanner-service-protocol' + +it('serializes canonical ownership into a separate production scanner before native candidate caps and fork folding', async () => { + const harness = await openSessionSearchIndexerHarness('sta10541-scanner-process') + const home = join(harness.root, 'child-home') + await mkdir(home) + const executable = + process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.env.npm_node_execpath ?? process.execPath + const entry = resolve('src/main/ai-vault/session-scanner-service-entry.ts') + const loader = createRequire(import.meta.url).resolve('tsx') + let child: ChildProcessHandle | undefined + let readyPid: number | undefined + let closed: Promise | undefined + let stderr = '' + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + expect(child).toBeUndefined() + const startedAtMonotonicNs = process.hrtime.bigint().toString() + child = spawnProcess({ + program: executable, + args: ['--max-old-space-size=384', '--conditions=orca-source', '--import', loader, entry], + serialization: 'json', + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { + ...buildAiVaultServiceEnv({ PATH: process.env.PATH, HOME: home, USERPROFILE: home }), + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + ORCA_E2E_USER_DATA_DIR: join(home, 'user-data'), + ORCA_E2E_HOME_DIR: home, + XDG_CONFIG_HOME: join(home, 'config'), + XDG_CACHE_HOME: join(home, 'cache'), + XDG_DATA_HOME: join(home, 'data'), + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEX_HOME: join(home, '.codex') + } + }) + closed = once(child, 'close') + child.on('message', (message: unknown) => { + if (isAiVaultServiceChildMessage(message) && message.type === 'ready') { + readyPid = message.pid + } + }) + console.info('scanner child ownership', { + pid: child.pid, + parentPid: process.pid, + startedAtMonotonicNs, + executable, + entry + }) + return child + }, + resolveSessionSearchRoots: async () => harness.roots, + init: () => ({ + sessionParseCache: null, + sessionSearch: { + databasePath: harness.databasePath, + roots: harness.roots, + settings: { enabled: true, historyDays: null } + } + }), + onStderr: (text) => { + stderr += text + } + }) + try { + await client.request({ type: 'request', operation: 'searchReconcile' }) + expect(readyPid, stderr).toBe(child?.pid) + expect(readyPid).toBeGreaterThan(0) + expect(readyPid).not.toBe(process.pid) + const repos: Repo[] = ['a', 'b'].map((id, index) => ({ + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' } + })) + const selection = await compileSessionSearchProjectOwnership( + { + repos, + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + }, + 'repo:b' + ) + // Seed only after the child's initial real reconciliation; no parser or engine is replaced. + const db = openSessionSearchDatabase(harness.databasePath) + const target = SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT + 2 + try { + db.exec('BEGIN') + for (let id = 1; id < target; id++) { + addSyntheticSession(db, { + id, + cwd: '/repos/a', + text: 'childboundary', + updatedAt: '2026-10-23' + }) + } + addSyntheticSession(db, { + id: target, + cwd: '/repos/b', + text: 'childboundary', + updatedAt: '2026-10-21' + }) + addSyntheticSession(db, { + id: target + 1, + cwd: '/repos/b/topic', + text: 'childboundary', + updatedAt: '2026-10-20' + }) + markFork(db, [1, target], 'shared-child-opening') + db.exec('COMMIT') + } finally { + db.close() + } + const request = { query: 'childboundary', limit: 1, filters: { sort: 'newest' as const } } + const legacy = await client.request({ + type: 'request', + operation: 'searchSessions', + request + }) + expect(legacy).toMatchObject({ kind: 'results', hits: [{ sessionId: '1' }] }) + const scoped = await client.request({ + type: 'request', + operation: 'searchSessions', + request, + hostScope: { kind: 'canonical', selection } + }) + expect(scoped).toMatchObject({ + kind: 'results', + hits: [{ sessionId: String(target) }], + page: { hasMore: true } + }) + if (scoped.kind !== 'results' || !scoped.page.cursor) { + throw new Error(`Missing canonical child continuation: ${stderr}`) + } + expect( + await client.request({ + type: 'request', + operation: 'searchSessions', + request: { ...request, cursor: scoped.page.cursor }, + hostScope: { kind: 'canonical', selection } + }) + ).toMatchObject({ + kind: 'results', + hits: [{ sessionId: String(target + 1) }], + page: { hasMore: false } + }) + } finally { + client.dispose() + const force = setTimeout(() => { + if (child && child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + }, 5_000) + try { + await closed + expect(child?.exitCode, stderr).toBe(0) + expect(child?.signalCode, stderr).toBeNull() + } finally { + clearTimeout(force) + await harness.cleanup() + } + } +}) diff --git a/src/main/ai-vault/session-scanner-service-search.test.ts b/src/main/ai-vault/session-scanner-service-search.test.ts index 7d6883d967ab..328c76d6cb7e 100644 --- a/src/main/ai-vault/session-scanner-service-search.test.ts +++ b/src/main/ai-vault/session-scanner-service-search.test.ts @@ -1,3 +1,4 @@ +import { compileSessionSearchProjectOwnership } from '../ai-vault-search/session-search-project-ownership' import { existsSync, rmSync } from 'node:fs' import { join } from 'node:path' import { afterAll, beforeAll, expect, it, vi } from 'vitest' @@ -132,6 +133,30 @@ it('answers a search and a reconcile over the protocol', async () => { } }) +it('executes the canonical selection through the actual child message executor and native index', async () => { + const selection = await compileSessionSearchProjectOwnership( + { + repos: [{ id: 'actual', path: '/repo/app', displayName: 'app', badgeColor: '', addedAt: 1 }], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + }, + 'repo:actual' + ) + const response = await call({ + type: 'request', + operation: 'searchSessions', + request: { query: 'distinctive' }, + hostScope: { kind: 'canonical', selection } + }) + expect(response.operation).toBe('searchSessions') + if (response.operation !== 'searchSessions') { + throw new Error('wrong operation') + } + expect(response.value).toMatchObject({ kind: 'results', hits: [{ sessionId: SESSION_ID }] }) +}) + it('discovers a new root through the parent exchange on manual reconciliation', async () => { const lateHome = join(harness.root, 'late-home') const id = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' diff --git a/src/main/ai-vault/session-scope-real-paths.ts b/src/main/ai-vault/session-scope-real-paths.ts index 7dfd122e7971..61e479320a29 100644 --- a/src/main/ai-vault/session-scope-real-paths.ts +++ b/src/main/ai-vault/session-scope-real-paths.ts @@ -32,8 +32,11 @@ export async function resolveAiVaultScopeRealPaths( } } const scanScopePaths = [...scopePaths] + const seen = new Set(scopePaths.map(normalizeRuntimePathForComparison)) for (const { realPath } of links) { - if (!scanScopePaths.some((path) => sameComparisonPath(path, realPath))) { + const key = normalizeRuntimePathForComparison(realPath) + if (!seen.has(key)) { + seen.add(key) scanScopePaths.push(realPath) } } diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts index 721de555dbab..244b53d295c5 100644 --- a/src/main/ipc/ai-vault-runtime-scan.ts +++ b/src/main/ipc/ai-vault-runtime-scan.ts @@ -66,6 +66,9 @@ function runtimeScanArgs( listArgs: AiVaultListArgs | undefined ): AiVaultListArgs { const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } + if (listArgs?.historyOwnership !== undefined) { + scanArgs.historyOwnership = listArgs.historyOwnership + } if (listArgs?.afterFingerprint !== undefined) { scanArgs.afterFingerprint = listArgs.afterFingerprint } diff --git a/src/main/ipc/ai-vault-search-all-hosts.test.ts b/src/main/ipc/ai-vault-search-all-hosts.test.ts index d4e470fb457e..c545c982729e 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.test.ts +++ b/src/main/ipc/ai-vault-search-all-hosts.test.ts @@ -127,20 +127,29 @@ it('answers an empty merge when no host is reachable to ask', async () => { }) }) -it('asks every leg for the merged order and passes freshness through unchanged', async () => { +it('asks every leg for the merged order and passes both Project keys and freshness unchanged', async () => { const host = new StubHost('local', sessions('a', 2)) + const remote = new StubHost('runtime:remote', sessions('b', 2)) + const within = { + kind: 'project' as const, + projectKey: 'project:P', + canonicalProjectKey: 'repo:b' + } await searchAllExecutionHosts( { query: 'needle', freshness: 'wait-until-current', + within, filters: { sort: 'newest' }, debug: true }, - [host.leg()] + [host.leg(), remote.leg()] ) + expect(remote.lastRequests[0]).toEqual(host.lastRequests[0]) expect(host.lastRequests[0]).toEqual({ query: 'needle', freshness: 'wait-until-current', + within, filters: { sort: 'newest' } }) }) @@ -232,37 +241,76 @@ it('names an unavailable host by its reason without losing the other hosts', asy expect(response.page.hasMore).toBe(false) }) -it('calls a leg that times out unreachable and retries it on the next page', async () => { - vi.useFakeTimers() - try { - const stalled: SessionSearchHostLeg = { - executionHostId: 'ssh:slow', - timeoutMs: 50, - search: () => new Promise(() => undefined) +it.each(['relevance', 'newest'] as const)( + 'keeps available %s hits when another leg times out and retries it', + async (sort) => { + vi.useFakeTimers() + try { + const stalled: SessionSearchHostLeg = { + executionHostId: 'ssh:slow', + timeoutMs: 50, + search: () => new Promise(() => undefined) + } + const healthy = new StubHost('local', sessions('a', 2)) + const pending = searchAllExecutionHosts({ query: 'needle', filters: { sort } }, [ + healthy.leg(), + stalled + ]) + await vi.advanceTimersByTimeAsync(60) + const first = resultsOf(await pending) + expect(first.hosts).toContainEqual({ + executionHostId: 'ssh:slow', + outcome: 'unreachable' + }) + expect(first.page.hasMore).toBe(true) + expect(first.hits.map((hit) => hit.sessionId)).toEqual(['a-0', 'a-1']) + + const recovered = new StubHost('ssh:slow', sessions('s', 2)) + const second = resultsOf( + await searchAllExecutionHosts( + { query: 'needle', filters: { sort }, cursor: first.page.cursor! }, + [healthy.leg(), recovered.leg()] + ) + ) + // The healthy host finished, so only the retried host is still in the walk. + expect(second.hosts).toEqual([{ executionHostId: 'ssh:slow', outcome: 'searched' }]) + expect(second.hits.map((hit) => hit.sessionId)).toEqual(['s-0', 's-1']) + } finally { + vi.useRealTimers() } - const healthy = new StubHost('local', sessions('a', 2)) - const pending = searchAllExecutionHosts({ query: 'needle' }, [healthy.leg(), stalled]) - await vi.advanceTimersByTimeAsync(60) - const first = resultsOf(await pending) - expect(first.hosts).toContainEqual({ - executionHostId: 'ssh:slow', - outcome: 'unreachable' - }) - expect(first.page.hasMore).toBe(true) + } +) - const recovered = new StubHost('ssh:slow', sessions('s', 2)) - const second = resultsOf( - await searchAllExecutionHosts({ query: 'needle', cursor: first.page.cursor! }, [ +it.each(['relevance', 'newest'] as const)( + 'keeps available %s hits and the retry cursor when another leg rejects', + async (sort) => { + const healthy = new StubHost('local', sessions('a', 2)) + const first = resultsOf( + await searchAllExecutionHosts({ query: 'needle', filters: { sort } }, [ healthy.leg(), - recovered.leg() + { + executionHostId: 'ssh:down', + search: async () => { + throw new Error('connection refused') + } + } ]) ) - expect(second.hosts).toEqual([{ executionHostId: 'ssh:slow', outcome: 'searched' }]) + expect(first.hits.map((hit) => hit.sessionId)).toEqual(['a-0', 'a-1']) + expect(first.hosts).toContainEqual({ executionHostId: 'ssh:down', outcome: 'unreachable' }) + expect(first.page.hasMore).toBe(true) + const recovered = new StubHost('ssh:down', sessions('s', 2)) + const second = resultsOf( + await searchAllExecutionHosts( + { query: 'needle', filters: { sort }, cursor: first.page.cursor! }, + [healthy.leg(), recovered.leg()] + ) + ) + expect(second.hosts).toEqual([{ executionHostId: 'ssh:down', outcome: 'searched' }]) expect(second.hits.map((hit) => hit.sessionId)).toEqual(['s-0', 's-1']) - } finally { - vi.useRealTimers() + expect(second.page.hasMore).toBe(false) } -}) +) it('reads at most three pages from one host per merged request', async () => { // Three-page budget stops a six-page host walk; retain its unread cursor for the next merged page. diff --git a/src/main/ipc/ai-vault-search-all-hosts.ts b/src/main/ipc/ai-vault-search-all-hosts.ts index 3ee0d6ac7462..c15306780456 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.ts +++ b/src/main/ipc/ai-vault-search-all-hosts.ts @@ -50,7 +50,8 @@ type HostWalk = { /** Independent relevance scores cannot be compared: merge by recency or host round-robin, retaining page overflow. */ export async function searchAllExecutionHosts( request: AiVaultSearchRequest, - legs: readonly SessionSearchHostLeg[] + legs: readonly SessionSearchHostLeg[], + isActive: () => boolean = () => true ): Promise { const startedAt = Date.now() const limit = resolveSessionSearchLimit(request.limit) @@ -74,9 +75,13 @@ export async function searchAllExecutionHosts( .map((leg) => newHostWalk(leg, legRequest(request, sort))) .sort((left, right) => left.executionHostId.localeCompare(right.executionHostId)) await Promise.all( - walks.map((walk) => fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null)) + walks.map((walk) => + isActive() + ? fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null) + : Promise.resolve() + ) ) - const hits = await drainMergedPage(walks, limit, sort) + const hits = await drainMergedPage(walks, limit, sort, isActive) return mergedSearchResponse(walks, { limit, sort }, hits, Date.now() - startedAt) } @@ -135,8 +140,12 @@ async function fetchHostPage(walk: HostWalk, entry: MergedSearchCursorEntry | nu endHostWalk(walk, 'stale', false) return } - // Emitted offsets are generation-specific; fence only after emitting hits. - if (walk.emitted > 0 && walk.generation !== response.generation) { + // `e` is an offset into one generation's ranked page, so it is only + // meaningful while that generation stands. Nothing emitted, nothing to fence. + if ( + (walk.emitted > 0 || walk.cursor !== null || walk.pages > 1) && + walk.generation !== response.generation + ) { endHostWalk(walk, 'stale', false) return } @@ -158,8 +167,8 @@ function endHostWalk(walk: HostWalk, outcome: HostOutcome, carry: boolean): void walk.carry = carry } -async function advanceHostWalk(walk: HostWalk): Promise { - while (walk.pending.length === 0 && walk.nextCursor !== null) { +async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise { + while (isActive() && walk.pending.length === 0 && walk.nextCursor !== null) { if (walk.pages >= MAX_HOST_PAGES_PER_REQUEST) { // Budget spent; the unread page's cursor is already this walk's nextCursor. walk.carry = true @@ -172,14 +181,15 @@ async function advanceHostWalk(walk: HostWalk): Promise { async function drainMergedPage( walks: readonly HostWalk[], limit: number, - sort: MergedSort + sort: MergedSort, + isActive: () => boolean ): Promise { const hits: AiVaultSearchHit[] = [] let turn = 0 - while (hits.length < limit) { + while (isActive() && hits.length < limit) { // Every head must be known before picking, so a lagging host is never skipped. for (const walk of walks) { - await advanceHostWalk(walk) + await advanceHostWalk(walk, isActive) } const next = sort === 'newest' ? mostRecentWalk(walks) : walkWithTurn(walks, turn) if (!next) { diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index cfb76ba3cf14..6a8ae1c7bc32 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -1,13 +1,18 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch } = vi.hoisted(() => ({ - clearSearch: vi.fn(), - handlers: new Map Promise>(), - sshSearch: vi.fn(), - sshHostInfos: vi.fn<() => { targetId: string }[]>(() => []), - runtimeSearch: vi.fn() -})) +const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch, runtimeHostInfos } = + vi.hoisted(() => ({ + clearSearch: vi.fn(), + handlers: new Map Promise>(), + sshSearch: vi.fn(), + sshHostInfos: vi.fn<() => { targetId: string }[]>(() => []), + runtimeSearch: vi.fn(), + runtimeHostInfos: vi.fn<() => readonly RuntimeAiVaultHostInfo[]>(() => []) + })) vi.mock('../ai-vault/session-scanner-service-spawn', () => ({ clearSessionSearchInService: clearSearch })) @@ -18,11 +23,23 @@ vi.mock('electron', () => ({ }, ipcRenderer: { invoke: (name: string, ...args: unknown[]) => handlers.get(name)!(null, ...args) } })) +vi.mock('./ai-vault', () => ({ + getActiveRuntimeAiVaultHostInfosResult: () => ({ hostInfos: runtimeHostInfos() }) +})) vi.mock('./ssh/ssh', () => ({ requestActiveSshSessionSearch: sshSearch, getActiveSshAiVaultHostInfos: sshHostInfos })) +import type { RuntimeAiVaultHostInfo } from './ai-vault-runtime-scan' +import { getSavedRuntimeAiVaultHostInfos } from '../ai-vault/runtime-session-scanner' +import { bindRuntimeSessionSearch } from '../ai-vault/runtime-session-search-call' +import { + addEnvironmentFromPairingCode, + removeEnvironment +} from '../../shared/runtime-environment-store' +import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto' +import { encodePairingOffer } from '../../shared/pairing' import { registerAiVaultSearchHandlers } from './ai-vault-search' import { aiVaultApi } from '../../preload/api/ai-vault-bridge' import { setSessionSearchService } from '../ai-vault-search/session-search-service-registry' @@ -42,6 +59,7 @@ beforeEach(() => { sshHostInfos.mockReset() sshHostInfos.mockReturnValue([]) runtimeSearch.mockReset() + runtimeHostInfos.mockReset().mockReturnValue([]) clearSearch.mockReset() registerAiVaultSearchHandlers({ callRuntimeSearch: runtimeSearch @@ -276,3 +294,70 @@ describe('local search hits owned by a native chat', () => { warn.mockRestore() }) }) + +it('binds the desktop leg once before awaited agent status and reuses the producer address', async () => { + const bound = vi.fn(async (method: string) => + method === 'aiVault.searchStatus' + ? { ...unavailableSessionSearchStatus(), supportedAgents: ['codex'] } + : searchResults() + ) + const bindRuntimeSearch = vi.fn(() => bound) + registerAiVaultSearchHandlers({ callRuntimeSearch: runtimeSearch, bindRuntimeSearch }) + const within = { + kind: 'project' as const, + projectKey: 'project:P', + canonicalProjectKey: 'repo:b' + } + await aiVaultApi.searchSessions( + { query: 'needle', filters: { agents: ['codex'] }, within }, + 'runtime:selected' + ) + expect(bindRuntimeSearch).toHaveBeenCalledExactlyOnceWith('selected') + expect(bound.mock.calls.map(([method]) => method)).toEqual([ + 'aiVault.searchStatus', + 'aiVault.searchSessions' + ]) + expect(bound).toHaveBeenLastCalledWith( + 'aiVault.searchSessions', + expect.objectContaining({ within }) + ) + expect(runtimeSearch).not.toHaveBeenCalled() +}) + +it('keeps healthy All results when a persisted runtime is removed between enumeration and eager binding', async () => { + const root = await mkdtemp(join(tmpdir(), 'sta10541-all-bind-')) + try { + const environment = addEnvironmentFromPairingCode(root, { + name: 'search-host', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:9', + deviceToken: 'a'.repeat(48), + publicKeyB64: publicKeyToBase64(generateKeyPair().publicKey) + }) + }) + runtimeHostInfos.mockImplementationOnce(() => { + const advertised = getSavedRuntimeAiVaultHostInfos(root) + removeEnvironment(root, environment.id) + return advertised + }) + const local = fakeSearchService() + setSessionSearchService(local) + const bindRuntimeSearch = vi.fn((id: string) => bindRuntimeSessionSearch(root, id)) + registerAiVaultSearchHandlers({ callRuntimeSearch: runtimeSearch, bindRuntimeSearch }) + const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'all') + expect(bindRuntimeSearch).toHaveBeenCalledExactlyOnceWith(environment.id) + expect(local.search).toHaveBeenCalledOnce() + expect(result).toMatchObject({ kind: 'results', hits: [{ executionHostId: 'local' }] }) + if (result.kind !== 'results') { + throw new Error('healthy results lost') + } + expect(result.hosts).toContainEqual({ + executionHostId: `runtime:${environment.id}`, + outcome: 'unreachable' + }) + expect(runtimeSearch).not.toHaveBeenCalled() + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 9f1a1a87873b..be3670958eb3 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -43,6 +43,9 @@ export type RuntimeSessionSearchCall = ( export type AiVaultSearchHandlerOptions = { callRuntimeSearch?: RuntimeSessionSearchCall + bindRuntimeSearch?: ( + environmentId: string + ) => (method: string, params: Record) => Promise ensureStructuredSessionOwnership?: () => Promise } @@ -166,14 +169,23 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] { } function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg { - const client = remoteSearchClient(host, handlerOptions.callRuntimeSearch) - return { - executionHostId: host.id, - timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.search, - search: (request) => - client - ? client.searchSessions(request) - : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }) + try { + const client = remoteSearchClient(host, handlerOptions.callRuntimeSearch) + return { + executionHostId: host.id, + timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.search, + search: (request) => + client + ? client.searchSessions(request) + : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }) + } + } catch (error) { + return { + executionHostId: host.id, + search: async () => { + throw error + } + } } } @@ -214,8 +226,9 @@ function remoteSearchClient( ): ReturnType | null { if (host.kind === 'runtime' && call) { const { environmentId } = host + const bound = handlerOptions.bindRuntimeSearch?.(environmentId) return createSessionSearchClient( - (method, params) => call(environmentId, method, params), + bound ?? ((method, params) => call(environmentId, method, params)), 'relay' ) } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 5a73496a65a6..267d4f364f1b 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -117,13 +117,11 @@ async function listAiVaultSessions( ? [...new Set(scopePaths)].sort() : scopePaths, executionHostScope, + historyOwnership: args?.historyOwnership, includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true }) const depth = requestedAiVaultSessionDepth(args) - const afterFingerprint = - parseRoutableExecutionHostId(executionHostScope)?.kind === 'runtime' - ? args?.afterFingerprint - : undefined + const afterFingerprint = parsedHost?.kind === 'runtime' ? args?.afterFingerprint : undefined // Why: every renderer request carries its own cancellation signal, so // coalescing has to survive them — the coordinator hands all same-key callers // one scan and only aborts it once every one of them has cancelled. diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index c5ea208ea6e7..02ff0bc0a33c 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -1,4 +1,6 @@ import { app } from 'electron' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { sessionSearchScopeCatalogFromStore } from '../../ai-vault-search/session-search-store-scope-catalog' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { registerAppHandlers } from '../app' import { registerCliHandlers } from '../cli' @@ -102,7 +104,10 @@ import { resolveRuntimeAiVaultSessionTitles, scanRuntimeAiVaultSessions } from '../../ai-vault/runtime-session-scanner' -import { callRuntimeSessionSearch } from '../../ai-vault/runtime-session-search-call' +import { + callRuntimeSessionSearch, + bindRuntimeSessionSearch +} from '../../ai-vault/runtime-session-search-call' import { configureRuntimeAiVaultSessionDelete } from '../ai-vault-delete' import { ensureStructuredAgentSessionHostUnlessRefused } from '../../runtime/structured-agent-session-host-refusal' import { getAgentSessionAttachmentStore } from '../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' @@ -270,11 +275,15 @@ export function registerCoreHandlers( registerAiVaultSearchHandlers({ callRuntimeSearch: (environmentId, method, params) => callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params), + bindRuntimeSearch: (environmentId) => + bindRuntimeSessionSearch(app.getPath('userData'), environmentId), ensureStructuredSessionOwnership }) registerAiVaultHandlers({ getUserDataPath: () => app.getPath('userData'), ensureStructuredSessionOwnership, + getProjectOwnershipCatalog: () => + sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => @@ -292,9 +301,7 @@ export function registerCoreHandlers( registerNativeChatHandlers() registerClipboardHandlers(store, { localChatAttachmentStore: async () => { - await ensureStructuredAgentSessionHostUnlessRefused(() => - runtime.ensureStructuredAgentSessionHost() - ) + await ensureStructuredSessionOwnership() return getAgentSessionAttachmentStore() } }) diff --git a/src/main/orca-profiles/profile-project-state-file.test.ts b/src/main/orca-profiles/profile-project-state-file.test.ts index 6d03ff335e3c..e386155d75e5 100644 --- a/src/main/orca-profiles/profile-project-state-file.test.ts +++ b/src/main/orca-profiles/profile-project-state-file.test.ts @@ -3,6 +3,8 @@ import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/persisted-state-types' import type { Project, ProjectHostSetup } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' +import { mergeProjectHostSetupCompatibilityState } from '../persistence/tracking-repos/project-host-compatibility' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' import { rebuildRepoBackedProjectState } from './profile-project-state-file' const upstreamIdentity = { @@ -110,3 +112,81 @@ describe('rebuildRepoBackedProjectState', () => { ]) }) }) + +it('reconciles and reloads split and ordinary handoff choices consistently with profile rebuilding', () => { + const projectId = 'github:acme/app' + const repos = [ + makeRepo({ id: 'a', path: 'C:/a', addedAt: 1, upstream: { owner: 'acme', repo: 'app' } }), + makeRepo({ id: 'b', path: 'C:/b', addedAt: 2, upstream: { owner: 'acme', repo: 'app' } }), + makeRepo({ + id: 'c', + path: '/srv/c', + executionHostId: 'runtime:remote', + upstream: { owner: 'acme', repo: 'app' } + }) + ] + const projection = projectHostSetupProjectionFromRepos(repos) + const previous = makeState({ + repos, + projects: [ + makeProject({ + id: projectId, + sourceRepoIds: ['a', 'b', 'c'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ], + projectHostSetups: [ + ...projection.setups.map((setup) => ({ ...setup, projectId })), + makeSetup({ + id: 'pending-b', + projectId: 'repo:b', + hostId: 'ssh:extra', + path: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + ] + }) + const split = { ...previous, ...mergeProjectHostSetupCompatibilityState(previous, repos) } + expect( + split.projects.find((project) => project.id === 'repo:b')?.localWindowsRuntimePreference + ).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + expect(rebuildRepoBackedProjectState(previous).projects).toEqual(split.projects) + for (const preference of [undefined, { kind: 'windows-host' as const }]) { + const saved = { + ...split, + projectHostSetups: split.projectHostSetups.map((setup) => + setup.id === 'pending-b' ? { ...setup, projectId: 'repo:b' } : setup + ), + projects: split.projects.map((project) => + project.id === 'repo:b' + ? { ...project, localWindowsRuntimePreference: preference } + : project + ) + } + const reloaded = JSON.parse(JSON.stringify(saved)) + const remaining = repos.filter((repo) => repo.id !== 'a') + const handedOff = mergeProjectHostSetupCompatibilityState(reloaded, remaining) + expect( + handedOff.projects.find((project) => project.id === projectId)?.localWindowsRuntimePreference + ).toEqual(preference) + expect(handedOff.projectHostSetups?.filter((setup) => setup.id === 'pending-b')).toEqual([ + expect.objectContaining({ + projectId, + hostId: 'ssh:extra', + repoId: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + ]) + expect( + rebuildRepoBackedProjectState({ ...reloaded, repos: remaining }).projectHostSetups + ).toEqual(handedOff.projectHostSetups) + expect(rebuildRepoBackedProjectState({ ...reloaded, repos: remaining }).projects).toEqual( + handedOff.projects + ) + expect( + mergeProjectHostSetupCompatibilityState(JSON.parse(JSON.stringify(handedOff)), remaining) + ).toEqual(handedOff) + } +}) diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 57977e33c4e5..8d065ab65c23 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -1,3 +1,4 @@ +import { isRepoBackedProjectHostSetup } from '../persistence/tracking-repos/project-host-compatibility' import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' @@ -219,16 +220,12 @@ export function writeSerializedProfileState( } } -function isRepoBackedProjectHostSetup( - setup: ProjectHostSetup, - currentRepoIds: ReadonlySet -): boolean { - return Boolean(setup.repoId && currentRepoIds.has(setup.repoId)) -} - export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState { const projection = projectHostSetupProjectionFromRepos(state.repos) - const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects) + const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects, { + previous: state.projectHostSetups, + current: projection.setups + }) const currentRepoIds = new Set(state.repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 06d8c2e0d6e1..44d0e2fb88ee 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -182,16 +182,15 @@ async function startOrcadRuntime( clientCodexHomes: codexAccountHomes }) + const { orcadAiVaultSessionSources, installOrcadSessionSearchService } = + await import('./orcad-session-search') const { loadAgentSessionClaimSigner } = await import('../runtime/agent-session/ownership/agent-session-claim-identity') const runtime = new OrcaRuntimeService(profileStore, stats, { // Persistent signatures let restarted runtimes adopt PTYs owned before restart. agentSessionClaimSigner: loadAgentSessionClaimSigner(runtimeUserDataPath, runtimeUserDataPath), - getAdditionalAiVaultCodexHomePaths: () => [ - ...codexAccountHomes.listSessionHomes(), - ...(accountLaunch?.listCodexSessionHomes() ?? []) - ], - // WSL must retain desktop-pushed CODEX_HOME instead of host-selected resolvers. + ...orcadAiVaultSessionSources(profileStore, codexAccountHomes, accountLaunch), + // Why absent on a WSL host: a resolver overrides the desktop-pushed CODEX_HOME. ...accountLaunch?.runtimeDeps, ...agentHookRuntimeDeps(agentHookServer, observedPaneIdentities, () => profileStore.getSettings() @@ -220,7 +219,6 @@ async function startOrcadRuntime( const { restoreOrcadHostAccountLaunchEnv } = await import('./orcad-host-account-launch-env-store') restoreOrcadHostAccountLaunchEnv(runtime, runtimeUserDataPath) - const { installOrcadSessionSearchService } = await import('./orcad-session-search') sessionSearch = await installOrcadSessionSearchService({ userDataPath: runtimeUserDataPath, getSettings: () => profileStore.getSettings() diff --git a/src/main/orcad/orcad-session-search.ts b/src/main/orcad/orcad-session-search.ts index 3ac0d2c3f536..bba252f26e15 100644 --- a/src/main/orcad/orcad-session-search.ts +++ b/src/main/orcad/orcad-session-search.ts @@ -1,3 +1,8 @@ +import type { RuntimeConstructionDependencies } from '../runtime/construction/runtime-construction-dependencies' +import { + sessionSearchScopeCatalogFromStore, + type SessionSearchScopeStore +} from '../ai-vault-search/session-search-store-scope-catalog' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings' @@ -18,3 +23,21 @@ export async function installOrcadSessionSearchService(args: { onError: (error) => console.error('[orcad] session search:', error) }) } + +export function orcadAiVaultSessionSources( + store: SessionSearchScopeStore, + client: { listSessionHomes(): readonly string[] }, + accounts: { listCodexSessionHomes(): readonly string[] } | null +): Pick< + RuntimeConstructionDependencies, + 'getProjectOwnershipCatalog' | 'getAdditionalAiVaultCodexHomePaths' +> { + return { + getProjectOwnershipCatalog: () => + sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID), + getAdditionalAiVaultCodexHomePaths: () => [ + ...client.listSessionHomes(), + ...(accounts?.listCodexSessionHomes() ?? []) + ] + } +} diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.ts b/src/main/persistence/tracking-repos/project-host-compatibility.ts index f461bf44c6b1..fded84d3a581 100644 --- a/src/main/persistence/tracking-repos/project-host-compatibility.ts +++ b/src/main/persistence/tracking-repos/project-host-compatibility.ts @@ -53,7 +53,8 @@ export function mergeProjectHostSetupCompatibilityState( const projection = projectHostSetupProjectionFromRepos(repos) const succession = carryProjectStateThroughIdentityChange( projection.projects, - state.projects ?? [] + state.projects ?? [], + { previous: state.projectHostSetups ?? [], current: projection.setups } ) const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index 67024d13d0ad..9f60df90df6f 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -192,6 +192,10 @@ const HOST_PORT_SOURCES: Record = { const NOT_HOST_PORTS = new Set(['setTimeout', 'setInterval', 'setImmediate']) const SPREAD_SOURCES: Record = { + 'orcadAiVaultSessionSources(': { + file: 'src/main/orcad/orcad-session-search.ts', + anchor: 'return {' + }, 'agentHookRuntimeDeps(': { file: 'src/main/agent-hooks/agent-hook-runtime-deps.ts', anchor: 'return {' diff --git a/src/main/runtime/construction/runtime-construction-dependencies.ts b/src/main/runtime/construction/runtime-construction-dependencies.ts index 57d6d52b1fe2..6667065ab737 100644 --- a/src/main/runtime/construction/runtime-construction-dependencies.ts +++ b/src/main/runtime/construction/runtime-construction-dependencies.ts @@ -1,3 +1,4 @@ +import type { AiVaultSessionSources } from '../../ai-vault/cached-session-list' import type { CliWriterObservation } from '../../agent-hooks/server/cli-writer-observation' import type { ExecutionHostScope } from '../../../shared/execution-host' import type { IPtyProvider } from '../../providers/pty-provider-contract' @@ -58,6 +59,7 @@ export type RuntimeConstructionDependencies = { dropAgentStatusForRemovedWorktree?: (worktreeId: string, host?: ExecutionHostScope) => void canRecoverPersistentLocalPtys?: () => boolean getPairedDeviceName?: (pairedDeviceId: string) => string | null + getProjectOwnershipCatalog?: AiVaultSessionSources['getProjectOwnershipCatalog'] getAdditionalAiVaultCodexHomePaths?: () => readonly string[] prepareAiVaultSessionResume?: ( args: AiVaultPrepareSessionResumeArgs diff --git a/src/main/runtime/construction/runtime-construction.ts b/src/main/runtime/construction/runtime-construction.ts index 1787638c7d4c..035bea03b9b9 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -229,11 +229,14 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo this.dropAgentStatusForRemovedWorktreeFn = deps?.dropAgentStatusForRemovedWorktree ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null) - // Headless serve skips registerCoreHandlers but still needs managed-Codex/WSL AiVault sources. + // Why: configure the shared AiVault scan cache from a serve-mode-reachable + // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions + // even on headless `orca serve` hosts where registerCoreHandlers never runs. + configureAiVaultSessionSources({ + getProjectOwnershipCatalog: deps?.getProjectOwnershipCatalog, + getAdditionalCodexHomePaths: deps?.getAdditionalAiVaultCodexHomePaths + }) if (deps?.getAdditionalAiVaultCodexHomePaths) { - configureAiVaultSessionSources({ - getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths - }) configureHostReadableTranscriptPathSources({ getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths }) diff --git a/src/main/runtime/mobile-cloud-relay/mobile-relay-e2ee.integration.test.ts b/src/main/runtime/mobile-cloud-relay/mobile-relay-e2ee.integration.test.ts index bf9ec2ce4315..de934643a961 100644 --- a/src/main/runtime/mobile-cloud-relay/mobile-relay-e2ee.integration.test.ts +++ b/src/main/runtime/mobile-cloud-relay/mobile-relay-e2ee.integration.test.ts @@ -1,3 +1,18 @@ +import '../rpc/unused-default-rpc-methods.test-fixture' +import { RpcDispatcher } from '../rpc/dispatcher' +import { OrcaRuntimeService } from '../orca-runtime' +import { AI_VAULT_METHODS } from '../rpc/methods/ai-vault' +import { + openSessionSearchHarness, + addSyntheticSession, + type SessionSearchHarness +} from '../../ai-vault-search/session-search-engine-test-fixture' +import { createSessionSearchService } from '../../ai-vault-search/session-search-service' +import { setSessionSearchService } from '../../ai-vault-search/session-search-service-registry' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from '../../ai-vault-search/session-search-scope-catalog' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -41,8 +56,12 @@ describe('desktop relay E2EE integration', () => { const servers: WebSocketServer[] = [] const transports: CloudRelayTransport[] = [] const userDataPaths: string[] = [] + let searchIndex: SessionSearchHarness | undefined afterEach(async () => { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + await searchIndex?.close() await Promise.all(transports.splice(0).map((transport) => transport.stop())) await Promise.all( servers.splice(0).map( @@ -128,6 +147,40 @@ describe('desktop relay E2EE integration', () => { const receivedText = deferred() const receivedBinary = deferred() const phoneBinary = deferred() + searchIndex = await openSessionSearchHarness('sta10541-relay-native') + addSyntheticSession(searchIndex.db, { id: 1, cwd: '/repo/app' }) + addSyntheticSession(searchIndex.db, { id: 2, cwd: '/other' }) + setSessionSearchService( + createSessionSearchService({ + engine: searchIndex.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => ({ + repos: [{ id: 'app', path: '/repo/app', displayName: 'app', badgeColor: '', addedAt: 1 }], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + })) + const dispatcher = new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + methods: AI_VAULT_METHODS + }) + const request = { + id: 'rpc-1', + authToken: 'test', + method: 'aiVault.searchSessions', + params: { + query: 'needle', + within: { kind: 'project', projectKey: 'repo:app', canonicalProjectKey: 'repo:app' } + } + } const wiring = new MobileSocketWiring({ deviceRegistry: registry, e2eeKeypair: { @@ -144,7 +197,10 @@ describe('desktop relay E2EE integration', () => { credentialKind: 'invite' }) receivedText.resolve(plaintext) - reply(JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } })) + expect(JSON.parse(plaintext)).toEqual(request) + void dispatcher + .dispatch(request, { clientKind: 'mobile' }) + .then((result) => reply(JSON.stringify(result))) sendBinary(new Uint8Array([4, 5, 6])) }, onBinary: (_socket, bytes) => receivedBinary.resolve(new Uint8Array(bytes)), @@ -226,16 +282,16 @@ describe('desktop relay E2EE integration', () => { }) phone.send(JSON.stringify(phoneSession.hello)) await authenticated.promise - phone.send(phoneSession.sealText(JSON.stringify({ id: 'rpc-1', method: 'status.get' }))) + phone.send(phoneSession.sealText(JSON.stringify(request))) phone.send(phoneSession.sealBinary(new Uint8Array([1, 2, 3]))) - await expect(receivedText.promise).resolves.toBe( - JSON.stringify({ id: 'rpc-1', method: 'status.get' }) - ) + await expect(receivedText.promise).resolves.toBe(JSON.stringify(request)) await expect(receivedBinary.promise).resolves.toEqual(new Uint8Array([1, 2, 3])) - await expect(phoneText.promise).resolves.toBe( - JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } }) - ) + expect(JSON.parse(await phoneText.promise)).toMatchObject({ + id: 'rpc-1', + ok: true, + result: { kind: 'results', hits: [{ sessionId: '1' }] } + }) await expect(phoneBinary.promise).resolves.toEqual(new Uint8Array([4, 5, 6])) phone.terminate() diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 6399217f5768..df098ecc26e3 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -30,6 +30,7 @@ import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams } from './ai-vault' +import { orcadAiVaultSessionSources } from '../../../orcad/orcad-session-search' import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-host-refusal' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' @@ -662,6 +663,68 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(options?.wslHomeDirs).toEqual([]) }) + it('constructs ordinary ownership independently of Codex homes or search enablement and forwards RPC intent', async () => { + const capture = vi.fn(() => ({ + repos: [{ id: 'source', path: '/tmp', displayName: 'source', badgeColor: '', addedAt: 1 }], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + })) + const orcadStore = { + getRepos: () => capture().repos, + getProjects: () => [], + getProjectHostSetups: () => [], + getAllWorktreeMeta: () => ({}), + getSettings: () => capture().settings + } + const clientHomes = { listSessionHomes: () => ['/client'] } + const deps = orcadAiVaultSessionSources(orcadStore, clientHomes, null) + expect(deps.getAdditionalAiVaultCodexHomePaths?.()).toEqual(['/client']) + expect( + orcadAiVaultSessionSources(orcadStore, clientHomes, { + listCodexSessionHomes: () => ['/managed'] + }).getAdditionalAiVaultCodexHomePaths?.() + ).toEqual(['/client', '/managed']) + new OrcaRuntimeService(null, undefined, { + getProjectOwnershipCatalog: deps.getProjectOwnershipCatalog + }) + scanAiVaultSessionsInService.mockResolvedValue({ + sessions: [makeSession()], + issues: [], + scannedAt: SCANNED_AT + }) + const dispatcher = makeDispatcher() + expect( + await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { + historyOwnership: {}, + limit: 2, + executionHostId: 'runtime:addressed', + afterFingerprint: null + }) + ) + ).toMatchObject({ + ok: true, + result: { + sessions: [ + { + projectOwnerKey: 'repo:source', + executionHostId: 'runtime:addressed', + cwd: '/tmp', + filePath: '/tmp/t.jsonl' + } + ], + fingerprint: expect.stringMatching(/^[0-9a-f]{64}$/) + } + }) + expect(capture).toHaveBeenCalledTimes(2) + expect( + await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 2 })) + ).toMatchObject({ ok: true, result: { sessions: [makeSession()] } }) + expect(capture).toHaveBeenCalledTimes(2) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) + }) it('forwards codex-home through the real OrcaRuntimeService construction path', async () => { // Use a real runtime to prove constructor-to-cache forwarding; directly seeding cache would miss a broken link. const runtime = new OrcaRuntimeService(null, undefined, { diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 97d7c9dba030..9bc03b516363 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -106,6 +106,7 @@ export const AI_VAULT_METHODS = [ limit: params.unlimited ? undefined : params.limit, unlimited: params.unlimited, force: params.force, + historyOwnership: params.historyOwnership, scopePaths: params.scopePaths, includeAntigravityIdeSessions: params.includeAntigravityIdeSessions }, diff --git a/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts b/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts index 17b7fea336d5..958926d35a4e 100644 --- a/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts +++ b/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts @@ -1,4 +1,14 @@ -import { mkdtempSync } from 'node:fs' +import { + openSessionSearchHarness, + addSyntheticSession +} from '../ai-vault-search/session-search-engine-test-fixture' +import { createSessionSearchService } from '../ai-vault-search/session-search-service' +import { setSessionSearchService } from '../ai-vault-search/session-search-service-registry' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from '../ai-vault-search/session-search-scope-catalog' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import nacl from 'tweetnacl' @@ -50,7 +60,7 @@ function keyedServer() { const reply: unknown = JSON.parse(replies.at(-1)!) return reply } - return { server, registry, keyed, legacy, send } + return { server, registry, keyed, legacy, send, userDataPath } } describe('requests on a key-authenticated socket', () => { @@ -135,3 +145,46 @@ describe('pairing.registerDeviceKey over the dispatcher', () => { } ) }) + +it('carries canonical intent through the actual key-authenticated dispatch into native results', async () => { + const { send, userDataPath, server } = keyedServer() + const index = await openSessionSearchHarness('sta10541-key-native') + try { + addSyntheticSession(index.db, { id: 1, cwd: '/repo/app' }) + addSyntheticSession(index.db, { id: 2, cwd: '/other' }) + setSessionSearchService( + createSessionSearchService({ + engine: index.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => ({ + repos: [{ id: 'app', path: '/repo/app', displayName: 'app', badgeColor: '', addedAt: 1 }], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + })) + expect( + await send({ + id: 'key-search', + method: 'aiVault.searchSessions', + params: { + query: 'needle', + within: { kind: 'project', projectKey: 'repo:app', canonicalProjectKey: 'repo:app' } + } + }) + ).toMatchObject({ ok: true, result: { kind: 'results', hits: [{ sessionId: '1' }] } }) + } finally { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + await index.close() + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } +}) diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 19bb23352bd3..6460afc21c85 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,5 +1,11 @@ import { existsSync } from 'node:fs' -import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite' +import type { + backup, + BackupOptions, + DatabaseSync, + FunctionOptions, + SQLInputValue +} from 'node:sqlite' import { hasNodeSqliteReaderApi } from './node-sqlite-reader-api' import { NodeSqliteStatement } from './node-sqlite-statement' import type { SqliteStatement } from './sqlite-statement' @@ -91,6 +97,14 @@ class SyncDatabase { this.db.exec(sql) } + function( + name: string, + options: FunctionOptions, + fn: Parameters[1] + ): void { + this.db.function(name, options, fn) + } + prepare(sql: string): SqliteStatement { const cached = this.statementCache.get(sql) if (cached) { diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index a03227b05c8a..6706e6256c02 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -113,7 +113,9 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // Resolve per call: the RPC server/device registry is constructed later with this runtime. getPairedDeviceName: (pairedDeviceId) => state.runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, - // Provide managed Codex homes here for desktop and headless session discovery; core IPC registration is window-only. + // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. + getProjectOwnershipCatalog: () => + sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID), getAdditionalAiVaultCodexHomePaths: () => state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [], prepareAiVaultSessionResume: codexAiVaultSessionResumePreparation(() => ({ diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index 280cd31602e9..c3f8a774e4db 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -33,7 +33,8 @@ export type AiVaultApi = { /** Omitted host means this host; `all` is merged by this desktop across every enumerated host. */ searchSessions: ( request: AiVaultSearchRequest, - executionHostScope?: ExecutionHostScope + executionHostScope?: ExecutionHostScope, + isActive?: () => boolean ) => Promise /** Status describes one index, so it never accepts the `all` scope. */ searchStatus: (executionHostScope?: ExecutionHostId) => Promise diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index c28de00481e1..b34303794225 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -34,8 +34,11 @@ function searchClient( } export const aiVaultApi = { - searchSessions: (request: AiVaultSearchRequest, executionHostScope?: ExecutionHostScope) => - searchClient(executionHostScope).searchSessions(request), + searchSessions: ( + request: AiVaultSearchRequest, + executionHostScope?: ExecutionHostScope, + isActive?: () => boolean + ) => searchClient(executionHostScope).searchSessions(request, isActive), searchStatus: (executionHostScope?: ExecutionHostId) => searchClient(executionHostScope).searchStatus(), setSearchEnabled: ( diff --git a/src/renderer/src/app-shell/use-runtime-graph-sync.ts b/src/renderer/src/app-shell/use-runtime-graph-sync.ts index 09c44839db9b..67b76ebbc490 100644 --- a/src/renderer/src/app-shell/use-runtime-graph-sync.ts +++ b/src/renderer/src/app-shell/use-runtime-graph-sync.ts @@ -59,8 +59,8 @@ export function useRuntimeGraphSync(): void { // Publish project-catalog changes to paired phones. if ( state.repos !== previousState.repos || - state.projects !== previousState.projects || - state.projectHostSetups !== previousState.projectHostSetups || + state.endpointProjects !== previousState.endpointProjects || + state.endpointProjectHostSetups !== previousState.endpointProjectHostSetups || state.projectOrderBy !== previousState.projectOrderBy ) { scheduleRuntimeGraphSync() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx index c1092a5294b9..054a6264f21c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx @@ -16,7 +16,8 @@ vi.mock('@/runtime/runtime-file-import-client', () => ({ vi.mock('@/store', () => ({ useAppStore: Object.assign( - (selector: (state: unknown) => unknown) => selector({ settings: {}, projects: [], repos: [] }), + (selector: (state: unknown) => unknown) => + selector({ settings: {}, endpointProjects: [], endpointProjectHostSetups: [], repos: [] }), { getState: () => ({ sshConnectionStates: connections, diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx index 1fe178fa7031..a6e092dd4291 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx @@ -50,9 +50,9 @@ beforeEach(() => { ], activeRepoId: 'local-project', activeModal: 'new-workspace-composer', - projects: [], + endpointProjects: [], projectGroups: [], - projectHostSetups: [], + endpointProjectHostSetups: [], newWorkspaceDraft: null, worktreesByRepo: {}, sparsePresetsByRepo: {} diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx index e3f3280641aa..a0422f0be46f 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx @@ -30,7 +30,8 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [] }), { getState: () => ({}) } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx index f5c613a60678..82a76f51bdd7 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -25,7 +25,8 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [] }), { getState: () => ({}) } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx index 7fa8caef83b5..f644a6e92c7e 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx @@ -17,7 +17,8 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {} }), diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 9e1470260e25..eeba0aaff83a 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -32,7 +32,8 @@ vi.mock('@/store', () => ({ activeModal: 'none', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [] }), { diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 712f38da399e..441b260d7889 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '@/store/project-host-setup-selector' import React from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -96,7 +97,7 @@ export default function NewWorkspaceComposerCard( (state) => state.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS ) const updateSettings = useAppStore((state) => state.updateSettings) - const projects = useAppStore((state) => state.projects) + const projects = useAppStore((state) => selectCanonicalProjects(state)) const repos = useAppStore((state) => state.repos) const nameInputFocusFrameRef = React.useRef(null) const branchNameInputId = React.useId() diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index 71599b39b5a4..924408ff4860 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -24,7 +24,6 @@ import { import { listedRow } from './automations-page-listed-items' import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' import type { Repo } from '../../../../shared/repo-types' -import type { ProjectHostSetup } from '../../../../shared/project-types' installAutomationsPageHarness() @@ -53,7 +52,7 @@ function addSshHost(): void { /** A project checked out on that SSH host rather than locally. */ function addSshProject(): void { - const repo = { + const repo: Repo = { id: SSH_REPO_ID, displayName: 'orca', path: '/repos/orca', @@ -61,12 +60,24 @@ function addSshProject(): void { addedAt: 1, worktreeBaseRef: 'main', connectionId: SSH_TARGET_ID - } as Repo + } mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] mocks.repoMap.set(SSH_REPO_ID, repo) - // The host needs a ready setup or save refuses the project before choosing a destination. - mocks.state.projectHostSetups = [ - ...(mocks.state.projectHostSetups as ProjectHostSetup[]), + // Without a ready setup on that host the project has no run context, and a + // save against it is refused before any destination is stated. + mocks.state.endpointProjects = [ + ...mocks.state.endpointProjects, + { + id: 'project-ssh', + displayName: 'orca', + badgeColor: '#222222', + sourceRepoIds: [SSH_REPO_ID], + createdAt: 1, + updatedAt: 1 + } + ] + mocks.state.endpointProjectHostSetups = [ + ...mocks.state.endpointProjectHostSetups, { id: 'setup-ssh', projectId: 'project-ssh', @@ -84,7 +95,7 @@ function addSshProject(): void { /** A runtime project with the same host-local id as the desktop project. */ function addCollidingRuntimeProject(): void { - const repo = { + const repo: Repo = { id: REPO_ID, displayName: 'orca on m4-air', path: '/repos/orca', @@ -92,10 +103,21 @@ function addCollidingRuntimeProject(): void { addedAt: 1, worktreeBaseRef: 'main', executionHostId: `runtime:${RUNTIME_ID}` - } as Repo + } mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] - mocks.state.projectHostSetups = [ - ...(mocks.state.projectHostSetups as ProjectHostSetup[]), + mocks.state.endpointProjects = [ + ...mocks.state.endpointProjects, + { + id: 'project-runtime-collision', + displayName: 'orca on m4-air', + badgeColor: '#111111', + sourceRepoIds: [REPO_ID], + createdAt: 1, + updatedAt: 1 + } + ] + mocks.state.endpointProjectHostSetups = [ + ...mocks.state.endpointProjectHostSetups, { id: 'setup-runtime-collision', projectId: 'project-runtime-collision', diff --git a/src/renderer/src/components/automations/automation-project-groups.ts b/src/renderer/src/components/automations/automation-project-groups.ts index 4dd4c2a6c39f..92d760ac9d3e 100644 --- a/src/renderer/src/components/automations/automation-project-groups.ts +++ b/src/renderer/src/components/automations/automation-project-groups.ts @@ -1,5 +1,6 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { getRepoHostIdentity } from '../../../../shared/repo-host-identity' import type { Repo } from '../../../../shared/repo-types' export type AutomationProjectGroup = { @@ -12,9 +13,15 @@ export function getAutomationProjectGroups( repos: readonly Repo[], selectedRepoId: string ): AutomationProjectGroup[] { + const projectIds = new Map( + projectHostSetupProjectionFromRepos(repos).setups.map((setup, index) => [ + getRepoHostIdentity(repos[index]), + setup.projectId + ]) + ) const groupsByProject = new Map() for (const repo of repos) { - const projectKey = getProjectIdentityKey(repo) + const projectKey = projectIds.get(getRepoHostIdentity(repo))! const current = groupsByProject.get(projectKey) if (!current) { groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index 1f3d1e5add8e..795124fa34fd 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -6,7 +6,7 @@ import type { AutomationRunUsage, ExternalAutomationManager } from '../../../../shared/automations-types' -import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { @@ -219,7 +219,10 @@ export function makeWorktree(overrides: Partial = {}): Worktree { } export type AutomationsPageStoreFixtures = { - state: Record + state: Record & { + endpointProjects: Project[] + endpointProjectHostSetups: ProjectHostSetup[] + } repoMap: Map worktreeMap: Map } @@ -232,7 +235,17 @@ export function makeStoreState(): AutomationsPageStoreFixtures { return { state: { repos: [repo], - projectHostSetups: [makeProjectHostSetup()], + endpointProjects: [ + { + id: 'project-1', + displayName: repo.displayName, + badgeColor: repo.badgeColor, + sourceRepoIds: [repo.id], + createdAt: 1, + updatedAt: 1 + } + ], + endpointProjectHostSetups: [makeProjectHostSetup()], worktreesByRepo: { [REPO_ID]: [worktree] }, unifiedTabsByWorktree: {}, terminalLayoutsByTabId: {}, diff --git a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts index 61d62d03f0ff..8f1635d4601c 100644 --- a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts @@ -19,7 +19,7 @@ export function selfScopedList(automations: Automation[]): Record + state: Record & { endpointProjectHostSetups: ProjectHostSetup[] } repoMap: Map worktreeMap: Map } @@ -55,10 +55,10 @@ export function addRuntimeProject(mocks: RuntimeFixtureMocks, runtimeId: string) updatedAt: 1 } const repos = mocks.state.repos as Repo[] - const setups = mocks.state.projectHostSetups as ProjectHostSetup[] + const setups = mocks.state.endpointProjectHostSetups const worktreesByRepo = mocks.state.worktreesByRepo as Record mocks.state.repos = [...repos, repo] - mocks.state.projectHostSetups = [...setups, setup] + mocks.state.endpointProjectHostSetups = [...setups, setup] mocks.state.worktreesByRepo = { ...worktreesByRepo, [RUNTIME_REPO_ID]: [worktree] } mocks.repoMap.set(RUNTIME_REPO_ID, repo) mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree) diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index 90da27fc8407..0cbae2b23acb 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -1,3 +1,4 @@ +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' /** Record child props to test page orchestration rather than presentation. Import this harness first so shared vi.mock registrations precede AutomationsPage loading. */ import { act, createElement, StrictMode, type ReactNode } from 'react' @@ -89,7 +90,10 @@ export type DeleteDialogProps = { } type AutomationsPageMocks = { - state: Record + state: Record & { + endpointProjects: Project[] + endpointProjectHostSetups: ProjectHostSetup[] + } repoMap: Map worktreeMap: Map listPanel: ListPanelProps | null @@ -107,7 +111,7 @@ type AutomationsPageMocks = { // Mock factories only close over this exported binding; vi.hoisted bindings cannot be exported. export const mocks: AutomationsPageMocks = { - state: {}, + state: { endpointProjects: [], endpointProjectHostSetups: [] }, repoMap: new Map(), worktreeMap: new Map(), listPanel: null, diff --git a/src/renderer/src/components/automations/use-automations-page-store-state.ts b/src/renderer/src/components/automations/use-automations-page-store-state.ts index d817789bf762..fef24234d127 100644 --- a/src/renderer/src/components/automations/use-automations-page-store-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-store-state.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '@/store/project-host-setup-selector' import { useCallback } from 'react' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import type { Repo } from '../../../../shared/repo-types' @@ -18,7 +19,7 @@ const AGENTS = getAgentCatalog().map((agent) => agent.id) /** Store-backed values shared by the page's state and action hooks. */ export function useAutomationsPageStoreState() { const repos = useAppStore((s) => s.repos) - const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index ca844d42bda2..a422b2cc9f71 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -516,25 +516,6 @@ describe('Cmd+J project and repo-group search', () => { ]) }) - it('splits independent same-host checkouts of one project into per-setup keys', () => { - // Independent cloned checkouts retain distinct jump targets, matching sidebar grouping. - const results = searchCmdJProjectResults({ - query: 'platform', - projectGroups: [], - repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-b')], - projects: [project('project-1', 'Platform')], - projectHostSetups: [ - setup('setup-1', 'project-1', 'local', 'repo-1'), - setup('setup-2', 'project-1', 'local', 'repo-2') - ] - }) - - expect(results.map((result) => result.rowKey)).toEqual([ - 'project:project-1::setup:repo-1', - 'project:project-1::setup:repo-2' - ]) - }) - it('keeps a provisioned runtime copy under one project key alongside a same-host checkout', () => { // Provisioned ephemeral copies nest with the real checkout under one project key, matching the sidebar. const results = searchCmdJProjectResults({ diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index 7bf660a702ac..eaa39897f5a6 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -95,7 +95,8 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState { activeWorktreeId: null, activeWorkspaceExecutionHostId: null, activeRepoId: null, - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], removedRuntimeEnvironmentIds: new Set() } } @@ -286,6 +287,8 @@ describe('useDashboardPopoutBridge', () => { // A folder workspace is not a git worktree; its host resolves through these. { folderWorkspaces: [] }, { projectGroups: [] }, + { endpointProjects: [] }, + { endpointProjectHostSetups: [] }, { restoredRuntimeHostIdByWorkspaceSessionKey: {} }, { runtimeEnvironments: [] }, { runtimeEnvironmentCatalogHydrated: true }, diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 70a0603be7a9..bc093176233e 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -7,7 +7,7 @@ import { } from '@/runtime/structured-chat-name-observation' import { subscribeChatProgramFacts } from '@/lib/host-chat-program' import { - type AgentLaunchRouteView, + type AgentLaunchRouteWatchState, agentLaunchRouteInputsChanged, selectAgentLaunchRouteView } from '@/lib/agent-launch-route-input' @@ -37,7 +37,7 @@ export function repoIconsUnchanged( type DashboardSnapshotWatchState = DashboardSnapshotState & Pick & - AgentLaunchRouteView + AgentLaunchRouteWatchState export function dashboardSnapshotInputsChanged( state: DashboardSnapshotWatchState, diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts index daab9c32d6bf..40840c031a18 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts @@ -226,7 +226,11 @@ it('publishes real chat options to main and popout, retaining card candidates an expect(view.result.current.launchableAgentsByWorktreeId?.['wt-2']).toEqual(['grok']) const lastPublished = () => publishSnapshot.mock.calls.at(-1)?.[0].launchableAgentsByWorktreeId await vi.waitFor(() => expect(lastPublished()?.['wt-1']).toEqual(['claude', 'grok'])) - for (const route of [{ projects: [] }, { activeWorkspaceExecutionHostId: 'local' as const }]) { + for (const route of [ + { endpointProjects: [] }, + { endpointProjectHostSetups: [] }, + { activeWorkspaceExecutionHostId: 'local' as const } + ]) { const [live, published] = [view.result.current, publishSnapshot.mock.calls.length] act(() => useAppStore.setState(route)) expect(view.result.current).not.toBe(live) diff --git a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx index fe77acc55621..565e4ad33b25 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx @@ -121,6 +121,9 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { } // openHttpLink reads the store through this injected accessor, not @/store. registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: true, activeRuntimeEnvironmentId: null }, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts index 600d01faf217..9b2368a6ce76 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts @@ -54,6 +54,9 @@ beforeEach(() => { } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: routing.settings, setActiveWorktree: vi.fn(), createBrowserTab diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-skill-target.test.tsx b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-skill-target.test.tsx index ec0342be2743..e499881bbbe5 100644 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-skill-target.test.tsx +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-skill-target.test.tsx @@ -50,7 +50,7 @@ beforeEach(() => { activeRepoId: null, activeWorktreeId: null, activeWorkspaceExecutionHostId: null, - projects: [], + endpointProjects: [], repos: [], worktreesByRepo: {}, folderWorkspaces: [], diff --git a/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx b/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx index a5ad59f2e201..fb233399af1a 100644 --- a/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx +++ b/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx @@ -8,6 +8,8 @@ type StoreState = { activeRepoId: string | null activeWorktreeId: string | null worktreesByRepo: Record + endpointProjects: never[] + endpointProjectHostSetups: never[] repos: unknown[] settings: { activeRuntimeEnvironmentId?: string | null } preflightStatus: PreflightStatus | null @@ -90,6 +92,8 @@ function installStore(preflightStatus: PreflightStatus): void { activeRepoId: null, activeWorktreeId: null, worktreesByRepo: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], settings, preflightStatus, diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts index 434d393786d1..d1b41b7e99b4 100644 --- a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -1,3 +1,7 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import type { AppState } from '../../store/types' import type { SkillDiscoveryTarget } from '../../../../shared/skills' import { parseExecutionHostId } from '../../../../shared/execution-host' @@ -25,7 +29,6 @@ export type NativeChatSkillStateInputs = Pick< | 'floatingWorkspacePath' | 'folderWorkspaces' | 'projectGroups' - | 'projects' | 'repos' | 'restoredRuntimeHostIdByWorkspaceSessionKey' | 'settings' @@ -33,7 +36,8 @@ export type NativeChatSkillStateInputs = Pick< | 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'worktreesByRepo' -> +> & + ProjectCatalogReadState type NativeChatSkillTab = { id: string; startupCwd?: string } @@ -59,7 +63,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki floatingWorkspacePath: state.floatingWorkspacePath, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, - projects: state.projects, + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), repos: state.repos, restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, settings: state.settings, 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 index 45876d084ddc..15c1554e99d6 100644 --- 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 @@ -112,7 +112,7 @@ function corpus(scale: number, dense = false) { worktreesByRepo: {}, detectedWorktreesByRepo: {}, projectGroups: [], - projects: [], + endpointProjects: [], runtimeEnvironments: [], runtimeEnvironmentCatalogHydrated: true, removedRuntimeEnvironmentIds: new Set(), 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 9c93ac0a0b0b..f28a7b0a7b70 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 @@ -35,7 +35,8 @@ function stateForHost(hostId: string) { activeWorktreeId: 'worktree-1', folderWorkspaces: [], projectGroups: [], - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [ { id: 'repo-1', diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index b6f92f7e355b..3a0df0820732 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -213,7 +213,8 @@ describe('floating workspace skill discovery', () => { floatingWorkspacePath: '/home/me/scratch', folderWorkspaces: [], projectGroups: [], - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], restoredRuntimeHostIdByWorkspaceSessionKey: {}, // Floating panels stay local even with a focused paired runtime. diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx index 29a4ba1b2d9d..d9dea9661a00 100644 --- a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx @@ -4,7 +4,7 @@ import { cleanup, render, renderHook, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' -import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { @@ -17,10 +17,12 @@ import { SetProjectLocationDialog } from './SetProjectLocationDialog' const storeMock = vi.hoisted(() => { const repos: Repo[] = [] - const projectHostSetups: ProjectHostSetup[] = [] + const endpointProjects: Project[] = [] + const endpointProjectHostSetups: ProjectHostSetup[] = [] return { repos, - projectHostSetups, + endpointProjects, + endpointProjectHostSetups, setupProjectExistingFolder: vi.fn(), setupProjectClone: vi.fn() } @@ -125,12 +127,14 @@ function createInput(): Parameters[0] { beforeEach(() => { storeMock.repos = [localRepo] - storeMock.projectHostSetups = [...localProjection.setups] + storeMock.endpointProjects = [...localProjection.projects] + storeMock.endpointProjectHostSetups = [...localProjection.setups] storeMock.setupProjectExistingFolder.mockReset() storeMock.setupProjectClone.mockReset() const publishRuntimeSetup = async () => { storeMock.repos = [localRepo, runtimeRepo] - storeMock.projectHostSetups = [...localProjection.setups, ...runtimeProjection.setups] + storeMock.endpointProjects = [...localProjection.projects, ...runtimeProjection.projects] + storeMock.endpointProjectHostSetups = [...localProjection.setups, ...runtimeProjection.setups] return { project: runtimeProjection.projects[0], repo: runtimeRepo, diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts index aa82e46ea46e..2f302d3a5336 100644 --- a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Worktree } from '../../../../shared/worktree/types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { orderProjectIdsByRecency } from './use-recent-project-ids' function worktree(partial: Partial): Worktree { @@ -58,3 +59,35 @@ describe('orderProjectIdsByRecency', () => { expect(ids[0]).toBe('beta') }) }) + +it('indexes current workspace ownership linearly at N and 10N and reuses an unchanged catalog', () => { + const counts = [10, 100].map((count) => { + let reads = 0 + const repos = Array.from({ length: count }, (_, i) => ({ + id: `checkout-${i}`, + path: `/checkout/${i}`, + displayName: `${i}`, + badgeColor: '', + addedAt: i + })) + const setups = projectHostSetupProjectionFromRepos(repos).setups.map((setup) => ({ + ...setup, + get repoId() { + reads++ + return setup.repoId + } + })) + const workspaces = repos.map((repo, i) => + worktree({ repoId: repo.id, projectId: 'old-shared-project', createdAt: i }) + ) + const ids = orderProjectIdsByRecency(workspaces, setups) + expect(ids).toHaveLength(count * 2) + expect(ids[0]).toBe(`repo:checkout-${count - 1}`) + const firstReads = reads + expect(orderProjectIdsByRecency(workspaces, setups)).toEqual(ids) + expect(reads).toBe(firstReads) + expect(firstReads).toBeLessThanOrEqual(count * 8) + return firstReads + }) + expect(counts[1]).toBeLessThanOrEqual(counts[0] * 10) +}) diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts index bd0c3d3f3fee..b226e35e290b 100644 --- a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts @@ -1,13 +1,25 @@ import { useMemo } from 'react' +import { useAllWorktrees, useProjectHostSetupProjection } from '@/store/selectors' +import { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' +import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Worktree } from '../../../../shared/worktree/types' -import { useAllWorktrees } from '@/store/selectors' import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-project-options' -/** Use each project’s newest workspace creation as recency; it matches this picker’s action without separate persisted state. */ -export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string[] { +/** + * Most-recently-used project option ids, newest first, derived from when the + * user last created a workspace in each project. Orca stores no explicit + * per-project recency, and workspace creation is exactly the action this picker + * is about to repeat, so it's the honest proxy rather than a new store field. + */ +export function orderProjectIdsByRecency( + worktrees: readonly Worktree[], + setups?: readonly ProjectHostSetup[] +): string[] { + const owners = getProjectHostSetupLookup(setups) const newestByProject = new Map() for (const worktree of worktrees) { - const projectId = worktree.projectId + const projectId = owners.projectId(worktree) + // Legacy repo-only workspaces have no projectId and can't be attributed. if (projectId === undefined || projectId === '') { continue } @@ -28,5 +40,6 @@ export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string export function useRecentProjectIds(): string[] { const worktrees = useAllWorktrees() - return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees]) + const { setups } = useProjectHostSetupProjection() + return useMemo(() => orderProjectIdsByRecency(worktrees, setups), [worktrees, setups]) } diff --git a/src/renderer/src/components/pull-request-page/checks/tab.tsx b/src/renderer/src/components/pull-request-page/checks/tab.tsx index 03f1b08cf813..d1cf634445c1 100644 --- a/src/renderer/src/components/pull-request-page/checks/tab.tsx +++ b/src/renderer/src/components/pull-request-page/checks/tab.tsx @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '@/store/project-host-setup-selector' import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { CircleDashed } from 'lucide-react' import { useAppStore } from '@/store' @@ -68,7 +69,7 @@ export function ChecksTab({ targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null ) const repos = useAppStore((s) => s.repos) - const projects = useAppStore((s) => s.projects) + const projects = useAppStore((s) => selectCanonicalProjects(s)) const [fixingChecks, setFixingChecks] = useState(false) const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState(null) const mountedRef = useMountedRef() @@ -136,7 +137,7 @@ export function ChecksTab({ { activeRepoId: null, activeWorktreeId: null, - projects, + projectHostSetupProjection: { projects, setups: [] }, repos, settings, worktreesByRepo: {} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx index 4ba56f3072e2..baf0dffc2186 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx @@ -11,6 +11,8 @@ const mockState: { folderWorkspaces: never[] projectGroups: never[] repos: never[] + endpointProjects: never[] + endpointProjectHostSetups: never[] worktreesByRepo: Record } = { settings: {}, @@ -18,6 +20,8 @@ const mockState: { folderWorkspaces: [], projectGroups: [], repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} } const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boolean } }) => { @@ -156,7 +160,11 @@ it('switches to index search with the same query once indexing is enabled', asyn aiVaultSearch: { enabled: true, historyDays: null } }) await waitFor(() => - expect(searchSessions).toHaveBeenCalledWith(expect.objectContaining({ query: 'foo' }), 'local') + expect(searchSessions).toHaveBeenCalledWith( + expect.objectContaining({ query: 'foo' }), + 'local', + expect.any(Function) + ) ) expect(screen.queryByRole('button', { name: 'Enable' })).toBeNull() await waitFor(() => expect(screen.queryByText('Fix the foo pipeline')).toBeNull()) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx new file mode 100644 index 000000000000..7ae5a0bd73d5 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type * as NewWorkspace from '@/lib/new-workspace' +import { getDefaultSettings } from '../../../../shared/constants' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo } from '../../../../shared/repo-types' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and-reveal' +import { resetAiVaultForcedRescanThrottleForTest } from './ai-vault-session-refresh' +import AiVaultPanel from './AiVaultPanel' +import { TooltipProvider } from '@/components/ui/tooltip' +import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context' + +vi.mock('@/lib/new-workspace', async (importOriginal) => ({ + ...(await importOriginal()), + CLIENT_PLATFORM: 'win32' +})) +vi.mock('@/lib/launch-ai-vault-session-and-reveal', () => ({ + launchAiVaultSessionAndReveal: vi.fn().mockResolvedValue(undefined) +})) +// happy-dom has no viewport measurements; retain the actual list, rows and actions. +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 80, + end: (index + 1) * 80, + size: 80 + })), + getTotalSize: () => count * 80, + measureElement: () => {}, + scrollToIndex: () => {}, + scrollOffset: 0 + }) +})) + +const original = useAppStore.getState() +const originalApi = Object.getOwnPropertyDescriptor(window, 'api') +afterEach(() => { + cleanup() + useAppStore.setState(original, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } + vi.clearAllMocks() +}) + +it('uses the mounted panel catalog for Resume eligibility and the concrete action target', async () => { + useAppStore.setState(useAppStore.getInitialState(), true) + resetAiVaultForcedRescanThrottleForTest() + const repo: Repo = { id: 'b', path: 'C:/app-b', displayName: 'B', badgeColor: '', addedAt: 1 } + const worktree = makeWorktree({ + id: 'b::C:/app-b', + repoId: 'b', + path: 'C:/app-b', + hostId: 'local' + }) + const catalog = projectHostSetupProjectionFromRepos([repo]) + useAppStore.setState({ + repos: [repo], + activeRepoId: 'b', + activeWorktreeId: worktree.id, + endpointProjects: catalog.projects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + })), + endpointProjectHostSetups: [...catalog.setups], + worktreesByRepo: { b: [worktree] }, + settings: { + ...getDefaultSettings('/tmp'), + localWindowsRuntimeDefault: { kind: 'windows-host' }, + terminalWindowsShell: 'powershell.exe' + } + }) + const wsl: AiVaultSession = { + id: 'claude:wsl', + executionHostId: 'local', + agent: 'claude', + sessionId: 'wsl-session', + title: 'Ubuntu conversation', + cwd: 'C:/app-b', + branch: null, + model: null, + filePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\.claude\projects\app\wsl-session.jsonl`, + codexHome: null, + createdAt: '2026-10-23T10:00:00Z', + updatedAt: '2026-10-23T10:00:00Z', + modifiedAt: '2026-10-23T10:00:00Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [{ role: 'user', text: 'continue this work', timestamp: null }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } + const native: AiVaultSession = { + ...wsl, + id: 'claude:native', + sessionId: 'native-session', + title: 'Windows conversation', + filePath: 'C:/Users/alice/.claude/projects/app/native-session.jsonl' + } + const prepare = vi + .fn() + .mockResolvedValue({ useRealCodexHome: false }) + const listSessions = vi.fn().mockResolvedValue({ + sessions: [wsl, native], + issues: [], + scannedAt: '2026-10-23T10:00:00Z' + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + aiVault: { + listSessions, + prepareSessionResume: prepare, + cancelListSessions: vi.fn().mockResolvedValue(undefined) + } + } + }) + render( + false}> + + + + + ) + const resumeButton = (title: string) => { + const row = screen.getByText(title).closest('.ai-vault-session-row') + if (!(row instanceof HTMLElement)) { + throw new Error('Expected the real session row') + } + return within(row).getByRole('button', { name: /^Resume in / }) + } + await waitFor(() => expect(screen.getByText(wsl.title)).toBeTruthy()) + expect(resumeButton(wsl.title).hasAttribute('disabled')).toBe(false) + expect(resumeButton(native.title).hasAttribute('disabled')).toBe(true) + fireEvent.click(resumeButton(wsl.title)) + await waitFor(() => expect(launchAiVaultSessionAndReveal).toHaveBeenCalledOnce()) + expect(prepare).toHaveBeenCalledWith({ + agent: 'claude', + sessionId: wsl.sessionId, + executionHostId: 'local', + filePath: wsl.filePath, + codexHome: null + }) + expect(launchAiVaultSessionAndReveal).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'claude', + worktreeId: worktree.id, + command: expect.stringContaining("'wsl-session'") + }), + expect.any(Function) + ) + expect(useAppStore.getState().repos[0]).toBe(repo) + expect(useAppStore.getState().worktreesByRepo.b[0]).toMatchObject({ + path: 'C:/app-b', + hostId: 'local' + }) + act(() => + useAppStore.setState((state) => ({ + endpointProjects: state.endpointProjects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + })) + })) + ) + await waitFor(() => expect(resumeButton(wsl.title).hasAttribute('disabled')).toBe(true)) + expect(resumeButton(native.title).hasAttribute('disabled')).toBe(false) + fireEvent.click(resumeButton(wsl.title)) + expect(launchAiVaultSessionAndReveal).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index cff14480cabe..ae15c615afe0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -1,3 +1,4 @@ +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import { useCallback, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { toast } from 'sonner' @@ -63,14 +64,15 @@ export default function AiVaultPanel({ const { workspaceId: activeWorktreeId, worktree: activeWorktree, - repo: activeRepo + repo: activeRepo, + legacyProjectKey } = useAiVaultPanelWorkspace(workspace) const repos = useRepos() const allWorktrees = useAllWorktrees() const projectHostSetupProjection = useProjectHostSetupProjection() const resumeTargetState = useAppStore( useShallow((state) => ({ - projects: state.projects, + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), settings: state.settings, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, @@ -154,11 +156,18 @@ export default function AiVaultPanel({ aiVaultSearchScopeIdentity({ scope, activeWorktreeId: activeWorktree?.id, - activeProjectKey + activeProjectKey: legacyProjectKey }), - [activeProjectKey, activeWorktree?.id, scope] + [legacyProjectKey, activeWorktree?.id, scope] + ) + const search = useAiVaultPanelSearch( + query, + agents, + searchWithin, + executionHostScope, + searchSort, + activeProjectKey ?? undefined ) - const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope, searchSort) const { searching, searchHits } = search const searchSessions = useMemo( () => withAiVaultHistoryPreviews(search.sessions, history), @@ -180,9 +189,10 @@ export default function AiVaultPanel({ repos, worktrees: allWorktrees, projectHostSetupProjection, + settings: settings ?? undefined, sessions }), - [allWorktrees, projectHostSetupProjection, repos, sessions] + [allWorktrees, projectHostSetupProjection, repos, sessions, settings] ) const effectiveActiveWorktreeId = activeWorktreeId ?? activeWorktree?.id ?? null const { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts index ad5a7c01b0a9..48bf33c52344 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts @@ -3,14 +3,14 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it } from 'vitest' -import type { AppState } from '@/store/types' +import { TEST_REPO, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' import { buildAiVaultHostScopeOptions, buildRuntimeAiVaultHostScopeOptions, useAiVaultExecutionHostScope } from './ai-vault-host-scope' -import type { ExecutionHostScope } from '../../../../shared/execution-host' +import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/execution-host' type HostScopeResult = ReturnType @@ -44,14 +44,17 @@ async function renderHook(props: { function stateForWorktree(args: { worktreeId: string repoId: string - executionHostId?: string | null - hostId?: string | null + executionHostId?: ExecutionHostId | null + hostId?: ExecutionHostId | null }): AiVaultSessionResumeTargetState { return { folderWorkspaces: [], projectGroups: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [ { + ...TEST_REPO, id: args.repoId, connectionId: null, executionHostId: args.executionHostId ?? 'local' @@ -59,14 +62,14 @@ function stateForWorktree(args: { ], worktreesByRepo: { [args.repoId]: [ - { + makeWorktree({ id: args.worktreeId, repoId: args.repoId, - hostId: args.hostId ?? null - } + hostId: args.hostId ?? undefined + }) ] } - } as unknown as Pick + } } afterEach(() => { @@ -113,8 +116,10 @@ describe('useAiVaultExecutionHostScope', () => { folderWorkspaces: [], projectGroups: [], repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} - } as unknown as AiVaultSessionResumeTargetState + } }) expect(latest?.executionHostScope).not.toBe('local') @@ -128,8 +133,10 @@ describe('useAiVaultExecutionHostScope', () => { folderWorkspaces: [], projectGroups: [], repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} - } as unknown as AiVaultSessionResumeTargetState + } }) expect(latest?.executionHostScope).toBe('local') diff --git a/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts b/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts index 0d5561ff39ec..e89bd1c048c9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts @@ -1,9 +1,36 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' +import type { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' export function resolveActiveProjectKey( + activeRepo: Repo | null, + activeWorktree: Worktree | null, + owners: ReturnType +): string | null { + const execution = parseExecutionHostId(activeRepo ? getRepoExecutionHostId(activeRepo) : null) + const owner = + activeWorktree ?? + (activeRepo + ? { + repoId: activeRepo.id, + hostId: activeRepo.connectionId + ? toSshExecutionHostId(activeRepo.connectionId) + : getRepoExecutionHostId(activeRepo), + runtimeOwnerEnvironmentId: + execution?.kind === 'runtime' ? execution.environmentId : undefined + } + : null) + return toAiVaultProjectKey(owner ? owners.projectId(owner) : null, owner?.repoId ?? null) +} + +export function resolveLegacyActiveProjectKey( activeRepo: Repo | null, activeWorktree: Worktree | null, setupByRepoId: ReadonlyMap @@ -21,3 +48,15 @@ export function resolveActiveProjectKey( return toAiVaultProjectKey(null, activeRepo?.id ?? activeWorktree?.repoId ?? null) } + +export function buildSetupByRepoId( + setups: readonly ProjectHostSetup[] +): ReadonlyMap { + const setupByRepoId = new Map() + for (const setup of setups) { + if (setup.repoId && !setupByRepoId.has(setup.repoId)) { + setupByRepoId.set(setup.repoId, setup) + } + } + return setupByRepoId +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts new file mode 100644 index 000000000000..3d60a3362e7e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts @@ -0,0 +1,172 @@ +import { expect, it } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + buildAiVaultProjectContext, + buildAiVaultSessionProjectById +} from './ai-vault-session-projects' +import { resolveProjectHostSetupProjection } from '../../../../shared/project-catalog-canonical-projection' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' + +const baseSession: AiVaultSession = { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement project history', + cwd: '/Users/ada/orca', + branch: 'feature/history', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: "cd '/Users/ada/orca' && claude --resume 'session-1'", + subagent: null +} + +// #28589: an imported folder whose copied .git/config points at another checkout's remote. +it("keeps a same-host folder that shares a remote out of the other checkout's project", () => { + const remote = { + canonicalKey: 'github.com/bennyoooo/open-social', + remoteName: 'origin', + remoteUrl: 'https://github.com/Bennyoooo/open-social.git' + } + const openSocial = makeRepo({ + id: 'open-social', + displayName: 'open-social', + path: '/Users/benny/orca/open-social', + addedAt: 100, + gitRemoteIdentity: remote + }) + const niw = makeRepo({ + id: 'niw', + displayName: 'niw', + path: '/Users/benny/orca/projects/niw', + addedAt: 200, + gitRemoteIdentity: remote + }) + const linkedPath = '/Users/benny/workspaces/niw-topic' + const oldProjectId = 'github:bennyoooo/open-social' + const worktree = makeWorktree({ + id: `niw::${linkedPath}`, + repoId: niw.id, + projectHostSetupId: niw.id, + projectId: oldProjectId, + path: linkedPath + }) + const niwSession = makeSession({ id: 'claude:niw', cwd: linkedPath }) + const derived = projectHostSetupProjectionFromRepos([niw, openSocial]) + const rawSetups = derived.setups.map((setup) => ({ ...setup, projectId: oldProjectId })) + const projection = resolveProjectHostSetupProjection( + [niw, openSocial], + [{ ...derived.projects[0], id: oldProjectId, sourceRepoIds: [niw.id, openSocial.id] }], + rawSetups + ) + expect(rawSetups.every((setup) => setup.projectId === oldProjectId)).toBe(true) + expect(projection.projects.find((project) => project.id === oldProjectId)?.sourceRepoIds).toEqual( + [openSocial.id] + ) + const openSocialSession = makeSession({ id: 'claude:open-social', cwd: openSocial.path }) + + const context = buildAiVaultProjectContext({ + repos: [niw, openSocial], + worktrees: [worktree], + projectHostSetupProjection: projection, + activeRepo: niw, + activeWorktree: worktree, + sessions: [niwSession, openSocialSession] + }) + + expect(context.activeProjectKey).toBe('repo:niw') + expect(context.sessionProjectById.get(niwSession.id)).toMatchObject({ + key: 'repo:niw', + label: 'niw' + }) + expect(context.sessionProjectById.get(openSocialSession.id)).toMatchObject({ + key: 'project:github:bennyoooo/open-social', + label: 'open-social' + }) +}) + +function makeSession(overrides: Partial): AiVaultSession { + return { ...baseSession, ...overrides } +} + +function makeRepo(overrides: Partial): Repo { + return { + id: 'repo-1', + path: '/Users/ada/orca', + displayName: 'Orca', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function makeWorktree(overrides: Partial): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + path: '/Users/ada/orca', + head: 'abc123', + branch: 'main', + isBare: false, + isMainWorktree: true, + ...overrides + } +} + +it('retains both hosts of a same-id repo for indexing-off managed history and prior workspace ownership', () => { + const local = makeRepo({ id: 'same', path: '/local/app', addedAt: 1 }) + const remote = makeRepo({ + ...local, + path: '/remote/app', + executionHostId: 'runtime:remote', + addedAt: 2 + }) + const repos = [local, remote] + const projection = resolveProjectHostSetupProjection(repos, [], []) + const sessions = [ + makeSession({ id: 'local-managed', cwd: '/managed/app/topic' }), + makeSession({ + id: 'remote-managed', + cwd: '/managed/app/topic', + executionHostId: 'runtime:remote' + }), + makeSession({ id: 'prior', cwd: '/prior/topic' }) + ] + const rows = buildAiVaultSessionProjectById({ + repos, + projectHostSetupProjection: projection, + sessions, + worktrees: [ + makeWorktree({ + id: 'same::/current', + repoId: 'same', + hostId: 'local', + path: '/current', + priorWorktreeIds: ['same::/prior'] + }) + ], + settings: { workspaceDir: '/managed', nestWorkspaces: true } + }) + expect([...rows.values()].map((row) => row.key)).toEqual(['repo:same', 'repo:same', 'repo:same']) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx index 43957af1cc9b..ba91caff3c9c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx @@ -20,7 +20,11 @@ const AGENTS = ['codex' as const] const WORKSPACE = { kind: 'workspace', worktreeId: 'repo-1::/work/app' } as const const searchSessions = vi.fn< - (request: AiVaultSearchRequest, scope?: ExecutionHostScope) => Promise + ( + request: AiVaultSearchRequest, + scope?: ExecutionHostScope, + isCurrent?: () => boolean + ) => Promise >() beforeEach(() => { @@ -90,9 +94,13 @@ describe('the panel hook under a scope identity', () => { await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { query: 'needle', filters: { agents: ['codex'] }, within, cursor: undefined }, - 'ssh:build-box' + 'ssh:build-box', + expect.any(Function) ) + const isCurrent = searchSessions.mock.calls[0][2] + expect(isCurrent?.()).toBe(true) unmount() + expect(isCurrent?.()).toBe(false) }) it('does not restart the search while the identity holds', async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx index 5f60f3a0a5ed..be9edd2e821a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx @@ -337,6 +337,20 @@ describe('conditional panel publication', () => { structuredSession: { sessionId: 'owner' } }) expect(latest?.sessions[0]?.previewMessages[0]?.text).toBe('New prompt') + list.mockResolvedValue({ + ...first, + fingerprint: 'c'.repeat(64), + sessions: [{ ...session, projectOwnerKey: 'repo:current' }] + }) + await refresh() + expect(latest?.sessions[0]?.projectOwnerKey).toBe('repo:current') + list.mockResolvedValue({ + ...first, + fingerprint: 'd'.repeat(64), + sessions: [{ ...session, projectOwnerKey: null }] + }) + await refresh() + expect(latest?.sessions[0]?.projectOwnerKey).toBeNull() }) it('does not reuse proofs across host, depth or project switches', async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts index a3d93b9fbe0d..8e36481923fc 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts @@ -22,6 +22,7 @@ export function aiVaultSessionListArgs( options: { force?: boolean; requestToken: string } ): AiVaultListArgs { return { + historyOwnership: {}, includeAntigravityIdeSessions: true, limit: request.sessionLimit === 'unlimited' ? undefined : request.sessionLimit, unlimited: request.sessionLimit === 'unlimited', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts index 0b2e545b431d..1de92931e54f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts @@ -1,4 +1,17 @@ -import { sessionFileExecutionNamespace } from '../../../../shared/ai-vault-scope-cwd-match' +import { + folderGroupKey, + folderLabel, + type AiVaultSessionProject +} from '../../../../shared/ai-vault-session-filters' +import { + createNormalizedAiVaultScopeCwdMatcher, + sessionFileExecutionNamespace +} from '../../../../shared/ai-vault-scope-cwd-match' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -51,3 +64,49 @@ export function matchAiVaultProjectCandidates( compare(left, right) ) } + +export type SessionProjectCandidate = { + source: 'worktree' | 'setup' + normalizedPath: string + hostKey: ExecutionHostId + projectId: string | null + repoId: string | null + // Precomputed so a 500-session pass doesn't re-normalize every root per session. + ownsNormalizedCwd: ReturnType +} + +export function makeProjectCandidate( + fields: Omit & { path: string } +): SessionProjectCandidate { + const { path, ...rest } = fields + const normalizedPath = normalizeRuntimePathForComparison(path) + return { + ...rest, + normalizedPath, + ownsNormalizedCwd: createNormalizedAiVaultScopeCwdMatcher([path]) + } +} + +export function resolveCandidateHostId( + ...values: readonly (string | null | undefined)[] +): ExecutionHostId { + for (const value of values) { + const hostId = normalizeExecutionHostId(value) + if (hostId) { + return hostId + } + } + return LOCAL_EXECUTION_HOST_ID +} + +export function hasCandidatePath(pathValue: string): boolean { + return pathValue.trim().length > 0 +} + +export function folderProject(cwd: string): AiVaultSessionProject { + return { + kind: 'folder', + key: folderGroupKey(cwd), + label: folderLabel(cwd) + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index 84573a4aaecb..3a6a1516de8e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -5,7 +5,6 @@ import type { Project, ProjectHostSetup } from '../../../../shared/project-types import * as paths from '../../../../shared/cross-platform-path' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' import { buildAiVaultProjectContext, buildAiVaultSessionProjectById @@ -39,14 +38,6 @@ const baseSession: AiVaultSession = { subagent: null } -describe('toAiVaultProjectKey', () => { - it('does not double-wrap compatibility repo project ids', () => { - expect(toAiVaultProjectKey('project-1', 'repo-1')).toBe('project:project-1') - expect(toAiVaultProjectKey('repo:repo-1', 'repo-1')).toBe('repo:repo-1') - expect(toAiVaultProjectKey(null, 'repo-1')).toBe('repo:repo-1') - }) -}) - describe('buildAiVaultProjectContext', () => { it('uses durable worktree project ids before repo fallback', () => { const repo = makeRepo({ id: 'repo-1', displayName: 'Legacy Repo', path: '/Users/ada/orca' }) @@ -257,7 +248,7 @@ describe('buildAiVaultProjectContext', () => { expect(context.sessionProjectById.get(secondSession.id)?.label).toBe('Canonical Project') }) - it('chooses the most specific nested path and lets worktrees win equal-length ties', () => { + it('keeps the most specific workspace path while using its current checkout affiliation', () => { const repo = makeRepo({ id: 'repo-root', displayName: 'Root', path: '/repo' }) const childRepo = makeRepo({ id: 'repo-child', displayName: 'Child Repo', path: '/repo/pkg' }) const worktree = makeWorktree({ @@ -272,7 +263,7 @@ describe('buildAiVaultProjectContext', () => { repos: [repo, childRepo], worktrees: [worktree], projectHostSetupProjection: makeProjection({ - projects: [makeProject({ id: 'child-project', displayName: 'Child Project' })], + projects: [makeProject({ id: 'setup-child', displayName: 'Child Project' })], setups: [ makeSetup({ repoId: repo.id, projectId: 'root-project', path: repo.path }), makeSetup({ repoId: childRepo.id, projectId: 'setup-child', path: childRepo.path }) @@ -284,7 +275,7 @@ describe('buildAiVaultProjectContext', () => { }) expect(context.sessionProjectById.get(session.id)).toMatchObject({ - key: 'project:child-project', + key: 'project:setup-child', label: 'Child Project' }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index 833c7f454776..788aeacf1f2e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -1,27 +1,33 @@ -import { createNormalizedAiVaultScopeCwdMatcher } from '../../../../shared/ai-vault-scope-cwd-match' +import { + getRepoHostIdentity, + getRepoHostIdentityForParts +} from '../../../../shared/repo-host-identity' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { managedWorktreeDirectories } from '../../../../shared/worktree/ownership' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { resolveActiveProjectKey } from './ai-vault-project-key' -import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' +import { isAiVaultProjectKey, toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' import { getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { ProjectHostSetup } from '../../../../shared/project-types' +import { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { + folderProject, aiVaultSessionProjectCwdKey, - matchAiVaultProjectCandidates + matchAiVaultProjectCandidates, + makeProjectCandidate, + resolveCandidateHostId, + hasCandidatePath, + type SessionProjectCandidate } from './ai-vault-session-project-candidates' -import { - folderGroupKey, - folderLabel, - type AiVaultSessionProject -} from '../../../../shared/ai-vault-session-filters' +import type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' export type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' @@ -32,16 +38,6 @@ export type AiVaultProjectContext = { sessionProjectById: Map } -type SessionProjectCandidate = { - source: 'worktree' | 'setup' - normalizedPath: string - hostKey: ExecutionHostId - projectId: string | null - repoId: string | null - // Precomputed so a 500-session pass doesn't re-normalize every root per session. - ownsNormalizedCwd: ReturnType -} - type ProjectResolverArgs = { repos: readonly Repo[] worktrees: readonly Worktree[] @@ -49,6 +45,7 @@ type ProjectResolverArgs = { activeRepo: Repo | null activeWorktree: Worktree | null sessions: readonly AiVaultSession[] + settings?: Pick } export function buildAiVaultProjectContext({ @@ -59,10 +56,10 @@ export function buildAiVaultProjectContext({ activeWorktree, sessions }: ProjectResolverArgs): AiVaultProjectContext { - const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) + const owners = getProjectHostSetupLookup(projectHostSetupProjection.setups) return { - activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId), + activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, owners), activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null, projectLabelByKey: buildProjectLabelByKey(repos, projectHostSetupProjection), sessionProjectById: buildAiVaultSessionProjectById({ @@ -79,16 +76,18 @@ export function buildAiVaultSessionProjectById({ repos, worktrees, projectHostSetupProjection, - sessions + sessions, + settings }: Omit): Map { - const repoById = new Map(repos.map((repo) => [repo.id, repo])) - const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) + const repoById = new Map(repos.map((repo) => [getRepoHostIdentity(repo), repo])) + const owners = getProjectHostSetupLookup(projectHostSetupProjection.setups) const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection) const candidates = buildProjectCandidates( worktrees, projectHostSetupProjection, repoById, - setupByRepoId + owners, + settings ) const sessionProjectById = new Map() const projectsByHostAndCwd = new Map< @@ -96,6 +95,18 @@ export function buildAiVaultSessionProjectById({ Map >() for (const session of sessions) { + if (session.projectOwnerKey === null || isAiVaultProjectKey(session.projectOwnerKey)) { + const key = session.projectOwnerKey + sessionProjectById.set( + session.id, + key === null + ? session.cwd + ? folderProject(session.cwd) + : { kind: 'unknown', key: 'unknown', label: '' } + : { kind: 'repo', key, label: projectLabelByKey.get(key) ?? key } + ) + continue + } const host = normalizeExecutionHostId(session.executionHostId) let projectsByCwd = projectsByHostAndCwd.get(host) if (!projectsByCwd) { @@ -113,18 +124,6 @@ export function buildAiVaultSessionProjectById({ return sessionProjectById } -function buildSetupByRepoId( - setups: readonly ProjectHostSetup[] -): ReadonlyMap { - const setupByRepoId = new Map() - for (const setup of setups) { - if (setup.repoId && !setupByRepoId.has(setup.repoId)) { - setupByRepoId.set(setup.repoId, setup) - } - } - return setupByRepoId -} - function buildProjectLabelByKey( repos: readonly Repo[], projection: ProjectHostSetupProjection @@ -159,36 +158,53 @@ function buildProjectCandidates( worktrees: readonly Worktree[], projection: ProjectHostSetupProjection, repoById: ReadonlyMap, - setupByRepoId: ReadonlyMap + owners: ReturnType, + settings?: ProjectResolverArgs['settings'] ): SessionProjectCandidate[] { const candidates: SessionProjectCandidate[] = [] const setupRepoIds = new Set() + const pathKey = (host: string, path: string) => + JSON.stringify([host, normalizeRuntimePathForComparison(path)]) + const occupied = new Set([ + ...worktrees.map((row) => pathKey(row.hostId ?? 'local', row.path)), + ...projection.setups.map((row) => pathKey(row.executionHostId ?? row.hostId, row.path)) + ]) for (const worktree of worktrees) { if (!hasCandidatePath(worktree.path)) { continue } - const repo = repoById.get(worktree.repoId) - const setup = setupByRepoId.get(worktree.repoId) - candidates.push( - makeProjectCandidate({ - source: 'worktree', - path: worktree.path, - hostKey: resolveCandidateHostId( - worktree.hostId, - setup?.hostId, - repo ? getRepoExecutionHostId(repo) : null - ), - projectId: worktree.projectId ?? setup?.projectId ?? null, - repoId: worktree.repoId - }) + const repo = repoById.get( + getRepoHostIdentityForParts(worktree.repoId, worktree.hostId ?? 'local') ) + const setup = owners.forWorkspace(worktree) + const candidate = makeProjectCandidate({ + source: 'worktree', + path: worktree.path, + hostKey: resolveCandidateHostId( + worktree.hostId, + setup?.hostId, + repo ? getRepoExecutionHostId(repo) : null + ), + projectId: owners.projectId(worktree) ?? null, + repoId: worktree.repoId + }) + candidates.push(candidate) + for (const id of worktree.priorWorktreeIds ?? []) { + const prior = splitWorktreeIdForFilesystem(id) + if ( + prior?.repoId === worktree.repoId && + !occupied.has(pathKey(candidate.hostKey, prior.worktreePath)) + ) { + candidates.push(makeProjectCandidate({ ...candidate, path: prior.worktreePath })) + } + } } for (const setup of projection.setups) { if (setup.repoId) { if (hasCandidatePath(setup.path)) { - setupRepoIds.add(setup.repoId) + setupRepoIds.add(getRepoHostIdentityForParts(setup.repoId, getRepoExecutionHostId(setup))) } } if (!hasCandidatePath(setup.path)) { @@ -206,7 +222,21 @@ function buildProjectCandidates( } for (const repo of repoById.values()) { - if (setupRepoIds.has(repo.id)) { + if (settings) { + const hostKey = getRepoExecutionHostId(repo) + for (const path of managedWorktreeDirectories(repo, settings)) { + candidates.push( + makeProjectCandidate({ + source: 'setup', + path, + hostKey, + repoId: repo.id, + projectId: owners.projectId({ repoId: repo.id, hostId: hostKey }) ?? null + }) + ) + } + } + if (setupRepoIds.has(getRepoHostIdentity(repo))) { continue } if (!hasCandidatePath(repo.path)) { @@ -226,34 +256,6 @@ function buildProjectCandidates( return candidates } -function makeProjectCandidate( - fields: Omit & { path: string } -): SessionProjectCandidate { - const { path, ...rest } = fields - const normalizedPath = normalizeRuntimePathForComparison(path) - return { - ...rest, - normalizedPath, - ownsNormalizedCwd: createNormalizedAiVaultScopeCwdMatcher([path]) - } -} - -function resolveCandidateHostId( - ...values: readonly (string | null | undefined)[] -): ExecutionHostId { - for (const value of values) { - const hostId = normalizeExecutionHostId(value) - if (hostId) { - return hostId - } - } - return LOCAL_EXECUTION_HOST_ID -} - -function hasCandidatePath(pathValue: string): boolean { - return pathValue.trim().length > 0 -} - function resolveSessionProject( session: AiVaultSession, candidates: readonly SessionProjectCandidate[], @@ -280,7 +282,15 @@ function resolveSessionProject( } const bestCandidate = hostMatches[0] - if (!bestCandidate) { + if ( + !bestCandidate || + hostMatches.some( + (candidate) => + compareCandidates(candidate, bestCandidate) === 0 && + toAiVaultProjectKey(candidate.projectId, candidate.repoId) !== + toAiVaultProjectKey(bestCandidate.projectId, bestCandidate.repoId) + ) + ) { return folderProject(cwd) } @@ -309,11 +319,3 @@ function compareCandidates(left: SessionProjectCandidate, right: SessionProjectC } return left.source === 'worktree' ? -1 : 1 } - -function folderProject(cwd: string): AiVaultSessionProject { - return { - kind: 'folder', - key: folderGroupKey(cwd), - label: folderLabel(cwd) - } -} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index ce832c055c9a..f80094a52cac 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -419,7 +419,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { await flushMicrotasks() const firstResult = latest?.scanResult - // Same scannedAt = the snapshot already on screen was replayed. + // Repeated stamp and equal content reuse the snapshot already on screen. listSessionsMock.mockResolvedValueOnce({ ...EMPTY_RESULT }) await advance(THROTTLE_MS + 1) await fireWindowFocused() @@ -435,7 +435,9 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(latest?.scanResult).toBe(firstResult) }) - // Merged stamps use the newest host clock; lagging-host changes require structural reconciliation. + // An 'all' result is a merge of legs on independent clocks, stamped with the + // newest leg. A paired host whose clock lags the desktop can return new + // sessions while the merged stamp repeats; final content decides publication. it('applies a changed all-host result whose merged scannedAt stood still', async () => { const pinned = '2026-07-01T00:00:00.000Z' listSessionsMock.mockResolvedValueOnce({ @@ -459,7 +461,8 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(latest?.sessions[1]?.id).toBe('session-2') }) - // Empty/unknown scopes also use the main-process all-host merge and cannot trust its stamp. + // The main process routes an empty or unrecognized scope to the same all-host + // merge; its changed content must also reach reconciliation. it('treats a scope that normalizes to all-host as merged', async () => { const pinned = '2026-07-01T00:00:00.000Z' listSessionsMock.mockResolvedValueOnce({ @@ -495,7 +498,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { const applied = latest?.scanResult const appliedSessions = latest?.sessions - // Structural reconciliation must still skip identical IPC clones after bypassing the all-host stamp guard. + // Reconciliation recognises an independently cloned identical body. listSessionsMock.mockResolvedValueOnce(structuredClone(first)) await advance(THROTTLE_MS + 1) await fireWindowFocused() diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index d4610439879f..77872aec7246 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -189,6 +189,7 @@ export function useAiVaultSessionRefresh( return } setFailure(null) + // Post-scan projection can change content without changing the transcript scan stamp. cacheAiVaultSessionList(request, result, { replaceHostEntries: args.force === true }) publicationGateRef.current.publish(result, (published) => { if (requestCurrent()) { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts index 2807664c2b0b..1f18ee2b742b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts @@ -40,7 +40,14 @@ function resumeInChat(session: AiVaultSession = codexSession) { session, resumeState: { blocked: true, worktreeId: null, usesSessionWorktree: false }, activeWorkspaceId: PAIRED_WORKSPACE, - targetState: { folderWorkspaces: [], projectGroups: [], repos: [], worktreesByRepo: {} }, + targetState: { + folderWorkspaces: [], + projectGroups: [], + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], + worktreesByRepo: {} + }, settings: { experimentalNativeChat: true } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index aa8f8c45b7e8..301de2e2a8d1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -53,9 +53,11 @@ function makeTargetState( folderWorkspaces: [], projectGroups: [], repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {}, ...overrides - } as AiVaultSessionResumeTargetState + } } function makeFolderTargetState( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 8b8d499efd38..4bc3e6346e6e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -10,6 +10,8 @@ import { isAiVaultSessionResumableContent, type AiVaultSession } from '../../../../shared/ai-vault-types' +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import type { AppState } from '@/store/types' import { getIndexedWorktreeMap } from '@/store/worktree-repo-index' import { translate } from '@/i18n/i18n' @@ -22,11 +24,9 @@ import { } from './ai-vault-session-worktree' import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' -export type AiVaultSessionResumeTargetState = Pick< - AppState, - 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo' -> & - Partial> +export type AiVaultSessionResumeTargetState = ProjectCatalogReadState & + Pick & + Partial> export type AiVaultSessionResumeState = { blocked: boolean @@ -227,10 +227,12 @@ function resolveAiVaultResumeTargetState(args: { for (const worktree of args.worktrees) { worktreesByRepo[worktree.repoId] = [...(worktreesByRepo[worktree.repoId] ?? []), worktree] } + const repos = [...args.repos] return { folderWorkspaces: [], projectGroups: [], - repos: [...args.repos], + repos, + projectHostSetupProjection: projectHostSetupProjectionFromRepos(repos), worktreesByRepo } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx index 72120cb84d8d..8c3c3f8d7805 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx @@ -303,6 +303,8 @@ describe('folder workspace resume targets', () => { } const folderKey = folderWorkspaceKey(folder.id) const targetState = { + endpointProjects: [], + endpointProjectHostSetups: [], folderWorkspaces: [folder], projectGroups: [], repos, diff --git a/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx b/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx new file mode 100644 index 000000000000..8c2c43380988 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { + createSessionSearchClient, + unavailableSessionSearchStatus +} from '../../../../shared/ai-vault-search-client' +import { searchHit, searchResults } from '../../../../shared/ai-vault-search-test-fixture' +import type { AiVaultSearchRequest } from '../../../../shared/ai-vault-search-types' +import { useAiVaultSearch } from './use-ai-vault-search' + +afterEach(() => vi.useRealTimers()) + +it('retires the direct Project chain before new work and allows one stale restart with finite continuation', async () => { + vi.useFakeTimers() + let release: () => void = () => {} + let statusHeld = true + const statusReady = new Promise((resolve) => { + release = resolve + }) + const calls: { method: string; params: Record }[] = [] + let stale = true + const client = createSessionSearchClient(async (method, params) => { + calls.push({ method, params }) + if (method === 'aiVault.searchStatus') { + if (statusHeld) { + await statusReady + } + return { ...unavailableSessionSearchStatus(), supportedAgents: ['codex'] } + } + if (params.cursor && stale) { + stale = false + return { kind: 'stale-cursor', generation: 2 } + } + return { + ...searchResults(), + hits: [{ ...searchHit(), agent: 'codex', sessionId: params.cursor ? 'second' : 'first' }], + page: { cursor: params.cursor ? null : 'next', hasMore: !params.cursor } + } + }, 'relay') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + aiVault: { + searchSessions: (request: AiVaultSearchRequest, _scope?: string, active?: () => boolean) => + client.searchSessions(request, active) + } + } + }) + const request: AiVaultSearchRequest = { + query: 'needle', + filters: { agents: ['codex'] }, + within: { kind: 'project', projectKey: 'project:P', canonicalProjectKey: 'repo:b' } + } + const retired = renderHook(() => useAiVaultSearch(request, 'runtime:old', '')) + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + expect(calls.map((call) => call.method)).toEqual(['aiVault.searchStatus']) + retired.unmount() + statusHeld = false + await act(async () => { + release() + await statusReady + }) + expect(calls).toHaveLength(1) + const live = renderHook(() => useAiVaultSearch(request, 'runtime:old', '')) + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + await act(async () => { + live.result.current.loadMore() + }) + expect(live.result.current.hits.map((hit) => hit.sessionId)).toEqual(['first']) + await act(async () => { + live.result.current.loadMore() + }) + expect(live.result.current.hits.map((hit) => hit.sessionId)).toEqual(['first', 'second']) + expect(live.result.current.response).toMatchObject({ kind: 'results', page: { hasMore: false } }) + expect( + calls + .filter((call) => call.method === 'aiVault.searchSessions') + .map((call) => call.params.cursor) + ).toEqual([undefined, 'next', undefined, 'next']) + for (const call of calls.filter((call) => call.method === 'aiVault.searchSessions')) { + expect(call.params.within).toEqual(request.within) + } + live.unmount() +}) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-workspace.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-workspace.ts index 90e937a37fd1..65b4583424e7 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-workspace.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-workspace.ts @@ -1,3 +1,6 @@ +import { useMemo } from 'react' +import { getProjectHostSetupCompatibilityFromState } from '@/store/project-host-setup-selector' +import { buildSetupByRepoId, resolveLegacyActiveProjectKey } from './ai-vault-project-key' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { useActiveRepo, useActiveWorktree, useActiveWorktreeId } from '@/store/selectors' @@ -11,14 +14,25 @@ const NO_ROWS: AiVaultPanelWorkspaceRows = { worktree: null, repo: null } export function useAiVaultPanelWorkspace( workspace: AiVaultPanelWorkspace | undefined -): AiVaultPanelWorkspaceRows & { workspaceId: string | null } { +): AiVaultPanelWorkspaceRows & { workspaceId: string | null; legacyProjectKey: string | null } { const activeWorktreeId = useActiveWorktreeId() const activeWorktree = useActiveWorktree() const activeRepo = useActiveRepo() const bound = useAppStore( useShallow((state) => (workspace ? selectAiVaultPanelWorkspace(state, workspace) : NO_ROWS)) ) - return workspace + const selected = workspace ? { workspaceId: workspace.worktreeId, ...bound } : { workspaceId: activeWorktreeId, worktree: activeWorktree, repo: activeRepo } + const compatibility = useAppStore(getProjectHostSetupCompatibilityFromState) + const legacyProjectKey = useMemo( + () => + resolveLegacyActiveProjectKey( + selected.repo, + selected.worktree, + buildSetupByRepoId(compatibility.setups) + ), + [selected.repo, selected.worktree, compatibility] + ) + return { ...selected, legacyProjectKey } } diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx index abe879cdd4d5..c3a8cb2590e6 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx @@ -1,3 +1,6 @@ +import { createSessionSearchClient } from '../../../../shared/ai-vault-search-client' +import { AiVaultSearchRequestSchema } from '../../../../shared/ai-vault-search-contract' +import type * as StoreModule from '@/store' // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -13,11 +16,17 @@ import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-w import { useAiVaultPanelSearch, useAiVaultSearch } from './use-ai-vault-search' const mockSettings: { aiVaultSearch?: { enabled: boolean } } = {} -vi.mock('@/store', () => ({ - useAppStore: ( - select: (state: { settings: typeof mockSettings; runtimeEnvironments: never[] }) => unknown - ) => select({ settings: mockSettings, runtimeEnvironments: [] }) -})) +vi.mock('@/store', async (importOriginal) => { + const actual = await importOriginal() + return { + useAppStore: Object.assign( + ( + select: (state: { settings: typeof mockSettings; runtimeEnvironments: never[] }) => unknown + ) => select({ settings: mockSettings, runtimeEnvironments: [] }), + { getState: actual.useAppStore.getState, setState: actual.useAppStore.setState } + ) + } +}) const feeds = vi.hoisted(() => ({ subscribe: vi.fn(), @@ -86,7 +95,8 @@ it('debounces, skips empty/disabled requests, and never substitutes local for an await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { query: 'latest', cursor: undefined }, - 'ssh:remote' + 'ssh:remote', + expect.any(Function) ) unmount() }) @@ -132,8 +142,12 @@ it('restarts page one after stale cursors without looping on a changing index', .mockResolvedValueOnce({ kind: 'stale-cursor', generation: 3 }) act(() => result.current.loadMore()) await debounce() - expect(searchSessions.mock.calls[1]).toEqual([{ ...request, cursor: 'page-2' }, 'runtime:owner']) - expect(searchSessions.mock.calls[2]).toEqual([request, 'runtime:owner']) + expect(searchSessions.mock.calls[1]).toEqual([ + { ...request, cursor: 'page-2' }, + 'runtime:owner', + expect.any(Function) + ]) + expect(searchSessions.mock.calls[2]).toEqual([request, 'runtime:owner', expect.any(Function)]) expect(searchSessions).toHaveBeenCalledTimes(3) expect(result.current.response?.kind).toBe('stale-cursor') unmount() @@ -183,7 +197,11 @@ it('discards pagination when a host is left and revisited, and replaces stale pa await debounce() rerender({ host: 'local' }) await debounce() - expect(searchSessions.mock.calls.at(-1)).toEqual([{ ...request, cursor: undefined }, 'local']) + expect(searchSessions.mock.calls.at(-1)).toEqual([ + { ...request, cursor: undefined }, + 'local', + expect.any(Function) + ]) unmount() }) @@ -229,7 +247,11 @@ it('does not revive old results or cursors before debounce when returning from a act(() => result.current.loadMore()) expect(searchSessions).toHaveBeenCalledTimes(1) await debounce() - expect(searchSessions.mock.calls.at(-1)).toEqual([{ ...request, cursor: undefined }, 'local']) + expect(searchSessions.mock.calls.at(-1)).toEqual([ + { ...request, cursor: undefined }, + 'local', + expect.any(Function) + ]) unmount() }) @@ -264,7 +286,8 @@ it('searches every computer at once and keeps each hit on the computer that owns await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { ...ALL_REQUEST, cursor: undefined }, - 'all' + 'all', + expect.any(Function) ) expect(result.current.sessions.map((session) => session.executionHostId)).toEqual([ 'ssh:build-box', @@ -310,8 +333,12 @@ it('restarts page one under the all scope when the merged cursor goes stale', as .mockResolvedValueOnce({ ...searchResults(), generation: 0 }) act(() => result.current.loadMore()) await debounce() - expect(searchSessions.mock.calls[1]).toEqual([{ ...ALL_REQUEST, cursor: 'merged' }, 'all']) - expect(searchSessions.mock.calls[2]).toEqual([ALL_REQUEST, 'all']) + expect(searchSessions.mock.calls[1]).toEqual([ + { ...ALL_REQUEST, cursor: 'merged' }, + 'all', + expect.any(Function) + ]) + expect(searchSessions.mock.calls[2]).toEqual([ALL_REQUEST, 'all', expect.any(Function)]) expect(result.current.sessions.map((session) => session.executionHostId)).toEqual(['local']) unmount() }) @@ -338,7 +365,8 @@ it('searches the local index with the same query once consent is on', async () = await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { ...ALL_REQUEST, cursor: undefined }, - 'local' + 'local', + expect.any(Function) ) expect(result.current.searching).toBe(true) expect(result.current.hasQuery).toBe(true) @@ -391,6 +419,33 @@ it('keeps full-text search for a host that is merely not ready', async () => { unmount() }) +// The real client parses before transport; top-level intent would disappear here. +it('carries both Project identities through the actual panel parser and continuation', async () => { + const requests: AiVaultSearchRequest[] = [] + const client = createSessionSearchClient(async (_method, params) => { + requests.push(AiVaultSearchRequestSchema.parse(params)) + return { + ...empty, + page: { cursor: requests.length === 1 ? 'next' : null, hasMore: requests.length === 1 } + } + }, 'ipc') + searchSessions.mockImplementation((request) => client.searchSessions(request)) + const within = { kind: 'project', projectKey: 'project:shared' } as const + const { result, unmount } = renderHook(() => + useAiVaultPanelSearch('needle', ALL_AGENTS, within, 'all', 'relevance', 'repo:b') + ) + await debounce() + act(() => result.current.loadMore()) + await debounce() + expect(requests.map((request) => request.within)).toEqual([ + { ...within, canonicalProjectKey: 'repo:b' }, + { ...within, canonicalProjectKey: 'repo:b' } + ]) + expect(requests.map((request) => request.cursor)).toEqual([undefined, 'next']) + expect(within).toEqual({ kind: 'project', projectKey: 'project:shared' }) + unmount() +}) + it('revalidates active search without losing hits, accepting old pages, or touching another host', async () => { const request = { query: 'inventory' } const owned = { diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts index f8dcb2a1ad7b..41c97a861d28 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts @@ -59,6 +59,8 @@ export function useAiVaultSearch( } let cancelled = false let pending = false + const search = (query: AiVaultSearchRequest, host: ExecutionHostScope) => + window.api.aiVault.searchSessions(query, host, () => !cancelled) let followUp = false let started = false async function run(cursor?: string, revalidate = false) { @@ -77,14 +79,14 @@ export function useAiVaultSearch( loading: true })) try { - let response = await window.api.aiVault.searchSessions({ ...request, cursor }, scope) + let response = await search({ ...request, cursor }, scope) let append = Boolean(cursor) if (!isCurrent()) { return } if (response.kind === 'stale-cursor') { append = false - response = await window.api.aiVault.searchSessions(request, scope) + response = await search(request, scope) } if (!isCurrent()) { return @@ -170,7 +172,8 @@ export function useAiVaultPanelSearch( /** Which scope the host resolves; undefined searches everything it has. */ within: AiVaultSearchScopeIdentity | undefined, executionHostScope: ExecutionHostScope, - sort: AiVaultSearchSort + sort: AiVaultSearchSort, + canonicalProjectKey?: string ) { const settings = useAppStore((state) => state.settings?.aiVaultSearch) const policy = resolveAiVaultSearchSettings({ aiVaultSearch: settings }) @@ -193,10 +196,17 @@ export function useAiVaultPanelSearch( ? { query: trimmed, filters: { agents: [...agents], ...(sort === 'relevance' ? {} : { sort }) }, - ...(within ? { within } : {}) + ...(within + ? { + within: + within.kind === 'project' && canonicalProjectKey + ? { ...within, canonicalProjectKey } + : within + } + : {}) } : null, - [searching, scope, agents, trimmed, within, sort] + [searching, scope, agents, trimmed, within, sort, canonicalProjectKey] ) const search = useAiVaultSearch(request, scope, JSON.stringify(policy)) const hostSearchOff = diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx index 543db2dc88e9..230845736ee8 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx @@ -24,7 +24,8 @@ const mockStoreState = { activeWorktreeId: null, openModal: storeMocks.openModal, recordFeatureInteraction: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], settings: null, worktreesByRepo: {} diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx new file mode 100644 index 000000000000..f06863eb56d9 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment happy-dom +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import { useAppStore } from '../../store' +import { RepositoryHostSetupsSection } from './repository/execution/RepositoryHostSetupsSection' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { buildSettingsProjectList } from './settings-project-list' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' +import type { Repo } from '../../../../shared/repo-types' + +it.each(['claimant', 'outclaimed', 'folder', 'remoteless'])( + '%s preserves the approved host actions', + async (kind) => { + const repos: Repo[] = (kind === 'claimant' || kind === 'outclaimed' ? ['a', 'b'] : [kind]).map( + (id, index) => ({ + id, + path: `/${id}`, + displayName: id, + addedAt: index + 1, + badgeColor: '', + kind: kind === 'folder' ? 'folder' : 'git', + ...(kind === 'claimant' || kind === 'outclaimed' + ? { upstream: { owner: 'acme', repo: 'app' } } + : {}) + }) + ) + const projection = projectHostSetupProjectionFromRepos(repos) + const repo = repos[kind === 'outclaimed' ? 1 : 0] + const projectId = projection.setups[kind === 'outclaimed' ? 1 : 0].projectId + const createProjectHostSetup = vi.fn().mockResolvedValue(null) + const existing = { + ...projection.setups[0], + id: 'pending', + repoId: '', + projectId, + hostId: 'ssh:configured' as const, + path: '', + setupMethod: 'provisioned' as const, + setupState: 'not-set-up' as const + } + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + repos, + endpointProjects: [...projection.projects], + endpointProjectHostSetups: [...projection.setups, existing], + sshTargetLabels: new Map([ + ['extra', 'extra'], + ['configured', 'configured'] + ]), + createProjectHostSetup + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const button = (label: string) => + Array.from(container.querySelectorAll('button')).find((node) => + node.textContent?.includes(label) + ) + const click = async (label: string) => { + expect(button(label), label).toBeTruthy() + expect(button(label)?.disabled).toBe(false) + await act(async () => { + button(label)?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + } + try { + await act(async () => + root.render( + React.createElement(RepositoryHostSetupsSection, { + repo, + forceVisible: true, + searchQuery: '', + searchEntries: [] + }) + ) + ) + expect(container.textContent).toContain('configured') + expect(container.textContent).toContain('Not set up') + if (kind === 'outclaimed') { + expect(button('Add to another host')).toBeUndefined() + expect(createProjectHostSetup).not.toHaveBeenCalled() + } else { + await click('Add to another host') + await click('Add host placeholder') + await click('Add extra') + expect(createProjectHostSetup).toHaveBeenCalledWith({ + projectId, + hostId: 'ssh:extra', + displayName: repo.displayName, + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + } + } finally { + await act(async () => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) + } + } +) + +it('reaches and edits/removes a reloaded handoff row through B Settings and concrete setup addressing', async () => { + useAppStore.setState(useAppStore.getInitialState(), true) + const repo: Repo = { + id: 'b', + path: '/b', + displayName: 'b', + badgeColor: '', + addedAt: 2, + upstream: { owner: 'acme', repo: 'app' } + } + const remote: Repo = { ...repo, id: 'c', path: '/c', executionHostId: 'runtime:remote' } + const projection = projectHostSetupProjectionFromRepos([repo, remote]) + const project = projection.projects[0] + const pending = { + ...projection.setups[0], + id: 'pending-b', + repoId: '', + hostId: 'ssh:extra' as const, + path: '', + setupState: 'not-set-up' as const, + setupMethod: 'provisioned' as const + } + // Persistence/profile lifecycle pins this exact independent row's successor before serving it. + const saved = JSON.parse( + JSON.stringify({ projects: projection.projects, setups: [...projection.setups, pending] }) + ) + const updated = { ...pending, displayName: 'Saved pending host' } + const updateHostSetup = vi.fn().mockResolvedValue({ project, setup: updated }) + const deleteHostSetup = vi.fn().mockResolvedValue({ project, setup: updated }) + const api = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { projects: { updateHostSetup, deleteHostSetup } } + }) + useAppStore.setState({ + repos: [repo, remote], + endpointProjects: saved.projects, + endpointProjectHostSetups: saved.setups, + sshTargetLabels: new Map([['extra', 'Extra']]) + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + try { + const canonical = getProjectHostSetupProjectionFromState(useAppStore.getState()) + const entries = buildSettingsProjectList([repo, remote], { + projects: canonical.projects, + projectHostSetups: canonical.setups + }) + expect(entries).toHaveLength(1) + expect(entries[0].representativeRepoId).toBe('b') + expect(entries[0].setups.find((setup) => setup.id === 'pending-b')?.projectId).toBe(project.id) + await act(async () => + root.render( + React.createElement(RepositoryHostSetupsSection, { + repo, + forceVisible: true, + searchQuery: '', + searchEntries: [] + }) + ) + ) + expect(container.textContent).toContain('Extra') + expect(container.textContent).toContain('Not set up') + await act(async () => { + await useAppStore.getState().updateProjectHostSetup({ + setupId: 'pending-b', + updates: { displayName: updated.displayName } + }) + }) + expect(updateHostSetup).toHaveBeenCalledExactlyOnceWith({ + setupId: 'pending-b', + updates: { displayName: updated.displayName } + }) + expect( + useAppStore.getState().endpointProjectHostSetups.find((setup) => setup.id === 'pending-b') + ?.displayName + ).toBe(updated.displayName) + const remove = Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === 'Remove' + ) + expect(remove).toBeTruthy() + await act(async () => remove?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + expect(deleteHostSetup).toHaveBeenCalledExactlyOnceWith({ + setupId: 'pending-b', + hostId: pending.hostId + }) + expect( + useAppStore.getState().endpointProjectHostSetups.some((setup) => setup.id === 'pending-b') + ).toBe(false) + } finally { + await act(async () => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) + if (api) { + Object.defineProperty(window, 'api', api) + } else { + Reflect.deleteProperty(window, 'api') + } + } +}) diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index aca26876086d..feb381a38532 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -15,7 +15,6 @@ import { SparsePresetSettingsSection } from './SparsePresetSettingsSection' import { RepositorySourceControlAiSection } from './RepositorySourceControlAiSection' import { SearchableSetting } from './SearchableSetting' import { RepositoryRemoveProjectButton } from './RepositoryRemoveProjectButton' -import type { SettingsProjectRemovalScope } from './settings-project-list' import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '@/store/index' import { getRepositoryIconSectionId } from './repository-settings-targets' @@ -59,9 +58,6 @@ type RepositoryPaneProps = { removeProject: (repoId: string) => void project?: Project | null selectedProjectSetupId?: string - settingsSelectionKey?: string - settingsEntryRepoIds?: ReadonlySet - removalScope?: SettingsProjectRemovalScope isLocalWindowsProject?: boolean wslAvailable?: boolean wslDistros?: string[] @@ -82,9 +78,6 @@ export function RepositoryPane({ removeProject, project = null, selectedProjectSetupId, - settingsSelectionKey, - settingsEntryRepoIds, - removalScope = 'project', isLocalWindowsProject = false, wslAvailable = false, wslDistros = EMPTY_WSL_DISTROS, @@ -247,7 +240,6 @@ export function RepositoryPane({ @@ -310,8 +302,6 @@ export function RepositoryPane({ void }) { @@ -54,7 +32,10 @@ export function RepositoryRemoveProjectButton({ return ( & Pick): Repo { - return { badgeColor: '#737373', addedAt: 100, kind: 'git', ...overrides } -} - -function makeSetup( - overrides: Partial & Pick -): ProjectHostSetup { - return { - projectId, - displayName: 'Orca', - kind: 'git', - setupState: 'ready', - setupMethod: 'legacy-repo', - createdAt: 100, - updatedAt: 100, - ...overrides - } -} - -function findButton(label: string): HTMLButtonElement | undefined { - return Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label) - ) -} - -beforeEach(() => { - useAppStore.setState(useAppStore.getInitialState(), true) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() - useAppStore.setState(useAppStore.getInitialState(), true) -}) - -describe('RepositoryHostSetupsSection settings entry scope', () => { - it('lists only its own settings entry when same-host clones split the project (#20861)', () => { - const cloneA = makeRepo({ id: 'clone-a', displayName: 'Orca', path: '/work/orca' }) - const cloneB = makeRepo({ id: 'clone-b', displayName: 'Orca B', path: '/work/orca-b' }) - const remoteRepo = makeRepo({ - id: 'remote-repo', - displayName: 'Orca', - path: '/home/alice/orca', - connectionId: 'openclaw 2' - }) - useAppStore.setState({ - repos: [cloneA, cloneB, remoteRepo], - projects: [ - { - id: projectId, - displayName: 'Orca', - badgeColor: '#737373', - sourceRepoIds: [cloneA.id, cloneB.id, remoteRepo.id], - createdAt: 100, - updatedAt: 100 - } - ], - projectHostSetups: [ - makeSetup({ id: 'clone-a', repoId: 'clone-a', hostId: 'local', path: '/work/orca' }), - makeSetup({ id: 'clone-b', repoId: 'clone-b', hostId: 'local', path: '/work/orca-b' }), - makeSetup({ - id: 'remote-repo', - repoId: 'remote-repo', - hostId: toSshExecutionHostId('openclaw 2'), - path: '/home/alice/orca' - }), - makeSetup({ - id: 'gpu-setup', - repoId: '', - hostId: 'runtime:gpu', - path: '', - setupState: 'not-set-up' - }) - ], - sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]) - }) - - act(() => { - root.render( - React.createElement(RepositoryHostSetupsSection, { - repo: cloneA, - settingsEntryRepoIds: new Set(['clone-a']), - forceVisible: true, - searchQuery: '', - searchEntries: [] - }) - ) - }) - - expect(container.textContent).toContain('/work/orca') - expect(container.textContent).toContain('Path pending') - expect(container.textContent).not.toContain('/work/orca-b') - expect(container.textContent).not.toContain('/home/alice/orca') - expect(findButton('Open')).toBeUndefined() - expect(findButton('Add to another host')).toBeUndefined() - }) - - it("stores a host switch under the clone entry's own selection key", () => { - const setSettingsProjectHostSelection = vi.fn() - const cloneA = makeRepo({ id: 'clone-a', displayName: 'Orca', path: '/work/orca' }) - const cloneATwin = makeRepo({ - id: 'clone-a', - displayName: 'Orca', - path: '/home/alice/orca', - connectionId: 'openclaw 2' - }) - const sshHostId = toSshExecutionHostId('openclaw 2') - useAppStore.setState({ - repos: [cloneA, cloneATwin], - projectHostSetups: [ - makeSetup({ id: 'clone-a', repoId: 'clone-a', hostId: 'local', path: '/work/orca' }), - makeSetup({ - id: 'clone-a-ssh', - repoId: 'clone-a', - hostId: sshHostId, - path: '/home/alice/orca' - }) - ], - sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]), - setSettingsProjectHostSelection - }) - - act(() => { - root.render( - React.createElement(RepositoryHostSetupsSection, { - repo: cloneA, - settingsEntryRepoIds: new Set(['clone-a']), - settingsSelectionKey: `${projectId}::setup:clone-a`, - forceVisible: true, - searchQuery: '', - searchEntries: [] - }) - ) - }) - act(() => { - findButton('Open')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(setSettingsProjectHostSelection).toHaveBeenCalledWith( - `${projectId}::setup:clone-a`, - sshHostId, - 'clone-a-ssh' - ) - }) -}) diff --git a/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.test.tsx index de63f92f6712..f3262deeddc1 100644 --- a/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.test.tsx +++ b/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.test.tsx @@ -135,8 +135,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo, remoteRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -178,8 +178,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo, remoteRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -230,8 +230,10 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [remoteRepo], - projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })], - projectHostSetups: [ + endpointProjects: [ + makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] }) + ], + endpointProjectHostSetups: [ makeSetup({ id: 'direct-setup', projectId: 'github:stablyai/orca', @@ -330,8 +332,10 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [remoteRepo], - projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })], - projectHostSetups: [ + endpointProjects: [ + makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] }) + ], + endpointProjectHostSetups: [ makeSetup({ id: 'hub-local-setup', projectId: 'github:stablyai/orca', @@ -371,8 +375,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -442,8 +446,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -519,8 +523,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -582,8 +586,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -633,8 +637,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -702,8 +706,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -776,8 +780,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', diff --git a/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.tsx index d7032bb0c9d1..4d3ff2a603a8 100644 --- a/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react' import { getExecutionHostLabel, + getRepoExecutionHostId, parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId @@ -19,6 +20,7 @@ import { SearchableSetting } from '../../SearchableSetting' import { SettingsBadge } from '../../SettingsFormControls' import { matchesSettingsSearch } from '../../settings-search' import type { SettingsSearchEntry } from '../../settings-search' +import { getProjectIdentityKey } from '../../../../../../shared/project-host-setup-projection' import { translate } from '@/i18n/i18n' import { buildSetupHostOptions, getSetupStateLabel } from './repository-host-setup-options' import { RepositoryHostSetupActions } from './RepositoryHostSetupActions' @@ -34,8 +36,6 @@ import { type RepositoryHostSetupsSectionProps = { repo: Repo selectedProjectSetupId?: string - settingsSelectionKey?: string - settingsEntryRepoIds?: ReadonlySet forceVisible: boolean searchQuery: string searchEntries: SettingsSearchEntry[] @@ -62,8 +62,6 @@ function setupsByOwnedExecutionHost( export function RepositoryHostSetupsSection({ repo, selectedProjectSetupId, - settingsSelectionKey, - settingsEntryRepoIds, forceVisible, searchQuery, searchEntries @@ -111,7 +109,7 @@ export function RepositoryHostSetupsSection({ getProjectHostSetupProjectionFromState(state) ) const repoProjectHostSetup = projectHostSetupProjection.setups.find( - (setup) => setup.repoId === repo.id + (setup) => setup.repoId === repo.id && setup.hostId === getRepoExecutionHostId(repo) ) const selectedProjectHostSetup = projectHostSetupProjection.setups.find( @@ -125,12 +123,8 @@ export function RepositoryHostSetupsSection({ (setup) => setup.projectId === selectedProjectHostSetup.projectId ) : [] - // Exclude sibling checkout setups, but retain project-level not-set-up placeholders for each entry. const projectHostSetups = setupsByOwnedExecutionHost( - allProjectHostSetups.filter( - (setup) => - !settingsEntryRepoIds || !setup.repoId.trim() || settingsEntryRepoIds.has(setup.repoId) - ), + allProjectHostSetups, selectedProjectHostSetup?.id ?? '' ) const openableProjectHostSetups = projectHostSetups.filter((setup) => setup.repoId.trim()) @@ -139,14 +133,17 @@ export function RepositoryHostSetupsSection({ selectedProjectHostSetup?.id ?? '' ) const setupHostOptions = buildSetupHostOptions({ + outclaimed: + repoProjectHostSetup?.projectId === `repo:${repo.id}` && + getProjectIdentityKey(repo) !== `repo:${repo.id}`, projectHostSetups: allProjectHostSetups, hostOptions }) const hostOptionById = new Map(hostOptions.map((option) => [option.id, option])) const [deletingSetupId, setDeletingSetupId] = useState(null) - // Split clones share project IDs but need independent selections. - const selectionKey = settingsSelectionKey ?? selectedProjectHostSetup?.projectId - // Switch hosts within the project pane through ephemeral per-entry selection. + const selectionKey = selectedProjectHostSetup?.projectId + // Why: the single project pane switches host in place — set the ephemeral + // per-entry selection instead of navigating to a separate repo section. const selectHost = (hostId: ExecutionHostId) => { if (selectionKey) { setSettingsProjectHostSelection(selectionKey, hostId) diff --git a/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.workspace-window.test.tsx b/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.workspace-window.test.tsx index 3ccb5de66b8d..1bf742598203 100644 --- a/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.workspace-window.test.tsx +++ b/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.workspace-window.test.tsx @@ -68,8 +68,8 @@ function makeStatus(overrides: Partial): RuntimeStatus { function renderWithOwnerStatus(status: RuntimeStatus | null): void { useAppStore.setState({ repos: [repo], - projects: [project], - projectHostSetups: [setup], + endpointProjects: [project], + endpointProjectHostSetups: [setup], runtimeStatusByEnvironmentId: new Map([['hub', { checkedAt: 1, appVersion: '1.8.0', status }]]) }) act(() => { @@ -137,8 +137,8 @@ describe('RepositoryHostSetupsSection workspace window availability', () => { it('does not mark a transport-connected but unavailable runtime setup Ready', () => { useAppStore.setState({ repos: [repo], - projects: [project], - projectHostSetups: [setup], + endpointProjects: [project], + endpointProjectHostSetups: [setup], runtimeStatusByEnvironmentId: new Map([ [ 'hub', diff --git a/src/renderer/src/components/settings/repository/execution/repository-host-setup-options.ts b/src/renderer/src/components/settings/repository/execution/repository-host-setup-options.ts index f431c25576f4..3070c123567b 100644 --- a/src/renderer/src/components/settings/repository/execution/repository-host-setup-options.ts +++ b/src/renderer/src/components/settings/repository/execution/repository-host-setup-options.ts @@ -45,11 +45,16 @@ export function getSetupStateLabel(setupState: ProjectHostSetupState): string { export function buildSetupHostOptions({ projectHostSetups, + outclaimed = false, hostOptions }: { + outclaimed?: boolean projectHostSetups: ProjectHostSetup[] hostOptions: readonly ExecutionHostRegistryEntry[] }): SetupHostOption[] { + if (outclaimed) { + return [] + } const setupHostIds = new Set(projectHostSetups.map((setup) => setup.hostId)) return pickerExecutionHosts(hostOptions) .filter( diff --git a/src/renderer/src/components/settings/settings-project-list.test.ts b/src/renderer/src/components/settings/settings-project-list.test.ts index 7d668a670843..6f295731ff84 100644 --- a/src/renderer/src/components/settings/settings-project-list.test.ts +++ b/src/renderer/src/components/settings/settings-project-list.test.ts @@ -1,15 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { buildRepoIdToHostSelection, buildRepoIdToRepresentative, buildSettingsProjectList, getSettingsProjectHostRepo, - getSettingsEntryHostSelection, - getSettingsProjectRemovalScope, getSettingsProjectRepresentativeRepoId, getSettingsTargetHostSelection, removeSettingsProjectFromAllHosts, @@ -66,7 +62,6 @@ describe('buildSettingsProjectList', () => { expect(projects).toHaveLength(1) expect(projects[0].setups).toHaveLength(2) expect(projects[0].representativeRepoId).toBe('local-1') - expect(getSettingsProjectRemovalScope(projects[0])).toBe('project') }) it('collapses a folder with the same id on local + runtime into one project', () => { @@ -82,112 +77,21 @@ describe('buildSettingsProjectList', () => { expect(projects[0].representativeRepoId).toBe('folder-x') }) - it('gives each same-host clone of one remote its own entry, like the sidebar (#20861)', () => { + it('gives each same-host clone of one remote its own entry; other hosts join the first (#20861)', () => { const repos: Repo[] = [ - makeRepo({ id: 'clone-a', displayName: 'app', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', displayName: 'app-b', gitRemoteIdentity: gitRemote }) - ] - - const projects = buildSettingsProjectList(repos) - - expect(projects.map((entry) => entry.representativeRepoId)).toEqual(['clone-a', 'clone-b']) - expect(projects.map((entry) => entry.checkoutLabel)).toEqual(['app', 'app-b']) - expect(buildRepoIdToRepresentative(projects).get('clone-b')).toBe('clone-b') - }) - - it('splits by the setups the sidebar groups with, not only the loaded repos', () => { - const cloneA = makeRepo({ id: 'clone-a', displayName: 'app', gitRemoteIdentity: gitRemote }) - const cloneB = makeRepo({ id: 'clone-b', displayName: 'app-b', gitRemoteIdentity: gitRemote }) - const sidebarProjection = projectHostSetupProjectionFromRepos([cloneA, cloneB]) - - const projects = buildSettingsProjectList([cloneA], { - projects: sidebarProjection.projects, - projectHostSetups: sidebarProjection.setups - }) - - expect(projects.map((entry) => entry.checkoutLabel)).toEqual(['app']) - expect(buildSettingsProjectList([cloneA]).map((entry) => entry.checkoutLabel)).toEqual([ - undefined - ]) - }) - - it("keeps a clone's same-id twin on another host in that clone's entry", () => { - const repos: Repo[] = [ - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote, executionHostId: 'runtime:mac' }) - ] - - const projects = buildSettingsProjectList(repos) - - expect(projects.map((entry) => entry.representativeRepoId)).toEqual(['clone-a', 'clone-b']) - expect(projects[0].setups.map((setup) => setup.hostId)).toEqual(['local', 'runtime:mac']) - }) - - it('gives each clone entry its own host selection key', () => { - const repos: Repo[] = [ - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'remote-9', gitRemoteIdentity: gitRemote, executionHostId: 'ssh:box' }) - ] - - const projects = buildSettingsProjectList(repos) - const keys = projects.map((entry) => entry.selectionKey) - - expect(new Set(keys).size).toBe(3) - expect(projects[2].selectionKey).toBe(projects[2].projectId) - expect(buildRepoIdToHostSelection(projects).get('clone-b')?.selectionKey).toBe(keys[1]) - }) - - it("keeps a clone's host pick when its sibling clone is removed", () => { - const cloneA = makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }) - const cloneATwin = makeRepo({ - id: 'clone-a', - gitRemoteIdentity: gitRemote, - executionHostId: 'runtime:mac' - }) - const cloneB = makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }) - const [splitCloneA] = buildSettingsProjectList([cloneA, cloneB, cloneATwin]) - const hostSelection: Record = { - [splitCloneA.selectionKey]: 'runtime:mac' - } - - const [unsplit] = buildSettingsProjectList([cloneA, cloneATwin]) - - expect(unsplit.selectionKey).not.toBe(splitCloneA.selectionKey) - expect(getSettingsEntryHostSelection(unsplit, hostSelection, {}).hostId).toBe('runtime:mac') - }) - - it("does not take a sibling clone's host pick", () => { - const projects = buildSettingsProjectList([ - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }) - ]) - const hostSelection: Record = { [projects[1].selectionKey]: 'local' } - - expect(getSettingsEntryHostSelection(projects[0], hostSelection, {}).hostId).toBeUndefined() - }) - - it('keeps other hosts in a project-level entry when same-host clones split', () => { - const repos: Repo[] = [ - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }), + makeRepo({ id: 'clone-b', displayName: 'app-b', addedAt: 2, gitRemoteIdentity: gitRemote }), + makeRepo({ id: 'clone-a', displayName: 'app', addedAt: 1, gitRemoteIdentity: gitRemote }), makeRepo({ id: 'remote-9', gitRemoteIdentity: gitRemote, executionHostId: 'ssh:box' }) ] const projects = buildSettingsProjectList(repos) expect(projects.map((entry) => entry.setups.map((setup) => setup.repoId))).toEqual([ - ['clone-a'], ['clone-b'], - ['remote-9'] - ]) - expect(projects[2].checkoutLabel).toBeUndefined() - expect(projects.map(getSettingsProjectRemovalScope)).toEqual([ - 'checkout', - 'checkout', - 'split-project' + ['clone-a', 'remote-9'] ]) + expect(projects.map((entry) => entry.project.displayName)).toEqual(['app-b', 'app']) + expect(buildRepoIdToRepresentative(projects).get('clone-b')).toBe('clone-b') }) it('keeps the representative stable when an unrelated host is removed', () => { @@ -268,7 +172,7 @@ describe('deep-link resolution', () => { it('maps a repoId to its owning project + host for selection', () => { const map = buildRepoIdToHostSelection(projects) expect(map.get('remote-9')).toEqual({ - selectionKey: projects[0].selectionKey, + projectId: projects[0].projectId, hostId: 'runtime:home-mac', setupId: 'remote-9' }) @@ -288,7 +192,7 @@ describe('deep-link resolution', () => { expect(getSettingsTargetHostSelection(sameIdProjects, 'same-repo', 'ssh:server')).toEqual( expect.objectContaining({ - selectionKey: sameIdProjects[0].selectionKey, + projectId: sameIdProjects[0].projectId, hostId: 'ssh:server' }) ) diff --git a/src/renderer/src/components/settings/settings-project-list.ts b/src/renderer/src/components/settings/settings-project-list.ts index 261c40eb700e..e44999e76820 100644 --- a/src/renderer/src/components/settings/settings-project-list.ts +++ b/src/renderer/src/components/settings/settings-project-list.ts @@ -1,3 +1,4 @@ +import type { ProjectGroupingModel } from '../../../../shared/project-section-grouping' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { @@ -6,67 +7,30 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' -import { getRepoHostIdentityForParts } from '../../../../shared/repo-host-identity' -import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' -import { - buildProjectGroupingIndex, - isCheckoutScopedProjectSetup, - type ProjectGroupingModel -} from '../../../../shared/project-section-grouping' export type SettingsProject = { projectId: string project: Project setups: ProjectHostSetup[] representativeRepoId: string - /** Keys this entry's host selection: the project id, or the clone's own key when split. */ - selectionKey: string - /** Only set when same-host clones split the project. */ - checkoutLabel?: string - /** Set on every entry of a split project; each holds only its own part. */ - splitProject?: boolean -} - -function getCheckoutSelectionKey(projectId: string, repoId: string): string { - return `${projectId}::setup:${repoId}` } -/** Carry the latest explicit host pick across split/unsplit selection keys while that host remains present. */ +/** + * The host (and setup) an entry's pane shows: the stored pick, while that host + * still has a setup in this entry. + */ export function getSettingsEntryHostSelection( settingsProject: SettingsProject, hostSelection: Readonly>, setupSelection: Readonly> ): { hostId: ExecutionHostId | undefined; setupId: string | undefined } { - const { projectId, selectionKey, setups } = settingsProject - const keys = new Set([ - selectionKey, - projectId, - ...setups.map((setup) => getCheckoutSelectionKey(projectId, setup.repoId)) - ]) - // Selection records are ordered by the store's last explicit pick, including alias keys. - for (const key of Object.keys(hostSelection).toReversed()) { - if (!keys.has(key)) { - continue - } - const hostId = hostSelection[key] - const setupId = setupSelection[key] - if (setups.some((setup) => setup.hostId === hostId && (!setupId || setup.id === setupId))) { - return { hostId, setupId } - } - } - return { hostId: undefined, setupId: undefined } -} - -/** What a pane's "Remove Project" removes: the project, one clone, or a split project's remainder. */ -export type SettingsProjectRemovalScope = 'project' | 'checkout' | 'split-project' - -export function getSettingsProjectRemovalScope( - settingsProject: SettingsProject -): SettingsProjectRemovalScope { - if (settingsProject.checkoutLabel !== undefined) { - return 'checkout' - } - return settingsProject.splitProject ? 'split-project' : 'project' + const hostId = hostSelection[settingsProject.projectId] + const setupId = setupSelection[settingsProject.projectId] + return settingsProject.setups.some( + (setup) => setup.hostId === hostId && (!setupId || setup.id === setupId) + ) + ? { hostId, setupId } + : { hostId: undefined, setupId: undefined } } /** Prefer local, then lowest repo ID, to keep navigation and pane identity stable until that repo is removed. */ @@ -89,73 +53,45 @@ export function getSettingsProjectRepresentativeRepoId( return lowest } -/** Match sidebar clone splitting; derive navigation and panes from the same repo list. */ +/** + * One entry per project. Entries are derived from repos alone so the nav and + * pane lists agree exactly. + */ export function buildSettingsProjectList( repos: readonly Repo[], - projectGrouping?: ProjectGroupingModel + grouping?: ProjectGroupingModel ): SettingsProject[] { - const projection = projectHostSetupProjectionFromRepos(repos) + const projection = grouping + ? { projects: grouping.projects, setups: grouping.projectHostSetups } + : projectHostSetupProjectionFromRepos(repos) const projectById = new Map(projection.projects.map((project) => [project.id, project])) - const groupingIndex = buildProjectGroupingIndex( - projectGrouping ?? { projects: projection.projects, projectHostSetups: projection.setups } - ) - const suppliedSetupByCheckout = new Map( - projectGrouping?.projectHostSetups.map((setup) => [getSettingsSetupCheckoutKey(setup), setup]) - ) - // Index once per refresh to avoid quadratic scans across projects. - const entriesByKey = new Map>() - // Same-ID host twins must join one clone entry to avoid duplicate repo section IDs. - const checkoutRepoIds = new Set( - groupingIndex === null - ? [] - : projection.setups - .filter((setup) => - isCheckoutScopedProjectSetup( - suppliedSetupByCheckout.get(getSettingsSetupCheckoutKey(setup)) ?? setup, - groupingIndex - ) - ) - .map((setup) => setup.repoId) - ) + // Why: Settings metadata is rebuilt as repos refresh across hosts; index + // setups once so many projects do not turn each refresh into an O(n²) scan. + const entriesByProjectId = new Map>() for (const setup of projection.setups) { const project = projectById.get(setup.projectId) if (!project) { continue } - const checkoutScoped = checkoutRepoIds.has(setup.repoId) - const key = checkoutScoped ? getCheckoutSelectionKey(project.id, setup.repoId) : project.id - const entry = entriesByKey.get(key) + const entry = entriesByProjectId.get(project.id) if (entry) { entry.setups.push(setup) } else { - entriesByKey.set(key, { - projectId: project.id, - selectionKey: key, - project, - setups: [setup], - ...(checkoutScoped ? { checkoutLabel: setup.displayName } : {}) - }) + entriesByProjectId.set(project.id, { projectId: project.id, project, setups: [setup] }) } } - const entryCountByProjectId = new Map() - for (const entry of entriesByKey.values()) { - entryCountByProjectId.set( - entry.projectId, - (entryCountByProjectId.get(entry.projectId) ?? 0) + 1 - ) - } - return [...entriesByKey.values()].map((entry) => ({ + return [...entriesByProjectId.values()].map((entry) => ({ ...entry, - representativeRepoId: getSettingsProjectRepresentativeRepoId(entry.setups), - ...((entryCountByProjectId.get(entry.projectId) ?? 0) > 1 ? { splitProject: true } : {}) + representativeRepoId: getSettingsProjectRepresentativeRepoId(entry.setups) })) } -function getSettingsSetupCheckoutKey(setup: ProjectHostSetup): string { - return `${getRepoHostIdentityForParts(setup.repoId, setup.hostId)}\0${normalizeRuntimePathForComparison(setup.path.trim())}` -} - -/** Validate saved host selection against live setups; fall back to local, ready, then first. */ +/** + * The host whose settings the project pane should show. Validates the stored + * selection against the live setups so a disconnected/removed host never leaves + * the pane rendering off a dangling hostId: falls back to local, then the first + * ready setup, then the first setup. + */ export function resolveEffectiveProjectHost( setups: readonly ProjectHostSetup[], selectedHostId: ExecutionHostId | undefined @@ -191,7 +127,7 @@ export function buildRepoIdToRepresentative( /** Which entry, host and setup row a Settings deep link opens. */ export type SettingsProjectHostSelection = { - selectionKey: string + projectId: string hostId: ExecutionHostId setupId: string } @@ -205,7 +141,7 @@ export function buildRepoIdToHostSelection( for (const setup of settingsProject.setups) { if (setup.repoId.trim().length > 0 && !map.has(setup.repoId)) { map.set(setup.repoId, { - selectionKey: settingsProject.selectionKey, + projectId: settingsProject.projectId, hostId: setup.hostId, setupId: setup.id }) @@ -225,7 +161,7 @@ export function getSettingsTargetHostSelection( (candidate) => candidate.repoId === repoId && candidate.hostId === hostId ) if (setup) { - return { selectionKey: settingsProject.selectionKey, hostId, setupId: setup.id } + return { projectId: settingsProject.projectId, hostId, setupId: setup.id } } } return null @@ -254,7 +190,11 @@ export function resolveSettingsTargetRepoId( return best } -/** Remove sequentially to avoid interleaved teardown/projection updates; planned hosts have no repo to remove. */ +/** + * Removes a Settings entry's setup on every host it exists on. Sequential so + * each host's teardown + projection recompute don't interleave; setups without + * a repo row (planned/not-set-up hosts) have nothing to remove. + */ export async function removeSettingsProjectFromAllHosts( setups: readonly ProjectHostSetup[], removeProject: ( diff --git a/src/renderer/src/components/settings/settings-project-regrouping.test.ts b/src/renderer/src/components/settings/settings-project-regrouping.test.ts index e2557dc07795..d0b88109dc75 100644 --- a/src/renderer/src/components/settings/settings-project-regrouping.test.ts +++ b/src/renderer/src/components/settings/settings-project-regrouping.test.ts @@ -27,7 +27,6 @@ function makeRepo(id: string, path: string): Repo { const nativeA = makeRepo('native-a', 'C:\\projects\\app-a') const nativeB = makeRepo('native-b', 'C:\\projects\\app-b') const wslA = makeRepo('wsl-a', '\\\\wsl.localhost\\Ubuntu\\home\\fixture\\app-a') -const wslB = makeRepo('wsl-b', '\\\\wsl.localhost\\Ubuntu\\home\\fixture\\app-b') function pickRepo( store: ReturnType, @@ -38,9 +37,7 @@ function pickRepo( if (!target) { throw new Error(`Missing Settings target for ${repo.id}`) } - store - .getState() - .setSettingsProjectHostSelection(target.selectionKey, target.hostId, target.setupId) + store.getState().setSettingsProjectHostSelection(target.projectId, target.hostId, target.setupId) } function selectedRepo(store: ReturnType, repos: Repo[], repo: Repo): Repo { @@ -72,12 +69,12 @@ describe('Settings project selection across local checkout regrouping', () => { expect(selectedRepo(store, [nativeA, wslA], nativeA).path).toBe(nativeA.path) }) - it('keeps a newer project-level WSL pick when an older checkout selection still exists', () => { + it('keeps the shared project setup pick when another native checkout arrives', () => { const store = createUIStore() pickRepo(store, buildSettingsProjectList([nativeA, nativeB, wslA]), nativeA) pickRepo(store, buildSettingsProjectList([nativeA, wslA]), wslA) - expect(selectedRepo(store, [nativeA, nativeB, wslA], nativeA).path).toBe(nativeA.path) + expect(selectedRepo(store, [nativeA, nativeB, wslA], nativeA).path).toBe(wslA.path) expect(selectedRepo(store, [nativeA, nativeB, wslA], wslA).path).toBe(wslA.path) expect(selectedRepo(store, [nativeA, wslA], nativeA).path).toBe(wslA.path) }) @@ -114,49 +111,36 @@ describe('Settings project selection across local checkout regrouping', () => { }) describe('Settings provided local setup grouping', () => { - it('keeps provided provisioned metadata aligned with the sidebar', () => { + it('uses current project membership for both Settings and sidebar, including independent setups', () => { const provisioned = makeRepo('provisioned-copy', 'C:\\projects\\provisioned') const repos = [nativeA, nativeB, provisioned] const projection = projectHostSetupProjectionFromRepos(repos) + const sharedId = projection.setups[0].projectId const grouping = { projects: projection.projects, projectHostSetups: projection.setups.map((setup) => - setup.repoId === provisioned.id ? { ...setup, setupMethod: 'provisioned' as const } : setup + setup.repoId === provisioned.id + ? { ...setup, projectId: sharedId, setupMethod: 'provisioned' as const } + : setup ) } + const entries = buildSettingsProjectList(repos, grouping) const sidebarEntry = getProjectGroupingForRepo( provisioned.id, new Map(repos.map((repo) => [repo.id, repo])), buildProjectGroupingIndex(grouping) ) - const entries = buildSettingsProjectList(repos, grouping) - const provisionedEntry = entries.find((entry) => - entry.setups.some((setup) => setup.repoId === provisioned.id) - ) - - expect(sidebarEntry.key).toBe(`project:${projection.projects[0].id}`) - expect(entries.filter((entry) => entry.checkoutLabel !== undefined)).toHaveLength(2) - expect(provisionedEntry?.checkoutLabel).toBeUndefined() - expect(provisionedEntry?.selectionKey).toBe(projection.projects[0].id) - }) - - it('classifies native and WSL setups separately even when their repo ids match', () => { - const twin = { ...wslA, id: nativeA.id } - const repos = [nativeA, nativeB, twin, wslB] - const projection = projectHostSetupProjectionFromRepos(repos) - const grouping = { - projects: projection.projects, - projectHostSetups: projection.setups.map((setup) => - setup.path === twin.path ? { ...setup, setupMethod: 'provisioned' as const } : setup - ) - } - const entries = buildSettingsProjectList(repos, grouping) - - expect(entries.find((entry) => entry.representativeRepoId === nativeA.id)?.checkoutLabel).toBe( - nativeA.displayName + const entry = entries.find((candidate) => + candidate.setups.some((setup) => setup.repoId === provisioned.id) ) + expect(entries).toHaveLength(2) + expect(sidebarEntry.key).toBe(`project:${sharedId}`) + expect(entry?.projectId).toBe(sharedId) + expect(entry?.setups.map((setup) => setup.repoId)).toEqual([nativeA.id, provisioned.id]) expect( - entries.find((entry) => entry.representativeRepoId === wslB.id)?.checkoutLabel - ).toBeUndefined() + entries + .find((candidate) => candidate.projectId === `repo:${nativeB.id}`) + ?.setups.map((setup) => setup.repoId) + ).toEqual([nativeB.id]) }) }) diff --git a/src/renderer/src/components/settings/settings-project-section-renderer.tsx b/src/renderer/src/components/settings/settings-project-section-renderer.tsx index 268b9fbc3f1c..253b9f719f8a 100644 --- a/src/renderer/src/components/settings/settings-project-section-renderer.tsx +++ b/src/renderer/src/components/settings/settings-project-section-renderer.tsx @@ -2,11 +2,7 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../sha import { getRepoHostIdentity } from '../../store/slices/repo-host-identity' import { RepositoryPane } from './RepositoryPane' import { SettingsSection } from './SettingsSection' -import { - getSettingsEntryHostSelection, - getSettingsProjectHostRepo, - getSettingsProjectRemovalScope -} from './settings-project-list' +import { getSettingsEntryHostSelection, getSettingsProjectHostRepo } from './settings-project-list' import { translate } from '@/i18n/i18n' import type { SettingsRenderContext } from './settings-render-context' @@ -40,7 +36,7 @@ export function renderProjectSettingsSections(context: SettingsRenderContext): R title={translate( 'auto.components.settings.Settings.3bf149e873', 'Project Settings > {{value0}}', - { value0: settingsProject.checkoutLabel ?? project.displayName } + { value0: project.displayName } )} description={repo.path} searchEntries={navigation.getSectionSearchEntries(repoSectionId)} @@ -58,13 +54,6 @@ export function renderProjectSettingsSections(context: SettingsRenderContext): R removeProject={() => void model.removeProjectAllHosts(settingsProject.setups)} project={project} selectedProjectSetupId={hostSelection.setupId} - settingsSelectionKey={settingsProject.selectionKey} - settingsEntryRepoIds={ - settingsProject.splitProject - ? new Set(settingsProject.setups.map((setup) => setup.repoId)) - : undefined - } - removalScope={getSettingsProjectRemovalScope(settingsProject)} isLocalWindowsProject={ getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID && terminal.isWindowsTerminalHost diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index d571f189abf5..ee8e6e4876fd 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -12,6 +12,9 @@ import { LinearIntegrationCard } from './task-tracker-integration-cards' const LOCAL_HOST_LABEL = getExecutionHostLabel('local') type StoreState = { + repos: never[] + endpointProjects: never[] + endpointProjectHostSetups: never[] linearStatus: { connected: boolean workspaces?: { id: string; organizationName: string; displayName: string; email?: string }[] @@ -59,6 +62,9 @@ function installStore( settings: StoreState['settings'] = { activeRuntimeEnvironmentId: null } ): StoreState { const state: StoreState = { + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], linearStatus: { connected, workspaces: connected diff --git a/src/renderer/src/components/settings/use-settings-navigation-model.ts b/src/renderer/src/components/settings/use-settings-navigation-model.ts index de9928abcd8f..d524531fb41b 100644 --- a/src/renderer/src/components/settings/use-settings-navigation-model.ts +++ b/src/renderer/src/components/settings/use-settings-navigation-model.ts @@ -7,7 +7,6 @@ import { getAgentSkillNavInstallStatus, getLinearAgentSkillNavInstallStatus } from '@/lib/agent-skill-nav-install-status' -import { getProjectHostSetupProjectionFromState } from '../../store/selectors' import { getSettingsSectionSearchEntries, rankSettingsSearchItems } from './settings-search' import { deriveNeededSectionIds } from './settings-load-performance' import { SCROLLBACK_PRESETS_ROWS } from './SettingsConstants' @@ -157,11 +156,7 @@ export function useSettingsNavigationModel( [visibleNavSections] ) const projectByRepoId = useMemo(() => { - const projection = getProjectHostSetupProjectionFromState({ - repos: model.repos, - projects: model.projects, - projectHostSetups: model.projectHostSetups - }) + const projection = { projects: model.projects, setups: model.projectHostSetups } const projectById = new Map(projection.projects.map((project) => [project.id, project])) const nextProjectByRepoId = new Map() for (const setup of projection.setups) { diff --git a/src/renderer/src/components/settings/use-settings-page-effects.ts b/src/renderer/src/components/settings/use-settings-page-effects.ts index 299411a6b048..bc69ada8cdf3 100644 --- a/src/renderer/src/components/settings/use-settings-page-effects.ts +++ b/src/renderer/src/components/settings/use-settings-page-effects.ts @@ -182,7 +182,7 @@ export function useSettingsPageEffects( : repoIdToHostSelection.get(targetRepoId) if (hostSelection) { setSettingsProjectHostSelection( - hostSelection.selectionKey, + hostSelection.projectId, hostSelection.hostId, hostSelection.setupId ) diff --git a/src/renderer/src/components/settings/use-settings-store-model.ts b/src/renderer/src/components/settings/use-settings-store-model.ts index 57fcee2fd5d2..acef8b234107 100644 --- a/src/renderer/src/components/settings/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/use-settings-store-model.ts @@ -1,9 +1,12 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useCallback, useMemo, useState } from 'react' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { useAppStore } from '../../store' -import { useProjectHostSetupProjection } from '@/store/selectors' import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' @@ -21,6 +24,7 @@ import { import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected' import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { useProjectHostSetupProjection } from '@/store/selectors' import { getFallbackTerminalFonts, mergeFontSuggestions } from './SettingsConstants' import { useGhosttyImport } from './useGhosttyImport' import { useWarpThemeImport } from './useWarpThemeImport' @@ -51,8 +55,8 @@ export function useSettingsStoreModel() { const fetchKeybindings = useAppStore((s) => s.fetchKeybindings) const closeSettingsPage = useAppStore((s) => s.closeSettingsPage) const repos = useAppStore((s) => s.repos) - const projects = useAppStore((s) => s.projects) - const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projects = useAppStore((s) => selectCanonicalProjects(s)) + const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) const updateProject = useAppStore((s) => s.updateProject) const updateRepo = useAppStore((s) => s.updateRepo) const removeProject = useAppStore((s) => s.removeProject) @@ -68,15 +72,15 @@ export function useSettingsStoreModel() { const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) - const projectHostSetupProjection = useProjectHostSetupProjection() - // Use navigation’s grouping inputs so pane and navigation lists agree. + // Why: same inputs as nav metadata so both lists agree — the source of truth for the pane list. + const projection = useProjectHostSetupProjection() const settingsProjectList = useMemo( () => buildSettingsProjectList(repos, { - projects: projectHostSetupProjection.projects, - projectHostSetups: projectHostSetupProjection.setups + projects: projection.projects, + projectHostSetups: projection.setups }), - [repos, projectHostSetupProjection] + [repos, projection] ) const repoIdToRepresentative = useMemo( () => buildRepoIdToRepresentative(settingsProjectList), diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts index 30d0e1f604ef..48c4ee941c55 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts @@ -8,8 +8,8 @@ const mocks = vi.hoisted(() => ({ stateIndex: 0, storeState: { repos: [] as Repo[], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], clearOrcaHookTrustForRepo: vi.fn(), openModal: vi.fn(), sshStateByEnvironment: new Map() @@ -103,8 +103,8 @@ describe('useRemoteRepo', () => { mocks.stateSetters = [] mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false] mocks.storeState.repos = [] - mocks.storeState.projects = [] - mocks.storeState.projectHostSetups = [] + mocks.storeState.endpointProjects = [] + mocks.storeState.endpointProjectHostSetups = [] mocks.listTargets.mockResolvedValue([ { id: 'ssh-1', label: 'Builder 1' }, { id: 'ssh-2', label: 'Builder 2' } diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 45b484193262..aa60c655fa46 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -18,6 +18,8 @@ const mocks = vi.hoisted(() => { removeWorktree: vi.fn(), clearWorktreeDeleteState: vi.fn(), allWorktrees: vi.fn<() => Worktree[]>(() => []), + endpointProjects: [], + endpointProjectHostSetups: [], repos: [] as Repo[], worktreeLineageById: {} as Record, updateSettings: vi.fn(), diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx index f97265cc415a..a24a7f764938 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { Workflow } from 'lucide-react' import { useMemo, type JSX } from 'react' import type { Repo } from '../../../../shared/repo-types' @@ -32,8 +36,8 @@ export function DeleteWorktreeLineageNotice({ dirtyChangePreviewsByWorktreeId, changeCheckStatesByWorktreeId }: DeleteWorktreeLineageNoticeProps): JSX.Element | null { - const projects = useAppStore((s) => s.projects) - const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projects = useAppStore((s) => selectCanonicalProjects(s)) + const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) const projectIndex = useMemo( () => buildProjectGroupingIndex({ diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx index a12be5954212..a154973a0370 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx @@ -21,8 +21,8 @@ const mocks = vi.hoisted(() => ({ addNonGitFolder: vi.fn(), runtimeEnvironments: [{ id: 'env-1', name: 'Remote Mac' }], repos: [] as Repo[], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} as Record, fetchWorktrees: vi.fn(), settings: {} @@ -130,8 +130,8 @@ describe('NonGitFolderDialog', () => { } mocks.state.runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] mocks.state.repos = [] - mocks.state.projects = [] - mocks.state.projectHostSetups = [] + mocks.state.endpointProjects = [] + mocks.state.endpointProjectHostSetups = [] mocks.state.worktreesByRepo = {} mocks.state.fetchWorktrees.mockResolvedValue(true) mocks.onboardingGet.mockResolvedValue(null) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 71236d95b41f..9d865281e5e7 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -33,6 +33,9 @@ let mockInlineAgentRows: DashboardAgentRowData[] = [] vi.mock('@/store', () => { const getState = () => ({ + repos: [makeRepo()], + endpointProjects: [], + endpointProjectHostSetups: [], browserTabsByWorktree: {}, agentActivityDisplayMode, createBrowserTab: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index 27b8c527a899..ae5686e45a37 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -24,6 +24,9 @@ let settings: Partial | null = { compactWorktreeCards: true } vi.mock('@/store', () => { const getState = () => ({ + repos: [makeRepo()], + endpointProjects: [], + endpointProjectHostSetups: [], browserTabsByWorktree: {}, agentActivityDisplayMode: undefined, createBrowserTab: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx index 29589aba6ca7..34885ba9a5f1 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx @@ -213,6 +213,8 @@ function setFlatWorktreeState(): void { reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], retainedAgentsByPaneKey: {}, revealWorktreeInSidebar: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts b/src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts index bfbbc8bb7181..8a6a9c2f23f2 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts @@ -64,6 +64,8 @@ function setEmptyUngroupedProjectState(filterRepoIds: string[] = []): void { reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), retainedAgentsByPaneKey: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], runtimePaneTitlesByTabId: {}, setFilterRepoIds: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts b/src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts index 8491fb76a6e4..4f0f4bdbee06 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts @@ -124,6 +124,8 @@ function setFolderWorkspaceFixtureState( reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), retainedAgentsByPaneKey: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], runtimePaneTitlesByTabId: {}, setFilterRepoIds: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts b/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts index d6290061a4f9..1d85203f5818 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts @@ -82,6 +82,8 @@ function setProjectGroupWithoutWorktreeRowsState( reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), retainedAgentsByPaneKey: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], runtimePaneTitlesByTabId: {}, setFilterRepoIds: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 4cec2697ff76..36fee6d3728f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -307,6 +307,8 @@ function setAgentLineageState(options: { reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], retainedAgentsByPaneKey: {}, revealWorktreeInSidebar: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 80b1ae17b015..996181854506 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -308,6 +308,8 @@ function setLineageState( reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], retainedAgentsByPaneKey: {}, revealWorktreeInSidebar: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 1de297e59289..11d9ef8f76d4 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -275,6 +275,8 @@ function setStatusLaneState(): void { reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], retainedAgentsByPaneKey: {}, revealWorktreeInSidebar: vi.fn(), diff --git a/src/renderer/src/components/sidebar/add-repo-store-upsert.ts b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts index 6f20999c0eee..0629cc4aedf6 100644 --- a/src/renderer/src/components/sidebar/add-repo-store-upsert.ts +++ b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts @@ -40,8 +40,8 @@ export function upsertAddedRepoWithProjectHostSetup( // Direct IPC bypasses repo-slice compatibility projection; synchronize project-first state here. useAppStore.setState({ repos, - projects: projection.projects, - projectHostSetups: projection.setups + endpointProjects: projection.projects, + endpointProjectHostSetups: projection.setups }) return { alreadyPresent, repo: ownedRepo } } diff --git a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx index a3cb16e27be0..8906b7a354f2 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx +++ b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx @@ -20,6 +20,8 @@ import { getRuntimeGitStatus } from '@/runtime/runtime-git-status-client' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' const state = vi.hoisted(() => ({ + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], settings: null, activeWorktreeId: null, diff --git a/src/renderer/src/components/sidebar/sidebar-list-golden-name-order.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-golden-name-order.test-fixture.ts index fa61812f3834..f0bf33cdba9c 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-golden-name-order.test-fixture.ts +++ b/src/renderer/src/components/sidebar/sidebar-list-golden-name-order.test-fixture.ts @@ -41,8 +41,8 @@ export function goldenNameOrderState(): Partial { [sshRepo.id]: sshWorktrees }, worktreeLineageById: {}, - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], // Equal tabOrder everywhere, so group names decide the order. projectGroups: [ folderGroup('pg-zebra', 'zebra'), diff --git a/src/renderer/src/components/sidebar/sidebar-list-golden-store.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-golden-store.test-fixture.ts index 2116d500744e..6c889f189e02 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-golden-store.test-fixture.ts +++ b/src/renderer/src/components/sidebar/sidebar-list-golden-store.test-fixture.ts @@ -150,8 +150,8 @@ export function buildGoldenStoreState( // Cross-project lineage: a lab workspace spawned from orca's feat-a. [lMr.id]: lineage(lMr, oA) }, - projects, - projectHostSetups: [setup(orca, 'local'), setup(orcaSsh, 'ssh:gpu-vm')], + endpointProjects: projects, + endpointProjectHostSetups: [setup(orca, 'local'), setup(orcaSsh, 'ssh:gpu-vm')], projectGroups: [ group('pg-work', 'Work', { tabOrder: 0 }), group('pg-nested', 'Nested', { parentGroupId: 'pg-work', tabOrder: 0 }), diff --git a/src/renderer/src/components/sidebar/sidebar-list-parity-project-catalog.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-parity-project-catalog.test-fixture.ts index eb6193115bf5..5883974bf786 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-parity-project-catalog.test-fixture.ts +++ b/src/renderer/src/components/sidebar/sidebar-list-parity-project-catalog.test-fixture.ts @@ -51,7 +51,10 @@ export function windowCatalogFromServerAnswers(base: Partial): Partial SAVED_REPO_ORDER ) const rows = mergeFetchedHostProjectCatalog({ - previous: { projects: base.projects ?? [], projectHostSetups: base.projectHostSetups ?? [] }, + previous: { + projects: base.endpointProjects ?? [], + projectHostSetups: base.endpointProjectHostSetups ?? [] + }, // An older server answers no project.list, so both sides derive its projection. fetched: projectHostSetupProjectionFromRepos(fetched), repos, @@ -59,8 +62,8 @@ export function windowCatalogFromServerAnswers(base: Partial): Partial }) return { repos, - projects: [...rows.projects], - projectHostSetups: [...rows.projectHostSetups], + endpointProjects: [...rows.projects], + endpointProjectHostSetups: [...rows.projectHostSetups], manualRepoOrder: SAVED_REPO_ORDER, projectGroups: [ ...(base.projectGroups ?? []), @@ -98,10 +101,10 @@ export async function projectCatalogFromWindowState(state: AppState) { { repos: own(state.repos), projectGroups: own(state.projectGroups ?? []), - projects: (state.projects ?? []).filter((project) => + projects: (state.endpointProjects ?? []).filter((project) => project.sourceRepoIds.some((id) => own(state.repos).some((entry) => entry.id === id)) ), - setups: (state.projectHostSetups ?? []).filter((setup) => !isServerHost(setup.hostId)) + setups: (state.endpointProjectHostSetups ?? []).filter((setup) => !isServerHost(setup.hostId)) }, [server], { manualRepoOrder: state.manualRepoOrder, projectOrderBy: state.projectOrderBy } diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts index 38ef53e739c7..1ee5c301291b 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts @@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({ storeState: { settings: { activeRuntimeEnvironmentId: null as string | null }, repos: [] as Repo[], - projects: [], - projectHostSetups: [] + endpointProjects: [], + endpointProjectHostSetups: [] }, cloneLocal: vi.fn(), pickDirectory: vi.fn(), @@ -94,8 +94,8 @@ describe('useAddRepoCloneFlow', () => { mocks.refValues = [] mocks.stateValues = ['https://github.com/stablyai/orca.git', '/srv', false, null, null] mocks.storeState.repos = [] - mocks.storeState.projects = [] - mocks.storeState.projectHostSetups = [] + mocks.storeState.endpointProjects = [] + mocks.storeState.endpointProjectHostSetups = [] vi.stubGlobal('window', { api: { repos: { diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 58677cbd3a1c..b7bcc35ab686 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -9,8 +9,8 @@ const mocks = vi.hoisted(() => ({ storeState: { settings: { activeRuntimeEnvironmentId: null as string | null }, repos: [] as Repo[], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} as Record }, createRepo: vi.fn(), @@ -105,8 +105,8 @@ describe('useCreateRepo default-checkout handoff', () => { mocks.stateValues[STATE_ERROR_MESSAGE] = null mocks.stateValues[STATE_IS_CREATING] = false mocks.storeState.repos = [] - mocks.storeState.projects = [] - mocks.storeState.projectHostSetups = [] + mocks.storeState.endpointProjects = [] + mocks.storeState.endpointProjectHostSetups = [] mocks.storeState.worktreesByRepo = {} mocks.createRepo.mockReset() mocks.storeState.settings.activeRuntimeEnvironmentId = null @@ -137,10 +137,10 @@ describe('useCreateRepo default-checkout handoff', () => { expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { requireAuthoritative: true }) - expect(mocks.storeState.projects).toEqual( + expect(mocks.storeState.endpointProjects).toEqual( expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) ) - expect(mocks.storeState.projectHostSetups).toEqual( + expect(mocks.storeState.endpointProjectHostSetups).toEqual( expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) ) expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, undefined) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts index 3f24166c42a1..2835355bf8d0 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts @@ -1,10 +1,14 @@ +import { ProjectSectionCatalogSchema } from '../../../../shared/project-section-catalog-schema' +import { resolveServedProjectSectionClaims } from '../../../../shared/project-section-catalog-claims' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { afterEach, describe, expect, it, vi } from 'vitest' import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import type { ProjectCatalogRows } from '../../../../shared/project-compatibility-merge' import { buildProjectGroupingIndex, - buildProjectSections + buildProjectSections, + getProjectGroupingForRepo } from '../../../../shared/project-section-grouping' import { getLogicalRepoOrderRankById, @@ -107,7 +111,11 @@ async function desktopCatalog(): Promise<{ repos: readonly Repo[]; rows: Project async function desktopHeaders(projectOrderBy: ProjectOrderBy) { const { repos, rows } = await desktopCatalog() - const projection = getProjectHostSetupProjectionFromState({ repos, ...rows }) + const projection = getProjectHostSetupProjectionFromState({ + repos, + endpointProjects: rows.projects, + endpointProjectHostSetups: rows.projectHostSetups + }) const worktrees: Worktree[] = PROJECT_SECTION_PARITY_ROWS.map((row) => ({ ...worktreeFixture, id: row.id, @@ -141,7 +149,12 @@ async function desktopHeaders(projectOrderBy: ProjectOrderBy) { /** Round-trip the published catalog through JSON as main serves it to the phone. */ async function servedCatalog(projectOrderBy: ProjectOrderBy): Promise { const { repos, rows } = await desktopCatalog() - const published = getProjectSectionCatalogFromState({ repos, ...rows, projectOrderBy }) + const published = getProjectSectionCatalogFromState({ + repos, + endpointProjects: rows.projects, + endpointProjectHostSetups: rows.projectHostSetups, + projectOrderBy + }) if (!published) { throw new Error('the renderer published no catalog') } @@ -187,3 +200,42 @@ describe('the phone groups by Project exactly as the paired desktop sidebar', () } ) }) + +it('publishes unresolved setups without letting their absent owners demote registered phone checkouts', () => { + const repo: Repo = { + id: 'a', + path: '/a', + displayName: 'A', + badgeColor: '', + addedAt: 1, + gitRemoteIdentity: { + canonicalKey: 'github.com/acme/app', + remoteName: 'origin', + remoteUrl: 'https://github.com/acme/app.git' + } + } + const derived = projectHostSetupProjectionFromRepos([repo]) + const setup = derived.setups[0] + if (!setup) { + throw new Error('no derived setup') + } + const published = getProjectSectionCatalogFromState({ + repos: [repo], + endpointProjects: derived.projects, + endpointProjectHostSetups: [...derived.setups, { ...setup, id: 'z', repoId: 'z', path: '/z' }], + projectOrderBy: 'manual' + }) + const parsed = ProjectSectionCatalogSchema.parse(JSON.parse(JSON.stringify(published))) + expect(parsed.setups.map((row) => row.repoId)).toEqual(['a', 'z']) + const phone = resolveServedProjectSectionClaims(parsed) + expect( + getProjectGroupingForRepo( + 'a', + new Map([[repo.id, repo]]), + buildProjectGroupingIndex({ + projects: phone.projects, + projectHostSetups: phone.setups + }) + ).key + ).toBe(`project:${derived.projects[0].id}`) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts index ea3a9834f549..fb7c9c7cb902 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts @@ -371,7 +371,7 @@ describe('buildRows with pinned worktrees', () => { }) }) - it('splits same-host checkouts of one project into separate per-setup groups', () => { + it('keeps a split checkout under its current project identity', () => { const repoB: Repo = { ...repo, id: 'repo-2', path: '/tmp/orca-2', displayName: 'orca-2' } const worktreeB: Worktree = { ...worktree, @@ -383,6 +383,7 @@ describe('buildRows with pinned worktrees', () => { const localSetupB: ProjectHostSetup = { ...projectHostSetups[0]!, id: repoB.id, + projectId: `repo:${repoB.id}`, repoId: repoB.id, path: repoB.path, displayName: repoB.displayName @@ -412,7 +413,15 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [{ ...project, sourceRepoIds: [repo.id, repoB.id] }], + projects: [ + project, + { + ...project, + id: `repo:${repoB.id}`, + displayName: repoB.displayName, + sourceRepoIds: [repoB.id] + } + ], projectHostSetups: [projectHostSetups[0]!, localSetupB] } ) @@ -422,18 +431,18 @@ describe('buildRows with pinned worktrees', () => { expect(headers).toEqual( expect.arrayContaining([ expect.objectContaining({ - key: 'project:github:stablyai/orca::setup:repo-1', - label: 'orca' + key: 'project:github:stablyai/orca', + label: 'Orca' }), expect.objectContaining({ - key: 'project:github:stablyai/orca::setup:repo-2', + key: 'project:repo:repo-2', label: 'orca-2' }) ]) ) }) - it('splits only the surface with duplicate checkouts', () => { + it('keeps shared-host association while grouping an outclaimed checkout independently', () => { const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -450,6 +459,7 @@ describe('buildRows with pinned worktrees', () => { const localSetupB: ProjectHostSetup = { ...projectHostSetups[0]!, id: localRepoB.id, + projectId: `repo:${localRepoB.id}`, repoId: localRepoB.id, path: localRepoB.path, displayName: localRepoB.displayName @@ -481,16 +491,23 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, remoteRepo.id] }], + projects: [ + project, + { + ...project, + id: `repo:${localRepoB.id}`, + displayName: localRepoB.displayName, + sourceRepoIds: [localRepoB.id] + } + ], projectHostSetups: [projectHostSetups[0]!, localSetupB, projectHostSetups[1]!] } ) const headers = rows.filter((row) => row.type === 'header') expect(headers.map((row) => row.key)).toEqual([ - 'project:github:stablyai/orca::setup:repo-1', - 'project:github:stablyai/orca::setup:repo-local-b', - 'project:github:stablyai/orca' + 'project:github:stablyai/orca', + 'project:repo:repo-local-b' ]) }) @@ -686,8 +703,11 @@ describe('buildRows with pinned worktrees', () => { }) }) - it('splits duplicate user checkouts while a provisioned copy nests, on one host', () => { - // Exercise real-checkout splitting and provisioned-copy nesting together on one host. + it('groups a provisioned copy by current association while keeping an outclaimed checkout separate', () => { + // Why: guards the intersection of #5374 (real same-host checkouts split) and + // #6320 (provisioned copies nest). Two legacy checkouts must each get their own + // header while a provisioned copy of the same project stays under the plain + // project header — all on one host surface, simultaneously. const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -704,6 +724,7 @@ describe('buildRows with pinned worktrees', () => { const localSetupB: ProjectHostSetup = { ...projectHostSetups[0]!, id: localRepoB.id, + projectId: `repo:${localRepoB.id}`, repoId: localRepoB.id, path: localRepoB.path, displayName: localRepoB.displayName @@ -756,7 +777,15 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, runtimeRepoB.id] }], + projects: [ + { ...project, sourceRepoIds: [repo.id, runtimeRepoB.id] }, + { + ...project, + id: `repo:${localRepoB.id}`, + displayName: localRepoB.displayName, + sourceRepoIds: [localRepoB.id] + } + ], projectHostSetups: [projectHostSetups[0]!, localSetupB, runtimeSetupB] } ) @@ -764,16 +793,11 @@ describe('buildRows with pinned worktrees', () => { const headers = rows.filter((row) => row.type === 'header') expect(headers.map((row) => row.key).sort()).toEqual([ 'project:github:stablyai/orca', - 'project:github:stablyai/orca::setup:repo-1', - 'project:github:stablyai/orca::setup:repo-local-b' + 'project:repo:repo-local-b' ]) - // Only real checkouts receive setup-scoped headers; provisioned copies retain the project label. - expect( - headers.some((row) => row.key === 'project:github:stablyai/orca::setup:repo-runtime-b') - ).toBe(false) expect(headers.find((row) => row.key === 'project:github:stablyai/orca')).toMatchObject({ label: 'Orca', - count: 1 + count: 2 }) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts b/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts index 6b122a41ada8..bd6466654266 100644 --- a/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts +++ b/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts @@ -35,6 +35,8 @@ export async function renderWorktreeListMarkup(): Promise { export function createAppStoreModuleMock(): Record { const getMockState = (): Record => ({ detectedWorktreesByRepo: {}, + endpointProjects: [], + endpointProjectHostSetups: [], ...mockStore.state }) const useAppStore = ((selector: (state: Record) => unknown) => diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts index bad2645ee053..6c12acc55eaa 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { resolveProjectHostSetupProjection } from '../../../../../../shared/project-catalog-canonical-projection' import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' import type { Worktree } from '../../../../../../shared/worktree/types' @@ -50,7 +51,7 @@ const worktreeMap = new Map([ [child.id, child] ]) -const grouping = { +const endpoint = { projects: [project], projectHostSetups: [repo, childRepo].map((checkout) => ({ ...projectHostSetups[0]!, @@ -61,6 +62,13 @@ const grouping = { })) } +const canonical = resolveProjectHostSetupProjection( + [repo, childRepo], + endpoint.projects, + endpoint.projectHostSetups +) +const grouping = { projects: canonical.projects, projectHostSetups: canonical.setups } + describe('cross-project sidebar lineage', () => { it('ranks by actual owner and keeps notices/actions there while counting displayed children', () => { const gammaRepo = { ...repo, id: 'gamma', displayName: 'Gamma' } @@ -128,7 +136,8 @@ describe('cross-project sidebar lineage', () => { } } const worktrees = [grandchild, child, pinnedParent] - const parentSection = `project:${project.id}::setup:${repo.id}` + const parentProjectId = canonical.setups.find((setup) => setup.repoId === repo.id)!.projectId + const parentSection = `project:${parentProjectId}` const plan = buildSidebarRowPlan( 'repo', worktrees, diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts index 2220d38d4d7a..f6514aa46f20 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts @@ -7,9 +7,7 @@ function getProjectIdFromHeaderRowKey(rowKey: string): string | null { if (!rowKey.startsWith('project:')) { return null } - const withoutPrefix = rowKey.slice('project:'.length) - const setupSeparator = withoutPrefix.indexOf('::setup:') - return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) + return rowKey.slice('project:'.length) } function getRepoIdsFromHeaderRowKey( @@ -20,11 +18,6 @@ function getRepoIdsFromHeaderRowKey( if (rowKey.startsWith('repo:')) { return [rowKey.slice('repo:'.length)] } - const setupMarker = '::setup:' - const setupIndex = rowKey.indexOf(setupMarker) - if (rowKey.startsWith('project:') && setupIndex !== -1) { - return [rowKey.slice(setupIndex + setupMarker.length)] - } const projectId = getProjectIdFromHeaderRowKey(rowKey) if (!projectId) { return [] diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index c10ec6739d48..9e94c698b910 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -35,6 +35,8 @@ const useAppStoreMock = vi.fn( activeTabId: string | null activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null gitStatusByWorktree: Record + endpointProjects: never[] + endpointProjectHostSetups: never[] repos: never[] worktreesByRepo: Record unifiedTabsByWorktree: Record @@ -51,6 +53,8 @@ const useAppStoreMock = vi.fn( activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, gitStatusByWorktree: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, @@ -95,6 +99,8 @@ useAppStoreExport.getState = vi.fn(() => ({ activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, gitStatusByWorktree: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 68ffa7d52246..adb5e24b72f1 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -109,13 +109,27 @@ describe('TabBar PowerShell launch wiring', () => { it('hides the WSL terminal row for local host-runtime projects', async () => { appStoreSnapshot.activeRepoId = 'repo-1' appStoreSnapshot.activeWorktreeId = 'wt-1' - appStoreSnapshot.projects = [ + appStoreSnapshot.endpointProjects = [ { id: 'project-1', localWindowsRuntimePreference: { kind: 'windows-host' }, sourceRepoIds: ['repo-1'] } ] + appStoreSnapshot.endpointProjectHostSetups = [ + { + id: 'repo-1', + repoId: 'repo-1', + projectId: 'project-1', + hostId: 'local', + path: 'C:/repo', + displayName: 'project', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ] appStoreSnapshot.repos = [{ id: 'repo-1' }] appStoreSnapshot.worktreesByRepo = { 'repo-1': [ @@ -177,13 +191,27 @@ describe('TabBar PowerShell launch wiring', () => { it('shows only the WSL terminal row for local WSL-runtime projects', async () => { appStoreSnapshot.activeRepoId = 'repo-1' - appStoreSnapshot.projects = [ + appStoreSnapshot.endpointProjects = [ { id: 'project-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, sourceRepoIds: ['repo-1'] } ] + appStoreSnapshot.endpointProjectHostSetups = [ + { + id: 'repo-1', + repoId: 'repo-1', + projectId: 'project-1', + hostId: 'local', + path: 'C:/repo', + displayName: 'project', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ] appStoreSnapshot.repos = [{ id: 'repo-1' }] appStoreSnapshot.worktreesByRepo = { 'repo-1': [ diff --git a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx index e0aced6d5667..cc08d544a80a 100644 --- a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx @@ -66,7 +66,12 @@ describe('TabBar worktree-write gate (non-Windows client)', () => { probeWindowsCapabilities.hostPlatform = 'darwin' tabBarSurfaceRenders.count = 0 localProjectRuntimeSpy.mockClear() - ;(await createTabBarProbeStore()).setState({ worktreesByRepo: {}, projects: [], repos: [] }) + ;(await createTabBarProbeStore()).setState({ + worktreesByRepo: {}, + endpointProjects: [], + endpointProjectHostSetups: [], + repos: [] + }) }) afterEach(() => { diff --git a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx index 7cdecadb4cf5..37052fccbe5d 100644 --- a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx @@ -52,7 +52,12 @@ describe('TabBar worktree-write gate (Windows client)', () => { probeWindowsCapabilities.hostPlatform = null tabBarSurfaceRenders.count = 0 localProjectRuntimeSpy.mockClear() - ;(await createTabBarProbeStore()).setState({ worktreesByRepo: {}, projects: [], repos: [] }) + ;(await createTabBarProbeStore()).setState({ + worktreesByRepo: {}, + endpointProjects: [], + endpointProjectHostSetups: [], + repos: [] + }) }) afterEach(() => { diff --git a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts index aec837548645..5a98511fcf0a 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts @@ -1,3 +1,4 @@ +import type { ProjectHostSetup } from '../../../../shared/project-types' import { vi } from 'vitest' export const appStoreSnapshot: { @@ -6,7 +7,8 @@ export const appStoreSnapshot: { activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null activeRuntimeEnvironmentId: string | null activeWorktreeId: string | null - projects: { + endpointProjectHostSetups: readonly ProjectHostSetup[] + endpointProjects: { id: string localWindowsRuntimePreference?: | { kind: 'inherit-global' | 'windows-host' } @@ -44,7 +46,8 @@ export const appStoreSnapshot: { activeTabType: null, activeRuntimeEnvironmentId: null, activeWorktreeId: null, - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], sshConnectionStates: new Map(), worktreesByRepo: {}, @@ -69,7 +72,8 @@ const useAppStoreMock = vi.fn( activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null activeWorktreeId: string | null gitStatusByWorktree: Record - projects: typeof appStoreSnapshot.projects + endpointProjectHostSetups: readonly ProjectHostSetup[] + endpointProjects: typeof appStoreSnapshot.endpointProjects repos: { id: string; connectionId?: string | null }[] sshConnectionStates: Map worktreesByRepo: typeof appStoreSnapshot.worktreesByRepo @@ -98,7 +102,8 @@ const useAppStoreMock = vi.fn( activeTabType: appStoreSnapshot.activeTabType, activeWorktreeId: appStoreSnapshot.activeWorktreeId, gitStatusByWorktree: {}, - projects: appStoreSnapshot.projects, + endpointProjects: appStoreSnapshot.endpointProjects, + endpointProjectHostSetups: appStoreSnapshot.endpointProjectHostSetups, repos: appStoreSnapshot.repos, sshConnectionStates: appStoreSnapshot.sshConnectionStates, worktreesByRepo: appStoreSnapshot.worktreesByRepo, @@ -130,7 +135,8 @@ useAppStoreExport.getState = vi.fn(() => ({ activeTabType: appStoreSnapshot.activeTabType, activeWorktreeId: appStoreSnapshot.activeWorktreeId, gitStatusByWorktree: {}, - projects: appStoreSnapshot.projects, + endpointProjects: appStoreSnapshot.endpointProjects, + endpointProjectHostSetups: appStoreSnapshot.endpointProjectHostSetups, repos: appStoreSnapshot.repos, sshConnectionStates: appStoreSnapshot.sshConnectionStates, worktreesByRepo: appStoreSnapshot.worktreesByRepo, @@ -159,7 +165,8 @@ export function resetAppStoreSnapshot(): void { appStoreSnapshot.activeTabType = null appStoreSnapshot.activeRuntimeEnvironmentId = null appStoreSnapshot.activeWorktreeId = null - appStoreSnapshot.projects = [] + appStoreSnapshot.endpointProjects = [] + appStoreSnapshot.endpointProjectHostSetups = [] appStoreSnapshot.repos = [] appStoreSnapshot.sshConnectionStates = new Map() appStoreSnapshot.worktreesByRepo = {} diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 0dfd6677eed0..dbb45e535c71 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -679,6 +679,7 @@ describe('lookupTabSessionHistoryRow', () => { }) expect(listSessions).toHaveBeenCalledTimes(1) expect(listSessions).toHaveBeenCalledWith({ + historyOwnership: {}, includeAntigravityIdeSessions: true, limit: 250, unlimited: false, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts index e126d1429849..268c0ed39dd2 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts @@ -11,7 +11,8 @@ export type TabBarProbeState = { activeGroupIdByWorktree: Record activeRepoId: string | null activeWorktreeId: string | null - projects: unknown[] + endpointProjects: unknown[] + endpointProjectHostSetups: unknown[] repos: unknown[] worktreesByRepo: Record sshConnectionStates: Map @@ -43,7 +44,8 @@ export async function createTabBarProbeStore(): Promise { activeGroupIdByWorktree: {}, activeRepoId: null, activeWorktreeId: null, - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts index 4251a8bd4f32..b9174a1d9ecf 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import type { GitFileStatus } from '../../../../shared/git-status-types' @@ -35,7 +39,7 @@ type AppStoreState = ReturnType type GitStatusEntries = AppStoreState['gitStatusByWorktree'][string] const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = [] const EMPTY_UNIFIED_TABS: readonly Tab[] = [] -const EMPTY_PROJECTS: AppStoreState['projects'] = [] +const EMPTY_PROJECTS: AppStoreState['endpointProjects'] = [] const EMPTY_REPOS: AppStoreState['repos'] = [] const EMPTY_WORKTREES_BY_REPO: AppStoreState['worktreesByRepo'] = {} @@ -171,7 +175,10 @@ export function useTabBarRuntimeModel({ // Subscribe to project/worktree maps only for the local shell menu; their routine writes otherwise repaint every retained strip. const needsLocalProjectRuntime = showWindowsShellMenu && !activeRuntimeEnvironmentId?.trim() && !worktreeConnectionId - const projects = useAppStore((s) => (needsLocalProjectRuntime ? s.projects : EMPTY_PROJECTS)) + const projectHostSetups = useAppStore(selectCanonicalProjectHostSetups) + const projects = useAppStore((s) => + needsLocalProjectRuntime ? selectCanonicalProjects(s) : EMPTY_PROJECTS + ) const repos = useAppStore((s) => (needsLocalProjectRuntime ? s.repos : EMPTY_REPOS)) const worktreesByRepo = useAppStore((s) => needsLocalProjectRuntime ? selectWorktreesByRepo(s) : EMPTY_WORKTREES_BY_REPO @@ -181,7 +188,14 @@ export function useTabBarRuntimeModel({ return undefined } return getLocalProjectExecutionRuntimeContext( - { activeRepoId, activeWorktreeId, projects, repos, settings, worktreesByRepo }, + { + activeRepoId, + activeWorktreeId, + projectHostSetupProjection: { projects, setups: projectHostSetups }, + repos, + settings, + worktreesByRepo + }, worktreeId, 'win32', { @@ -198,6 +212,7 @@ export function useTabBarRuntimeModel({ activeWorktreeId, needsLocalProjectRuntime, projects, + projectHostSetups, repos, settings, windowsTerminalCapabilities.isLoading, diff --git a/src/renderer/src/components/task-page/task-page-default-repo-selection.ts b/src/renderer/src/components/task-page/task-page-default-repo-selection.ts index 1e8177bfe433..ac31f2695693 100644 --- a/src/renderer/src/components/task-page/task-page-default-repo-selection.ts +++ b/src/renderer/src/components/task-page/task-page-default-repo-selection.ts @@ -1,10 +1,12 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { - getProjectIdentityKey, + projectHostSetupProjectionFromRepos, hasProjectRemoteIdentity, isProjectRemoteIdentityPending } from '../../../../shared/project-host-setup-projection' import { isGitRepoKind } from '../../../../shared/repo-kind' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' import type { Repo } from '../../../../shared/repo-types' export type TaskProjectPickerGroup = { @@ -23,9 +25,10 @@ export function getTaskEligibleRepos(repos: readonly Repo[]): Repo[] { } export function getDefaultTaskRepoSelection(repos: readonly Repo[]): Set { + const setups = projectHostSetupProjectionFromRepos(repos).setups const selectedByProject = new Map() for (const repo of repos) { - const projectKey = getTaskRepoProjectKey(repo) + const projectKey = getTaskRepoProjectKey(repo, setups) const current = selectedByProject.get(projectKey) if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { selectedByProject.set(projectKey, repo) @@ -38,9 +41,10 @@ export function getTaskProjectPickerGroups( repos: readonly Repo[], preferredSelection: ReadonlySet = new Set() ): TaskProjectPickerGroup[] { + const setups = projectHostSetupProjectionFromRepos(repos).setups const groupsByProject = new Map() for (const repo of repos) { - const projectKey = getTaskRepoProjectKey(repo) + const projectKey = getTaskRepoProjectKey(repo, setups) const current = groupsByProject.get(projectKey) if (!current) { groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) @@ -61,13 +65,14 @@ export function normalizeTaskRepoSelection( repos: readonly Repo[], selection: ReadonlySet ): Set { + const setups = projectHostSetupProjectionFromRepos(repos).setups const selectedByProject = new Map() const selectedIds = new Set(selection) for (const repo of repos) { if (!selectedIds.has(repo.id)) { continue } - const projectKey = getTaskRepoProjectKey(repo) + const projectKey = getTaskRepoProjectKey(repo, setups) const current = selectedByProject.get(projectKey) if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { selectedByProject.set(projectKey, repo) @@ -79,8 +84,13 @@ export function normalizeTaskRepoSelection( return new Set([...selectedByProject.values()].map((repo) => repo.id)) } -export function getTaskRepoProjectKey(repo: Repo): string { - return getProjectIdentityKey(repo) +export function getTaskRepoProjectKey(repo: Repo, setups: readonly ProjectHostSetup[]): string { + return ( + getProjectHostSetupLookup(setups).projectId({ + repoId: repo.id, + hostId: getRepoExecutionHostId(repo) + }) ?? `repo:${repo.id}` + ) } function compareTaskProjectPickerCandidate( diff --git a/src/renderer/src/components/task-page/use-task-page-composer-actions.ts b/src/renderer/src/components/task-page/use-task-page-composer-actions.ts index b06113fa2132..699666b95c00 100644 --- a/src/renderer/src/components/task-page/use-task-page-composer-actions.ts +++ b/src/renderer/src/components/task-page/use-task-page-composer-actions.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '@/store/project-host-setup-selector' import type { TaskPageJiraListEffectsModel } from '../use-task-page-jira-list-effects' import { useCallback } from 'react' import type { LinearIssue } from '../../../../shared/linear/issue-types' @@ -220,7 +221,10 @@ export function useTaskPageComposerActions(model: TaskPageJiraListEffectsModel) taskSourceContext, initialRepoId: mappedRepo?.id, initialWorkspaceTarget: mappedRepo - ? getRepoWorkspaceCreationTargetHint(mappedRepo, useAppStore.getState().projectHostSetups) + ? getRepoWorkspaceCreationTargetHint( + mappedRepo, + selectCanonicalProjectHostSetups(useAppStore.getState()) + ) : undefined, prefilledName: getJiraIssueWorkspaceSeed(issue), telemetrySource: 'sidebar' diff --git a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts index f8193e89cb50..3b574af15aff 100644 --- a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts +++ b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts @@ -64,6 +64,9 @@ describe('edge-wrapped terminal HTTP links', () => { beforeEach(() => { vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/framed-overlay-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/framed-overlay-terminal-http-links.test.ts index 72bfdcd5892a..f7bf1b042cc7 100644 --- a/src/renderer/src/components/terminal-pane/framed-overlay-terminal-http-links.test.ts +++ b/src/renderer/src/components/terminal-pane/framed-overlay-terminal-http-links.test.ts @@ -41,6 +41,9 @@ describe('framed overlay terminal HTTP links', () => { const openUrl = vi.fn() vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts index dc1d743ba4ef..8d5db348a569 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts @@ -1,5 +1,7 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' +import type { AppState } from '@/store/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { flushAsyncTicks } from './pty-connection-test-async' import { sendTerminalInputThroughPane } from './pty-connection-test-dom' @@ -236,6 +238,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transportFactoryQueue.push(transport) + const repos: AppState['repos'][number][] = [{ ...TEST_REPO, id: 'repo1', path: '/tmp/wt-1' }] mockStoreState = { ...mockStoreState, settings: { @@ -244,7 +247,22 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'windows-host' } }] + repos, + endpointProjectHostSetups: [ + { + id: 'repo1', + projectId: 'repo1', + repoId: 'repo1', + hostId: 'local', + path: '/tmp/wt-1', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'windows-host' } }] } connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) @@ -298,7 +316,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { { id: 'tab-floating-agent', ptyId: null, launchAgent: 'codex' } ] }, - projects: [ + endpointProjects: [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } ], settings: { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts index 9038166329e8..c33e04381017 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts @@ -29,7 +29,8 @@ export function createInitialStoreState(getState: () => StoreState): StoreState }, runtimeStatusByEnvironmentId: new Map(), repos: [{ id: 'repo1', connectionId: null, displayName: 'orca' }], - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], sshConnectionStates: new Map(), transientClearedAgentStatusConnectionIds: {}, cacheTimerByKey: {}, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index c89990da68e2..71d3477a0ddf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -55,7 +55,8 @@ export type StoreState = { displayName?: string executionHostId?: string | null }[] - projects: { + endpointProjectHostSetups: AppState['endpointProjectHostSetups'] + endpointProjects: { id: string localWindowsRuntimePreference?: | { kind: 'inherit-global' } diff --git a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts index 7b16e52e1e8e..c38d4816db19 100644 --- a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts +++ b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts @@ -79,6 +79,9 @@ describe('#8832 hard-wrapped path candidates glue next-line text into URLs', () vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index 331380121197..b188f2f26a50 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' @@ -13,15 +16,15 @@ const mockToast = { const mockWriteClipboardText = vi.fn(async () => undefined) const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const repos: Repo[] = [] +const endpointProjects: Project[] = [] +const endpointProjectHostSetups: ProjectHostSetup[] = [] const store = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', - projects: [] as { - id: string - sourceRepoIds: string[] - localWindowsRuntimePreference?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } - }[], - repos: [] as { id: string; kind?: 'git' | 'folder'; connectionId?: string | null }[], + endpointProjects, + endpointProjectHostSetups, + repos, settings: {} as { localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } }, @@ -78,13 +81,19 @@ describe('forkAgentSessionFromPane', () => { vi.clearAllMocks() store.activeRepoId = 'repo-1' store.activeWorktreeId = 'wt-1' - store.projects = [ + store.repos = [ { id: 'repo-1', - sourceRepoIds: ['repo-1'] + path: 'C:\\repo', + displayName: 'Repo', + badgeColor: '', + addedAt: 0, + kind: 'git' } ] - store.repos = [{ id: 'repo-1', kind: 'git' }] + const catalog = projectHostSetupProjectionFromRepos(store.repos) + store.endpointProjects = [...catalog.projects] + store.endpointProjectHostSetups = [...catalog.setups] store.settings = { localWindowsRuntimeDefault: { kind: 'windows-host' } } store.worktreesByRepo = { 'repo-1': [ @@ -281,7 +290,7 @@ describe('forkAgentSessionFromPane', () => { ) it('uses Linux startup quoting for SSH workspaces', async () => { - store.repos = [{ id: 'repo-1', kind: 'git', connectionId: 'ssh-1' }] + store.repos = [{ ...store.repos[0], connectionId: 'ssh-1' }] store.agentStatusByPaneKey = { [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } } @@ -338,13 +347,10 @@ describe('forkAgentSessionFromPane', () => { }) it('uses Linux startup quoting when a Windows-path project is forced to WSL', async () => { - store.projects = [ - { - id: 'repo-1', - sourceRepoIds: ['repo-1'], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ] + store.endpointProjects = store.endpointProjects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + })) store.agentStatusByPaneKey = { [`tab-1:${LEAF_ID}`]: { agentType: 'pi' } } @@ -482,7 +488,7 @@ describe('forkAgentSessionFromPane', () => { }) it('does not create a worktree from a folder-only source workspace', async () => { - store.repos = [{ id: 'repo-1', kind: 'folder' }] + store.repos = [{ ...store.repos[0], kind: 'folder' }] store.getKnownWorktreeById.mockReturnValue({ id: 'wt-1', repoId: 'repo-1', diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index ceab7e0c2aff..6f6aca04f6d0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -8,6 +8,13 @@ const mocks = vi.hoisted(() => { > = { repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo', hostId: 'runtime:env-1' }] } + const endpointProjects: { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[] = [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] return { toastLoading: vi.fn(() => 'toast-1'), toastDismiss: vi.fn(), @@ -19,18 +26,21 @@ const mocks = vi.hoisted(() => { activeRepoId: 'repo1', activeWorktreeId: 'wt-1', settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, - projects: [ + endpointProjects, + endpointProjectHostSetups: [ { id: 'repo1', - localWindowsRuntimePreference: { kind: 'inherit-global' as const } + repoId: 'repo1', + projectId: 'repo1', + hostId: 'local' as const, + path: '/remote/repo', + displayName: 'repo', + setupState: 'ready' as const, + setupMethod: 'legacy-repo' as const, + createdAt: 0, + updatedAt: 0 } - ] as { - id: string - localWindowsRuntimePreference: - | { kind: 'inherit-global' } - | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string | null } - }[], + ], repos: [ { id: 'repo1', @@ -107,7 +117,7 @@ describe('handleTerminalFileDrop', () => { } }) mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } - mocks.storeState.projects = [ + mocks.storeState.endpointProjects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'inherit-global' } @@ -374,7 +384,7 @@ describe('handleTerminalFileDrop', () => { it('pastes Linux-readable paths for local Windows-path projects forced to WSL', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - mocks.storeState.projects = [ + mocks.storeState.endpointProjects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } @@ -432,7 +442,7 @@ describe('handleTerminalFileDrop', () => { it('pastes a spaced image from a Windows-path project forced to WSL with POSIX escaping', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: null } - mocks.storeState.projects = [ + mocks.storeState.endpointProjects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } ] mocks.storeState.repos = [ diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts index 953e7adf8eed..790f069bfeaa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -50,6 +50,9 @@ beforeEach(() => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree, createBrowserTab diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts index 3a36fee0f528..93e158007ad7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts @@ -1,5 +1,6 @@ import { vi, type Mock } from 'vitest' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { AppState } from '@/store/types' import type { Tab, TabGroup } from '../../../../shared/tab-types' export type TerminalLinkStoreSettings = { @@ -8,7 +9,10 @@ export type TerminalLinkStoreSettings = { activeRuntimeEnvironmentId?: string | null } -export type TerminalLinkStoreState = { +export type TerminalLinkStoreState = Pick< + AppState, + 'repos' | 'endpointProjects' | 'endpointProjectHostSetups' +> & { settings: TerminalLinkStoreSettings | undefined setActiveWorktree: Mock createBrowserTab: Mock @@ -96,6 +100,9 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles { } ) const storeState: TerminalLinkStoreState = { + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: undefined as TerminalLinkStoreSettings | undefined, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock, diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts index e992047716d3..28b20403a9ad 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts @@ -103,6 +103,9 @@ beforeEach(() => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: true, activeRuntimeEnvironmentId: null }, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts index c1c7658691f1..c418876348b8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -169,6 +169,9 @@ describe('hard-wrapped terminal HTTP clicks', () => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/wrapped-terminal-http-link-provider.test.ts b/src/renderer/src/components/terminal-pane/wrapped-terminal-http-link-provider.test.ts index ebde32ea15d3..68c9a04a3c6f 100644 --- a/src/renderer/src/components/terminal-pane/wrapped-terminal-http-link-provider.test.ts +++ b/src/renderer/src/components/terminal-pane/wrapped-terminal-http-link-provider.test.ts @@ -23,6 +23,9 @@ describe('wrapped terminal HTTP link provider', () => { const openUrl = vi.fn() vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() @@ -131,6 +134,9 @@ describe('wrapped terminal HTTP link provider', () => { const openUrl = vi.fn() vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() @@ -199,6 +205,9 @@ describe('wrapped terminal HTTP link provider', () => { const openUrl = vi.fn() vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index 39358c99491e..4a2764ce841a 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' @@ -46,8 +50,8 @@ export function useWorktreeJumpPaletteStoreState({ ) const repos = useAppStore((state) => state.repos) const projectGroups = useAppStore((state) => state.projectGroups) - const projects = useAppStore((state) => state.projects) - const projectHostSetups = useAppStore((state) => state.projectHostSetups) + const projects = useAppStore((state) => selectCanonicalProjects(state)) + const projectHostSetups = useAppStore((state) => selectCanonicalProjectHostSetups(state)) const detectedWorktreesByRepo = useAppStore((state) => selectDetectedWorktreesByRepo(state)) const pendingWorktreeCreations = useAppStore((state) => state.pendingWorktreeCreations) const pluginCommands = usePluginCommands() diff --git a/src/renderer/src/components/use-worktree-jump-palette-task-url.ts b/src/renderer/src/components/use-worktree-jump-palette-task-url.ts index 7d4d735bb36b..500c530dc113 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-task-url.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-task-url.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { @@ -39,8 +43,8 @@ function getComposerDefaultWorkspaceTarget(state: ReturnType { useAppStore.setState({ settings: null, repos, - projects: [...projection.projects], - projectHostSetups: [...projection.setups], + endpointProjects: [...projection.projects], + endpointProjectHostSetups: [...projection.setups], projectGroups: [], newWorkspaceDraft: null, worktreesByRepo: {}, @@ -158,6 +158,7 @@ function renderUnavailableMappedComposer( includeSetupHint?: boolean emptyRepoOverride?: boolean missingSshSetup?: boolean + missingSshRepo?: boolean canonicalSameHostSetup?: boolean } = {} ) { @@ -179,9 +180,9 @@ function renderUnavailableMappedComposer( } useAppStore.setState({ settings: null, - repos, - projects: [...projection.projects], - projectHostSetups: projection.setups.filter( + repos: options.missingSshRepo ? repos.filter((repo) => repo !== mappedRepo) : repos, + endpointProjects: [...projection.projects], + endpointProjectHostSetups: projection.setups.filter( (setup) => !options.missingSshSetup || setup.hostId !== 'ssh:builder' ), projectGroups: [], @@ -245,7 +246,7 @@ describe('unavailable mapped Jira destinations', () => { }) it('recovers the intended host when it becomes configured', () => { - const { result, mappedRepo } = renderUnavailableMappedComposer() + const { result, mappedRepo } = renderUnavailableMappedComposer({ missingSshSetup: true }) act(() => useAppStore.setState({ sshTargetLabels: new Map([['builder', 'Builder']]) })) expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toMatchObject({ @@ -275,7 +276,10 @@ describe('unavailable mapped Jira destinations', () => { act(() => useAppStore.setState((state) => ({ - projectHostSetups: state.projectHostSetups.filter((setup) => setup.hostId !== 'ssh:builder') + endpointProjectHostSetups: state.endpointProjectHostSetups.filter( + (setup) => setup.hostId !== 'ssh:builder' + ), + repos: state.repos.filter((repo) => repo !== mappedRepo) })) ) @@ -288,7 +292,8 @@ describe('unavailable mapped Jira destinations', () => { const { result } = renderUnavailableMappedComposer({ repeatedId: true, configured: true, - missingSshSetup: true + missingSshSetup: true, + missingSshRepo: true }) expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toEqual({ diff --git a/src/renderer/src/hooks/composer-state/composer-target-store.ts b/src/renderer/src/hooks/composer-state/composer-target-store.ts index 4f16dac76267..60c28e2d6c50 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-store.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-store.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useMemo } from 'react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' @@ -93,11 +97,11 @@ export function useComposerTargetStore(options: ComposerStateInput, decisions: C const repos = useAppStore((s) => s.repos) - const projects = useAppStore((s) => s.projects) + const projects = useAppStore((s) => selectCanonicalProjects(s)) const projectGroups = useAppStore((s) => s.projectGroups) - const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) const activeRepoId = useAppStore((s) => s.activeRepoId) diff --git a/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx b/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx index 9c5200feb076..b2b25e1401f5 100644 --- a/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx +++ b/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx @@ -83,8 +83,8 @@ function seedRepos(repos: readonly Repo[] = [localRepo, executionRepo], configur useAppStore.setState({ settings: null, repos, - projects: [...projection.projects], - projectHostSetups: [...projection.setups], + endpointProjects: [...projection.projects], + endpointProjectHostSetups: [...projection.setups], projectGroups: [], newWorkspaceDraft: null, worktreesByRepo: {}, diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts index 6304f8faa72d..32d8c8223f2e 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -167,7 +167,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe { activeRepoId, activeWorktreeId: null, - projects, + projectHostSetupProjection: { projects, setups: projectHostSetups }, repos, settings, worktreesByRepo @@ -178,6 +178,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe ) }, [ activeRepoId, + projectHostSetups, projects, repos, runtimeStatusByEnvironmentId, diff --git a/src/renderer/src/hooks/composer-state/target-change-actions.ts b/src/renderer/src/hooks/composer-state/target-change-actions.ts index c1b0c3c2a98d..3f6a5006acac 100644 --- a/src/renderer/src/hooks/composer-state/target-change-actions.ts +++ b/src/renderer/src/hooks/composer-state/target-change-actions.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '@/store/project-host-setup-selector' import type { ComposerModel } from './composer-model' type TargetChangeActionsInput = Pick< @@ -245,7 +246,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { ? option : buildProjectHostSetupOptions({ projectId: selectedRepoProjectId, - projectHostSetups: useAppStore.getState().projectHostSetups, + projectHostSetups: selectCanonicalProjectHostSetups(useAppStore.getState()), eligibleRepos: getComposerEligibleRepos(useAppStore.getState().repos), hosts: hostOptions }).find( diff --git a/src/renderer/src/hooks/settings-navigation-remote-sections.ts b/src/renderer/src/hooks/settings-navigation-remote-sections.ts index 4c9df1e63811..b8c76f9d7b6b 100644 --- a/src/renderer/src/hooks/settings-navigation-remote-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-remote-sections.ts @@ -158,7 +158,7 @@ export function buildRemoteSettingsSections( : []), // Collapse host-specific repo rows to one project entry using the same inputs as settings panes. ...buildSettingsProjectList(repos, projectGrouping).map((settingsProject) => { - const { project, representativeRepoId, setups, checkoutLabel } = settingsProject + const { project, representativeRepoId, setups } = settingsProject const representativeRepo = reposById.get(representativeRepoId) ?? repos[0] const hostSummary = setups.length > 1 @@ -170,7 +170,7 @@ export function buildRemoteSettingsSections( : (setups[0]?.path ?? representativeRepo.path) return { id: `repo-${representativeRepoId}`, - title: checkoutLabel ?? project.displayName, + title: project.displayName, description: `${getRepoKindLabel(project)} • ${hostSummary}`, icon: SlidersHorizontal, searchEntries: getRepositoryPaneSearchEntries(representativeRepo, { diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx index d7799b843240..15f8e3bdfc67 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx @@ -40,7 +40,7 @@ describe('useActiveProjectSkillRuntime', () => { activeRepoId: null, activeWorktreeId: null, activeWorkspaceExecutionHostId: null, - projects: [], + endpointProjects: [], repos: [], worktreesByRepo: {}, folderWorkspaces: [], diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts index 028089ab13b1..cd3e42d8f935 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -1,3 +1,4 @@ +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import { useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' @@ -77,7 +78,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { useShallow((state) => ({ activeRepoId: state.activeRepoId, activeWorktreeId: state.activeWorktreeId, - projects: state.projects, + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), repos: state.repos, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, diff --git a/src/renderer/src/hooks/useComposerState.integration.test.ts b/src/renderer/src/hooks/useComposerState.integration.test.ts index 0c4b738953e9..b165e6698282 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -1,9 +1,20 @@ // @vitest-environment happy-dom -import { act, renderHook } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import type * as NewWorkspace from '@/lib/new-workspace' +import { getDefaultSettings } from '../../../shared/constants' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { AppState } from '@/store/types' + +vi.mock('@/lib/new-workspace', async (importOriginal) => ({ + ...(await importOriginal()), + CLIENT_PLATFORM: 'win32' +})) import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../shared/worktree/retired-name-registry' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' import type { Repo } from '../../../shared/repo-types' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { normalizeTaskSourceContext } from '../../../shared/task-source-context' @@ -84,8 +95,8 @@ describe('useComposerState integrated lifecycle', () => { const projection = projectHostSetupProjectionFromRepos([localRepo, runtimeRepo]) useAppStore.setState({ repos: [localRepo, runtimeRepo], - projects: [...projection.projects], - projectHostSetups: projection.setups.map((setup) => ({ + endpointProjects: [...projection.projects], + endpointProjectHostSetups: projection.setups.map((setup) => ({ ...setup, id: `${setup.hostId}:setup` })), @@ -123,12 +134,205 @@ describe('useComposerState integrated lifecycle', () => { composer.unmount() }) + it('creates in concrete B after a normal composer reads old shared endpoint metadata', async () => { + const original = useAppStore.getState() + useAppStore.setState(useAppStore.getInitialState(), true) + const repos: Repo[] = ['a', 'b'].map((id, index) => ({ + id, + path: `/${id}`, + displayName: id, + badgeColor: '', + addedAt: index + 1, + upstream: { owner: 'acme', repo: 'app' } + })) + const projection = projectHostSetupProjectionFromRepos(repos) + const projectId = projection.setups[0].projectId + const endpointProjects = [{ ...projection.projects[0], sourceRepoIds: ['a', 'b'] }] + const endpointProjectHostSetups = projection.setups.map((setup) => ({ ...setup, projectId })) + const response = Promise.withResolvers() + const create = vi.fn().mockReturnValue(response.promise) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ...window.api, + gh: { repoSlug: vi.fn().mockResolvedValue(null) }, + worktrees: { ...window.api.worktrees, create } + } + }) + useAppStore.setState({ repos, endpointProjects, endpointProjectHostSetups }) + const composer = renderHook(() => + useComposerState({ initialRepoId: 'b', initialName: 'topic', persistDraft: false }) + ) + try { + expect(composer.result.current.cardProps.projectOptions.map((project) => project.id)).toEqual( + expect.arrayContaining([projectId, 'repo:b']) + ) + expect(composer.result.current.cardProps.selectedProjectId).toBe('repo:b') + act(() => composer.result.current.cardProps.onProjectChange(projectId)) + act(() => composer.result.current.cardProps.onProjectChange('repo:b')) + expect(composer.result.current.cardProps.selectedRepoPath).toBe('/b') + expect(composer.result.current.cardProps.selectedRepoExecutionHostId).toBe('local') + expect(useAppStore.getState().endpointProjects).toBe(endpointProjects) + expect(useAppStore.getState().endpointProjectHostSetups).toBe(endpointProjectHostSetups) + await act(async () => composer.result.current.submitQuick(null)) + await waitFor(() => expect(create).toHaveBeenCalledOnce()) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ repoId: 'b' })) + const pending = Object.values(useAppStore.getState().pendingWorktreeCreations)[0] + expect(pending.request.workspaceRunContext).toMatchObject({ + repoId: 'b', + hostId: 'local', + path: '/b', + projectId: 'repo:b' + }) + act(() => useAppStore.getState().removePendingWorktreeCreation(pending.creationId)) + await act(async () => + response.resolve({ + worktree: makeWorktree({ id: 'b::topic', repoId: 'b', path: '/b-topic' }) + }) + ) + await waitFor(() => expect(useAppStore.getState().worktreesByRepo.b[0].id).toBe('b::topic')) + } finally { + composer.unmount() + useAppStore.setState(original, true) + } + }) + + it.each(['claude', 'codex'] as const)( + 'uses the live Project runtime for the %s quick-create startup request', + async (agent) => { + const original = useAppStore.getState() + useAppStore.setState(useAppStore.getInitialState(), true) + const repo: Repo = { id: 'b', path: 'C:/app-b', displayName: 'B', badgeColor: '', addedAt: 1 } + const catalog = projectHostSetupProjectionFromRepos([repo]) + const settings: GlobalSettings = { + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'powershell.exe', + localWindowsRuntimeDefault: { kind: 'windows-host' as const }, + agentDefaultArgs: { claude: '', codex: '' }, + agentPermissionDefault: 'own', + agentDefaultEnv: { claude: {}, codex: {} } + } + useAppStore.setState({ + repos: [repo], + endpointProjects: catalog.projects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + })), + endpointProjectHostSetups: [...catalog.setups], + settings, + sparsePresetsByRepo: { b: [] } + }) + const responses: ReturnType>[] = [] + const create = vi.fn().mockImplementation(() => { + const response = Promise.withResolvers() + responses.push(response) + return response.promise + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ...window.api, + gh: { repoSlug: vi.fn().mockResolvedValue(null) }, + worktrees: { + ...window.api.worktrees, + create, + updateMeta: vi.fn().mockResolvedValue(undefined) + } + } + }) + const composer = renderHook(() => + useComposerState({ + initialRepoId: 'b', + initialName: 'topic', + persistDraft: false, + initialLinkedWorkItem: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Linked task', + url: 'https://linear.app/acme/issue/ENG-123', + linearIdentifier: 'ENG-123' + } + }) + ) + try { + act(() => composer.result.current.cardProps.onNoteChange(`fix it's "quoted" $HOME`)) + for (const windows of [false, true]) { + if (windows) { + act(() => + useAppStore.setState((state): Partial => ({ + endpointProjects: state.endpointProjects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + })), + endpointProjectHostSetups: state.endpointProjectHostSetups.map((setup) => ({ + ...setup, + updatedAt: setup.updatedAt + 1 + })), + settings: { + ...settings, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + })) + ) + } + expect(composer.result.current.cardProps.selectedRepo).toBe(repo) + expect(composer.result.current.cardProps.selectedProjectId).toBe('repo:b') + expect(composer.result.current.cardProps.selectedRepoExecutionHostId).toBe('local') + expect(composer.result.current.cardProps.selectedRepoPath).toBe('C:/app-b') + await act(async () => composer.result.current.submitQuick(agent)) + await waitFor(() => expect(create).toHaveBeenCalledTimes(windows ? 2 : 1)) + const pending = Object.values(useAppStore.getState().pendingWorktreeCreations)[0] + expect(pending.request.workspaceRunContext).toMatchObject({ + repoId: 'b', + projectId: 'repo:b', + projectHostSetupId: catalog.setups[0].id, + hostId: 'local', + path: 'C:/app-b' + }) + const draft = `fix it's "quoted" $HOME\n\nLinked Linear issue: ENG-123\nhttps://linear.app/acme/issue/ENG-123\n` + if (agent === 'claude') { + const quoted = windows + ? '"fix it\'s `"quoted`" `$HOME`n`nLinked Linear issue: ENG-123`nhttps://linear.app/acme/issue/ENG-123"' + : `'${draft.trim().replaceAll("'", "'\"'\"'")}'` + expect(pending.request.startup?.command).toBe(`claude --prefill ${quoted}`) + expect(pending.request.startupPlan?.draftPrompt).toBeUndefined() + } else { + expect(pending.request.startup).toBeUndefined() + expect(pending.request.startupPlan?.launchCommand).toBe('codex') + expect(pending.request.startupPlan?.draftPrompt).toBe(draft) + } + act(() => useAppStore.getState().removePendingWorktreeCreation(pending.creationId)) + await act(async () => + responses[windows ? 1 : 0].resolve({ + worktree: makeWorktree({ + id: `b::topic-${windows}`, + repoId: 'b', + path: 'C:/app-b-topic' + }) + }) + ) + } + } finally { + composer.unmount() + await act(async () => { + for (const response of responses) { + response.resolve({ + worktree: makeWorktree({ id: 'b::cleanup', repoId: 'b', path: 'C:/app-b-topic' }) + }) + } + }) + useAppStore.setState(original, true) + } + } + ) + it('composes two live composers and exposes the parent-worktree control state', () => { useAppStore.setState({ repos: [], - projects: [], + endpointProjects: [], projectGroups: [], - projectHostSetups: [], + endpointProjectHostSetups: [], newWorkspaceDraft: null, worktreesByRepo: {}, sparsePresetsByRepo: {} diff --git a/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx b/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx index 1eb266a675c2..e2df80f3be12 100644 --- a/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx @@ -6,6 +6,7 @@ import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fix import { useNewTabAgentMenuRows } from '@/components/tab-bar/use-new-tab-agent-menu-rows' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' import type { Project } from '../../../shared/project-types' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { useDetectedAgents, type AgentDetectionTarget } from './useDetectedAgents' const initialState = useAppStore.getInitialState() @@ -60,21 +61,19 @@ it.each(localTargets)('restarts an invalidated first probe for $worktreeId', asy }) it('retargets a mounted first load when the project switches to WSL', async () => { + const worktree = makeWorktree({ id: 'wt1', repoId: TEST_REPO.id, path: 'C:\\project' }) + const repos = [{ ...TEST_REPO, path: worktree.path }] + const catalog = projectHostSetupProjectionFromRepos(repos) const project: Project = { - id: TEST_REPO.id, - displayName: TEST_REPO.displayName, - badgeColor: TEST_REPO.badgeColor, - sourceRepoIds: [TEST_REPO.id], - createdAt: 0, - updatedAt: 0, + ...catalog.projects[0], localWindowsRuntimePreference: { kind: 'windows-host' } } - const worktree = makeWorktree({ id: 'wt1', repoId: TEST_REPO.id, path: 'C:\\project' }) useAppStore.setState({ activeRepoId: TEST_REPO.id, activeWorktreeId: worktree.id, - repos: [{ ...TEST_REPO, path: worktree.path }], - projects: [project], + repos, + endpointProjects: [project], + endpointProjectHostSetups: [...catalog.setups], worktreesByRepo: { [TEST_REPO.id]: [worktree] }, settings: { ...getDefaultSettings('/home/test'), diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx index 4be7dff1a38c..92a366d693be 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx @@ -7,6 +7,7 @@ import { getDefaultSettings } from '../../../shared/constants' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { SettingsNavSection } from '@/lib/settings-navigation-types' import type { RuntimeEnvironmentStatus } from '@/store/slices/runtime-status-types' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' @@ -20,6 +21,8 @@ const testState = vi.hoisted(() => ({ type MockedSettingsNavState = { settings: GlobalSettings | null repos: Repo[] + endpointProjects: Project[] + endpointProjectHostSetups: ProjectHostSetup[] runtimeEnvironments: typeof testState.runtimeEnvironments runtimeStatusByEnvironmentId: Map } @@ -29,6 +32,8 @@ vi.mock('@/store', () => ({ selector({ settings: testState.settings, repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], runtimeEnvironments: testState.runtimeEnvironments, runtimeStatusByEnvironmentId: new Map() }) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index c76b3a672856..f9d1b70c5a7d 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -17,13 +17,13 @@ import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { defaultScopeHost } from '@/lib/default-creation-host' -import { useAppStore } from '@/store' import { useProjectHostSetupProjection } from '@/store/selectors' import { useChatSettingsOwnPermissions, useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability' import type { ProjectGroupingModel } from '../../../shared/project-section-grouping' +import { useAppStore } from '@/store' import type { Repo } from '../../../shared/repo-types' import { buildCapabilitySettingsSections, @@ -114,7 +114,11 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const { i18n } = useTranslation() const activeLocale = i18n.language const repos = useAppStore((state) => state.repos) - const projectHostSetupProjection = useProjectHostSetupProjection() + const projection = useProjectHostSetupProjection() + const projectGrouping = useMemo( + () => ({ projects: projection.projects, projectHostSetups: projection.setups }), + [projection] + ) const settings = useAppStore((state) => state.settings) const [managedBrowserCreationEnabled, mobileEmulatorCreationEnabled] = useAppStore( useShallow((state) => { @@ -176,10 +180,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isDev: import.meta.env.DEV, isLinearConnected, repos, - projectGrouping: { - projects: projectHostSetupProjection.projects, - projectHostSetups: projectHostSetupProjection.setups - } + projectGrouping }), // oxlint-disable-next-line react-hooks/exhaustive-deps -- activeLocale is read implicitly by the translate() calls inside buildSettingsNavigationMetadata; without it the memo keeps the previous language's sections. [ @@ -195,7 +196,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isLinearConnected, repos, - projectHostSetupProjection, + projectGrouping, activeLocale ] ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9cb29149900b..74606124b6a9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8760,8 +8760,6 @@ "c7ef4415de": "Display Name", "b0a0c14a1c": "Project-specific display details for the sidebar and tabs.", "removeProjectAllHosts": "Remove this project from Orca on all configured hosts.", - "removeProjectCheckout": "Remove this checkout from Orca. Other checkouts of this project stay.", - "removeProjectKeepCheckouts": "Remove this project from Orca. Checkouts that have their own settings stay.", "0909e5d650": "Remove Project", "ee5a290616": "Opened as folder. Git features are unavailable for this workspace.", "323debba71": "Type:", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5f72d7bd4a50..bfa816538729 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8364,8 +8364,6 @@ "c7ef4415de": "Nombre para mostrar", "b0a0c14a1c": "Detalles de visualización específicos del proyecto para la barra lateral y las pestañas.", "removeProjectAllHosts": "Eliminar este proyecto de Orca en todos los hosts configurados.", - "removeProjectCheckout": "Eliminar este checkout de Orca. Los demás checkouts de este proyecto se conservan.", - "removeProjectKeepCheckouts": "Eliminar este proyecto de Orca. Los checkouts que tienen su propia configuración se conservan.", "0909e5d650": "Eliminar proyecto", "ee5a290616": "Abierto como carpeta. Las funciones de Git no están disponibles para este espacio de trabajo.", "323debba71": "Tipo:", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0d94a71bd0b2..b1c2e29a89b6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -8546,8 +8546,6 @@ "c7ef4415de": "Nom d'affichage", "b0a0c14a1c": "Détails d'affichage propres au projet pour la barre latérale et les onglets.", "removeProjectAllHosts": "Retirer ce projet d'Orca sur tous les hôtes configurés.", - "removeProjectCheckout": "Retirer ce checkout d'Orca. Les autres checkouts de ce projet sont conservés.", - "removeProjectKeepCheckouts": "Retirer ce projet d'Orca. Les checkouts qui ont leurs propres paramètres sont conservés.", "0909e5d650": "Retirer le projet", "ee5a290616": "Ouvert en tant que dossier. Les fonctions Git sont indisponibles pour cet espace de travail.", "323debba71": "Type :", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d8d6af27750e..d0fb490a4aa0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8370,8 +8370,6 @@ "c7ef4415de": "表示名", "b0a0c14a1c": "サイドバーとタブのプロジェクト固有の表示詳細。", "removeProjectAllHosts": "このプロジェクトを、設定済みのすべてのホストの Orca から削除します。", - "removeProjectCheckout": "このチェックアウトを Orca から削除します。このプロジェクトの他のチェックアウトは残ります。", - "removeProjectKeepCheckouts": "このプロジェクトを Orca から削除します。独自の設定を持つチェックアウトは残ります。", "0909e5d650": "プロジェクトの削除", "ee5a290616": "フォルダーとして開きます。このワークスペースでは Git 機能を使用できません。", "323debba71": "タイプ:", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 39849c566731..d9f6390ccfb5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8333,8 +8333,6 @@ "c7ef4415de": "표시 이름", "b0a0c14a1c": "사이드바 및 탭에 대한 프로젝트별 표시 세부 정보입니다.", "removeProjectAllHosts": "구성된 모든 호스트의 Orca에서 이 프로젝트를 제거합니다.", - "removeProjectCheckout": "Orca에서 이 체크아웃을 제거합니다. 이 프로젝트의 다른 체크아웃은 유지됩니다.", - "removeProjectKeepCheckouts": "Orca에서 이 프로젝트를 제거합니다. 자체 설정이 있는 체크아웃은 유지됩니다.", "0909e5d650": "프로젝트 제거", "ee5a290616": "폴더로 열렸습니다. 이 워크스페이스에서는 Git 기능을 사용할 수 없습니다.", "323debba71": "유형:", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index a5d77ada15ef..8ffd13284ac9 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -4295,9 +4295,7 @@ "show": "Mostrar", "hide": "Ocultar", "externalWorktreesGlobal": "Padrão global: {{value0}}", - "useGlobal": "Usar global", - "removeProjectCheckout": "Remova este checkout do Orca. Outros checkouts deste projeto permanecem.", - "removeProjectKeepCheckouts": "Remova este projeto do Orca. Os checkouts que têm suas próprias configurações permanecem." + "useGlobal": "Usar global" }, "Settings": { "3bf149e873": "Configurações do Projeto > {{value0}}", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 938d745deee5..0b0c7b199053 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8333,8 +8333,6 @@ "c7ef4415de": "显示名称", "b0a0c14a1c": "侧边栏和选项卡的项目特定显示详细信息。", "removeProjectAllHosts": "从所有已配置主机的 Orca 中删除该项目。", - "removeProjectCheckout": "从 Orca 中删除此检出。此项目的其他检出将保留。", - "removeProjectKeepCheckouts": "从 Orca 中删除该项目。拥有独立设置的检出将保留。", "0909e5d650": "删除项目", "ee5a290616": "作为文件夹打开。 Git 功能对此工作区不可用。", "323debba71": "类型:", diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts index 97cba9543d4d..b048d4880378 100644 --- a/src/renderer/src/lib/agent-background-session-test-state.ts +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -17,7 +17,7 @@ export type AgentBackgroundSessionTestState = { activeRuntimeEnvironmentId: string | null terminalMainSideEffectAuthority: boolean | undefined } - projects: { + endpointProjects: { id: string localWindowsRuntimePreference: | { kind: 'inherit-global' } @@ -69,6 +69,9 @@ export function createAgentBackgroundSessionTestState(mocks: { setTabLayout: TestMock registerAgentLaunchConfig: TestMock }): AgentBackgroundSessionTestState { + const endpointProjects: AgentBackgroundSessionTestState['endpointProjects'] = [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } + ] const state = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', @@ -78,18 +81,7 @@ export function createAgentBackgroundSessionTestState(mocks: { activeRuntimeEnvironmentId: null as string | null, terminalMainSideEffectAuthority: undefined as boolean | undefined }, - projects: [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'inherit-global' as const } - } - ] as { - id: string - localWindowsRuntimePreference: - | { kind: 'inherit-global' } - | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string | null } - }[], + endpointProjects, repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], worktreesByRepo: { 'repo-1': [ @@ -138,7 +130,9 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi activeRuntimeEnvironmentId: null, terminalMainSideEffectAuthority: undefined } - state.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] + state.endpointProjects = [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } + ] state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] state.folderWorkspaces = [] state.projectGroups = [] diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index b0cab494308f..f4ccce9f3523 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, @@ -41,6 +43,7 @@ vi.mock('@/lib/structured-agent-launch-settlement', () => ({ import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { buildAgentLaunchRouteInput, + selectAgentLaunchRouteView, workspaceKindForWorktreeId, type AgentLaunchRouteArgs, type AgentLaunchRouteStore @@ -492,3 +495,24 @@ describe('a cwd that names the workspace root', () => { expect(at('/srv/notes/drafts')).toBe(true) }) }) + +it('captures both actual endpoint catalogs and invalidates the route view for either write', () => { + const store = createTestStore() + const first = selectAgentLaunchRouteView(store.getState()) + expect(first.projectHostSetupProjection).toBe( + getProjectHostSetupProjectionFromState(store.getState()) + ) + expect(selectAgentLaunchRouteView(store.getState())).toBe(first) + store.setState({ endpointProjects: [...store.getState().endpointProjects] }) + const projectsChanged = selectAgentLaunchRouteView(store.getState()) + expect(projectsChanged).not.toBe(first) + expect(projectsChanged.projectHostSetupProjection).toBe( + getProjectHostSetupProjectionFromState(store.getState()) + ) + store.setState({ endpointProjectHostSetups: [...store.getState().endpointProjectHostSetups] }) + const setupsChanged = selectAgentLaunchRouteView(store.getState()) + expect(setupsChanged).not.toBe(projectsChanged) + expect(setupsChanged.projectHostSetupProjection).toBe( + getProjectHostSetupProjectionFromState(store.getState()) + ) +}) diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index 4b91ba97de32..b93be6bc4bed 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -5,6 +5,10 @@ import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import type { AppState } from '@/store/types' +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { requestsCwdOutsideWorkspaceRootForWorkspace } from '../../../shared/terminal-startup-cwd' import type { TuiAgent } from '../../../shared/tui-agent' @@ -70,21 +74,26 @@ const AGENT_LAUNCH_ROUTE_KEYS = [ 'runtimeEnvironments', 'runtimeEnvironmentCatalogHydrated', 'removedRuntimeEnvironmentIds', - 'activeRepoId', - 'projects' -] as const satisfies readonly (keyof AgentLaunchRouteStore)[] + 'activeRepoId' +] as const satisfies readonly (keyof AppState)[] type AgentLaunchRouteKey = (typeof AGENT_LAUNCH_ROUTE_KEYS)[number] // Completeness, not just membership: a reader input missing from the list fails to compile. type AssertNoMissingRouteKey = T export type AgentLaunchRouteKeysAreComplete = AssertNoMissingRouteKey< Exclude > -export type AgentLaunchRouteView = { [K in AgentLaunchRouteKey]: AppState[K] } +export type AgentLaunchRouteWatchState = Pick & + ProjectCatalogReadState +export type AgentLaunchRouteView = Pick & { + projectHostSetupProjection: ReturnType +} export const agentLaunchRouteInputsChanged = ( - next: AgentLaunchRouteView, - previous: AgentLaunchRouteView -): boolean => AGENT_LAUNCH_ROUTE_KEYS.some((key) => next[key] !== previous[key]) + next: AgentLaunchRouteWatchState, + previous: AgentLaunchRouteWatchState +): boolean => + AGENT_LAUNCH_ROUTE_KEYS.some((key) => next[key] !== previous[key]) || + getProjectHostSetupProjectionFromState(next) !== getProjectHostSetupProjectionFromState(previous) let lastRouteView: AgentLaunchRouteView | undefined export function selectAgentLaunchRouteView(state: AppState): AgentLaunchRouteView { @@ -104,7 +113,7 @@ export function selectAgentLaunchRouteView(state: AppState): AgentLaunchRouteVie runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds, activeRepoId: state.activeRepoId, - projects: state.projects + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state) } } return lastRouteView diff --git a/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts index 16df933a70d2..9d6e9d71e0aa 100644 --- a/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts +++ b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts @@ -21,7 +21,10 @@ const linuxFile = '/home/example/.gemini/antigravity-ide/brain/copied-id/transcr const debianFile = '//wsl.localhost/Debian/home/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl' -function stateFor(host: ExecutionHostId, distro?: string): State { +function stateFor( + host: ExecutionHostId, + distro?: string +): Extract { const worktree = makeWorktree({ id: 'repo::workspace', repoId: 'repo', @@ -45,7 +48,21 @@ function stateFor(host: ExecutionHostId, distro?: string): State { executionHostId: host } ], - projects: [ + endpointProjectHostSetups: [ + { + id: 'repo', + projectId: 'repo', + repoId: 'repo', + hostId: host, + path: worktree.path, + displayName: 'project', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [ { id: 'repo', displayName: 'project', @@ -139,7 +156,7 @@ describe('Antigravity transcript reference ownership', () => { const state = stateFor('local') state.activeRepoId = null state.repos = [] - state.projects = [] + state.endpointProjects = [] state.activeWorktreeId = 'folder:folder-1' state.folderWorkspaces = [ makeFolderWorkspace({ folderPath: '//wsl.localhost/Debian/home/example/project' }) @@ -308,7 +325,7 @@ describe('Antigravity transcript reference ownership', () => { it('refuses local references while the project runtime requires repair', () => { const state = stateFor('local') - state.projects[0].localWindowsRuntimePreference = { kind: 'inherit-global' } + state.endpointProjects[0].localWindowsRuntimePreference = { kind: 'inherit-global' } state.settings = createGlobalSettingsFixture({ localWindowsRuntimeDefault: { kind: 'wsl', distro: null } }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts index 139b4e3fd97c..58ffcec99eb4 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts @@ -1,5 +1,6 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store/types' import { buildAiVaultDropRepinStartup } from './ai-vault-resume-command' vi.mock('@/lib/new-workspace', () => ({ @@ -9,17 +10,7 @@ vi.mock('@/lib/new-workspace', () => ({ const RECORDED_HOME = '/tmp/orca/codex-accounts/aaaa/home' const SELECTED_HOME = '/tmp/orca/codex-accounts/bbbb/home' -type DropRepinState = Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'folderWorkspaces' - | 'projectGroups' - | 'projects' - | 'repos' - | 'settings' - | 'worktreesByRepo' -> +type DropRepinState = Parameters[0]['state'] function makeState(): DropRepinState { return { @@ -27,16 +18,29 @@ function makeState(): DropRepinState { activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], + endpointProjectHostSetups: [], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + createdAt: 0, + updatedAt: 0, + sourceRepoIds: ['repo-1'] + } + ], settings: { + ...getDefaultSettings('/tmp'), agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { - 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }] + 'repo-1': [ + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }) + ] } - } as unknown as DropRepinState + } } function payload(overrides: { diff --git a/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts b/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts index 25d28065b1f9..86119e7b1410 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts @@ -1,5 +1,6 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store/types' import { buildAiVaultResumeCopyCommandForWorktree, buildAiVaultResumeStartupForWorktree @@ -9,17 +10,7 @@ vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' })) -type ResumableAgentState = Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'folderWorkspaces' - | 'projectGroups' - | 'projects' - | 'repos' - | 'settings' - | 'worktreesByRepo' -> +type ResumableAgentState = Parameters[0]['state'] function makeState(): ResumableAgentState { return { @@ -27,16 +18,29 @@ function makeState(): ResumableAgentState { activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], + endpointProjectHostSetups: [], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + createdAt: 0, + updatedAt: 0, + sourceRepoIds: ['repo-1'] + } + ], settings: { + ...getDefaultSettings('/tmp'), agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { - 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }] + 'repo-1': [ + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }) + ] } - } as unknown as ResumableAgentState + } } const KIMI_SESSION = { diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index bc0198a9e350..471f26b19260 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -1,5 +1,6 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store/types' import { getAiVaultAgentProviderSession, buildAiVaultResumeCopyCommandForWorktree, @@ -23,32 +24,44 @@ afterEach(() => { type RuntimePreference = { kind: 'windows-host' } | { kind: 'wsl'; distro: string } -type AiVaultResumeCommandState = Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'folderWorkspaces' - | 'projectGroups' - | 'projects' - | 'repos' - | 'settings' - | 'worktreesByRepo' -> +type AiVaultResumeCommandState = Parameters< + typeof buildAiVaultResumeCopyCommandForWorktree +>[0]['state'] function makeState(args: { worktreePath: string localWindowsRuntimePreference?: RuntimePreference terminalWindowsShell?: string -}): AiVaultResumeCommandState { +}): Extract & { + settings: NonNullable +} { return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }], - projects: [ + repos: [{ ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\repo' }], + endpointProjectHostSetups: [ { id: 'repo-1', + projectId: 'repo-1', + repoId: 'repo-1', + hostId: 'local', + path: 'C:/Users/alice/repo', + displayName: 'repo', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + createdAt: 0, + updatedAt: 0, sourceRepoIds: ['repo-1'], ...(args.localWindowsRuntimePreference ? { localWindowsRuntimePreference: args.localWindowsRuntimePreference } @@ -56,21 +69,23 @@ function makeState(args: { } ], settings: { + ...getDefaultSettings('/tmp'), localWindowsRuntimeDefault: { kind: 'windows-host' }, ...(args.terminalWindowsShell ? { terminalWindowsShell: args.terminalWindowsShell } : {}), agentDefaultArgs: { claude: '', codex: '' }, + agentPermissionDefault: 'own', agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: args.worktreePath - } + }) ] } - } as unknown as AiVaultResumeCommandState + } } function buildQueuedAiVaultResumeCommand( @@ -205,6 +220,7 @@ describe('ai vault resume command runtime', () => { it('follows the live Windows shell for Cursor resume', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + state.settings.agentPermissionOverrides = { cursor: 'full-access' } expect( buildQueuedAiVaultResumeCommand({ diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 717dfa01e415..c3039d61a113 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -1,3 +1,4 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import { assertAntigravityReferenceTarget, buildAntigravityReferenceStartup @@ -33,11 +34,11 @@ export type AiVaultResumeWorktreeArgs = { | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' - > + > & + ProjectCatalogReadState worktreeId?: string | null session: AiVaultResumeCommandSession commandOverride?: string | null diff --git a/src/renderer/src/lib/ai-vault-resume-platform.ts b/src/renderer/src/lib/ai-vault-resume-platform.ts index 9676172a7378..50c5c8baa259 100644 --- a/src/renderer/src/lib/ai-vault-resume-platform.ts +++ b/src/renderer/src/lib/ai-vault-resume-platform.ts @@ -1,3 +1,4 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' @@ -15,11 +16,11 @@ export function getAiVaultResumePlatform( | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' - >, + > & + ProjectCatalogReadState, worktreeId?: string | null ): NodeJS.Platform { const targetWorktreeId = worktreeId ?? state.activeWorktreeId diff --git a/src/renderer/src/lib/ai-vault-resume-shell.test.ts b/src/renderer/src/lib/ai-vault-resume-shell.test.ts index f1538df180b7..5112490c65c8 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.test.ts @@ -1,5 +1,6 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store/types' vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' @@ -9,29 +10,43 @@ import { buildAiVaultResumeCopyCommandForWorktree } from './ai-vault-resume-comm type ResumeShellState = Parameters[0]['state'] -function makeState(worktreeHostId?: string): ResumeShellState { +function makeState( + worktreeHostId?: Parameters[0]['hostId'] +): ResumeShellState { return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: '/home/alice/repo' }], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/home/alice/repo' }], + endpointProjectHostSetups: [], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + createdAt: 0, + updatedAt: 0, + sourceRepoIds: ['repo-1'] + } + ], settings: { + ...getDefaultSettings('/tmp'), + agentPermissionOverrides: { codex: 'own' }, agentDefaultArgs: { codex: '' }, agentDefaultEnv: { codex: {} } }, worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/home/alice/repo', ...(worktreeHostId ? { hostId: worktreeHostId } : {}) - } + }) ] } - } as unknown as AppState + } } describe('copied real-home Codex resume command', () => { diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts index df4c7c53f8b4..acc934429cb5 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.ts @@ -1,3 +1,7 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' @@ -19,11 +23,11 @@ type AiVaultResumeShellState = Pick< | 'activeRepoId' | 'activeWorktreeId' | 'folderWorkspaces' - | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' -> +> & + ProjectCatalogReadState export function resolveAiVaultResumeStartupShell(args: { state: AiVaultResumeShellState @@ -79,12 +83,8 @@ export function getAiVaultResumeWorkspacePath( export function getAiVaultResumeWorkspaceWslDistro( state: Pick & - Partial< - Pick< - AppState, - 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'settings' | 'projectGroups' - > - >, + Partial> & + ProjectCatalogReadState, workspaceId: string | null | undefined ): string | null | undefined { const workspacePath = getAiVaultResumeWorkspacePath(state, workspaceId) @@ -92,7 +92,7 @@ export function getAiVaultResumeWorkspaceWslDistro( const runtimeState = { activeRepoId: state.activeRepoId ?? null, activeWorktreeId: state.activeWorktreeId ?? null, - projects: state.projects ?? [], + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), settings: state.settings ?? null, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups ?? [], diff --git a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts index 14f21a2c8571..e288e4fbd60d 100644 --- a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts +++ b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts @@ -1,3 +1,5 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' import { buildAiVaultForkStartupForWorktree } from './ai-vault-session-fork-startup' @@ -12,29 +14,43 @@ type ForkState = Pick< | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' + | 'endpointProjects' + | 'endpointProjectHostSetups' | 'repos' | 'settings' | 'worktreesByRepo' > function makeState(): ForkState { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fork builder reads only these slices; the rest of AppState is never reached. return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + sourceRepoIds: ['repo-1'], + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjectHostSetups: [], settings: { + ...getDefaultSettings('/tmp'), + agentPermissionDefault: 'own', agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { - 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }] + 'repo-1': [ + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }) + ] } - } as unknown as ForkState + } } const SESSION_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' diff --git a/src/renderer/src/lib/codex-pane-selection-lane.test.ts b/src/renderer/src/lib/codex-pane-selection-lane.test.ts index 3d2f636730e3..f964f45ebfdc 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.test.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts @@ -15,7 +15,8 @@ type LaneState = Pick< | 'activeRepoId' | 'activeWorktreeId' | 'folderWorkspaces' - | 'projects' + | 'endpointProjects' + | 'endpointProjectHostSetups' | 'repos' | 'runtimeEnvironments' | 'settings' @@ -57,7 +58,21 @@ function laneState(args?: { terminalWindowsWslDistro: args?.terminalWindowsWslDistro ?? null }, repos: [{ id: 'repo1', path: 'C:\\code\\app' }], - projects: args?.projectWslDistro + endpointProjectHostSetups: [ + { + id: 'repo1', + projectId: 'proj1', + repoId: 'repo1', + hostId: 'local', + path: 'C:/code/app', + displayName: 'app', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: args?.projectWslDistro ? [ { id: 'proj1', diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts index 2d53a62d6ea4..2aaa20eeefae 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -1,3 +1,4 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import type { AppState } from '@/store' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' @@ -31,12 +32,12 @@ type CodexPaneLaneState = Pick< | 'activeRepoId' | 'activeWorktreeId' | 'folderWorkspaces' - | 'projects' | 'repos' | 'runtimeEnvironments' | 'settings' | 'worktreesByRepo' -> +> & + ProjectCatalogReadState /** Foreign machines have independent credentials; assign distinct lanes so local host/WSL account switches cannot mute their panes. */ const RUNTIME_ENVIRONMENT_LANE_PREFIX = 'env:' diff --git a/src/renderer/src/lib/execution-host-facts.test.ts b/src/renderer/src/lib/execution-host-facts.test.ts index 46824937b487..9af5fe599776 100644 --- a/src/renderer/src/lib/execution-host-facts.test.ts +++ b/src/renderer/src/lib/execution-host-facts.test.ts @@ -37,7 +37,8 @@ function state(): State { detectedWorktreesByRepo: {}, folderWorkspaces: [], projectGroups: [], - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], activeRepoId: null, activeWorktreeId: null, settings: getDefaultSettings('/tmp'), diff --git a/src/renderer/src/lib/execution-host-facts.ts b/src/renderer/src/lib/execution-host-facts.ts index 8236cd26b36d..6637de834be7 100644 --- a/src/renderer/src/lib/execution-host-facts.ts +++ b/src/renderer/src/lib/execution-host-facts.ts @@ -1,3 +1,4 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import { getRepoExecutionHostId, parseExecutionHostId, @@ -35,11 +36,11 @@ type WorktreeHostFactsState = HostFactsState & | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'projectGroups' - | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' - > + > & + ProjectCatalogReadState const SSH_PLATFORM_WITHHELD = 'The SSH host has not reported its operating system yet. Connect to it and try again.' diff --git a/src/renderer/src/lib/http-link-localhost-route.ts b/src/renderer/src/lib/http-link-localhost-route.ts index dd84c0bf4400..7e2583ea5317 100644 --- a/src/renderer/src/lib/http-link-localhost-route.ts +++ b/src/renderer/src/lib/http-link-localhost-route.ts @@ -1,3 +1,8 @@ +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' +import { + getProjectHostSetupLookup, + type WorkspaceProjectOwner +} from '../../../shared/project-host-setup-lookup' import type { HttpLinkSourceOwner, HttpLinkStoreState } from './http-link-routing' import { parseLoopbackUrlWithPort, @@ -12,10 +17,7 @@ export type LocalhostLinkRepo = { export type LocalhostLinkProject = LocalhostLinkRepo -export type LocalhostLinkWorktree = { - id: string - projectId?: string -} +export type LocalhostLinkWorktree = { id: string } & Partial export function localhostLabelRouteForHttpLink( url: string, @@ -73,10 +75,14 @@ function localhostLabelRouteForTerminalLink( return null } const worktree = findWorktreeById(state, port.owner.worktreeId) - const project = - worktree?.projectId && state.projects - ? (state.projects.find((entry) => entry.id === worktree.projectId) ?? null) - : null + const projection = getProjectHostSetupProjectionFromState(state) + const projectId = getProjectHostSetupLookup(projection.setups).projectId({ + ...worktree, + repoId: port.owner.repoId + }) + const project = projectId + ? (projection.projects.find((entry) => entry.id === projectId) ?? null) + : null const projectSource = project ?? repo return { targetUrl: parsed.toString(), diff --git a/src/renderer/src/lib/http-link-modifier-routing.test.ts b/src/renderer/src/lib/http-link-modifier-routing.test.ts index 3a3fe793ed00..268d8295e7bc 100644 --- a/src/renderer/src/lib/http-link-modifier-routing.test.ts +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -11,6 +11,9 @@ describe('modifier routing across link source owners', () => { const createBrowserTabMock = vi.fn() const openRuntimeBrowserTabMock = vi.fn((): Promise => Promise.resolve(null)) const storeState = { + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: {} as { openLinksInApp?: boolean openLinksInAppModifierInverts?: boolean diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 140b1785ca20..f163c9840bb1 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -8,6 +8,8 @@ import { resolveLocalhostHttpLinkDisplayUrl } from './http-link-routing' +import type { Repo } from '../../../shared/repo-types' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { ExecutionHostId } from '../../../shared/execution-host' const toastErrorMock = vi.hoisted(() => vi.fn()) @@ -29,6 +31,9 @@ function noActiveWorkspace(): { return { activeWorktreeId: null, activeWorkspaceExecutionHostId: null } } +const endpointProjects: Project[] = [] +const endpointProjectHostSetups: ProjectHostSetup[] = [] + type LinkTestSettings = { openLinksInApp?: boolean openLinksInAppModifierInverts?: boolean @@ -47,8 +52,9 @@ const storeState = { setActiveView: setActiveViewMock, ...noActiveWorkspace(), createBrowserTab: createBrowserTabMock, - repos: [] as { id: string; displayName: string; repoIcon?: null; badgeColor?: string }[], - projects: [] as { id: string; displayName: string; repoIcon?: null; badgeColor?: string }[], + repos: [] as Repo[], + endpointProjects, + endpointProjectHostSetups, worktreesByRepo: {} as Record< string, { id: string; projectId?: string; repoId?: string; displayName?: string }[] @@ -358,8 +364,8 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true } storeState.repos = [ - { id: 'repo-local', displayName: 'Local' }, - { id: 'repo-remote', displayName: 'Remote' } + { id: 'repo-local', displayName: 'Local', path: '/local', badgeColor: '', addedAt: 0 }, + { id: 'repo-remote', displayName: 'Remote', path: '/remote', badgeColor: '', addedAt: 0 } ] storeState.worktreesByRepo = { 'repo-local': [{ id: 'wt-local', projectId: 'repo-local' }], @@ -443,6 +449,8 @@ describe('openHttpLink', () => { storeState.repos = [ { id: 'repo-1', + path: '/repo', + addedAt: 0, displayName: 'snapstudio', repoIcon: null, badgeColor: '#f97316' @@ -505,6 +513,8 @@ describe('openHttpLink', () => { storeState.repos = [ { id: 'repo-1', + path: '/repo', + addedAt: 0, displayName: 'snapstudio', repoIcon: null, badgeColor: '#f97316' @@ -596,7 +606,9 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true, activeRuntimeEnvironmentId: null } - storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }] + storeState.repos = [ + { id: 'repo-1', path: '/repo', addedAt: 0, badgeColor: '', displayName: 'snapstudio' } + ] storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] } storeState.workspacePortScan = { result: { @@ -634,7 +646,9 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true, activeRuntimeEnvironmentId: 'env-other' } - storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }] + storeState.repos = [ + { id: 'repo-1', path: '/repo', addedAt: 0, badgeColor: '', displayName: 'snapstudio' } + ] storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] } storeState.workspacePortScansByKey = { 'local:all': { diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index e1f922ac7208..8463e2487f90 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -1,8 +1,7 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import { localhostLabelRouteForHttpLink, openLabeledLocalhostLink, - type LocalhostLinkRepo, - type LocalhostLinkProject, type LocalhostLinkWorktree } from './http-link-localhost-route' import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' @@ -49,30 +48,29 @@ export type HttpLinkSourceOwner = | { kind: 'ssh'; connectionId: string } | { kind: 'unknown' } -export type HttpLinkStoreState = HttpLinkBrowserTabState & { - unifiedTabsByWorktree?: LinkSourceState['unifiedTabsByWorktree'] - groupsByWorktree?: AppState['groupsByWorktree'] - layoutByWorktree?: AppState['layoutByWorktree'] - settings?: Partial< - Pick< - GlobalSettings, - | 'openLinksInAppInBackground' - | 'openLinksInApp' - | 'openLinksInAppModifierInverts' - | 'activeRuntimeEnvironmentId' - | 'localhostWorktreeLabelsEnabled' - > - > | null - setActiveWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) => void - activeWorktreeId?: string | null - activeWorkspaceExecutionHostId?: ExecutionHostId | null - repos?: readonly LocalhostLinkRepo[] - projects?: readonly LocalhostLinkProject[] - worktreesByRepo?: Record - allWorktrees?: () => LocalhostLinkWorktree[] - workspacePortScan?: { result: WorkspacePortScanResult } | null - workspacePortScansByKey?: Record -} +export type HttpLinkStoreState = ProjectCatalogReadState & + HttpLinkBrowserTabState & { + unifiedTabsByWorktree?: LinkSourceState['unifiedTabsByWorktree'] + groupsByWorktree?: AppState['groupsByWorktree'] + layoutByWorktree?: AppState['layoutByWorktree'] + settings?: Partial< + Pick< + GlobalSettings, + | 'openLinksInAppInBackground' + | 'openLinksInApp' + | 'openLinksInAppModifierInverts' + | 'activeRuntimeEnvironmentId' + | 'localhostWorktreeLabelsEnabled' + > + > | null + setActiveWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) => void + activeWorktreeId?: string | null + activeWorkspaceExecutionHostId?: ExecutionHostId | null + worktreesByRepo?: Record + allWorktrees?: () => LocalhostLinkWorktree[] + workspacePortScan?: { result: WorkspacePortScanResult } | null + workspacePortScansByKey?: Record + } export type HttpLinkGroupActionRequest = { workspaceId: string diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 9098f8b9a260..4896500c6be0 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -320,7 +320,7 @@ describe('launchAgentBackgroundSession', () => { }) it('uses WSL launch quoting for Windows-path projects forced to WSL', async () => { - state.projects = [ + state.endpointProjects = [ { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } diff --git a/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts b/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts index ae8c9eb019f1..bdcf7a287831 100644 --- a/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts +++ b/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts @@ -1,5 +1,7 @@ // Load each renderer client OS against a real one-workspace store; suites own their vi.mock declarations. import { vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import type * as StoreHelpersNamespace from '@/store/slices/store-test-helpers' import type * as CapabilitiesNamespace from '@/runtime/local-runtime-capabilities' import { @@ -65,7 +67,7 @@ export function launchWorkspaceState( settings: Record ): Record { const repoPath = workspace.kind === 'repo' ? workspace.path : workspace.repoPath - const repo = repoPath + const repo: Repo | null = repoPath ? { id: LAUNCH_REPO_ID, path: repoPath, @@ -96,24 +98,17 @@ export function launchWorkspaceState( sortOrder: 0, lastActivityAt: 0 } + const catalog = projectHostSetupProjectionFromRepos(repo ? [repo] : []) return { settings, repos: repo ? [repo] : [], - projects: repo - ? [ - { - id: LAUNCH_REPO_ID, - displayName: 'repo', - badgeColor: '#000000', - sourceRepoIds: [LAUNCH_REPO_ID], - createdAt: 0, - updatedAt: 0, - ...(workspace.projectRuntime - ? { localWindowsRuntimePreference: workspace.projectRuntime } - : {}) - } - ] - : [], + endpointProjects: catalog.projects.map((project) => ({ + ...project, + ...(workspace.projectRuntime + ? { localWindowsRuntimePreference: workspace.projectRuntime } + : {}) + })), + endpointProjectHostSetups: catalog.setups, worktreesByRepo: repo ? { [LAUNCH_REPO_ID]: [worktree] } : {}, folderWorkspaces: workspace.kind === 'folder' diff --git a/src/renderer/src/lib/launch-work-item-direct-catalog.test-fixture.ts b/src/renderer/src/lib/launch-work-item-direct-catalog.test-fixture.ts new file mode 100644 index 000000000000..8e76fe5fe7d1 --- /dev/null +++ b/src/renderer/src/lib/launch-work-item-direct-catalog.test-fixture.ts @@ -0,0 +1,41 @@ +import type { AppState } from '@/store' + +export function directWorkItemCatalogFixture() { + return { + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 1 + } + ] satisfies AppState['repos'], + activeRepoId: 'repo-1', + activeWorktreeId: null, + endpointProjectHostSetups: [ + { + id: 'repo-1', + projectId: 'repo-1', + repoId: 'repo-1', + path: '/repo', + displayName: 'Repo', + hostId: 'local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] satisfies AppState['endpointProjectHostSetups'], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'Repo', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } + ] satisfies AppState['endpointProjects'] + } +} diff --git a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts index 10deaf7a9a94..a560c8cb27b5 100644 --- a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { SshConnectionState } from '../../../shared/ssh-types' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' @@ -83,7 +85,7 @@ function sshState(remotePlatform: 'linux' | 'win32'): SshConnectionState { type HostCase = { name: string client: NodeJS.Platform - repo: { path: string; connectionId?: string; executionHostId?: string } + repo: Pick ssh?: Map runtime?: Map expected: NodeJS.Platform @@ -91,11 +93,15 @@ type HostCase = { function setStore(hostCase: HostCase): void { mocks.clientPlatform = hostCase.client + const repos: Repo[] = [ + { id: 'repo-1', displayName: 'Repo', badgeColor: '#000000', addedAt: 1, ...hostCase.repo } + ] mocks.store = { - repos: [{ id: 'repo-1', displayName: 'Repo', addedAt: 1, ...hostCase.repo }], + repos, activeRepoId: null, activeWorktreeId: null, - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], projectGroups: [], folderWorkspaces: [], worktreesByRepo: {}, @@ -227,7 +233,21 @@ describe('launchWorkItemDirect quotes for the execution host (#27971)', () => { it("quotes a local Windows project's WSL runtime for that distro", async () => { setStore({ name: 'wsl', client: 'win32', repo: { path: 'C:\\repo' }, expected: 'linux' }) - mocks.store.projects = [ + mocks.store.endpointProjectHostSetups = [ + { + id: 'repo-1', + projectId: 'repo-1', + repoId: 'repo-1', + path: 'C:\\repo', + displayName: 'Repo', + hostId: 'local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] satisfies ProjectHostSetup[] + mocks.store.endpointProjects = [ { id: 'repo-1', displayName: 'Repo', @@ -237,7 +257,7 @@ describe('launchWorkItemDirect quotes for the execution host (#27971)', () => { updatedAt: 1, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } - ] + ] satisfies Project[] mocks.createWorktree.mockResolvedValue({ worktree: { id: 'repo-1::C:\\repo-wt', path: 'C:\\repo-wt' } }) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 30cef574b9dd..d09bcb031ae2 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -4,6 +4,7 @@ import * as inventory from './agent-detection-target-inventory' import * as localCapabilities from '@/runtime/local-runtime-capabilities' import { RUNTIME_CAPABILITIES } from '../../../shared/protocol-version' import type { AppState } from '@/store' +import { directWorkItemCatalogFixture } from './launch-work-item-direct-catalog.test-fixture' import type * as TuiAgentSelectionModule from '../../../shared/tui-agent-selection' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' import type * as DirectAgentRoutingModule from '@/lib/launch-work-item-direct-agent-routing' @@ -171,26 +172,7 @@ describe('launchWorkItemDirect', () => { mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'tab-1' }) mocks.pasteDraftWhenAgentReady.mockResolvedValue(true) mocks.store = { - repos: [ - { - id: 'repo-1', - path: '/repo', - displayName: 'Repo', - addedAt: 1 - } - ], - activeRepoId: 'repo-1', - activeWorktreeId: null, - projects: [ - { - id: 'repo-1', - displayName: 'Repo', - badgeColor: '#000000', - sourceRepoIds: ['repo-1'], - createdAt: 1, - updatedAt: 1 - } - ], + ...directWorkItemCatalogFixture(), worktreesByRepo: {}, settings: { defaultTuiAgent: 'codex', @@ -205,7 +187,7 @@ describe('launchWorkItemDirect', () => { seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed - } as typeof mocks.store + } // Why: SSH fixtures use target `ssh-1`, which reports a Linux host once connected. mocks.store.sshConnectionStates = new Map([ [ diff --git a/src/renderer/src/lib/local-preflight-context-cache.test.ts b/src/renderer/src/lib/local-preflight-context-cache.test.ts index 4a9cec278121..b2007a702503 100644 --- a/src/renderer/src/lib/local-preflight-context-cache.test.ts +++ b/src/renderer/src/lib/local-preflight-context-cache.test.ts @@ -20,23 +20,30 @@ function makeRepos(id: string, path: string): AppState['repos'] { return [{ id, path, displayName: id, badgeColor: 'blue', addedAt: 1 }] } -function makeWslState(distro: string): AppState { +function makeWslState(distro: string): Parameters[0] { return { + endpointProjects: [], + endpointProjectHostSetups: [], activeRepoId: 'repo-1', activeWorktreeId: null, repos: makeRepos('repo-1', `\\\\wsl.localhost\\${distro}\\home\\alice\\repo`), + settings: null, worktreesByRepo: {} - } as AppState + } } -function makeWindowsProjectState(projectId: string): AppState { +function makeWindowsProjectState( + projectId: string +): Parameters[0] { return { + endpointProjects: [], + endpointProjectHostSetups: [], activeRepoId: projectId, activeWorktreeId: null, repos: makeRepos(projectId, `C:\\Users\\alice\\${projectId}`), - settings: {}, + settings: null, worktreesByRepo: {} - } as AppState + } } describe('local preflight context caches', () => { diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 8a4df24577a0..5deaa67935fa 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -1,8 +1,11 @@ +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { describe, expect, it } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import type { AppState } from '@/store/types' +import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures' +import type { Project } from '../../../shared/project-types' import { getLocalAgentPreflightContext, getLocalPreflightContext, @@ -12,12 +15,31 @@ import { localPreflightContextKey } from './local-preflight-context' +function legacySettings() { + const settings = getDefaultSettings('/tmp') + Reflect.deleteProperty(settings, 'localWindowsRuntimeDefault') + return settings +} + +type PreflightState = Parameters[0] & + Pick +function project(overrides: Pick & Partial): Project { + return { + displayName: '', + badgeColor: '', + createdAt: 0, + updatedAt: 0, + sourceRepoIds: [], + ...overrides + } +} + function makeState(args: { repoPath?: string - worktreePath?: string | null + worktreePath?: string repo?: Partial worktree?: Partial -}): AppState { +}): PreflightState { const repoId = 'repo-1' const worktreeId = `${repoId}::worktree-1` const repos: AppState['repos'] = @@ -33,7 +55,11 @@ function makeState(args: { ...args.repo } ] + const catalog = projectHostSetupProjectionFromRepos(repos) return { + settings: legacySettings(), + endpointProjects: catalog.projects.map((project) => ({ ...project, id: repoId })), + endpointProjectHostSetups: catalog.setups.map((setup) => ({ ...setup, projectId: repoId })), activeRepoId: repoId, activeWorktreeId: args.worktreePath === undefined ? null : worktreeId, repos, @@ -42,15 +68,15 @@ function makeState(args: { ? {} : { [repoId]: [ - { + makeWorktree({ id: worktreeId, repoId, path: args.worktreePath, ...args.worktree - } + }) ] } - } as AppState + } } describe('local preflight context', () => { @@ -110,14 +136,15 @@ describe('local preflight context', () => { }) it('keys preflight by active runtime before local WSL or host context', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` }), settings: { + ...legacySettings(), activeRuntimeEnvironmentId: 'runtime-1' } - } as AppState + } const context = getLocalPreflightContext(state) expect(context?.runtimeContextKey).toMatch(/^runtime:runtime-1#\d+$/) @@ -125,13 +152,13 @@ describe('local preflight context', () => { }) it('keys preflight by the focused worktree host over the default host', () => { - const localFocused: AppState = { + const localFocused: PreflightState = { ...makeState({ repoPath: '/Users/alice/repo', worktreePath: '/Users/alice/repo', repo: { executionHostId: 'local' } }), - settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' } + settings: { ...legacySettings(), activeRuntimeEnvironmentId: 'runtime-1' } } expect(localPreflightContextKey(getLocalPreflightContext(localFocused))).toBe('host') @@ -190,12 +217,13 @@ describe('local preflight context', () => { }) it('returns repair context for Windows local preflight when the selected WSL distro is missing', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } const context = getLocalPreflightContext(state, 'win32', { wslAvailable: true, @@ -218,13 +246,14 @@ describe('local preflight context', () => { }) it('uses the migrated WSL runtime for local agent checks when WSL is the default shell', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian' } - } as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -246,14 +275,15 @@ describe('local preflight context', () => { }) it('migrates explicit legacy agent location to the project runtime default', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian', localAgentRuntime: 'host' } - } as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -273,14 +303,15 @@ describe('local preflight context', () => { }) it('lets explicit agent location choose a WSL distro independent of the terminal shell', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'powershell.exe', localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } - } as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -302,37 +333,40 @@ describe('local preflight context', () => { }) it('ignores stale terminal WSL settings when no Windows project runtime is available', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian' } - } as AppState + } expect(getLocalAgentPreflightContext(state, 'linux')).toBeUndefined() }) it('ignores stale explicit agent runtime settings on non-Windows hosts', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } - } as AppState + } expect(getLocalAgentPreflightContext(state, 'darwin')).toBeUndefined() }) it('returns repair context for explicit WSL agent location without a selected distro', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'powershell.exe', localAgentRuntime: 'wsl' } - } as unknown as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -352,14 +386,15 @@ describe('local preflight context', () => { }) it('uses the global WSL runtime for local agent checks without an active project', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: undefined }), activeRepoId: null, activeWorktreeId: null, settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -381,15 +416,16 @@ describe('local preflight context', () => { }) it('uses the global runtime default over stale legacy agent location without an active project', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: undefined }), activeRepoId: null, activeWorktreeId: null, settings: { + ...legacySettings(), localAgentRuntime: 'host', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -398,7 +434,7 @@ describe('local preflight context', () => { // Why (#18837): the Windows host is neither this machine's default nor the SSH host's context. it('uses the global runtime default for this machine while an SSH project is active', () => { - const state: AppState = { + const state: PreflightState = { ...makeState({ repoPath: '/home/alice/repo', worktreePath: '/home/alice/repo', @@ -418,15 +454,18 @@ describe('local preflight context', () => { }) it('uses the project override over legacy agent location for local agent checks', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' }, - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] - } as unknown as AppState + endpointProjects: [ + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }) + ] + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -445,36 +484,41 @@ describe('local preflight context', () => { }) it('uses the target worktree runtime for local agent checks', () => { - const state = { + const repos: AppState['repos'] = [ + { ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\active' }, + { ...TEST_REPO, id: 'repo-2', path: 'C:\\Users\\alice\\target' } + ] + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\active', worktreePath: 'C:\\Users\\alice\\active' }), - repos: [ - { id: 'repo-1', path: 'C:\\Users\\alice\\active' }, - { id: 'repo-2', path: 'C:\\Users\\alice\\target' } - ], + repos, worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: 'C:\\Users\\alice\\active' - } + }) ], 'repo-2': [ - { + makeWorktree({ id: 'repo-2::worktree-1', repoId: 'repo-2', path: 'C:\\Users\\alice\\target' - } + }) ] }, - projects: [ - { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }, - { id: 'repo-2', localWindowsRuntimePreference: { kind: 'windows-host' } } + endpointProjects: [ + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }), + project({ id: 'repo-2', localWindowsRuntimePreference: { kind: 'windows-host' } }) ] - } as unknown as AppState + } + + state.endpointProjectHostSetups = projectHostSetupProjectionFromRepos(state.repos).setups.map( + (setup) => ({ ...setup, projectId: setup.repoId }) + ) const context = getLocalAgentPreflightContext(state, 'win32', {}, 'repo-2::worktree-1') @@ -482,15 +526,18 @@ describe('local preflight context', () => { }) it('resolves a project host override for a specific worktree over a WSL default', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] - } as unknown as AppState + endpointProjects: [ + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }) + ] + } expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32')).toEqual({ status: 'resolved', @@ -505,10 +552,12 @@ describe('local preflight context', () => { }) it('does not assign the active project runtime to the floating workspace', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }] - } as unknown as AppState + endpointProjects: [ + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) + ] + } expect( getLocalProjectExecutionRuntimeContext(state, FLOATING_TERMINAL_WORKTREE_ID, 'win32') @@ -516,16 +565,17 @@ describe('local preflight context', () => { }) it('keeps Floating on the host despite global and explicit WSL agent settings', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' }, localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } - } as AppState + } expect( getLocalAgentPreflightContext(state, 'win32', {}, FLOATING_TERMINAL_WORKTREE_ID) @@ -533,18 +583,19 @@ describe('local preflight context', () => { }) it('keeps the active project runtime fallback for a detected-only worktree', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), detectedWorktreesByRepo: { 'repo-1': { - repoId: 'repo-1', - authoritative: true, - source: 'git', - worktrees: [{ id: 'repo-1::detected', repoId: 'repo-1', path: 'C:\\detected' }] + worktrees: [ + makeWorktree({ id: 'repo-1::detected', repoId: 'repo-1', path: 'C:\\detected' }) + ] } }, - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }] - } as unknown as AppState + endpointProjects: [ + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) + ] + } expect( getLocalProjectExecutionRuntimeContext(state, 'repo-1::detected', 'win32') @@ -573,16 +624,16 @@ describe('local preflight context', () => { }) it('resolves a local repo project WSL override before a worktree exists', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: String.raw`C:\Users\alice\repo` }), - projects: [ - { + endpointProjects: [ + project({ id: 'project-1', sourceRepoIds: ['repo-1'], localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } + }) ] - } as unknown as AppState + } expect(getLocalRepoProjectExecutionRuntimeContext(state, 'repo-1', 'win32')).toEqual({ status: 'resolved', @@ -627,42 +678,45 @@ describe('local preflight context', () => { }) it('uses the requested worktree repo owner before resolving a local project runtime', () => { - const state = { + const repos: AppState['repos'] = [ + { + ...TEST_REPO, + id: 'repo-1', + path: 'C:\\Users\\alice\\repo', + executionHostId: 'local' + }, + { + ...TEST_REPO, + id: 'repo-ssh', + path: '/remote/repo', + connectionId: 'builder', + executionHostId: 'ssh:builder' + } + ] + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), - repos: [ - { - id: 'repo-1', - path: 'C:\\Users\\alice\\repo', - executionHostId: 'local' - }, - { - id: 'repo-ssh', - path: '/remote/repo', - connectionId: 'builder', - executionHostId: 'ssh:builder' - } - ], + repos, worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: 'C:\\Users\\alice\\repo' - } + }) ], 'repo-ssh': [ - { + makeWorktree({ id: 'repo-ssh::worktree-1', repoId: 'repo-ssh', path: '/remote/repo', hostId: 'ssh:builder' - } + }) ] } - } as unknown as AppState + } expect(getLocalProjectExecutionRuntimeContext(state, 'repo-ssh::worktree-1', 'win32')).toBe( undefined @@ -670,13 +724,16 @@ describe('local preflight context', () => { }) it('returns repair when the resolved project WSL distro is unavailable', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'windows-host' } }, - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }] - } as unknown as AppState + endpointProjects: [ + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) + ] + } expect( getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32', { @@ -696,12 +753,13 @@ describe('local preflight context', () => { }) it('defers WSL repair while capability loading leaves distro availability unknown', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } expect( getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32', { @@ -722,7 +780,7 @@ describe('local preflight context', () => { }) it('resolves the requested worktree instead of the active worktree', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' @@ -730,22 +788,23 @@ describe('local preflight context', () => { activeWorktreeId: 'repo-1::worktree-1', worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' - }, - { + }), + makeWorktree({ id: 'repo-1::host-worktree', repoId: 'repo-1', path: 'C:\\Users\\alice\\repo' - } + }) ] }, settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'windows-host' } } - } as unknown as AppState + } expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::host-worktree', 'win32')).toEqual( { diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 3611e91b2df4..39982ee598f4 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -1,3 +1,5 @@ +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import type { AppState } from '@/store/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseWslUncPath } from '../../../shared/wsl-paths' @@ -14,7 +16,11 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { projectMatchesRepo } from '../../../shared/project-repo-match' import { getIndexedRepoMap } from '@/store/worktree-repo-index' import { defaultScopeHost } from './default-creation-host' -import { getLocalProjectRuntimeWorkspace } from './local-project-runtime-workspace' +import { + getLocalPreflightProjectId, + getLocalProjectRuntimeWorkspace, + type LocalProjectRuntimeState +} from './local-project-runtime-workspace' import { getActiveWorkspaceOwnerTarget } from './resolved-worktree-execution-host' import { getEnvironmentRuntimeContextKey } from './provider-runtime-context' import { getRendererAppPlatform } from './renderer-app-platform' @@ -38,13 +44,8 @@ export { resetLocalPreflightContextCachesForTests } from './local-preflight-context-cache' -type LocalProjectRuntimeState = Pick< - AppState, - 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' -> & - Partial> - -// Reuse fallback identities so WeakMap slice indexes hit instead of rebuilding on every read. +// Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}` +// or `[]` fallback would miss the cache on every read. const EMPTY_REPOS: AppState['repos'] = [] type LocalProjectRuntimeWslContext = { @@ -153,7 +154,7 @@ export function getLocalRepoProjectExecutionRuntimeContext( } export function getLocalPreflightContext( - state: AppState, + state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, appPlatform: NodeJS.Platform = getRendererAppPlatform(), wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext() ): LocalPreflightContext { @@ -176,7 +177,7 @@ export function getLocalPreflightContext( } export function getLocalAgentPreflightContext( - state: AppState, + state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, appPlatform: NodeJS.Platform = getRendererAppPlatform(), wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext(), worktreeId?: string | null @@ -254,7 +255,9 @@ export function getLocalAgentPreflightContext( return undefined } -function isActiveWorkspaceOnAnotherHost(state: AppState): boolean { +function isActiveWorkspaceOnAnotherHost( + state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState +): boolean { const activeWorktree = getLocalProjectRuntimeWorkspace(state, undefined) const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree) return Boolean(repo) && (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) @@ -272,7 +275,10 @@ function getCachedLocalProjectRuntimeWslContext(): LocalProjectRuntimeWslContext } } -function getLocalPreflightWslDistro(state: AppState, worktreeId?: string | null): string | null { +function getLocalPreflightWslDistro( + state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, + worktreeId?: string | null +): string | null { const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree) if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) { @@ -311,17 +317,9 @@ function getLocalRuntimeProject( projectId: string, repoId: string ) { - return state.projects?.find( - (entry) => entry.id === projectId || projectMatchesRepo(entry, repoId) - ) -} - -function getLocalPreflightProjectId( - state: LocalProjectRuntimeState, - worktreeId?: string | null -): string { - const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) + const { projects } = getProjectHostSetupProjectionFromState(state) return ( - activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project' + projects.find((entry) => entry.id === projectId) ?? + projects.find((entry) => projectMatchesRepo(entry, repoId)) ) } diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts index 5d006191211a..8e4f89b71193 100644 --- a/src/renderer/src/lib/local-project-runtime-workspace.ts +++ b/src/renderer/src/lib/local-project-runtime-workspace.ts @@ -1,3 +1,8 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' +import { getProjectHostSetupLookup } from '../../../shared/project-host-setup-lookup' import type { AppState } from '@/store/types' import { getIndexedWorktreeById } from '@/store/worktree-repo-index' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' @@ -12,12 +17,28 @@ import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type RuntimeWorkspaceState = Pick & Partial> +export type LocalProjectRuntimeState = Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'repos' | 'settings' | 'worktreesByRepo' +> & + Partial> & + ProjectCatalogReadState + const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} export function getLocalProjectRuntimeWorkspace( state: RuntimeWorkspaceState, worktreeId?: string | null -): Pick | null { +): Pick< + Worktree, + | 'id' + | 'repoId' + | 'projectId' + | 'path' + | 'hostId' + | 'projectHostSetupId' + | 'runtimeOwnerEnvironmentId' +> | null { const targetWorktreeId = worktreeId ?? state.activeWorktreeId if (!targetWorktreeId) { return null @@ -58,3 +79,20 @@ export function getLocalProjectRuntimeWorkspace( } : null } + +export function getLocalPreflightProjectId( + state: RuntimeWorkspaceState & Pick & ProjectCatalogReadState, + worktreeId?: string | null +): string { + const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) + return ( + (activeWorktree + ? getProjectHostSetupLookup(getProjectHostSetupProjectionFromState(state).setups).projectId( + activeWorktree + ) + : undefined) ?? + activeWorktree?.repoId ?? + state.activeRepoId ?? + 'local-project' + ) +} diff --git a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts index d893d76afe9f..dd353ac188a3 100644 --- a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts +++ b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts @@ -1,17 +1,23 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import type { LocalhostWorktreeLabelRoute } from '../../../shared/localhost-worktree-labels' import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { Project } from '../../../shared/project-types' -import type { Repo } from '../../../shared/repo-types' +import { + getProjectHostSetupLookup, + type WorkspaceProjectOwner +} from '../../../shared/project-host-setup-lookup' import type { WorkspacePort } from '../../../shared/workspace-ports' import { useAppStore } from '@/store' import { localhostWorktreeLabelRouteForPort } from './workspace-port-localhost-label' -// Share port → repo → workspace → project routing across reactive and imperative surfaces. -type LocalhostLabelLookupState = { +// Why: the port → repo → worktree → project resolution feeding +// localhostWorktreeLabelRouteForPort was duplicated across every ports surface; +// this is the single source for both reactive and imperative call sites. +type LocalhostLabelLookupState = ProjectCatalogReadState & { settings?: Pick | null - repos?: readonly Repo[] - projects?: readonly Project[] - getKnownWorktreeById?: (worktreeId: string) => { projectId?: string | null } | null | undefined + getKnownWorktreeById?: (worktreeId: string) => Partial | null | undefined } export function resolveLocalhostLabelRouteForPort( @@ -23,8 +29,16 @@ export function resolveLocalhostLabelRouteForPort( } const repo = (state.repos ?? []).find((entry) => entry.id === port.owner.repoId) ?? null const worktree = state.getKnownWorktreeById?.(port.owner.worktreeId) ?? null - const project = worktree?.projectId - ? ((state.projects ?? []).find((entry) => entry.id === worktree.projectId) ?? null) + const projectId = getProjectHostSetupLookup( + getProjectHostSetupProjectionFromState(state).setups + ).projectId({ + ...worktree, + repoId: port.owner.repoId + }) + const project = projectId + ? (getProjectHostSetupProjectionFromState(state).projects.find( + (entry) => entry.id === projectId + ) ?? null) : null return localhostWorktreeLabelRouteForPort({ port, repo, project, settings: state.settings }) } @@ -41,9 +55,19 @@ export function useLocalhostLabelRouteForPort( const worktree = useAppStore((s) => portWorktreeId ? (s.getKnownWorktreeById?.(portWorktreeId) ?? null) : null ) + const projectId = useAppStore((s) => + portRepoId + ? getProjectHostSetupLookup(getProjectHostSetupProjectionFromState(s).setups).projectId({ + ...worktree, + repoId: portRepoId + }) + : undefined + ) const project = useAppStore((s) => - worktree?.projectId - ? ((s.projects ?? []).find((entry) => entry.id === worktree.projectId) ?? null) + projectId + ? (getProjectHostSetupProjectionFromState(s).projects.find( + (entry) => entry.id === projectId + ) ?? null) : null ) return localhostWorktreeLabelRouteForPort({ port, repo, project, settings }) diff --git a/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts b/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts index 4c8b812c7892..32ca36eafa7b 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts @@ -40,8 +40,8 @@ const BASE_STATE = { agentStatusByPaneKey: {}, browserTabsByWorktree: {}, repos: [repo], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], projectOrderBy: 'recent' } diff --git a/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts index e375e04d9ead..b902d1278afa 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts @@ -17,7 +17,10 @@ let cachedCatalogSource: CatalogSource | null = null /** What the sidebar groups Project headers by: state.repos in its order and the selector's projection. */ export function getProjectSectionCatalogFromState( - state: Pick + state: Pick< + AppState, + 'repos' | 'endpointProjects' | 'endpointProjectHostSetups' | 'projectOrderBy' + > ): MobileProjectSectionCatalog | null { // Why: graph publication must not fail on a store slice that holds no repo catalog. if (!Array.isArray(state.repos)) { diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index cf1c3890c979..884ab48c8330 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -296,7 +296,7 @@ export function createProjectGroupMutationActions( return { repos: nextRepos, ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + previous: s, nextRepos, hostId: movedHostId }), diff --git a/src/renderer/src/store/project-host-setup-selector.test.ts b/src/renderer/src/store/project-host-setup-selector.test.ts index c6de8f658202..596505cd7aeb 100644 --- a/src/renderer/src/store/project-host-setup-selector.test.ts +++ b/src/renderer/src/store/project-host-setup-selector.test.ts @@ -22,9 +22,11 @@ function countCollectionReads(items: readonly T[]): { counters[property] += 1 return array[property].bind(array) } - if (property === Symbol.iterator) { + if (property === Symbol.iterator || property === 'entries') { counters.iterator += 1 - return array[Symbol.iterator].bind(array) + return property === 'entries' + ? array.entries.bind(array) + : array[Symbol.iterator].bind(array) } if (property === 'length') { counters.length += 1 @@ -83,14 +85,14 @@ describe('project host setup selector', () => { const countedSetups = countCollectionReads(setups) const state = { repos: countedRepos.value, - projects: countedProjects.value, - projectHostSetups: countedSetups.value + endpointProjects: countedProjects.value, + endpointProjectHostSetups: countedSetups.value } const first = getProjectHostSetupProjectionFromState(state) expect(countedRepos.counters.map).toBeGreaterThan(0) expect(countedRepos.counters.iterator).toBeGreaterThan(0) - expect(countedProjects.counters.flatMap).toBeGreaterThan(0) + expect(countedProjects.counters.map).toBeGreaterThan(0) expect(countedSetups.counters.map).toBeGreaterThan(0) countedRepos.counters.map = 0 countedRepos.counters.flatMap = 0 @@ -121,15 +123,15 @@ describe('project host setup selector', () => { const setups = [makeSetup(repo.id, 'repo:repo-1')] const first = getProjectHostSetupProjectionFromState({ repos, - projects, - projectHostSetups: setups + endpointProjects: projects, + endpointProjectHostSetups: setups }) const replacementSetups = [...setups] const next = getProjectHostSetupProjectionFromState({ repos, - projects, - projectHostSetups: replacementSetups + endpointProjects: projects, + endpointProjectHostSetups: replacementSetups }) expect(next).not.toBe(first) @@ -142,13 +144,17 @@ describe('project host setup selector', () => { const repos = [repo] const projects = [makeProject('repo:repo-1', repo.id)] const setups = [makeSetup(repo.id, 'repo:repo-1')] - getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups: setups }) + getProjectHostSetupProjectionFromState({ + repos, + endpointProjects: projects, + endpointProjectHostSetups: setups + }) const replacementRepos = [makeRepo('repo-2')] const next = getProjectHostSetupProjectionFromState({ repos: replacementRepos, - projects, - projectHostSetups: setups + endpointProjects: projects, + endpointProjectHostSetups: setups }) expect(next.projects.map((project) => project.id)).toContain('repo:repo-2') diff --git a/src/renderer/src/store/project-host-setup-selector.ts b/src/renderer/src/store/project-host-setup-selector.ts index ea9be95434de..950aebf7cc07 100644 --- a/src/renderer/src/store/project-host-setup-selector.ts +++ b/src/renderer/src/store/project-host-setup-selector.ts @@ -3,7 +3,8 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../shared/project-host-setup-projection' -import { resolveProjectHostSetupProjection } from '../../../shared/project-host-setup-hydration' +import { resolveProjectHostSetupProjection } from '../../../shared/project-catalog-canonical-projection' +import { resolveProjectHostSetupProjection as resolveCompatibility } from '../../../shared/project-host-setup-hydration' import type { AppState } from './types' const derivedProjectionByRepos = new WeakMap() @@ -23,14 +24,21 @@ function getDerivedProjection(repos: AppState['repos']): ProjectHostSetupProject return projection } -// Object.is selector results must be stable per repos/projects/setups identities or every render retriggers. +export type ProjectCatalogReadState = Pick & + ( + | { projectHostSetupProjection: ProjectHostSetupProjection } + | Pick + ) + +// Why: this is a zustand selector compared with Object.is, so the result must be +// reference-stable per (repos, projects, setups) input or every render re-renders. export function getProjectHostSetupProjectionFromState( - state: Pick & Partial> + state: ProjectCatalogReadState ): ProjectHostSetupProjection { - const { repos, projects, projectHostSetups: setups } = state - if (!projects || !setups) { - return getDerivedProjection(repos) + if ('projectHostSetupProjection' in state) { + return state.projectHostSetupProjection } + const { repos, endpointProjects: projects, endpointProjectHostSetups: setups } = state let byProjects = resolvedProjectionByCatalog.get(repos) if (!byProjects) { byProjects = new WeakMap() @@ -54,3 +62,35 @@ export function getProjectHostSetupProjectionFromState( bySetups.set(setups, projection) return projection } + +export const selectCanonicalProjects = ( + state: Parameters[0] +) => getProjectHostSetupProjectionFromState(state).projects + +export const selectCanonicalProjectHostSetups = ( + state: Parameters[0] +) => getProjectHostSetupProjectionFromState(state).setups + +const compatibilityByCanonical = new WeakMap< + ProjectHostSetupProjection, + ProjectHostSetupProjection +>() +export function getProjectHostSetupCompatibilityFromState( + state: Pick +): ProjectHostSetupProjection { + const canonical = getProjectHostSetupProjectionFromState({ + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + }) + let compatibility = compatibilityByCanonical.get(canonical) + if (!compatibility) { + compatibility = resolveCompatibility( + state.repos, + state.endpointProjects, + state.endpointProjectHostSetups + ) + compatibilityByCanonical.set(canonical, compatibility) + } + return compatibility +} diff --git a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts index 3b67ccfcdc5e..73b5e8263187 100644 --- a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts +++ b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts @@ -98,8 +98,8 @@ describe('project catalog ingest with non-string row fields', () => { const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) const projection = getProjectHostSetupProjectionFromState({ repos, - projects: [...ingested.projects], - projectHostSetups: [...ingested.setups] + endpointProjects: [...ingested.projects], + endpointProjectHostSetups: [...ingested.setups] }) expect(() => buildProjectByRepoIdLikeSettings(projection.setups, projection.projects) @@ -140,8 +140,8 @@ describe('project catalog ingest with non-string row fields', () => { const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) const args = { repos, - projects: [...ingested.projects], - projectHostSetups: [...ingested.setups] + endpointProjects: [...ingested.projects], + endpointProjectHostSetups: [...ingested.setups] } expect(getProjectHostSetupProjectionFromState(args)).toBe( getProjectHostSetupProjectionFromState(args) diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts index 83c84a616b97..fe0572cd027c 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '../project-host-setup-selector' import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' @@ -45,7 +46,8 @@ export function createProjectHostSetupActions( await assertProjectHostSetupMutationRuntimeCapabilities(target) const projectProviderIdentity = args.projectProviderIdentity ?? - get().projects.find((project) => project.id === args.projectId)?.providerIdentity + selectCanonicalProjects(get()).find((project) => project.id === args.projectId) + ?.providerIdentity // Why: the target host may not have a project record yet; carry the selected source-host identity across the boundary. const setupArgs = projectProviderIdentity ? { ...args, projectProviderIdentity } : args const result = @@ -77,14 +79,14 @@ export function createProjectHostSetupActions( repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry ) : [...s.repos, repo] - const nextProjects = s.projects.some((entry) => entry.id === project.id) - ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) - : [...s.projects, project] - const nextSetups = upsertProjectHostSetup(s.projectHostSetups, setup) + const nextProjects = s.endpointProjects.some((entry) => entry.id === project.id) + ? s.endpointProjects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.endpointProjects, project] + const nextSetups = upsertProjectHostSetup(s.endpointProjectHostSetups, setup) return { repos: nextRepos, - projects: nextProjects, - projectHostSetups: nextSetups + endpointProjects: nextProjects, + endpointProjectHostSetups: nextSetups } }) toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { @@ -123,10 +125,10 @@ export function createProjectHostSetupActions( }) const project = normalizeProjectRow(result.project) set((s) => ({ - projects: s.projects.some((entry) => entry.id === project.id) - ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) - : [...s.projects, project], - projectHostSetups: upsertProjectHostSetup(s.projectHostSetups, setup) + endpointProjects: s.endpointProjects.some((entry) => entry.id === project.id) + ? s.endpointProjects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.endpointProjects, project], + endpointProjectHostSetups: upsertProjectHostSetup(s.endpointProjectHostSetups, setup) })) return { project, setup } } catch (err) { @@ -143,7 +145,7 @@ export function createProjectHostSetupActions( updateProjectHostSetup: async (args) => { try { const { target, hostArgs } = await prepareProjectHostSetupMutation( - get().projectHostSetups, + get().endpointProjectHostSetups, args ) const result = @@ -174,10 +176,10 @@ export function createProjectHostSetupActions( ) : [...s.repos, repo] : s.repos, - projects: s.projects.some((entry) => entry.id === project.id) - ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) - : [...s.projects, project], - projectHostSetups: upsertProjectHostSetup(s.projectHostSetups, setup) + endpointProjects: s.endpointProjects.some((entry) => entry.id === project.id) + ? s.endpointProjects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.endpointProjects, project], + endpointProjectHostSetups: upsertProjectHostSetup(s.endpointProjectHostSetups, setup) })) return { ...result, project, repo, setup } } catch (err) { @@ -194,7 +196,7 @@ export function createProjectHostSetupActions( deleteProjectHostSetup: async (args) => { try { const { target, hostArgs } = await prepareProjectHostSetupMutation( - get().projectHostSetups, + get().endpointProjectHostSetups, args ) const result = @@ -217,7 +219,7 @@ export function createProjectHostSetupActions( row: result.setup }) set((s) => { - const projectHostSetups = s.projectHostSetups.filter( + const projectHostSetups = s.endpointProjectHostSetups.filter( (setup) => !isSameProjectHostSetup(setup, removedSetup) ) const repos = @@ -226,14 +228,14 @@ export function createProjectHostSetupActions( : s.repos const projects = repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) - ? s.projects.filter((project) => project.id !== result.project.id) - : s.projects + ? s.endpointProjects.filter((project) => project.id !== result.project.id) + : s.endpointProjects const survivingRepoIds = new Set(repos.map((r) => r.id)) const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) return { repos, - projects, - projectHostSetups, + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups, ...omitSparsePresetsForRepos(s, removedRepoIds) } }) diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index 8855c8cfeb98..19ea419b67c7 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -6,6 +6,7 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill import type { RepoSlice } from '../repos/repo-state' import { getProjectSetupRuntimeTarget } from './project-host-routing' import { mergeUpdatedProjectCompatibilityProject } from '../../../../shared/project-compatibility-merge' +import { getProjectHostSetupProjectionFromState } from '../project-host-setup-selector' import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' export function createProjectUpdateActions( @@ -16,15 +17,23 @@ export function createProjectUpdateActions( updateProject: async (projectId, updates, hostId) => { try { // Why: the caller names the host whose project row it edits; focus never picks it. + const state = get() + const owner = getProjectHostSetupProjectionFromState(state).setups.find( + (setup) => setup.projectId === projectId && setup.hostId === hostId + ) + const endpointProjectId = + state.endpointProjectHostSetups.find( + (setup) => owner && setup.id === owner.id && setup.hostId === owner.hostId + )?.projectId ?? projectId const target = getProjectSetupRuntimeTarget(hostId) const updatedProject = target.kind === 'local' - ? await window.api.projects.update({ projectId, updates }) + ? await window.api.projects.update({ projectId: endpointProjectId, updates }) : ( await callRuntimeRpc<{ project: Project }>( target, 'project.update', - { projectId, updates }, + { projectId: endpointProjectId, updates }, { timeoutMs: 15_000 } ) ).project @@ -35,8 +44,8 @@ export function createProjectUpdateActions( const normalizedProject = normalizeProjectRow(updatedProject) const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ - projects: state.projects.map((project) => - project.id === projectId + endpointProjects: state.endpointProjects.map((project) => + project.id === endpointProjectId ? mergeUpdatedProjectCompatibilityProject(project, normalizedProject, updates) : project ), diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index ffea9f7c1def..08b79c8df174 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -67,9 +67,9 @@ export function createAllHostRepoCatalogActions( const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { - projects: s.projects, + projects: s.endpointProjects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, + s.endpointProjectHostSetups, result.repos, finalizedRepos ) @@ -85,7 +85,8 @@ export function createAllHostRepoCatalogActions( repos: finalizedRepos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileFetchedCatalogWorktreeState(s, catalog, finalizedRepos), - ...mergedProjectCompatibility, + endpointProjects: mergedProjectCompatibility.projects, + endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, ...(arrayElementsUnchanged(finalizedRepos, s.repos) ? {} : { folderWorkspacePathStatuses: {} }), diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index dc5f4e8319b4..e5d78e23f46b 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '../project-host-setup-selector' import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' @@ -108,7 +109,10 @@ export function createRepoAddActions( return { repos: nextRepos, ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + previous: { + endpointProjects: s.endpointProjects, + endpointProjectHostSetups: s.endpointProjectHostSetups + }, nextRepos, hostId }), @@ -131,7 +135,11 @@ export function createRepoAddActions( // Why: the cross-profile advisory applies to SSH-added projects too; the presence lookup already keys on connection/host. await warnIfProjectKnownInAnotherProfile(repo, get().activeOrcaProfileId) // Why after the set(): the project row carrying the runtime override only exists once the repo is in state. - warnIfProjectCrossesWslFilesystemBoundary(repo, get().projects, get().settings) + warnIfProjectCrossesWslFilesystemBoundary( + repo, + selectCanonicalProjects(get()), + get().settings + ) } return repo } catch (err) { diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts index be3d86963bbf..e94a19dbc571 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -55,22 +55,26 @@ export function createRepoCatalogActions( const reconciliation = reconcileReadoptedSshRepoRows(s.repos, pendingSshRepoReadoptions) const repos = reconciliation.repos const worktreeState = reconcileReadoptedSshWorktreeState(s, pendingSshRepoReadoptions) - const remainingSetups = filterSetupsForPrunedRepoRows(s.projectHostSetups, s.repos, repos) + const remainingSetups = filterSetupsForPrunedRepoRows( + s.endpointProjectHostSetups, + s.repos, + repos + ) const compatibility = mergeProjectHostSetupCompatibility( projectCompatibilityFromRepos(repos), { - projects: s.projects, + projects: s.endpointProjects, setups: remainingSetups } ) // All-repos readoption cannot use host-scoped merge. Reconcile always-allocating compat results to preserve unchanged catalog arrays and avoid selector churn. const projects = reconcileCatalogRows( - s.projects, + s.endpointProjects, compatibility.projects, (project) => project.id ) const projectHostSetups = reconcileCatalogRows( - s.projectHostSetups, + s.endpointProjectHostSetups, compatibility.projectHostSetups, getProjectHostSetupOwnerKey ) @@ -78,8 +82,8 @@ export function createRepoCatalogActions( repos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...worktreeState, - projects, - projectHostSetups + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups } }), @@ -111,9 +115,9 @@ export function createRepoCatalogActions( const validRepoHostIdentities = new Set(prunedRepos.map(getRepoHostIdentity)) const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { - projects: s.projects, + projects: s.endpointProjects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, + s.endpointProjectHostSetups, result.repos, prunedRepos ) @@ -129,7 +133,8 @@ export function createRepoCatalogActions( repos: prunedRepos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileFetchedCatalogWorktreeState(s, catalog, prunedRepos), - ...mergedProjectCompatibility, + endpointProjects: mergedProjectCompatibility.projects, + endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, ...(arrayElementsUnchanged(prunedRepos, s.repos) ? {} : { folderWorkspacePathStatuses: {} }), diff --git a/src/renderer/src/store/repos/repo-catalog-identity.ts b/src/renderer/src/store/repos/repo-catalog-identity.ts index 1f0a215cd938..10d08c303968 100644 --- a/src/renderer/src/store/repos/repo-catalog-identity.ts +++ b/src/renderer/src/store/repos/repo-catalog-identity.ts @@ -30,14 +30,18 @@ export function mergeProjectCompatibilityForHostRepoChange({ nextRepos, hostId }: { - previous: Pick + previous: Pick nextRepos: readonly Repo[] hostId: string -}): Pick { - return mergeFetchedProjectCompatibilityForHost({ - previous, +}): Pick { + const merged = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: previous.endpointProjects, + projectHostSetups: previous.endpointProjectHostSetups + }, fetched: projectCompatibilityFromRepos(nextRepos), repos: nextRepos, hostId }) + return { endpointProjects: merged.projects, endpointProjectHostSetups: merged.projectHostSetups } } diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 5d28e1c0ccfe..306647d00625 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -283,7 +283,7 @@ export function createRepoRemovalActions( // Why: drop removed repos' sparse-preset maps so they don't outlive the repo for the whole session. ...omitSparsePresetsForRepos(s, removedRepoIds), ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + previous: s, nextRepos, hostId: ownerHostId }), diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index f35a23c6e370..33f7fd8a2273 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -157,8 +157,8 @@ export type RuntimeCatalogFetchOptions = { export type RepoSlice = { repos: readonly Repo[] - projects: readonly Project[] - projectHostSetups: readonly ProjectHostSetup[] + endpointProjects: readonly Project[] + endpointProjectHostSetups: readonly ProjectHostSetup[] projectGroups: readonly ProjectGroup[] folderWorkspaces: readonly FolderWorkspace[] folderWorkspacePathStatuses: Record diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index be72d383efbb..a8cd6751cb18 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -205,7 +205,10 @@ export function createRepoUpdateActions( return { repos: nextRepos, ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + previous: { + endpointProjects: s.endpointProjects, + endpointProjectHostSetups: s.endpointProjectHostSetups + }, nextRepos, hostId: ownerHostId }), diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index 0b1a8c6f8d50..df93403ec8e1 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -57,9 +57,9 @@ export function createRuntimeRepoCatalogActions( const validRepoHostIdentities = new Set(finalizedRepos.map(getRepoHostIdentity)) const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { - projects: s.projects, + projects: s.endpointProjects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, + s.endpointProjectHostSetups, result.repos, finalizedRepos ) @@ -106,7 +106,8 @@ export function createRuntimeRepoCatalogActions( : {}), pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileFetchedCatalogWorktreeState(s, catalog, finalizedRepos), - ...mergedProjectCompatibility, + endpointProjects: mergedProjectCompatibility.projects, + endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index 4be4d5af4153..01a8d9effeb4 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -264,7 +264,9 @@ describe('store selectors', () => { displayName: 'orca' }) ] - const state = { repos } + const endpointProjects: AppState['endpointProjects'] = [] + const endpointProjectHostSetups: AppState['endpointProjectHostSetups'] = [] + const state = { repos, endpointProjects, endpointProjectHostSetups } const projection = getProjectHostSetupProjectionFromState(state) @@ -274,8 +276,10 @@ describe('store selectors', () => { projectId: 'repo:repo-1', hostId: 'local' }) - expect(getProjectHostSetupProjectionFromState({ repos })).toBe(projection) - expect(getProjectHostSetupProjectionFromState({ repos: [...repos] })).not.toBe(projection) + expect(getProjectHostSetupProjectionFromState({ ...state, repos })).toBe(projection) + expect(getProjectHostSetupProjectionFromState({ ...state, repos: [...repos] })).not.toBe( + projection + ) }) it('does not fall back to a same-ID local repo when the active runtime row is absent', () => { @@ -488,7 +492,13 @@ describe('store selectors', () => { } ] - expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toEqual({ + expect( + getProjectHostSetupProjectionFromState({ + repos, + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups + }) + ).toEqual({ projects, setups: projectHostSetups }) @@ -557,8 +567,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - projects, - projectHostSetups + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups }) expect(projection.projects.map((project) => project.id)).toEqual(['github:stablyai/orca']) @@ -582,8 +592,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - projects: [], - projectHostSetups: [] + endpointProjects: [], + endpointProjectHostSetups: [] }) expect(projection.projects).toEqual([ @@ -638,8 +648,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - projects, - projectHostSetups + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups }) expect(projection.projects.map((project) => project.id)).toEqual([ @@ -647,9 +657,13 @@ describe('store selectors', () => { 'cloud-project' ]) expect(projection.setups.map((setup) => setup.id)).toEqual(['repo-1', 'cloud-project::gpu-vm']) - expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toBe( - projection - ) + expect( + getProjectHostSetupProjectionFromState({ + repos, + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups + }) + ).toBe(projection) }) }) diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 9c755629ac01..0c11ed0e359c 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import type { Worktree } from '../../../../shared/worktree/types' import { _getRemoteDetectPromiseCountForTest, createDetectedAgentsSlice } from './detected-agents' import { @@ -45,12 +46,14 @@ function createTestStore(initial?: Partial) { }) as AppState ) store.setState({ + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, activeRepoId: null, activeWorktreeId: null, ...initial - } as Partial) + }) return store } @@ -132,10 +135,10 @@ describe('createDetectedAgentsSlice WSL context', () => { runtime: { kind: 'wsl', hostPlatform: 'wsl', - projectId: 'repo-1', + projectId: 'repo:repo-1', distro: 'Ubuntu', reason: 'project-override', - cacheKey: 'repo-1:wsl:Ubuntu' + cacheKey: 'repo:repo-1:wsl:Ubuntu' } } }) @@ -373,13 +376,16 @@ describe('createDetectedAgentsSlice WSL context', () => { }) it('detects agents in the project override runtime instead of legacy agent location', async () => { + const repos = [makeRepo({ id: 'repo-1', path: 'C:\\repo' })] + const { setups } = projectHostSetupProjectionFromRepos(repos) const store = createTestStore({ settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } as AppState['settings'], - projects: [ + endpointProjectHostSetups: setups.map((setup) => ({ ...setup, projectId: 'repo-1' })), + endpointProjects: [ { id: 'repo-1', displayName: 'repo-1', @@ -390,10 +396,10 @@ describe('createDetectedAgentsSlice WSL context', () => { localWindowsRuntimePreference: { kind: 'windows-host' } } ], - repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + repos, activeRepoId: 'repo-1', activeWorktreeId: null - } as Partial) + }) await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) diff --git a/src/renderer/src/store/slices/local-detected-agent-state.test.ts b/src/renderer/src/store/slices/local-detected-agent-state.test.ts index 8a2121e4ab79..3a1a0b9a4733 100644 --- a/src/renderer/src/store/slices/local-detected-agent-state.test.ts +++ b/src/renderer/src/store/slices/local-detected-agent-state.test.ts @@ -32,7 +32,8 @@ function createTestStore(repos: Repo[]) { ) store.setState({ repos, - projects: repos.map((repo) => ({ + endpointProjectHostSetups: [], + endpointProjects: repos.map((repo) => ({ id: repo.id, displayName: repo.displayName, badgeColor: repo.badgeColor, @@ -96,7 +97,7 @@ describe('local detected agent context lifecycle', () => { it('keeps native provider inventory across WSL selection and refresh', async () => { const store = createTestStore([makeRepo('repo-1')]) - const projects = store.getState().projects + const projects = store.getState().endpointProjects detectAgents.mockResolvedValueOnce(['grok']).mockResolvedValueOnce([]) await store.getState().ensureNativeDetectedAgents() expect(detectAgents).toHaveBeenNthCalledWith(1, undefined) @@ -106,7 +107,7 @@ describe('local detected agent context lifecycle', () => { expect(store.getState().detectedAgentIds).toEqual(['codex']) const inventory = store.getState().readLocalAgentInventory(FLOATING_TERMINAL_WORKTREE_ID) expect(inventory).toEqual({ ids: ['grok'], state: 'successful' }) - store.setState({ projects: [] }) + store.setState({ endpointProjects: [] }) refreshAgents.mockRejectedValueOnce(new Error('native refresh failed')) await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['grok']) expect(readNativeDetectedAgents(store.getState())).toEqual(['grok']) @@ -122,14 +123,14 @@ describe('local detected agent context lifecycle', () => { finish = resolve }) ) - store.setState({ projects }) + store.setState({ endpointProjects: projects }) const wslRefresh = store.getState().refreshDetectedAgents() expect(store.getState().detectedAgentIds).toBeNull() finish({ agents: ['codex'] }) await wslRefresh expect(store.getState().detectedAgentIds).toEqual(['codex']) expect(readNativeDetectedAgents(store.getState())).toEqual(['grok']) - store.setState({ projects: [] }) + store.setState({ endpointProjects: [] }) await store.getState().refreshDetectedAgents() expect(readNativeDetectedAgents(store.getState())).toEqual(['codex']) }) diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index 3f6de017adfb..50e8e9cc2f03 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { create } from 'zustand' import type { PreflightStatus } from '../../../../preload/api-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { getDefaultSettings } from '../../../../shared/constants' import type { AppState } from '../types' -import { createPreflightSlice } from './preflight' -import { createRuntimeStatusSlice } from './runtime-status' +import { createTestStore } from './store-test-helpers' import { resetRendererAppPlatformCacheForTests } from '@/lib/renderer-app-platform' const preflightCheck = vi.fn() @@ -45,16 +43,6 @@ globalThis.window = { } as unknown as Window['api'] } as Window & typeof globalThis -function createTestStore() { - return create()( - (...a) => - ({ - ...createPreflightSlice(...a), - ...createRuntimeStatusSlice(...a) - }) as AppState - ) -} - function resetPreflightMocks(): void { preflightCheck.mockReset() callRuntimeRpc.mockReset() @@ -247,10 +235,10 @@ describe('createPreflightSlice', () => { runtime: { kind: 'wsl', hostPlatform: 'wsl', - projectId: 'repo-1', + projectId: 'repo:repo-1', distro: 'Ubuntu', reason: 'project-override', - cacheKey: 'repo-1:wsl:Ubuntu' + cacheKey: 'repo:repo-1:wsl:Ubuntu' } }, wslDistro: 'Ubuntu' diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index 851ec2d1e833..22a50fd2c501 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -84,7 +84,7 @@ describe('purgeStaleRuntimeHostState', () => { executionHostId: RUNTIME_NEVER_SAVED } ], - projectHostSetups: [ + endpointProjectHostSetups: [ setup({ id: 's-local', repoId: 'repo1', hostId: 'local' }), setup({ id: 's-a', repoId: 'repoA', hostId: RUNTIME_A }), setup({ id: 's-a-repoless', repoId: '', hostId: RUNTIME_A }), @@ -112,7 +112,7 @@ describe('purgeStaleRuntimeHostState', () => { const s = store.getState() expect(s.repos.map((r) => r.id)).toEqual(['repo1', 'repoServing']) - expect(s.projectHostSetups.map((x) => x.id).sort()).toEqual(['s-local', 's-serving']) + expect(s.endpointProjectHostSetups.map((x) => x.id).sort()).toEqual(['s-local', 's-serving']) expect(s.worktreesByRepo.repoA).toEqual([]) expect(s.detectedWorktreesByRepo.repoA.worktrees).toEqual([]) expect(s.tabsByWorktree[WT_A]).toBeUndefined() @@ -162,7 +162,7 @@ describe('purgeStaleRuntimeHostState', () => { const store = createTestStore() seedStore(store, { repos: [TEST_REPO], - projectHostSetups: [setup({ id: 's-local', repoId: 'repo1', hostId: 'local' })], + endpointProjectHostSetups: [setup({ id: 's-local', repoId: 'repo1', hostId: 'local' })], worktreesByRepo: { repo1: [makeWorktree({ id: 'repo1::/wt', repoId: 'repo1', hostId: 'local' })] } @@ -171,7 +171,7 @@ describe('purgeStaleRuntimeHostState', () => { const epochBefore = before.sortEpoch const worktreesRef = before.worktreesByRepo const reposRef = before.repos - const setupsRef = before.projectHostSetups + const setupsRef = before.endpointProjectHostSetups store.getState().purgeStaleRuntimeHostState([]) store.getState().purgeStaleRuntimeHostState(['env-does-not-exist']) @@ -181,7 +181,7 @@ describe('purgeStaleRuntimeHostState', () => { // Nothing stale => the reducer returns state untouched (same references). expect(s.worktreesByRepo).toBe(worktreesRef) expect(s.repos).toBe(reposRef) - expect(s.projectHostSetups).toBe(setupsRef) + expect(s.endpointProjectHostSetups).toBe(setupsRef) }) it('same-repoId-on-two-hosts: keeps an ambiguous unhosted row and the repo key', () => { @@ -340,7 +340,7 @@ describe('purgeStaleRuntimeHostState', () => { const worktreeId = 'repoA::/legacy' seedStore(store, { repos: [], - projectHostSetups: [setup({ id: 's-a', repoId: 'repoA', hostId: RUNTIME_A })], + endpointProjectHostSetups: [setup({ id: 's-a', repoId: 'repoA', hostId: RUNTIME_A })], worktreesByRepo: { repoA: [makeWorktree({ id: worktreeId, repoId: 'repoA', hostId: undefined })] }, @@ -350,7 +350,7 @@ describe('purgeStaleRuntimeHostState', () => { store.getState().purgeStaleRuntimeHostState(['env-a']) const s = store.getState() - expect(s.projectHostSetups).toEqual([]) + expect(s.endpointProjectHostSetups).toEqual([]) expect(s.worktreesByRepo.repoA).toEqual([]) expect(s.tabsByWorktree[worktreeId]).toBeUndefined() }) @@ -436,7 +436,7 @@ describe('purgeStaleRuntimeHostState', () => { executionHostId: RUNTIME_A } ], - projectHostSetups: [setup({ id: 's-b', repoId: 'shared', hostId: RUNTIME_B })], + endpointProjectHostSetups: [setup({ id: 's-b', repoId: 'shared', hostId: RUNTIME_B })], worktreesByRepo: { shared: [makeWorktree({ id: worktreeId, repoId: 'shared', hostId: undefined })] }, @@ -447,7 +447,7 @@ describe('purgeStaleRuntimeHostState', () => { const s = store.getState() expect(s.repos).toEqual([]) - expect(s.projectHostSetups).toHaveLength(1) + expect(s.endpointProjectHostSetups).toHaveLength(1) expect(s.worktreesByRepo.shared).toHaveLength(1) expect(s.tabsByWorktree[worktreeId]).toBe(tabs) }) diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index 762d6558d176..bac2547944e9 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -376,8 +376,8 @@ describe('fetchReposForAllHosts', () => { .repos.map((repo) => repo.id) .sort() expect(ids).toEqual(['local-repo', 'remote-repo']) - expect(store.getState().projects).toContainEqual(localProject) - expect(store.getState().projectHostSetups).toContainEqual(localProjectHostSetup) + expect(store.getState().endpointProjects).toContainEqual(localProject) + expect(store.getState().endpointProjectHostSetups).toContainEqual(localProjectHostSetup) }) it('preserves runtime-owned project metadata during a local-only repo refresh', async () => { @@ -440,10 +440,12 @@ describe('fetchReposForAllHosts', () => { expect( store .getState() - .projects.map((project) => project.id) + .endpointProjects.map((project) => project.id) .sort() ).toEqual(['local-project', 'repo:remote-repo']) - expect(store.getState().projects.find((project) => project.id === runtimeProject.id)).toEqual( + expect( + store.getState().endpointProjects.find((project) => project.id === runtimeProject.id) + ).toEqual( expect.objectContaining({ displayName: runtimeProject.displayName, badgeColor: runtimeProject.badgeColor @@ -460,10 +462,10 @@ describe('fetchReposForAllHosts', () => { expect( store .getState() - .projects.map((project) => project.id) + .endpointProjects.map((project) => project.id) .sort() ).toEqual(['local-project', 'repo:remote-repo']) - expect(store.getState().projects).toContainEqual(localProject) + expect(store.getState().endpointProjects).toContainEqual(localProject) }) it('preserves shared project metadata when the same project id is fetched from multiple hosts', async () => { @@ -473,8 +475,8 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() - expectSharedProjectMetadata(store.getState().projects, sharedProjectId) - const setups = store.getState().projectHostSetups + expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) expect(setups).toEqual( expect.arrayContaining([ @@ -502,7 +504,7 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() expect( - store.getState().projects.find((project) => project.id === sharedProjectId) + store.getState().endpointProjects.find((project) => project.id === sharedProjectId) ?.localWindowsRuntimePreference ).toEqual({ kind: 'windows-host' }) }) @@ -515,7 +517,7 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() await store.getState().fetchRuntimeEnvironmentRepos('env-1') - expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) }) it('keeps the local side of a shared project when a runtime refresh removes its repos', async () => { @@ -561,10 +563,10 @@ describe('fetchReposForAllHosts', () => { const sharedProject = store .getState() - .projects.find((project) => project.id === sharedProjectId) + .endpointProjects.find((project) => project.id === sharedProjectId) expect(sharedProject?.sourceRepoIds).toEqual(['local-repo']) expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ projectId: sharedProjectId, hostId: 'local', @@ -581,7 +583,7 @@ describe('fetchReposForAllHosts', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) await store.getState().fetchReposForAllHosts() - expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { if (args.method === 'repo.list') { return { @@ -617,11 +619,12 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') - expect(store.getState().projects.map((project) => project.id)).toEqual([sharedProjectId]) + const { endpointProjects, endpointProjectHostSetups } = store.getState() + expect(endpointProjects.map((project) => project.id)).toEqual([sharedProjectId]) expect( - store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + endpointProjects.find((project) => project.id === sharedProjectId)?.sourceRepoIds ).toEqual(['local-repo']) - expect(store.getState().projectHostSetups).toEqual([ + expect(endpointProjectHostSetups).toEqual([ expect.objectContaining({ projectId: sharedProjectId, hostId: 'local', @@ -671,16 +674,15 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') + const { endpointProjects, endpointProjectHostSetups } = store.getState() expect( - store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + endpointProjects.find((project) => project.id === sharedProjectId)?.sourceRepoIds ).toEqual(['local-repo']) - expect( - store - .getState() - .projects.map((project) => project.id) - .sort() - ).toEqual(['github:stablyai/orca', 'repo:remote-repo']) - expect(store.getState().projectHostSetups).toEqual( + expect(endpointProjects.map((project) => project.id).sort()).toEqual([ + 'github:stablyai/orca', + 'repo:remote-repo' + ]) + expect(endpointProjectHostSetups).toEqual( expect.arrayContaining([ expect.objectContaining({ projectId: sharedProjectId, @@ -738,7 +740,8 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') expect( - store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + store.getState().endpointProjects.find((project) => project.id === sharedProjectId) + ?.sourceRepoIds ).toEqual(['local-repo']) }) diff --git a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.spec.ts b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.spec.ts index 92af0e9c61b1..9b3bbb3afc2d 100644 --- a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.spec.ts +++ b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.spec.ts @@ -87,7 +87,7 @@ export function registerReposCrossHostRefreshIdentityTests(): void { } function sharedProject(store: ReturnType): Project | undefined { - return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID) + return store.getState().endpointProjects.find((project) => project.id === SHARED_PROJECT_ID) } describe('cross-host project refresh identity', () => { diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 6a837d6f916f..4cc332970207 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -314,7 +314,7 @@ describe('repo slice host identity routing', () => { repos: [localDuplicate, remoteDuplicate], activeRepoId: 'same-repo', filterRepoIds: ['same-repo'], - projects: [ + endpointProjects: [ { id: 'repo:same-repo', displayName: 'Same Repo', @@ -324,7 +324,7 @@ describe('repo slice host identity routing', () => { updatedAt: 1 } satisfies Project ], - projectHostSetups: [ + endpointProjectHostSetups: [ projectHostSetup({ id: 'local-setup', hostId: 'local' }), projectHostSetup({ id: 'remote-setup', hostId: 'runtime:env-1' }) ], @@ -354,11 +354,11 @@ describe('repo slice host identity routing', () => { expect(store.getState().tabsByWorktree[remoteWorktree.id]).toEqual([ { id: 'remote-tab', worktreeId: remoteWorktree.id } ]) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ hostId: 'runtime:env-1', repoId: 'same-repo' }) ]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [remoteWorktree.id]: 20 }) - expect(store.getState().projects).toEqual([ + expect(store.getState().endpointProjects).toEqual([ expect.objectContaining({ id: 'repo:same-repo', sourceRepoIds: ['same-repo'] }) ]) // Host-scope local removal or main also deletes runtime twin’s persisted row. diff --git a/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts b/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts index 8016f3546481..434d9c39de3d 100644 --- a/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts +++ b/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts @@ -74,7 +74,7 @@ it('preserves distinct SSH execution setups behind the same runtime owner', asyn await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) expect(setups).toEqual( expect.arrayContaining([ @@ -147,7 +147,7 @@ it('prefers an authoritative paired-runtime setup over its repo-derived fallback await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ { ...setup, hostId: 'runtime:env-1', diff --git a/src/renderer/src/store/slices/repos-project-host-capability.spec.ts b/src/renderer/src/store/slices/repos-project-host-capability.spec.ts index 6ca81212ce2e..7efa00184783 100644 --- a/src/renderer/src/store/slices/repos-project-host-capability.spec.ts +++ b/src/renderer/src/store/slices/repos-project-host-capability.spec.ts @@ -76,7 +76,7 @@ export function registerReposProjectHostCapabilityTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) ]) expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts index 8ffcdc5487ff..1eb0746d5892 100644 --- a/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts @@ -92,8 +92,8 @@ export function registerReposProjectHostLifecycleTests(): void { }) ).resolves.toEqual({ project, setup }) - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) + expect(store.getState().endpointProjects).toEqual([project]) + expect(store.getState().endpointProjectHostSetups).toEqual([setup]) expect(projectsCreateHostSetup).toHaveBeenCalledWith({ projectId: project.id, hostId: 'local', @@ -117,7 +117,7 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - projectHostSetups: [runtimeSetup], + endpointProjectHostSetups: [runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -158,8 +158,8 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [runtimeSetup], + endpointProjects: [project], + endpointProjectHostSetups: [runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -171,8 +171,8 @@ export function registerReposProjectHostLifecycleTests(): void { repo: undefined }) - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([]) + expect(store.getState().endpointProjects).toEqual([project]) + expect(store.getState().endpointProjectHostSetups).toEqual([]) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'projectHostSetup.delete', @@ -194,7 +194,7 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - projectHostSetups: [localSetup, runtimeSetup], + endpointProjectHostSetups: [localSetup, runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -210,7 +210,7 @@ export function registerReposProjectHostLifecycleTests(): void { updates: { displayName: 'Local renamed' } }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().projectHostSetups).toEqual([updatedLocalSetup, runtimeSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([updatedLocalSetup, runtimeSetup]) }) it('a setup id shared with another route removes only its own row', async () => { @@ -222,8 +222,8 @@ export function registerReposProjectHostLifecycleTests(): void { projectsDeleteHostSetup.mockResolvedValue({ project, setup: localSetup }) const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [localSetup, runtimeSetup], + endpointProjects: [project], + endpointProjectHostSetups: [localSetup, runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -234,15 +234,15 @@ export function registerReposProjectHostLifecycleTests(): void { hostId: 'local' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().projectHostSetups).toEqual([runtimeSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([runtimeSetup]) }) it('refuses an unqualified setup id two hosts share instead of acting on the first', async () => { const localSetup: ProjectHostSetup = { ...runtimeSetup, hostId: 'local' } const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [localSetup, runtimeSetup], + endpointProjects: [project], + endpointProjectHostSetups: [localSetup, runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -259,15 +259,15 @@ export function registerReposProjectHostLifecycleTests(): void { expect(projectsDeleteHostSetup).not.toHaveBeenCalled() expect(projectsUpdateHostSetup).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().projectHostSetups).toEqual([localSetup, runtimeSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([localSetup, runtimeSetup]) }) it('refuses a named host the catalog has no setup for instead of deleting by id', async () => { const localSetup: ProjectHostSetup = { ...runtimeSetup, hostId: 'local' } const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [localSetup], + endpointProjects: [project], + endpointProjectHostSetups: [localSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -276,7 +276,7 @@ export function registerReposProjectHostLifecycleTests(): void { ).resolves.toBeNull() expect(projectsDeleteHostSetup).not.toHaveBeenCalled() - expect(store.getState().projectHostSetups).toEqual([localSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([localSetup]) }) it('names the SSH twin of a local setup on this computer', async () => { @@ -285,8 +285,8 @@ export function registerReposProjectHostLifecycleTests(): void { projectsDeleteHostSetup.mockResolvedValue({ project, setup: sshSetup }) const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [localSetup, sshSetup], + endpointProjects: [project], + endpointProjectHostSetups: [localSetup, sshSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -296,7 +296,7 @@ export function registerReposProjectHostLifecycleTests(): void { setupId: sshSetup.id, hostId: 'ssh:box' }) - expect(store.getState().projectHostSetups).toEqual([localSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([localSetup]) }) it('preserves runtime-fetched setup-only states during repo hydration', async () => { @@ -339,7 +339,7 @@ export function registerReposProjectHostLifecycleTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).toEqual( + expect(store.getState().endpointProjectHostSetups).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'setup-pending', diff --git a/src/renderer/src/store/slices/repos-project-runtime.spec.ts b/src/renderer/src/store/slices/repos-project-runtime.spec.ts index 508d06dfb920..2bd4078467f8 100644 --- a/src/renderer/src/store/slices/repos-project-runtime.spec.ts +++ b/src/renderer/src/store/slices/repos-project-runtime.spec.ts @@ -6,6 +6,8 @@ import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' +import { getProjectHostSetupProjectionFromState } from '../project-host-setup-selector' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' export function registerReposProjectRuntimeTests(): void { @@ -87,7 +89,7 @@ export function registerReposProjectRuntimeTests(): void { }) const store = createTestStore() store.setState({ - projects: [project], + endpointProjects: [project], detectedAgentIds: ['claude'], isDetectingAgents: true, isRefreshingAgents: true @@ -107,6 +109,68 @@ export function registerReposProjectRuntimeTests(): void { expectInstalledSkillRefreshEvent() }) + it('updates an old endpoint by its concrete advertised project and rehydrates the split view', async () => { + const repos = ['a', 'b'].map((id, index) => ({ + ...localRepo, + id, + path: `/${id}`, + addedAt: index + 1, + executionHostId: 'runtime:old' as const, + upstream: { owner: 'acme', repo: 'app' } + })) + const projection = projectHostSetupProjectionFromRepos(repos) + const rawProject = { + ...projection.projects[0], + id: 'github:acme/app', + sourceRepoIds: ['a', 'b'] + } + runtimeEnvironmentCall.mockImplementation(async (args) => { + expect(args).toMatchObject({ + selector: 'old', + method: 'project.update', + params: { + projectId: rawProject.id, + updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } + } + }) + return { + id: 'update', + ok: true, + result: { + project: { ...rawProject, localWindowsRuntimePreference: { kind: 'windows-host' } } + } + } + }) + const store = createTestStore() + store.setState({ + repos, + endpointProjects: [rawProject], + endpointProjectHostSetups: projection.setups.map((setup) => ({ + ...setup, + projectId: rawProject.id + })) + }) + expect( + await store + .getState() + .updateProject( + 'repo:b', + { localWindowsRuntimePreference: { kind: 'windows-host' } }, + 'runtime:old' + ) + ).toBe(true) + const view = getProjectHostSetupProjectionFromState(store.getState()) + expect(view.setups.find((setup) => setup.repoId === 'b')?.projectId).toBe('repo:b') + expect( + view.projects.find((project) => project.id === 'repo:b')?.localWindowsRuntimePreference + ).toEqual({ kind: 'windows-host' }) + expect( + store + .getState() + .endpointProjectHostSetups.every((setup) => setup.projectId === rawProject.id) + ).toBe(true) + }) + it('hydrates projects from local IPC when the project API is available', async () => { const project: Project = { id: 'project-1', @@ -135,8 +199,8 @@ export function registerReposProjectRuntimeTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) + expect(store.getState().endpointProjects).toEqual([project]) + expect(store.getState().endpointProjectHostSetups).toEqual([setup]) expect(projectsList).toHaveBeenCalled() expect(window.api.projects.listHostSetups).toHaveBeenCalled() }) @@ -175,11 +239,11 @@ export function registerReposProjectRuntimeTests(): void { window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) reposList.mockResolvedValue([localRepo]) const store = createTestStore() - store.setState({ projects: [staleProject], projectHostSetups: [setup] }) + store.setState({ endpointProjects: [staleProject], endpointProjectHostSetups: [setup] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() }) it('updates local project runtime preferences through the projects API', async () => { @@ -196,7 +260,7 @@ export function registerReposProjectRuntimeTests(): void { localWindowsRuntimePreference: { kind: 'windows-host' } }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await store.getState().updateProject( project.id, @@ -206,7 +270,7 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) expect(projectsUpdate).toHaveBeenCalledWith({ @@ -232,8 +296,8 @@ export function registerReposProjectRuntimeTests(): void { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projects: [project], - projectHostSetups: [ + endpointProjects: [project], + endpointProjectHostSetups: [ { id: 'setup-1', projectId: project.id, @@ -287,8 +351,8 @@ export function registerReposProjectRuntimeTests(): void { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projects: [project], - projectHostSetups: [ + endpointProjects: [project], + endpointProjectHostSetups: [ { id: 'setup-1', projectId: project.id, @@ -312,7 +376,7 @@ export function registerReposProjectRuntimeTests(): void { 'runtime:env-1' ) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) @@ -346,7 +410,7 @@ export function registerReposProjectRuntimeTests(): void { localWindowsRuntimePreference: { kind: 'windows-host' } }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await store.getState().updateProject( project.id, @@ -356,8 +420,8 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().projects[0]?.createdAt).toBe(100) - expect(store.getState().projects[0]?.updatedAt).toBe(100) + expect(store.getState().endpointProjects[0]?.createdAt).toBe(100) + expect(store.getState().endpointProjects[0]?.updatedAt).toBe(100) }) it('preserves shared project source repos when updating local runtime preferences', async () => { @@ -376,7 +440,7 @@ export function registerReposProjectRuntimeTests(): void { updatedAt: 3 }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await store.getState().updateProject( project.id, @@ -386,8 +450,11 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().endpointProjects[0]?.sourceRepoIds).toEqual([ + 'local-repo', + 'remote-repo' + ]) + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) }) @@ -411,7 +478,7 @@ export function registerReposProjectRuntimeTests(): void { updatedAt: 3 }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await store.getState().updateProject( project.id, @@ -421,8 +488,11 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().endpointProjects[0]?.sourceRepoIds).toEqual([ + 'local-repo', + 'remote-repo' + ]) + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() }) }) diff --git a/src/renderer/src/store/slices/repos-refresh-identity.spec.ts b/src/renderer/src/store/slices/repos-refresh-identity.spec.ts index 144388346978..ca94efac77a0 100644 --- a/src/renderer/src/store/slices/repos-refresh-identity.spec.ts +++ b/src/renderer/src/store/slices/repos-refresh-identity.spec.ts @@ -80,23 +80,23 @@ export function registerReposRefreshIdentityTests(): void { it('keeps the projects and host setups arrays and entries across a no-op refetch', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups + const projects = store.getState().endpointProjects + const setups = store.getState().endpointProjectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('lets a nested hookSettings change through', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups mockRepos({ ...repo, @@ -104,20 +104,20 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).not.toBe(setups) - expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') + expect(store.getState().endpointProjectHostSetups).not.toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]?.hookSettings?.mode).toBe('override') }) it('lets a displayName change through on projects', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects + const projects = store.getState().endpointProjects mockRepos({ ...repo, displayName: 'Renamed' }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects[0]?.displayName).toBe('Renamed') + expect(store.getState().endpointProjects).not.toBe(projects) + expect(store.getState().endpointProjects[0]?.displayName).toBe('Renamed') }) it('lets an array-field change through', async () => { @@ -135,7 +135,7 @@ export function registerReposRefreshIdentityTests(): void { it('lets a nested gitRemoteIdentity change through', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects + const projects = store.getState().endpointProjects mockRepos({ ...repo, @@ -143,39 +143,39 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') + expect(store.getState().endpointProjects).not.toBe(projects) + expect(store.getState().endpointProjects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') }) it('treats clearing localWindowsRuntimePreference as a change', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projectId = store.getState().projects[0]!.id + const projectId = store.getState().endpointProjects[0]!.id const withPreference: Project = { - ...store.getState().projects[0]!, + ...store.getState().endpointProjects[0]!, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } - store.setState({ projects: [withPreference] }) + store.setState({ endpointProjects: [withPreference] }) await store.getState().fetchRepos(catalogOwner(store)) // Why: a local-host refresh is authoritative; an absent key must not read as unchanged. - expect(store.getState().projects[0]).not.toBe(withPreference) - expect(store.getState().projects[0]?.id).toBe(projectId) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().endpointProjects[0]).not.toBe(withPreference) + expect(store.getState().endpointProjects[0]?.id).toBe(projectId) + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() }) it('reuses an unchanged setup element while a sibling changes', async () => { mockRepos(repo, secondRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) mockRepos(repo, { ...secondRepo, displayName: 'Repo 2 renamed' }) await store.getState().fetchRepos(catalogOwner(store)) - const next = store.getState().projectHostSetups + const next = store.getState().endpointProjectHostSetups expect(next).not.toBe(setups) expect(next[0]).toBe(setups[0]) expect(next[1]).not.toBe(setups[1]) @@ -184,14 +184,14 @@ export function registerReposRefreshIdentityTests(): void { it('does not reuse a setup that moved to a different execution host', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups[0]?.hostId).toBe('local') // Fallback setup.id equals repo.id; cross-host same-ID case must retain owner routing metadata. mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) await store.getState().fetchRepos(catalogOwner(store)) - const next = store.getState().projectHostSetups + const next = store.getState().endpointProjectHostSetups expect(next[0]).not.toBe(setups[0]) expect(next[0]?.hostId).toBe('ssh:host-a') }) @@ -202,29 +202,29 @@ export function registerReposRefreshIdentityTests(): void { mockRepos(repo, sshRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) expect(new Set(setups.map((setup) => setup.id)).size).toBe(1) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) - expect(store.getState().projectHostSetups[1]).toBe(setups[1]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjectHostSetups[1]).toBe(setups[1]) }) it('drops a project and its setup when its repo disappears', async () => { mockRepos(repo, secondRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects + const projects = store.getState().endpointProjects mockRepos(repo) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects).toHaveLength(1) - expect(store.getState().projectHostSetups).toHaveLength(1) + expect(store.getState().endpointProjects).not.toBe(projects) + expect(store.getState().endpointProjects).toHaveLength(1) + expect(store.getState().endpointProjectHostSetups).toHaveLength(1) }) it('keeps a project owned only through a repo on another host', async () => { @@ -242,13 +242,13 @@ export function registerReposRefreshIdentityTests(): void { updatedAt: 1 } store.setState({ - projects: [...store.getState().projects, sshOwned], - projectHostSetups: [] + endpointProjects: [...store.getState().endpointProjects, sshOwned], + endpointProjectHostSetups: [] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned') + expect(store.getState().endpointProjects.map((project) => project.id)).toContain('ssh-owned') }) it('keeps a project whose repo id is cloned onto a second host', async () => { @@ -266,34 +266,36 @@ export function registerReposRefreshIdentityTests(): void { updatedAt: 1 } store.setState({ - projects: [...store.getState().projects, dualHostOwned], - projectHostSetups: [] + endpointProjects: [...store.getState().endpointProjects, dualHostOwned], + endpointProjectHostSetups: [] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects.map((project) => project.id)).toContain('dual-host-owned') + expect(store.getState().endpointProjects.map((project) => project.id)).toContain( + 'dual-host-owned' + ) }) it('adds a project and its setup when a repo appears', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects + const projects = store.getState().endpointProjects mockRepos(repo, secondRepo) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects).toHaveLength(2) - expect(store.getState().projectHostSetups).toHaveLength(2) + expect(store.getState().endpointProjects).not.toBe(projects) + expect(store.getState().endpointProjects).toHaveLength(2) + expect(store.getState().endpointProjectHostSetups).toHaveLength(2) }) it('keeps catalog identity when repo.addedAt is 0', async () => { mockRepos({ ...repo, addedAt: 0 }) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups + const projects = store.getState().endpointProjects + const setups = store.getState().endpointProjectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) expect(projects[0]?.createdAt).toBe(0) @@ -301,18 +303,18 @@ export function registerReposRefreshIdentityTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('keeps catalog identity when repo.addedAt is omitted', async () => { mockRepos(omitAddedAt(repo)) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups + const projects = store.getState().endpointProjects + const setups = store.getState().endpointProjectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) expect(projects[0]?.createdAt).toBe(0) @@ -320,17 +322,17 @@ export function registerReposRefreshIdentityTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('lets a nested hookSettings change through when repo.addedAt is 0', async () => { mockRepos({ ...repo, addedAt: 0 }) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups mockRepos({ ...repo, @@ -339,9 +341,9 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).not.toBe(setups) - expect(store.getState().projectHostSetups[0]).not.toBe(setups[0]) - expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') + expect(store.getState().endpointProjectHostSetups).not.toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).not.toBe(setups[0]) + expect(store.getState().endpointProjectHostSetups[0]?.hookSettings?.mode).toBe('override') }) }) @@ -412,8 +414,8 @@ export function registerReposRefreshIdentityTests(): void { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) const before = store.getState() - const projects = before.projects - const setups = before.projectHostSetups + const projects = before.endpointProjects + const setups = before.endpointProjectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) @@ -422,26 +424,26 @@ export function registerReposRefreshIdentityTests(): void { // Empty readoption returns state unchanged; fresh pending arrays alone wake all subscribers. expect(store.getState()).toBe(before) expect(store.getState().pendingSshRepoReadoptions).toBe(before.pendingSshRepoReadoptions) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('keeps catalog identity for a pending-only readoption while pending updates', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups + const projects = store.getState().endpointProjects + const setups = store.getState().endpointProjectHostSetups const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } store.getState().recordSshRepoReadoptions([readoption]) expect(store.getState().pendingSshRepoReadoptions).toEqual([readoption]) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('replaces the moved setup on a real prune/rehome', async () => { @@ -458,7 +460,7 @@ export function registerReposRefreshIdentityTests(): void { mockRepos(oldHostRepo, newHostRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) const oldSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-old') const newSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-new') @@ -471,7 +473,7 @@ export function registerReposRefreshIdentityTests(): void { { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } ]) - const next = store.getState().projectHostSetups + const next = store.getState().endpointProjectHostSetups expect(next).not.toBe(setups) expect(next).toHaveLength(1) expect(next[0]).not.toBe(oldSetup) diff --git a/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts b/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts index b954ca79aa25..401185a1a818 100644 --- a/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts +++ b/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts @@ -131,7 +131,8 @@ export function registerReposSharedProjectBadgeColorTests(): void { }) function badgeColor(store: ReturnType): string | undefined { - return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID)?.badgeColor + return store.getState().endpointProjects.find((project) => project.id === SHARED_PROJECT_ID) + ?.badgeColor } describe('shared project badge color across hosts', () => { diff --git a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts index 58d694c84d06..1fd4d043ef64 100644 --- a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts +++ b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts @@ -157,7 +157,7 @@ describe('SSH repo host reconciliation', () => { const store = createTestStore() store.setState({ repos: [staleRepo], - projectHostSetups: [directSshSetup('ssh-old')], + endpointProjectHostSetups: [directSshSetup('ssh-old')], worktreesByRepo: { [repoId]: [ directSshWorktree('ssh-old', 'stale'), @@ -172,7 +172,7 @@ describe('SSH repo host reconciliation', () => { store.getState().recordSshRepoReadoptions([readoption]) expect(store.getState().repos).toEqual([liveRepo]) - expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')]) + expect(store.getState().endpointProjectHostSetups).toEqual([directSshSetup('ssh-new')]) expect(store.getState().worktreesByRepo[repoId]).toEqual([ directSshWorktree('ssh-new', 'authoritative') ]) @@ -192,7 +192,7 @@ describe('SSH repo host reconciliation', () => { const store = createTestStore() store.setState({ repos: [directSshRepo('ssh-old')], - projectHostSetups: [directSshSetup('ssh-old'), runtimeSetup] + endpointProjectHostSetups: [directSshSetup('ssh-old'), runtimeSetup] }) store.getState().recordSshRepoReadoptions([readoption]) @@ -202,10 +202,10 @@ describe('SSH repo host reconciliation', () => { await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) expect(store.getState().repos).toEqual([liveRepo]) - expect(store.getState().projectHostSetups).toEqual( + expect(store.getState().endpointProjectHostSetups).toEqual( expect.arrayContaining([directSshSetup('ssh-new'), runtimeSetup]) ) - expect(store.getState().projectHostSetups).toHaveLength(2) + expect(store.getState().endpointProjectHostSetups).toHaveLength(2) expect(store.getState().pendingSshRepoReadoptions).toEqual([]) }) @@ -229,7 +229,7 @@ describe('SSH repo host reconciliation', () => { }) .mockResolvedValueOnce([directSshSetup('ssh-new')]) const store = createTestStore() - store.setState({ repos: [staleRepo], projectHostSetups: [directSshSetup('ssh-old')] }) + store.setState({ repos: [staleRepo], endpointProjectHostSetups: [directSshSetup('ssh-old')] }) store.getState().recordSshRepoReadoptions([readoption]) const olderFetch = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) @@ -240,7 +240,7 @@ describe('SSH repo host reconciliation', () => { await olderFetch expect(store.getState().repos).toEqual([liveRepo]) - expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')]) + expect(store.getState().endpointProjectHostSetups).toEqual([directSshSetup('ssh-new')]) }) it('rejects an old-host worktree response that resolves after re-adoption', async () => { @@ -387,14 +387,17 @@ describe('SSH repo host reconciliation', () => { projectsList.mockResolvedValue([project]) setupsList.mockResolvedValue([directSshSetup('ssh-old')]) const store = createTestStore() - store.setState({ repos: [localRepo, oldRepo], projectHostSetups: [directSshSetup('ssh-old')] }) + store.setState({ + repos: [localRepo, oldRepo], + endpointProjectHostSetups: [directSshSetup('ssh-old')] + }) await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }, oldRepo]) - expect(store.getState().projectHostSetups).toEqual( + expect(store.getState().endpointProjectHostSetups).toEqual( expect.arrayContaining([directSshSetup('ssh-old')]) ) - expect(store.getState().projectHostSetups).toHaveLength(2) + expect(store.getState().endpointProjectHostSetups).toHaveLength(2) }) }) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 9cf3fb9917a6..2d7504003a62 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -50,10 +50,10 @@ describe('repo slice runtime routing', () => { await store.getState().fetchRepos({ runtimeEnvironmentId: null }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) - expect(store.getState().projects).toEqual([ + expect(store.getState().endpointProjects).toEqual([ expect.objectContaining({ id: 'repo:local-repo', sourceRepoIds: ['local-repo'] }) ]) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ id: 'local-repo', hostId: 'local' }) ]) expect(reposList).toHaveBeenCalled() @@ -116,10 +116,10 @@ describe('repo slice runtime routing', () => { await store.getState().fetchRepos({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().repos).toEqual([{ ...remoteRepo, executionHostId: 'runtime:env-1' }]) - expect(store.getState().projects).toEqual([ + expect(store.getState().endpointProjects).toEqual([ expect.objectContaining({ id: 'repo:remote-repo', sourceRepoIds: ['remote-repo'] }) ]) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) ]) expect(store.getState().activeRepoId).toBeNull() @@ -280,7 +280,7 @@ describe('repo slice runtime routing', () => { } projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: localRepo }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await expect( store.getState().setupProjectExistingFolder({ @@ -296,8 +296,8 @@ describe('repo slice runtime routing', () => { }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) + expect(store.getState().endpointProjects).toEqual([project]) + expect(store.getState().endpointProjectHostSetups).toEqual([setup]) expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ projectId: project.id, projectProviderIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, @@ -336,8 +336,8 @@ describe('repo slice runtime routing', () => { expect(projectsSetupExistingFolder).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(store.getState().repos).toBe(before.repos) - expect(store.getState().projects).toBe(before.projects) - expect(store.getState().projectHostSetups).toBe(before.projectHostSetups) + expect(store.getState().endpointProjects).toBe(before.endpointProjects) + expect(store.getState().endpointProjectHostSetups).toBe(before.endpointProjectHostSetups) expect(toast.error).toHaveBeenCalledWith('Failed to add project', { description: SERVER_SSH_REFUSAL, duration: expect.any(Number) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 543607cd7a37..bddfec291068 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -35,8 +35,8 @@ export const createRepoSlice: StateCreator = (set, const repoOrdering = createRepoOrderingActions(set, get) return { repos: [], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], projectGroups: [], folderWorkspaces: [], folderWorkspacePathStatuses: {}, diff --git a/src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts b/src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts index 4cb4d02ee0a2..5e12e1cb5cbb 100644 --- a/src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts +++ b/src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts @@ -40,7 +40,7 @@ function seedStale(store: ReturnType): void { executionHostId: RUNTIME_A } ], - projectHostSetups: [ + endpointProjectHostSetups: [ { id: 's-a', projectId: 'p', @@ -70,7 +70,7 @@ describe('setRuntimeEnvironments removal diff fires the stale-runtime purge', () const s = store.getState() expect(s.repos.map((r) => r.id)).toEqual(['repo1']) - expect(s.projectHostSetups).toEqual([]) + expect(s.endpointProjectHostSetups).toEqual([]) expect(s.worktreesByRepo.repoA).toEqual([]) expect(s.tabsByWorktree['repoA::/wt-a']).toBeUndefined() expect(s.runtimeEnvironments.map((e) => e.id)).toEqual(['env-b']) diff --git a/src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts b/src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts index 7a099cabed83..83767d90cb68 100644 --- a/src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts @@ -267,7 +267,21 @@ describe('setActiveWorktree', () => { terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian' }, - projects: [ + endpointProjectHostSetups: [ + { + id: 'setup-project-1', + projectId: 'project-1', + repoId: 'repo1', + hostId: 'local', + path: '/repo1', + displayName: 'Repo 1', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [ { id: 'project-1', displayName: 'Project', @@ -312,7 +326,21 @@ describe('setActiveWorktree', () => { seedStore(store, { settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'powershell.exe' }, - projects: [ + endpointProjectHostSetups: [ + { + id: 'setup-project-1', + projectId: 'project-1', + repoId: 'repo1', + hostId: 'local', + path: '/repo1', + displayName: 'Repo 1', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [ { id: 'project-1', displayName: 'Project', diff --git a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts index d13c58951efd..fb13c9ba42fb 100644 --- a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts +++ b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts @@ -275,6 +275,8 @@ describe('createWorktree base status merge', () => { expect(mockApi.worktrees.create).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['repo-remote']?.[0]).toEqual({ ...created, + projectId: 'repo:repo-remote', + projectHostSetupId: 'repo-remote', hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' }) diff --git a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts index 84dfe75bb76f..ace14540a716 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts @@ -161,7 +161,10 @@ describe('fetchWorktrees', () => { }) ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().worktreesByRepo['same-repo']).toEqual([remote, local]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([ + remote, + { ...local, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' } + ]) expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( expect.arrayContaining([ expect.objectContaining({ id: remote.id, hostId: 'runtime:env-1' }), @@ -207,7 +210,12 @@ describe('fetchWorktrees', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() // Why: SSH worktrees are fetched via local IPC but belong to the SSH host, so they carry the repo's ssh host id. expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { ...sshWorktree, hostId: 'ssh:ssh-1' } + { + ...sshWorktree, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) }) @@ -252,7 +260,9 @@ describe('fetchWorktrees', () => { }) ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([ + { ...localWorktree, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' } + ]) }) it('honors an explicit runtime owner before the repo catalog is hydrated', async () => { @@ -287,7 +297,11 @@ describe('fetchWorktrees', () => { }) expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['repo-missing']).toEqual([ - { ...remote, hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } + { + ...remote, + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1' + } ]) }) @@ -435,7 +449,13 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('repo-remote', { forceLocalOwner: true }) expect(store.getState().worktreesByRepo['repo-remote']).toEqual([ - { ...remote, hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } + { + ...remote, + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1', + projectId: 'repo:repo-remote', + projectHostSetupId: 'repo-remote' + } ]) }) @@ -496,7 +516,13 @@ describe('fetchWorktrees', () => { await expect(staleRefresh).resolves.toBe(false) expect(store.getState().worktreesByRepo['repo-remote']).toEqual([ - { ...fresh, hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } + { + ...fresh, + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1', + projectId: 'repo:repo-remote', + projectHostSetupId: 'repo-remote' + } ]) expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() }) @@ -521,7 +547,17 @@ describe('fetchWorktrees', () => { executionHostId: 'runtime:env-1' } ], - projectHostSetups: [ + endpointProjects: [ + { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#000', + sourceRepoIds: ['repo-remote'], + createdAt: 1, + updatedAt: 1 + } + ], + endpointProjectHostSetups: [ { id: 'repo-remote', projectId: 'github:stablyai/orca', @@ -535,7 +571,7 @@ describe('fetchWorktrees', () => { updatedAt: 1 } ] - } as Partial) + }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-runtime-worktree', ok: true, diff --git a/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts b/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts index ca4cb4584b32..7b0c3351e023 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts @@ -84,7 +84,7 @@ describe('fetchWorktrees', () => { await expect(store.getState().fetchWorktrees(sshRepo.id)).resolves.toBe(false) expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...queued, hostId: 'ssh:ssh-1' } + { ...queued, hostId: 'ssh:ssh-1', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } ]) expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ repoId: sshRepo.id, @@ -136,7 +136,7 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ existing, - { ...queued, hostId: 'ssh:ssh-1' } + { ...queued, hostId: 'ssh:ssh-1', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } ]) }) @@ -187,7 +187,12 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ sshExisting, - { ...queued, hostId: 'ssh:ssh-1' }, + { + ...queued, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-shared', + projectHostSetupId: 'repo-shared' + }, localExisting ]) }) @@ -286,14 +291,16 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(sshRepo.id) expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...stale, hostId: 'ssh:ssh-1' } + { ...stale, hostId: 'ssh:ssh-1', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } ]) worktreeListMock.mockResolvedValueOnce([live]) store.setState({ sshConnectionStates: connectedStates } as Partial) await store.getState().fetchWorktrees(sshRepo.id) - expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...live, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + ]) }) it('keeps the repo detection entry authoritative while appending metadata rows', async () => { @@ -381,21 +388,30 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(sshRepo.id) expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...deletedOnRemote, hostId: 'ssh:ssh-1' } + { + ...deletedOnRemote, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) // The host connects and an authoritative scan proves the worktree is gone. worktreeListMock.mockResolvedValueOnce([live]) store.setState({ sshConnectionStates: connectedStates } as Partial) await store.getState().fetchWorktrees(sshRepo.id) - expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...live, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + ]) // The host drops again; persisted metadata still lists the deleted worktree. listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult()) store.setState({ sshConnectionStates: new Map() } as Partial) await store.getState().fetchWorktrees(sshRepo.id) - expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...live, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + ]) }) it('retires persisted metadata for worktrees an authoritative SSH scan proved gone', async () => { @@ -474,7 +490,9 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(localRepo.id) - expect(store.getState().worktreesByRepo[localRepo.id]).toEqual([survivor]) + expect(store.getState().worktreesByRepo[localRepo.id]).toEqual([ + { ...survivor, projectId: 'repo:repo-local', projectHostSetupId: 'repo-local' } + ]) // Local metas are GC-eligible on their own; only the SSH exemption needs this IPC. expect(forgetRemovedForExecutionHostMock).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts index a9ed1993a08a..63a8c3b5c206 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts @@ -347,8 +347,8 @@ describe('fetchWorktrees', () => { await refresh expect(store.getState().worktreesByRepo.repo1).toEqual([ - localWorktree, - { ...sshWorktree, hostId: 'ssh:ssh-1' } + { ...localWorktree, projectId: 'repo:repo1', projectHostSetupId: 'repo1' }, + { ...sshWorktree, hostId: 'ssh:ssh-1', projectId: 'repo:repo1', projectHostSetupId: 'repo1' } ]) expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(2) }) @@ -397,7 +397,12 @@ describe('fetchWorktrees', () => { expect(visibleResult).toBe(true) expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { ...sshWorktree, hostId: 'ssh:ssh-1' } + { + ...sshWorktree, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) expect(store.getState().detectedWorktreesByRepo['repo-ssh']?.worktrees).toEqual([ expect.objectContaining({ id: sshWorktree.id, hostId: 'ssh:ssh-1' }) @@ -449,7 +454,12 @@ describe('fetchWorktrees', () => { expect(visibleResult).toBe(true) expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { ...sshWorktree, hostId: 'ssh:ssh-1' } + { + ...sshWorktree, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) expect(store.getState().detectedWorktreesByRepo['repo-ssh']?.worktrees).toEqual([ expect.objectContaining({ id: sshWorktree.id, hostId: 'ssh:ssh-1' }) diff --git a/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts b/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts index 284f41983581..f71743aea7af 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts @@ -82,7 +82,9 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('repo1') - expect(store.getState().worktreesByRepo.repo1).toEqual([refreshed]) + expect(store.getState().worktreesByRepo.repo1).toEqual([ + { ...refreshed, projectId: 'repo:repo1', projectHostSetupId: 'repo1' } + ]) expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) expect(store.getState().sortEpoch).toBe(8) }) @@ -116,7 +118,9 @@ describe('fetchWorktrees', () => { path: '/remote/wt1', branch: 'refs/heads/remote', hostId: 'runtime:env-1', - runtimeOwnerEnvironmentId: 'env-1' + runtimeOwnerEnvironmentId: 'env-1', + projectId: 'repo:repo1', + projectHostSetupId: 'repo1' }) const staleLineage = makeLineage({ worktreeId: worktree.id, @@ -528,6 +532,8 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([ { ...refreshed, + projectId: 'repo:repo1', + projectHostSetupId: 'repo1', hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } diff --git a/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts b/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts index b5fef2ccb326..d09591186fa7 100644 --- a/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts +++ b/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts @@ -104,7 +104,12 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...queued, hostId: 'ssh:ssh-1' } + { + ...queued, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ repoId: sshRepo.id, @@ -477,9 +482,11 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(refreshed).toHaveLength(2) expect(refreshed).toEqual( expect.arrayContaining([ - localWorktree, + { ...localWorktree, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' }, { ...refreshedRemoteWorktree, + projectId: 'repo:same-repo', + projectHostSetupId: 'same-repo', hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts index 9c322acb3e33..051e42c06a18 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts @@ -178,6 +178,8 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([ { ...wt, + projectId: 'repo:repo1', + projectHostSetupId: 'repo1', hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } diff --git a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts index e5e1e8c980ec..27d69bb59e81 100644 --- a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts +++ b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts @@ -15,6 +15,25 @@ import { resetWorktreeSliceModuleMemory } from './worktrees-slice-test-harness' +function makeOrphanedSetup(): AppState['endpointProjectHostSetups'][number] { + const row = { + id: '', + projectId: '', + repoId: '', + hostId: 'local' as const, + path: '', + displayName: '', + setupState: 'ready' as const, + setupMethod: 'legacy-repo' as const, + createdAt: 0, + updatedAt: 0 + } + for (const property of Object.keys(row)) { + Reflect.deleteProperty(row, property) + } + return row +} + const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ @@ -174,20 +193,20 @@ describe('removeWorktree state cleanup', () => { it('purges the orphaned project that pointed at a destroyed runtime-owned SSH target', async () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) - const orphanedSetup = { + const orphanedSetup = Object.assign(makeOrphanedSetup(), { id: 'setup-runtime-ssh', hostId: 'ssh:runtime-ssh-orca-1' - } as unknown as AppState['projectHostSetups'][number] - const userSshSetup = { + }) + const userSshSetup = Object.assign(makeOrphanedSetup(), { id: 'setup-user-ssh', hostId: 'ssh:my-server' - } as unknown as AppState['projectHostSetups'][number] + }) const deleteProjectHostSetup = vi.fn().mockResolvedValue(null) store.setState({ worktreesByRepo: { repo1: [wt] }, - projectHostSetups: [orphanedSetup, userSshSetup], + endpointProjectHostSetups: [orphanedSetup, userSshSetup], deleteProjectHostSetup - } as unknown as Partial) + }) mockApi.ephemeralVm.listRuntimes.mockResolvedValueOnce([ { id: 'runtime-1', diff --git a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts index ca727c6662bf..7bc8e74068e9 100644 --- a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts +++ b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts @@ -150,7 +150,8 @@ export function createTestStore() { ] ]), repos: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], deleteProjectHostSetup: vi.fn().mockResolvedValue(null), updateFolderWorkspace: vi.fn().mockResolvedValue(true), updateSettings: vi.fn().mockResolvedValue(undefined), diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts index 748efb01ae94..1d256dcb73cd 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts @@ -1,3 +1,4 @@ +import { getProjectHostSetupCompatibilityFromState } from '@/store/project-host-setup-selector' import type { AppState } from '../../../types' import type { ProjectHostSetup } from '../../../../../../shared/project-types' import type { DetectedWorktreeListResult, Worktree } from '../../../../../../shared/worktree/types' @@ -122,11 +123,11 @@ export function toVisibleWorktrees( } export function getProjectHostSetupForRepoHost( - state: Partial>, + state: Parameters[0], repoId: string, hostId: ExecutionHostId ): ProjectHostSetup | undefined { - return state.projectHostSetups?.find( + return getProjectHostSetupCompatibilityFromState(state).setups.find( (setup) => setup.repoId === repoId && setup.hostId === hostId ) } diff --git a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts index 8aa1f039e6ae..5aeb1d825837 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts @@ -20,7 +20,7 @@ export async function purgeOrphanedRuntimeSshProjects( purgeableSshTargetIds.map((id) => toSshExecutionHostId(id)) ) const orphanedSetups = get() - .projectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId)) + .endpointProjectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId)) .map((setup) => ({ setupId: setup.id, hostId: setup.hostId })) const purgedRepoIds = new Set() for (const target of orphanedSetups) { diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index 580bff2797e9..129961cf682e 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -56,7 +56,7 @@ export function createPurgeStaleRuntimeHostState( // Why: a repoId-less setup on the removed host can still split a surviving project group, so drop every setup it owns. const survivingSetups: ProjectHostSetup[] = [] - for (const setup of s.projectHostSetups) { + for (const setup of s.endpointProjectHostSetups) { if (isRemovedRuntimeHostId(setup.hostId, removed)) { if (setup.repoId) { repoIdsWithRemovedOwners.add(setup.repoId) @@ -68,7 +68,7 @@ export function createPurgeStaleRuntimeHostState( } } } - const setupsChanged = survivingSetups.length !== s.projectHostSetups.length + const setupsChanged = survivingSetups.length !== s.endpointProjectHostSetups.length const projectGroups = dropRuntimeHostedCatalogRows(s.projectGroups, removed) const folderWorkspaces = dropRuntimeHostedCatalogRows(s.folderWorkspaces, removed) const groupsChanged = @@ -278,7 +278,7 @@ export function createPurgeStaleRuntimeHostState( return { ...purgeState, ...(reposChanged ? { repos: survivingRepos } : {}), - ...(setupsChanged ? { projectHostSetups: survivingSetups } : {}), + ...(setupsChanged ? { endpointProjectHostSetups: survivingSetups } : {}), ...(groupsChanged ? { projectGroups, folderWorkspaces } : {}), ...(worktreesChanged ? { worktreesByRepo: worktreeDrop.rowsByRepo } : {}), ...(detectedChanged ? { detectedWorktreesByRepo } : {}), diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index 3999a685339e..855c64c5bd45 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -22,7 +22,8 @@ import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/exe import { assertEnvironmentPairing, callRuntimeEnvelope, - callRuntimeResult + callRuntimeResult, + callEnvironmentEnvelope } from './web-runtime-calls' import { readRuntimeAiVaultDeleteSessionResponse, @@ -33,19 +34,42 @@ import { noopUnsubscribe } from './web-storage' import { translate } from '@/i18n/i18n' export function createWebAiVaultApi(): NonNullable['aiVault']> { - const search = createSessionSearchClient( - (method, params) => callRuntimeResult(method, params), - 'relay' - ) return { // A browser searches only its selected paired runtime. - searchSessions: (request, executionHostScope) => - addressesOwnRuntime(executionHostScope) - ? search.searchSessions(request) - : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }), + searchSessions: (request, executionHostScope, isActive = () => true) => { + if (!addressesOwnRuntime(executionHostScope)) { + return Promise.resolve({ kind: 'unavailable', reason: 'no-service' }) + } + const environment = requireActiveEnvironment() + const pairingRevision = environment.pairingRevision ?? environment.createdAt + const runtimeId = environment.runtimeId ?? undefined + const active = () => isActive() && requireActiveEnvironment().id === environment.id + const search = createSessionSearchClient(async (method, params) => { + if (!active()) { + throw new Error('Session search retired') + } + const response = await callEnvironmentEnvelope( + environment.id, + method, + params, + undefined, + pairingRevision, + runtimeId, + active + ) + if (!response.ok) { + throw Object.assign(new Error(response.error.message), { code: response.error.code }) + } + return response.result + }, 'relay') + return search.searchSessions(request, active) + }, searchStatus: (executionHostScope) => addressesOwnRuntime(executionHostScope) - ? search.searchStatus() + ? createSessionSearchClient( + (method, params) => callRuntimeResult(method, params), + 'relay' + ).searchStatus() : Promise.resolve(unavailableSessionSearchStatus()), // Host-index consent requires the desktop operator’s settings surface. setSearchEnabled: () => Promise.reject(new Error('unsupported')), @@ -69,6 +93,7 @@ export function createWebAiVaultApi(): NonNullable['aiVault' ...(requestedScope === executionHostId && args?.afterFingerprint !== undefined ? { afterFingerprint: args.afterFingerprint } : {}), + historyOwnership: args?.historyOwnership, scopePaths: args?.scopePaths, executionHostId }).then((result) => diff --git a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts index 6e0ff9520c09..cf84faa4cf10 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts @@ -4,7 +4,13 @@ import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-sear import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' const callRuntimeResult = vi.hoisted(() => vi.fn()) -vi.mock('./web-runtime-calls', () => ({ callRuntimeResult })) +vi.mock('./web-runtime-calls', () => ({ + callRuntimeResult, + callEnvironmentEnvelope: async (_selector: string, method: string, params: unknown) => ({ + ok: true, + result: await callRuntimeResult(method, params) + }) +})) vi.mock('./web-runtime-session', () => ({ requireActiveEnvironment: () => ({ id: 'owning-host' }) })) @@ -33,6 +39,23 @@ describe('web session search preload compatibility', () => { expect(await api.searchStatus()).toEqual(unavailableSessionSearchStatus()) expect(callRuntimeResult).toHaveBeenLastCalledWith('aiVault.searchStatus', {}) }) + it('forwards returned-row ownership intent without changing the concrete list scope', async () => { + callRuntimeResult.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'same' }) + await createWebAiVaultApi().listSessions({ + historyOwnership: {}, + scopePaths: ['/repo'], + limit: 25 + }) + expect(callRuntimeResult).toHaveBeenCalledExactlyOnceWith( + 'aiVault.listSessions', + expect.objectContaining({ + historyOwnership: {}, + scopePaths: ['/repo'], + limit: 25, + executionHostId: 'runtime:owning-host' + }) + ) + }) it('treats an old host as unavailable but propagates transport failures', async () => { const api = createWebAiVaultApi() callRuntimeResult.mockRejectedValue( diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts index 2a02f200d85f..3fead54be411 100644 --- a/src/renderer/src/web/preload-api/web-runtime-calls.ts +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -120,7 +120,8 @@ export async function callEnvironmentEnvelope( params?: unknown, timeoutMs?: number, expectedEnvironmentPairingRevision?: number, - expectedEnvironmentRuntimeId?: string + expectedEnvironmentRuntimeId?: string, + isActive: () => boolean = () => true ): Promise> { const environment = resolveEnvironment(selector) const assertPairing = () => assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision) @@ -129,6 +130,9 @@ export async function callEnvironmentEnvelope( return manuallyDisconnectedResponse(environment) } const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + if (!isActive()) { + throw new Error('Session search retired') + } assertPairing() if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { return Promise.resolve(manuallyDisconnectedResponse(environment)) diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts index c47608bdca0e..64f36aca0702 100644 --- a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts +++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts @@ -275,3 +275,99 @@ describe('web preload runtime calls', () => { expect(call).toHaveBeenLastCalledWith('runtime.queued-timeout', undefined, { timeoutMs: 25 }) }) }) + +describe('direct History search lifetime on the real browser queue', () => { + it('retires queued work and preserves captured pairing across an awaited status response', async () => { + vi.resetModules() + const calls: string[] = [] + let release: () => void = () => {} + let held = new Promise((resolve) => { + release = resolve + }) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + connectionEpoch = 1 + async call(method: string): Promise> { + calls.push(method) + if (method === 'hold' || method === 'aiVault.searchStatus') { + await held + } + const { searchResults } = await import('../../../shared/ai-vault-search-test-fixture') + const { unavailableSessionSearchStatus } = + await import('../../../shared/ai-vault-search-client') + return { + id: method, + ok: true, + result: + method === 'aiVault.searchStatus' + ? { ...unavailableSessionSearchStatus(), supportedAgents: ['codex'] } + : searchResults(), + _meta: { runtimeId: 'runtime-a' } + } + } + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'selected') + const { createWebAiVaultApi } = await import('./preload-api/web-ai-vault-api') + const { webRuntimeState, runtimeCallQueuePool } = + await import('./preload-api/web-runtime-session') + const api = createWebAiVaultApi() + const blockers = Array.from({ length: 8 }, () => + runtimeCallQueuePool.enqueue('selected', 'hold', () => held) + ) + let active = true + const queued = api + .searchSessions({ query: 'needle' }, 'runtime:selected', () => active) + .then( + () => { + throw new Error('retired work accepted') + }, + (error: unknown) => error + ) + active = false + release() + await Promise.all(blockers) + expect(await queued).toBeInstanceOf(Error) + expect(calls).not.toContain('aiVault.searchSessions') + calls.length = 0 + held = new Promise((resolve) => { + release = resolve + }) + const waiting = api + .searchSessions({ query: 'needle', filters: { agents: ['codex'] } }, 'runtime:selected') + .then( + () => { + throw new Error('replaced pairing accepted') + }, + (error: unknown) => error + ) + await vi.waitFor(() => expect(calls).toContain('aiVault.searchStatus')) + const selected = webRuntimeState.activeEnvironment! + webRuntimeState.activeEnvironment = { ...selected, pairingRevision: 2 } + release() + expect(await waiting).toBeInstanceOf(Error) + expect(calls).not.toContain('aiVault.searchSessions') + // Already-issued work may settle, but a selected-environment switch cannot launch its search. + calls.length = 0 + held = new Promise((resolve) => { + release = resolve + }) + const switched = api + .searchSessions({ query: 'needle', filters: { agents: ['codex'] } }, 'runtime:selected') + .then( + () => { + throw new Error('switched target accepted') + }, + (error: unknown) => error + ) + await vi.waitFor(() => expect(calls).toContain('aiVault.searchStatus')) + webRuntimeState.activeEnvironment = { ...selected, id: 'other' } + release() + expect(await switched).toBeInstanceOf(Error) + expect(calls).not.toContain('aiVault.searchSessions') + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) +}) diff --git a/src/shared/ai-vault-project-key.ts b/src/shared/ai-vault-project-key.ts index bf665e4e1c32..cf43593b24a3 100644 --- a/src/shared/ai-vault-project-key.ts +++ b/src/shared/ai-vault-project-key.ts @@ -9,3 +9,9 @@ export function toAiVaultProjectKey( } return repoId ? `repo:${repoId}` : null } + +export function isAiVaultProjectKey(value: unknown): value is string { + return ( + typeof value === 'string' && value.length <= 1024 && /^(?:project|repo):[\s\S]+$/.test(value) + ) +} diff --git a/src/shared/ai-vault-runtime-capabilities.ts b/src/shared/ai-vault-runtime-capabilities.ts index 10e78b3034d6..66419dbd4be0 100644 --- a/src/shared/ai-vault-runtime-capabilities.ts +++ b/src/shared/ai-vault-runtime-capabilities.ts @@ -2,12 +2,16 @@ import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparat // aiVault.listSessions is unconditional, so getStatus advertises this statically rather than as a runtime-dependent capability. export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const +// Canonical Project intent is resolved before native candidate limits; absent intent stays legacy. +export const AI_VAULT_CANONICAL_PROJECT_SEARCH_CAPABILITY = + 'aiVault.search.canonical-project.v1' as const export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-titles.v1' as const /** The host deletes its own history rows through aiVault.deleteSession. */ export const AI_VAULT_DELETE_SESSION_RUNTIME_CAPABILITY = 'aiVault.delete-session.v1' as const export const AI_VAULT_RUNTIME_CAPABILITIES = [ AI_VAULT_RUNTIME_CAPABILITY, + AI_VAULT_CANONICAL_PROJECT_SEARCH_CAPABILITY, AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY, AI_VAULT_DELETE_SESSION_RUNTIME_CAPABILITY, AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index 3ca5184ed1f1..bdb497398cbb 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -42,11 +42,17 @@ export function createSessionSearchClient( call: (method: string, params: Record) => Promise, transport: SessionSearchTransport ): { - searchSessions(request: AiVaultSearchRequest): Promise + searchSessions( + request: AiVaultSearchRequest, + isActive?: () => boolean + ): Promise searchStatus(): Promise } { return { - searchSessions: async (request) => { + searchSessions: async (request, isActive = () => true) => { + if (!isActive()) { + throw new Error('Session search retired') + } const parsed = AiVaultSearchRequestSchema.parse(request) let hostRequest = parsed let raw: unknown @@ -60,6 +66,9 @@ export function createSessionSearchClient( } hostRequest = { ...parsed, filters: { ...parsed.filters, agents } } } + if (!isActive()) { + throw new Error('Session search retired') + } raw = await call('aiVault.searchSessions', { ...hostRequest, supportedAgents: [...AI_VAULT_AGENTS], diff --git a/src/shared/ai-vault-search-scope.ts b/src/shared/ai-vault-search-scope.ts index 1ffe4501043c..0f513ab504e3 100644 --- a/src/shared/ai-vault-search-scope.ts +++ b/src/shared/ai-vault-search-scope.ts @@ -3,7 +3,11 @@ import { z } from 'zod' // Scope by identity for each host's catalog; request scope already names the search tier. export const AiVaultSearchScopeIdentitySchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('workspace'), worktreeId: z.string().min(1).max(8192) }), - z.object({ kind: z.literal('project'), projectKey: z.string().min(1).max(1024) }) + z.object({ + kind: z.literal('project'), + projectKey: z.string().min(1).max(1024), + canonicalProjectKey: z.string().min(1).max(1024).optional() + }) ]) export type AiVaultSearchScopeIdentity = z.infer diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 6ffcaf6d2ef1..e005e950e4aa 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -115,6 +115,8 @@ export type AiVaultSession = { cwd: string | null /** The host's spellings of `cwd` under a symlinked scope path. Scope matching only; resume reads `cwd`. */ cwdAliases?: string[] + /** Host-derived affiliation; null is unknown, absence uses legacy attribution. */ + projectOwnerKey?: string | null branch: string | null model: string | null filePath: string @@ -216,6 +218,7 @@ export type AiVaultScanIssue = { } export type AiVaultListArgs = { + historyOwnership?: { projectKey?: string } /** Opts a selected runtime-host refresh into content-conditional replies. */ afterFingerprint?: string | null /** Opt-in promises this client starts IDE history in a new CLI conversation. */ diff --git a/src/shared/git-remote-identity.ts b/src/shared/git-remote-identity.ts index 081310b9d1d5..a1ece2eb1b99 100644 --- a/src/shared/git-remote-identity.ts +++ b/src/shared/git-remote-identity.ts @@ -136,3 +136,13 @@ export function deriveGitRemoteIdentity(stdout: string): GitRemoteIdentity | nul } : null } + +export function normalizeSavedGitRemoteIdentity( + identity: GitRemoteIdentity | null | undefined +): GitRemoteIdentity | null { + const canonicalKey = + typeof identity?.canonicalKey === 'string' ? identity.canonicalKey.trim() : '' + const remoteName = typeof identity?.remoteName === 'string' ? identity.remoteName.trim() : '' + const remoteUrl = typeof identity?.remoteUrl === 'string' ? identity.remoteUrl.trim() : '' + return canonicalKey && remoteName && remoteUrl ? { canonicalKey, remoteName, remoteUrl } : null +} diff --git a/src/shared/launch-parity-window-request.test-fixture.ts b/src/shared/launch-parity-window-request.test-fixture.ts index 49d6896e501f..58d2125a63f0 100644 --- a/src/shared/launch-parity-window-request.test-fixture.ts +++ b/src/shared/launch-parity-window-request.test-fixture.ts @@ -103,7 +103,7 @@ const AGENT_ARGS = { } as const function sentRuntime(runtime: ProjectRuntimeSent): Record { - const base = { projectId: LAUNCH_REPO_ID, reason: runtime.reason } + const base = { projectId: `repo:${LAUNCH_REPO_ID}`, reason: runtime.reason } return { status: 'resolved', runtime: @@ -113,13 +113,13 @@ function sentRuntime(runtime: ProjectRuntimeSent): Record { kind: 'wsl', hostPlatform: 'wsl', distro: runtime.distro, - cacheKey: `${LAUNCH_REPO_ID}:wsl:${runtime.distro}` + cacheKey: `repo:${LAUNCH_REPO_ID}:wsl:${runtime.distro}` } : { ...base, kind: 'windows-host', hostPlatform: 'win32', - cacheKey: `${LAUNCH_REPO_ID}:windows-host` + cacheKey: `repo:${LAUNCH_REPO_ID}:windows-host` } } } diff --git a/src/shared/project-catalog-canonical-projection.ts b/src/shared/project-catalog-canonical-projection.ts new file mode 100644 index 000000000000..4ccbc7d5e8e7 --- /dev/null +++ b/src/shared/project-catalog-canonical-projection.ts @@ -0,0 +1,92 @@ +import type { Project, ProjectHostSetup } from './project-types' +import type { Repo } from './repo-types' +import { getProjectHostSetupLookup } from './project-host-setup-lookup' +import { + getRepoDerivedSetupKey, + mergeProjectHostSetupCompatibility +} from './project-compatibility-merge' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from './project-host-setup-projection' + +/** Canonical client affiliation; the supplied endpoint catalog remains untouched. */ +export function resolveProjectHostSetupProjection( + repos: readonly Repo[], + projects: readonly Project[], + setups: readonly ProjectHostSetup[], + derived: ProjectHostSetupProjection = projectHostSetupProjectionFromRepos(repos) +): ProjectHostSetupProjection { + if (setups.some((setup) => setup.setupMethod === 'provisioned' && setup.repoId)) { + derived = projectHostSetupProjectionFromRepos(repos, undefined, setups) + } + const owners = getProjectHostSetupLookup(derived.setups) + const projectById = new Map(projects.map((project) => [project.id, project])) + const replacedProjectIds = new Set() + const unresolved = new Set( + derived.projects.flatMap((project) => + project.providerIdentity || project.gitRemoteIdentity ? [] : [project.id] + ) + ) + const metadataByProject = new Map() + const normalizedSetups = setups.map((setup) => { + const projectId = owners.projectId({ + repoId: setup.repoId, + hostId: setup.executionHostId ?? setup.hostId, + runtimeOwnerEnvironmentId: setup.runtimeOwnerEnvironmentId + }) + if (!projectId || unresolved.has(projectId)) { + return setup + } + const metadata = projectById.get(setup.projectId) + if (metadata && !metadataByProject.has(projectId)) { + metadataByProject.set(projectId, metadata) + } + if (projectId === setup.projectId) { + return setup + } + replacedProjectIds.add(setup.projectId) + return { ...setup, projectId } + }) + const covered = new Set(setups.map(getRepoDerivedSetupKey)) + if ( + replacedProjectIds.size === 0 && + derived.setups.every((setup) => covered.has(getRepoDerivedSetupKey(setup))) + ) { + return { projects, setups } + } + const allSetups = mergeProjectHostSetupCompatibility( + { projects: derived.projects, projectHostSetups: derived.setups }, + { projects, setups: normalizedSetups } + ).projectHostSetups + const membership = new Map>() + for (const setup of allSetups) { + const ids = membership.get(setup.projectId) ?? new Set() + if (setup.repoId) { + ids.add(setup.repoId) + } + membership.set(setup.projectId, ids) + } + const canonicalProjects = derived.projects + .filter((project) => membership.has(project.id)) + .map((project) => ({ + ...project, + ...metadataByProject.get(project.id), + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + sourceRepoIds: [...(membership.get(project.id) ?? [])] + })) + const canonicalIds = new Set(canonicalProjects.map((project) => project.id)) + return { + projects: [ + ...canonicalProjects, + ...projects.filter( + (project) => + !canonicalIds.has(project.id) && + (!replacedProjectIds.has(project.id) || membership.has(project.id)) + ) + ], + setups: allSetups + } +} diff --git a/src/shared/project-checkout-claim.ts b/src/shared/project-checkout-claim.ts new file mode 100644 index 000000000000..13af343f3fc0 --- /dev/null +++ b/src/shared/project-checkout-claim.ts @@ -0,0 +1,72 @@ +import { getRepoSetupOwnerScope } from './repo-host-identity' +import type { ProjectHostSetup } from './project-types' +import { parseWslUncPath } from './wsl-paths' +import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from './cross-platform-path' + +export type ProjectCheckoutClaim = Pick< + ProjectHostSetup, + | 'id' + | 'projectId' + | 'hostId' + | 'repoId' + | 'path' + | 'kind' + | 'setupMethod' + | 'createdAt' + | 'connectionId' + | 'executionHostId' +> + +export function getOutclaimedProjectSetups( + setups: readonly T[] +): ReadonlySet { + const claimantBySurface = new Map() + for (const setup of setups) { + if (!isDistinctUserCheckout(setup)) { + continue + } + const surfaceKey = getProjectSetupSurfaceKey(setup) + const claimant = claimantBySurface.get(surfaceKey) + if (!claimant || compareClaims(setup, claimant) < 0) { + claimantBySurface.set(surfaceKey, setup) + } + } + return new Set( + setups.filter((setup) => { + const claimant = claimantBySurface.get(getProjectSetupSurfaceKey(setup)) + return ( + isDistinctUserCheckout(setup) && + claimant !== undefined && + getCheckoutIdentity(claimant) !== getCheckoutIdentity(setup) + ) + }) + ) +} + +function isDistinctUserCheckout(setup: ProjectCheckoutClaim): boolean { + return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' +} + +// Windows and WSL namespaces compete independently while retaining cross-surface association. +function getProjectSetupSurfaceKey(setup: ProjectCheckoutClaim): string { + return `${setup.projectId}::${getRepoSetupOwnerScope(setup)}::${getPathSurface(setup)}` +} + +function getPathSurface(setup: ProjectCheckoutClaim): string { + const wslPath = parseWslUncPath(setup.path) + return wslPath + ? `wsl:${wslPath.distro.toLowerCase()}` + : isWindowsAbsolutePathLike(setup.path) + ? 'windows-host' + : 'default' +} + +function getCheckoutIdentity(setup: ProjectCheckoutClaim): string { + return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id +} + +// Stable addedAt/id order makes client and server claims agree despite enumeration order. +function compareClaims(left: ProjectCheckoutClaim, right: ProjectCheckoutClaim): number { + const byAddedAt = left.createdAt - right.createdAt + return byAddedAt || (left.repoId < right.repoId ? -1 : left.repoId > right.repoId ? 1 : 0) +} diff --git a/src/shared/project-host-setup-hydration.test.ts b/src/shared/project-host-setup-hydration.test.ts new file mode 100644 index 000000000000..c39df74cd384 --- /dev/null +++ b/src/shared/project-host-setup-hydration.test.ts @@ -0,0 +1,91 @@ +import { expect, it } from 'vitest' +import { resolveProjectHostSetupProjection } from './project-catalog-canonical-projection' +import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' +import { adoptEndpointProjectCatalog, adoptEndpointRepo } from './endpoint-catalog-adoption' +import type { Repo } from './repo-types' + +it('retains nested SSH execution ownership without duplicating derived fallback setups', () => { + const endpoint = { kind: 'environment', environmentId: 'server' } as const + const native: Repo[] = ['one', 'two'].map((connectionId) => ({ + id: 'same', + path: `/${connectionId}/app`, + connectionId, + displayName: connectionId, + addedAt: 1, + badgeColor: '', + upstream: { owner: 'acme', repo: 'app' } + })) + const nativeProjection = projectHostSetupProjectionFromRepos(native) + const raw = adoptEndpointProjectCatalog( + endpoint, + nativeProjection.projects.map((project) => ({ ...project, id: 'repo:legacy' })), + nativeProjection.setups.map((setup) => ({ ...setup, projectId: 'repo:legacy' })) + ) + const repos = native.map((repo) => adoptEndpointRepo(endpoint, repo)) + const canonical = resolveProjectHostSetupProjection(repos, raw.projects, raw.setups) + expect(canonical.setups).toHaveLength(2) + expect( + canonical.setups.map((setup) => [setup.hostId, setup.executionHostId, setup.projectId]) + ).toEqual([ + ['runtime:server', 'ssh:one', 'github:acme/app'], + ['runtime:server', 'ssh:two', 'github:acme/app'] + ]) + expect(raw.setups.every((setup) => setup.projectId === 'repo:legacy')).toBe(true) +}) + +it('preserves unresolved legacy project rows during a partial repo-only catalog hydrate', () => { + const repo: Repo = { id: 'a', path: '/a', displayName: 'A', addedAt: 1, badgeColor: '' } + const legacy = { + id: 'legacy', + displayName: 'Legacy', + sourceRepoIds: ['not-loaded'], + createdAt: 1, + updatedAt: 1, + badgeColor: '' + } + expect(resolveProjectHostSetupProjection([repo], [legacy], []).projects).toContainEqual(legacy) +}) + +it('qualifies provisioned setup facts by nested execution owner while claims retain repo addedAt', () => { + const endpoint = { kind: 'environment', environmentId: 'server' } as const + const native: Repo[] = [ + { id: 'same', connectionId: 'one', path: '/one/copy', addedAt: 1 }, + { id: 'same', connectionId: 'two', path: '/two/user', addedAt: 1 }, + { id: 'user', connectionId: 'one', path: '/one/user', addedAt: 2 }, + { id: 'later', connectionId: 'one', path: '/one/later', addedAt: 3 } + ].map((repo) => ({ + ...repo, + displayName: repo.id, + badgeColor: '', + upstream: { owner: 'acme', repo: 'app' } + })) + const projection = projectHostSetupProjectionFromRepos(native) + const raw = adoptEndpointProjectCatalog( + endpoint, + [ + { ...projection.projects[0], id: 'github:acme/app', sourceRepoIds: ['same', 'user', 'later'] } + ], + projection.setups.map((setup) => ({ + ...setup, + projectId: 'github:acme/app', + setupMethod: + setup.repoId === 'same' && setup.connectionId === 'one' + ? 'provisioned' + : 'imported-existing-folder', + createdAt: setup.repoId === 'later' ? 0 : 99 + })) + ) + const canonical = resolveProjectHostSetupProjection( + native.map((repo) => adoptEndpointRepo(endpoint, repo)), + raw.projects, + raw.setups + ) + expect(canonical.setups.find((setup) => setup.repoId === 'user')?.projectId).toBe( + 'github:acme/app' + ) + expect(canonical.setups.find((setup) => setup.repoId === 'later')?.projectId).toBe('repo:later') + expect( + canonical.setups.find((setup) => setup.repoId === 'same' && setup.executionHostId === 'ssh:one') + ?.setupMethod + ).toBe('provisioned') +}) diff --git a/src/shared/project-host-setup-hydration.ts b/src/shared/project-host-setup-hydration.ts index 50096689dcae..75f40a794baf 100644 --- a/src/shared/project-host-setup-hydration.ts +++ b/src/shared/project-host-setup-hydration.ts @@ -2,7 +2,7 @@ import type { Project, ProjectHostSetup } from './project-types' import type { Repo } from './repo-types' import { getProjectIdentityKey, - projectHostSetupProjectionFromRepos, + projectHostSetupCompatibilityFromRepos, type ProjectHostSetupProjection } from './project-host-setup-projection' @@ -103,7 +103,7 @@ export function resolveProjectHostSetupProjection( repos: readonly Repo[], projects: readonly Project[], setups: readonly ProjectHostSetup[], - derived: ProjectHostSetupProjection = projectHostSetupProjectionFromRepos(repos) + derived: ProjectHostSetupProjection = projectHostSetupCompatibilityFromRepos(repos) ): ProjectHostSetupProjection { const normalized = normalizeHydratedProjectHostSetupProjection(repos, projects, setups, derived) // Older runtimes/profiles may provide partial/empty project rows beside repos; keep repo-backed fallbacks visible. diff --git a/src/shared/project-host-setup-lookup.test.ts b/src/shared/project-host-setup-lookup.test.ts new file mode 100644 index 000000000000..7dc42a7b1d20 --- /dev/null +++ b/src/shared/project-host-setup-lookup.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' +import { getProjectHostSetupLookup } from './project-host-setup-lookup' + +it('keeps host twins, nested SSH and independent setups distinct while resolving current affiliation', () => { + const owners = getProjectHostSetupLookup([ + { id: 'repo', repoId: 'repo', hostId: 'local', projectId: 'local-project' }, + { id: 'repo', repoId: 'repo', hostId: 'runtime:hub', projectId: 'hub-project' }, + { + id: 'nested', + repoId: 'repo', + hostId: 'runtime:hub', + executionHostId: 'ssh:target', + projectId: 'nested-project' + }, + { id: 'independent', repoId: '', hostId: 'local', projectId: 'independent-project' } + ]) + const legacy = { repoId: 'repo', projectId: 'saved-project' } + expect(owners.projectId({ ...legacy, hostId: 'local' })).toBe('local-project') + expect(owners.projectId({ ...legacy, projectHostSetupId: 'repo', hostId: 'runtime:hub' })).toBe( + 'hub-project' + ) + expect( + owners.projectId({ ...legacy, hostId: 'ssh:target', runtimeOwnerEnvironmentId: 'hub' }) + ).toBe('nested-project') + expect(owners.projectId({ ...legacy, hostId: 'local', projectHostSetupId: 'independent' })).toBe( + 'independent-project' + ) + expect(owners.projectId(legacy)).toBe('saved-project') + expect(owners.projectId({ ...legacy, hostId: 'runtime:other' })).toBe('saved-project') + expect(owners.projectId({ ...legacy, hostId: 'not-a-host' })).toBe('saved-project') + expect( + owners.projectId({ ...legacy, hostId: 'local', projectHostSetupId: 'missing-independent' }) + ).toBe('saved-project') +}) diff --git a/src/shared/project-host-setup-lookup.ts b/src/shared/project-host-setup-lookup.ts index d7d59f4cef94..e9c7681c554c 100644 --- a/src/shared/project-host-setup-lookup.ts +++ b/src/shared/project-host-setup-lookup.ts @@ -1,8 +1,107 @@ -import { getRepoExecutionHostId } from './execution-host' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + toRuntimeExecutionHostId +} from './execution-host' import type { ProjectHostSetup } from './project-types' +import { getRepoHostIdentityForParts, getRepoSetupOwnerScope } from './repo-host-identity' import type { Repo } from './repo-types' import type { WorktreeMeta } from './worktree/meta-types' import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' +import { resolveProjectHostSetupProjection } from './project-host-setup-hydration' + +type SetupOwner = Pick & + Partial> + +export type WorkspaceProjectOwner = { + repoId: string + projectId?: string | null + projectHostSetupId?: string + hostId?: string | null + runtimeOwnerEnvironmentId?: string +} + +const ownerLookupCache = new WeakMap() +const EMPTY_SETUPS: readonly SetupOwner[] = [] + +type ProjectHostSetupLookup = { + forWorkspace: (workspace: WorkspaceProjectOwner) => SetupOwner | undefined + projectId: (workspace: WorkspaceProjectOwner) => string | undefined +} + +/** Current checkout ownership outranks a saved association, without guessing an absent owner. */ +export function getProjectHostSetupLookup( + setups: readonly SetupOwner[] = EMPTY_SETUPS +): ProjectHostSetupLookup { + const cached = ownerLookupCache.get(setups) + if (cached) { + return cached + } + const byRepo = new Map() + const bySetup = new Map() + const ownerKey = getRepoHostIdentityForParts + const scope = getRepoSetupOwnerScope + const add = (index: Map, key: string, setup: SetupOwner) => { + const prior = index.get(key) + index.set( + key, + prior === undefined || + (prior && prior.projectId === setup.projectId && scope(prior) === scope(setup)) + ? setup + : null + ) + } + for (const setup of setups) { + for (const [index, id] of [ + [byRepo, setup.repoId], + [bySetup, setup.id ?? setup.repoId] + ] as const) { + if (!id) { + continue + } + add(index, id, setup) + add(index, ownerKey(id, scope(setup)), setup) + if (scope(setup) !== setup.hostId) { + add(index, ownerKey(id, setup.hostId ?? 'local'), setup) + } + } + } + const forWorkspace = (workspace: WorkspaceProjectOwner): SetupOwner | undefined => { + const host = normalizeExecutionHostId(workspace.hostId) + if (workspace.hostId && !host) { + return undefined + } + const transport = workspace.runtimeOwnerEnvironmentId + ? toRuntimeExecutionHostId(workspace.runtimeOwnerEnvironmentId) + : host + const ownerScope = + transport && host?.startsWith('ssh:') && transport !== host + ? `${transport}\0${host}` + : transport + const key = (id: string) => (ownerScope ? ownerKey(id, ownerScope) : id) + if (workspace.projectHostSetupId) { + const setupKey = key(workspace.projectHostSetupId) + if (bySetup.has(setupKey)) { + const setup = bySetup.get(setupKey) + return setup?.repoId === workspace.repoId || !setup?.repoId + ? (setup ?? undefined) + : undefined + } + // Independent setup metadata must not be reassigned to the repo's default setup. + if (workspace.projectHostSetupId !== workspace.repoId) { + return undefined + } + } + return byRepo.get(key(workspace.repoId)) ?? undefined + } + const lookup = { + forWorkspace, + projectId: (workspace: WorkspaceProjectOwner) => + forWorkspace(workspace)?.projectId ?? workspace.projectId ?? undefined + } + ownerLookupCache.set(setups, lookup) + return lookup +} export function getProjectHostSetupForRepo( setups: readonly ProjectHostSetup[], @@ -21,10 +120,20 @@ export function getProjectHostSetupWorktreeMeta( setups: readonly ProjectHostSetup[], repo: Repo ): Pick { - const setup = getProjectHostSetupForRepo(setups, repo) + const setup = getProjectHostSetupForRepo( + resolveProjectHostSetupProjection([repo], [], setups).setups, + repo + ) return { projectId: setup.projectId, hostId: setup.hostId, projectHostSetupId: setup.id } } + +export function getProjectHostSetupsForProject( + setups: readonly ProjectHostSetup[], + projectId: string +): readonly ProjectHostSetup[] { + return setups.filter((setup) => setup.projectId === projectId) +} diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index aade8b166117..ed4e89f476db 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import { projectHostSetupProjectionFromRepos, - getProjectHostSetupsForProject, getProjectIdForProviderIdentity, isProjectRemoteIdentityPending } from './project-host-setup-projection' -import { getProjectHostSetupWorktreeMeta } from './project-host-setup-lookup' +import { + getProjectHostSetupsForProject, + getProjectHostSetupWorktreeMeta +} from './project-host-setup-lookup' import type { Repo } from './repo-types' function repo(overrides: Partial & Pick): Repo { @@ -18,6 +20,53 @@ function repo(overrides: Partial & Pick { + it('lets a shared remote join checkouts across hosts but not two checkouts on one host', () => { + const upstream = { owner: 'stablyai', repo: 'orca' } + const firstLocal = repo({ id: 'local-a', path: '/a', displayName: 'a', addedAt: 100, upstream }) + const secondLocal = repo({ + id: 'local-b', + path: '/b', + displayName: 'b', + addedAt: 200, + upstream + }) + const remoteCopy = repo({ + id: 'remote-a', + path: '/home/a', + displayName: 'a', + addedAt: 300, + connectionId: 'gpu-vm', + upstream + }) + + const projection = projectHostSetupProjectionFromRepos([secondLocal, remoteCopy, firstLocal]) + expect(projection.setups.map((setup) => [setup.repoId, setup.projectId])).toEqual([ + ['local-b', 'repo:local-b'], + ['remote-a', 'github:stablyai/orca'], + ['local-a', 'github:stablyai/orca'] + ]) + + // A WSL checkout is another surface of the same machine, so it joins the project too. + const wslCopy = repo({ + id: 'local-wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\a\\orca', + displayName: 'a', + addedAt: 400, + upstream + }) + expect( + projectHostSetupProjectionFromRepos([firstLocal, wslCopy]).projects.map( + (p) => p.sourceRepoIds + ) + ).toEqual([['local-a', 'local-wsl']]) + + // Removing the claimant hands the shared id to the next checkout on that host. + const handedOff = projectHostSetupProjectionFromRepos([secondLocal, remoteCopy]) + expect(handedOff.projects.map((project) => [project.id, project.sourceRepoIds])).toEqual([ + ['github:stablyai/orca', ['local-b', 'remote-a']] + ]) + }) + it('keeps timestamps stable when addedAt is 0 across different now values', () => { const target = repo({ id: 'repo-1', @@ -300,8 +349,9 @@ describe('project host setup projection', () => { }), repo({ id: 'old-branch-checkout', - path: '/Users/alice/orca/workspaces/orca/re-enable-webgl-for-remote-runtime-terminals', + path: '/home/alice/orca/workspaces/orca/re-enable-webgl-for-remote-runtime-terminals', displayName: 're-enable-webgl-for-remote-runtime-terminals', + connectionId: 'gpu-vm', repoIcon: { type: 'image', src: 'https://github.com/stablyai.png?size=64', @@ -621,6 +671,7 @@ it('appends project source IDs without repeatedly copying an accumulating array' id: `source-${i}`, path: `/repos/${i}`, displayName: `Repo ${i}`, + connectionId: `host-${i}`, upstream: { owner: 'acme', repo: 'project' } }) ) diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index eb6417688959..d936326156bf 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -1,3 +1,4 @@ +import { normalizeSavedGitRemoteIdentity } from './git-remote-identity' import { getRepoExecutionHostId } from './execution-host' import { normalizeProjectHostSetupRow, @@ -7,6 +8,8 @@ import { normalizeGitHubRemoteHost } from './git-remote-host-alias' import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types' import type { Repo } from './repo-types' +import { getRepoSetupOwnerScope } from './repo-host-identity' +import { getOutclaimedProjectSetups } from './project-checkout-claim' /** Own fresh mutable project drafts: using persisted/caller rows leaks mergeProjectRepo writes. Mirror sourceRepoIds in a set * to avoid membership rescans. */ @@ -68,18 +71,9 @@ export function getGitRemoteProviderIdentity( ) } -function getProjectGitRemoteIdentity( - repo: Pick -): NonNullable | null { - const identity = repo.gitRemoteIdentity - const canonicalKey = - typeof identity?.canonicalKey === 'string' ? identity.canonicalKey.trim() : '' - const remoteName = typeof identity?.remoteName === 'string' ? identity.remoteName.trim() : '' - const remoteUrl = typeof identity?.remoteUrl === 'string' ? identity.remoteUrl.trim() : '' - return canonicalKey && remoteName && remoteUrl ? { canonicalKey, remoteName, remoteUrl } : null -} - -/** Limit gh setup prompts to GitHub-backed projects proven by remote, explicit upstream or generated GitHub avatar. */ +/** True when the repo resolves to a GitHub provider identity (via explicit + * upstream or a GitHub-sourced avatar icon). Used to scope GitHub-CLI setup + * prompts to users who actually have GitHub-backed projects. */ export function isGitHubBackedRepo( repo: Pick ): boolean { @@ -89,7 +83,10 @@ export function isGitHubBackedRepo( export function hasProjectRemoteIdentity( repo: Pick ): boolean { - return getProjectProviderIdentity(repo) !== null || getProjectGitRemoteIdentity(repo) !== null + return ( + getProjectProviderIdentity(repo) !== null || + normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) !== null + ) } /** Undefined remote identity is pending/unreachable, distinct from resolved null (checked, unusable). @@ -111,7 +108,7 @@ export function getProjectIdentityKey( if (identity) { return getProjectIdForProviderIdentity(identity) } - const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) + const gitRemoteIdentity = normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) if (gitRemoteIdentity) { return `${GIT_REMOTE_PROJECT_ID_PREFIX}${gitRemoteIdentity.canonicalKey}` } @@ -127,12 +124,6 @@ export function getProjectIdForProviderIdentity(identity: ProjectProviderIdentit return `github:${githubRepoIdentityKey(identity)}` } -function getProjectId( - repo: Pick -): string { - return getProjectIdentityKey(repo) -} - function isGitHubRemoteHost(host: string): boolean { const hostname = host.toLowerCase().replace(/:\d+$/, '') // A generic git remote is provider-neutral. Only infer GHES when the host @@ -246,7 +237,7 @@ export function mergeCatalogUpdatedAt(left: number, right: number): number { function createProjectFromRepo(repo: Repo, projectId: string): Project { const identity = getProjectProviderIdentity(repo) - const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) + const gitRemoteIdentity = normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) const addedAt = catalogTimestampFromAddedAt(repo.addedAt) return { id: projectId, @@ -301,22 +292,53 @@ function createSetupFromRepo(repo: Repo, projectId: string): ProjectHostSetup { export function projectHostSetupProjectionFromRepos( repos: readonly Repo[], - _now?: number + _now?: number, + setupFacts: readonly ProjectHostSetup[] = [] +): ProjectHostSetupProjection { + return projectHostSetupProjection(repos, setupFacts, true) +} + +export function projectHostSetupCompatibilityFromRepos( + repos: readonly Repo[] +): ProjectHostSetupProjection { + return projectHostSetupProjection(repos, [], false) +} + +function projectHostSetupProjection( + repos: readonly Repo[], + setupFacts: readonly ProjectHostSetup[], + claims: boolean ): ProjectHostSetupProjection { const projectById = new Map() const setups: ProjectHostSetup[] = [] - for (const repo of repos) { - const projectId = getProjectId(repo) - const existing = projectById.get(projectId) + // Why normalize here: a repo row is untrusted persisted/wire data too, and these + // constructors copy repo.path / repo.id straight onto fields consumers call .trim() on. + const factsByOwner = new Map( + setupFacts.map((setup) => [ + JSON.stringify([getRepoSetupOwnerScope(setup), setup.repoId]), + setup + ]) + ) + const remoteSetups = repos.map((repo) => { + const setup = createSetupFromRepo(repo, getProjectIdentityKey(repo)) + const facts = factsByOwner.get(JSON.stringify([getRepoSetupOwnerScope(setup), repo.id])) + const setupMethod = facts?.setupMethod ?? setup.setupMethod + return normalizeProjectHostSetupRow({ ...setup, setupMethod }) + }) + const outclaimed = claims ? getOutclaimedProjectSetups(remoteSetups) : new Set() + for (const [index, repo] of repos.entries()) { + const remoteSetup = remoteSetups[index] + const setup = outclaimed.has(remoteSetup) + ? { ...remoteSetup, projectId: `${HOST_LOCAL_PROJECT_ID_PREFIX}${repo.id}` } + : remoteSetup + const existing = projectById.get(setup.projectId) if (existing) { mergeProjectRepo(existing, repo) } else { - const project = createProjectFromRepo(repo, projectId) - projectById.set(projectId, { project, sourceRepoIds: new Set(project.sourceRepoIds) }) + const project = createProjectFromRepo(repo, setup.projectId) + projectById.set(setup.projectId, { project, sourceRepoIds: new Set(project.sourceRepoIds) }) } - // Repo rows are untrusted too; normalize constructors copying id/path into fields callers trim. - const setup = normalizeProjectHostSetupRow(createSetupFromRepo(repo, projectId)) setups.push(setup) } @@ -325,10 +347,3 @@ export function projectHostSetupProjectionFromRepos( setups } } - -export function getProjectHostSetupsForProject( - setups: readonly ProjectHostSetup[], - projectId: string -): readonly ProjectHostSetup[] { - return setups.filter((setup) => setup.projectId === projectId) -} diff --git a/src/shared/project-identity-succession.ts b/src/shared/project-identity-succession.ts index 25968ae7558b..ac2458173e28 100644 --- a/src/shared/project-identity-succession.ts +++ b/src/shared/project-identity-succession.ts @@ -1,4 +1,6 @@ -import type { Project } from './project-types' +import { getRepoExecutionHostId } from './execution-host' +import type { Project, ProjectHostSetup } from './project-types' +import { getRepoSetupOwnerScope } from './repo-host-identity' export type ProjectIdentitySuccession = { /** Projected projects with user-set state carried forward from their predecessor row. */ @@ -21,12 +23,18 @@ function compareStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } -/** Carry user state/references through repo→git→github identity promotion and re-probes, preserving preferences without ghost rows. - * Choose predecessors by shared source repos, newest updatedAt, then lowest prior/new IDs. Claim each prior once: - * never merge distinct preferences; unmatched rows retain their own identity. */ +/** + * Carries persisted per-project user state (and the rows that point at it) across a + * project id change. Project ids are derived from repo identity, so `repo:` -> `git:` -> + * `github:` promotion and remote re-probes rewrite them mid-session; matching only on id + * would drop the user's runtime preference and leave a ghost row behind. + * + * Association remapping stays one-to-one; preference continuity follows concrete owners. + */ export function carryProjectStateThroughIdentityChange( projectedProjects: readonly Project[], - previousProjects: readonly Project[] + previousProjects: readonly Project[], + membership?: { previous: readonly ProjectHostSetup[]; current: readonly ProjectHostSetup[] } ): ProjectIdentitySuccession { const previousById = new Map(previousProjects.map((project) => [project.id, project])) const projectedIds = new Set(projectedProjects.map((project) => project.id)) @@ -94,5 +102,82 @@ export function carryProjectStateThroughIdentityChange( remappedProjectIds.set(predecessor.id, project.id) return carryUserState(project, predecessor) }) + if (membership) { + const ownerKey = (setup: ProjectHostSetup) => + `${getRepoSetupOwnerScope(setup)}\0${setup.repoId}` + const currentByOwner = new Map>() + for (const setup of membership.current) { + if (!setup.repoId) { + continue + } + const key = ownerKey(setup) + const ids = currentByOwner.get(key) ?? new Set() + ids.add(setup.projectId) + currentByOwner.set(key, ids) + } + const successors = new Map>() + for (const setup of membership.previous) { + if (projectedIds.has(setup.projectId) || !setup.repoId) { + continue + } + const ids = successors.get(setup.projectId) ?? new Set() + for (const id of currentByOwner.get(ownerKey(setup)) ?? []) { + ids.add(id) + } + successors.set(setup.projectId, ids) + } + for (const [previousId, ids] of successors) { + remappedProjectIds.delete(previousId) + if (ids.size === 1) { + remappedProjectIds.set(previousId, [...ids][0]!) + } + } + const local = (setup: ProjectHostSetup) => + setup.hostId === 'local' && getRepoExecutionHostId(setup) === 'local' + const previousByRepo = new Map() + const recordedRepoIds = new Set(membership.previous.map((setup) => setup.repoId)) + for (const previous of previousProjects) { + for (const repoId of previous.sourceRepoIds) { + if (!recordedRepoIds.has(repoId)) { + previousByRepo.set(repoId, previous) + } + } + } + for (const setup of membership.previous) { + if (!local(setup)) { + continue + } + const previous = previousById.get(setup.projectId) + if (previous) { + previousByRepo.set(setup.repoId, previous) + } + } + const predecessors = new Map>() + for (const setup of membership.current) { + if (!local(setup)) { + continue + } + const previous = previousByRepo.get(setup.repoId) + if (!previous) { + continue + } + const owners = predecessors.get(setup.projectId) ?? new Set() + owners.add(previous) + predecessors.set(setup.projectId, owners) + } + for (const [index, project] of projects.entries()) { + const owners = predecessors.get(project.id) + // Only an unambiguous surviving local owner can carry a preference. + if (owners?.size !== 1) { + continue + } + const previous = [...owners][0]! + const inherited = { ...carryUserState(project, previous) } + if (!previous.localWindowsRuntimePreference) { + delete inherited.localWindowsRuntimePreference + } + projects[index] = inherited + } + } return { projects, remappedProjectIds } } diff --git a/src/shared/project-section-catalog-claims.ts b/src/shared/project-section-catalog-claims.ts new file mode 100644 index 000000000000..3c604c01246d --- /dev/null +++ b/src/shared/project-section-catalog-claims.ts @@ -0,0 +1,39 @@ +import type { ServedProjectSections } from './project-section-catalog-schema' +import { getOutclaimedProjectSetups } from './project-checkout-claim' +import { getRepoSetupOwnerScope } from './repo-host-identity' +import { getRepoExecutionHostId } from './execution-host' + +/** Old publishers advertise association without the remote metadata used by full hydration. */ +export function resolveServedProjectSectionClaims( + catalog: ServedProjectSections +): ServedProjectSections { + const addedAtByOwner = new Map( + catalog.repos.map((repo) => [ + `${getRepoSetupOwnerScope({ ...repo, hostId: getRepoExecutionHostId(repo) })}\0${repo.id}`, + Number.isFinite(repo.addedAt) ? repo.addedAt! : 0 + ]) + ) + const claims = catalog.setups.map((setup) => ({ + ...setup, + createdAt: + addedAtByOwner.get(`${getRepoSetupOwnerScope(setup)}\0${setup.repoId}`) ?? + Number.POSITIVE_INFINITY + })) + const outclaimed = getOutclaimedProjectSetups(claims) + if (!outclaimed.size) { + return catalog + } + const projects = new Map(catalog.projects.map((project) => [project.id, project])) + const setups = catalog.setups.map((setup, index) => { + if (!outclaimed.has(claims[index]!)) { + return setup + } + const projectId = `repo:${setup.repoId}` + if (!projects.has(projectId)) { + projects.set(projectId, { id: projectId, displayName: setup.displayName }) + } + return { ...setup, projectId } + }) + const occupied = new Set(setups.map((setup) => setup.projectId)) + return { ...catalog, projects: [...projects.values()].filter((p) => occupied.has(p.id)), setups } +} diff --git a/src/shared/project-section-grouping.ts b/src/shared/project-section-grouping.ts index d26a16abfb10..7a2b07991004 100644 --- a/src/shared/project-section-grouping.ts +++ b/src/shared/project-section-grouping.ts @@ -2,11 +2,8 @@ import type { ExecutionHostId } from './execution-host' import { orderProjectSections, type ActivityItem } from './project-section-order' import type { ProjectOrderBy } from './ui-chrome-types' -import { toSshExecutionHostId } from './execution-host' import type { Project, ProjectHostSetup } from './project-types' import type { Repo } from './repo-types' -import { parseWslUncPath } from './wsl-paths' -import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from './cross-platform-path' /** The project fields grouping reads. */ export type ProjectSectionProject = Pick @@ -32,7 +29,6 @@ export type ProjectSectionModel = { export type ProjectGroupingIndex = { projectById: Map setupByRepoId: Map - surfaceKeysRequiringSetupGroups: Set } /** The repo fields section grouping, labelling and ordering read. */ @@ -57,40 +53,6 @@ export type ProjectSectionEntry = { const projectGroupingIndexCache = new WeakMap() -// Why: provisioned and folder setups are not independent Git checkouts. -function isDistinctUserCheckout(setup: ProjectSectionSetup): boolean { - return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' -} - -// Why: execution target and filesystem namespace independently identify a surface. -function getProjectSetupSurfaceKey(setup: ProjectSectionSetup): string { - return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}` -} - -function getExecutionSurface(setup: ProjectSectionSetup): string { - const connectionId = setup.connectionId?.trim() - if (connectionId) { - return toSshExecutionHostId(connectionId) - } - return setup.executionHostId?.trim() || setup.hostId -} - -// Why: projection twins differ by row identity, not checkout directory. -function getCheckoutIdentity(setup: ProjectSectionSetup): string { - return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id -} - -function getPathSurface(setup: ProjectSectionSetup): string { - const wslPath = parseWslUncPath(setup.path) - if (wslPath) { - return `wsl:${wslPath.distro.toLowerCase()}` - } - if (isWindowsAbsolutePathLike(setup.path)) { - return 'windows-host' - } - return 'default' -} - export function buildProjectGroupingIndex( model?: ProjectSectionModel ): ProjectGroupingIndex | null { @@ -107,45 +69,14 @@ export function buildProjectGroupingIndex( projectGroupingIndexCache.set(model, null) return null } - const checkoutsByProjectSurface = new Map>() - for (const setup of projectHostSetups) { - if (!isDistinctUserCheckout(setup)) { - continue - } - const key = getProjectSetupSurfaceKey(setup) - const existing = checkoutsByProjectSurface.get(key) - if (existing) { - existing.add(getCheckoutIdentity(setup)) - } else { - checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)])) - } - } - const surfaceKeysRequiringSetupGroups = new Set() - for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) { - if (checkouts.size > 1) { - surfaceKeysRequiringSetupGroups.add(surfaceKey) - } - } const index = { projectById: new Map(projects.map((project) => [project.id, project])), - setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), - surfaceKeysRequiringSetupGroups + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])) } projectGroupingIndexCache.set(model, index) return index } -// Why: Settings splits projects by this same rule so it matches the sidebar headers. -export function isCheckoutScopedProjectSetup( - setup: ProjectSectionSetup, - projectIndex: ProjectGroupingIndex -): boolean { - return ( - projectIndex.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) && - isDistinctUserCheckout(setup) - ) -} - export type ProjectHeaderRevealTarget = Repo> = { key: string label: string @@ -168,16 +99,8 @@ export function getProjectGroupingForRepo + setup.repoId === 'local-workspace' || setup.repoId === 'm4-orca-two' + ? { ...setup, projectId: `repo:${setup.repoId}` } + : setup + ) +} diff --git a/src/shared/repo-host-identity.ts b/src/shared/repo-host-identity.ts index 0f8dfad70970..b115a1e5c87f 100644 --- a/src/shared/repo-host-identity.ts +++ b/src/shared/repo-host-identity.ts @@ -1,4 +1,8 @@ -import { getRepoExecutionHostId, type ExecutionHostId } from './execution-host' +import { + getRepoExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from './execution-host' import type { Repo } from './repo-types' declare const hostScopedRepoKeyBrand: unique symbol @@ -28,3 +32,13 @@ export function getRepoRegistrationHostIds( ): Set { return new Set(repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId)) } + +export function getRepoSetupOwnerScope( + setup: Pick & { hostId?: string } +): string { + const host = setup.hostId ?? 'local' + const execution = setup.connectionId + ? toSshExecutionHostId(setup.connectionId) + : getRepoExecutionHostId(setup) + return execution.startsWith('ssh:') && execution !== host ? `${host}\0${execution}` : host +} diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts index eeca524e47d9..bc2dd92f1cf8 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -30,6 +30,7 @@ export const AiVaultListSessionsParams = z ) .pipe(z.union([z.number().int(), z.undefined()])) .optional(), + historyOwnership: z.object({ projectKey: z.string().min(1).max(1024).optional() }).optional(), includeAntigravityIdeSessions: OptionalBoolean, unlimited: OptionalBoolean, force: OptionalBoolean, diff --git a/src/shared/sidebar-list/sidebar-project-catalog.ts b/src/shared/sidebar-list/sidebar-project-catalog.ts index cb2983e96dc8..bcf6241e08f0 100644 --- a/src/shared/sidebar-list/sidebar-project-catalog.ts +++ b/src/shared/sidebar-list/sidebar-project-catalog.ts @@ -14,7 +14,7 @@ import type { ProjectCatalogRows } from '../project-compatibility-merge' import { mergeFetchedHostProjectCatalog } from '../project-compatibility-host-merge' import type { ProjectGroup } from '../project-group-types' import type { ProjectHostSetupProjection } from '../project-host-setup-projection' -import { resolveProjectHostSetupProjection } from '../project-host-setup-hydration' +import { resolveProjectHostSetupProjection } from '../project-catalog-canonical-projection' import type { Project, ProjectHostSetup } from '../project-types' import type { PersistedUIState } from '../persisted-ui-state-types' import type { Repo } from '../repo-types' diff --git a/src/shared/worktree/ownership.ts b/src/shared/worktree/ownership.ts index 77b97b0cbe52..999a973b65db 100644 --- a/src/shared/worktree/ownership.ts +++ b/src/shared/worktree/ownership.ts @@ -1,4 +1,10 @@ -import { normalizeRuntimePathForComparison, relativePathInsideRoot } from '../cross-platform-path' +import { isFolderRepo } from '../repo-kind' +import { + getRuntimePathBasename, + resolveRuntimePath, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../cross-platform-path' import { parseWslUncPath } from '../wsl-paths' import { isRuntimePathAbsoluteForRepo, @@ -265,3 +271,25 @@ function canClassifyAsExternal( } return true } + +export function managedWorktreeDirectories( + repo: Pick, + settings: Pick +): string[] { + if (isFolderRepo(repo)) { + return [] + } + const configured = new Set( + resolveConfiguredWorktreeBasePaths(repo).map(normalizeRuntimePathForComparison) + ) + const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/, '') + const directories: string[] = [] + for (const layout of buildKnownOrcaWorkspaceLayouts(settings, repo)) { + if (configured.has(normalizeRuntimePathForComparison(layout.path))) { + directories.push(layout.path) + } else if (layout.nestWorkspaces && repoName) { + directories.push(resolveRuntimePath(layout.path, repoName)) + } + } + return directories +} diff --git a/tests/e2e/ai-vault-history-publication.unit.test.ts b/tests/e2e/ai-vault-history-publication.unit.test.ts new file mode 100644 index 000000000000..e23a740c29fc --- /dev/null +++ b/tests/e2e/ai-vault-history-publication.unit.test.ts @@ -0,0 +1,297 @@ +// @vitest-environment happy-dom +import * as crypto from 'node:crypto' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import { + closeTestStores, + createSqliteTestStore, + writePersistedStateJson +} from '../../src/main/persistence/persistence-test-harness' +import { Store } from '../../src/main/persistence/loading-store/store' +import { getDefaultPersistedState } from '../../src/shared/constants' +import { hostWorktreeTarget } from '../../src/shared/worktree/host-qualified-identity' +import { sessionSearchScopeCatalogFromStore } from '../../src/main/ai-vault-search/session-search-store-scope-catalog' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { + AI_VAULT_METHODS, + AiVaultListSessionsParams +} from '../../src/main/runtime/rpc/methods/ai-vault' +import { + configureAiVaultSessionSources, + resetAiVaultSessionListCacheForTests +} from '../../src/main/ai-vault/cached-session-list' +import * as ownership from '../../src/main/ai-vault-search/session-search-project-ownership' +import * as fingerprints from '../../src/main/ai-vault/session-list-fingerprint' +import type { + AiVaultListArgs, + AiVaultListResult, + AiVaultSession +} from '../../src/shared/ai-vault-types' +import * as equality from '../../src/shared/structural-value-equality' +import * as identity from '../../src/renderer/src/components/right-sidebar/ai-vault-session-identity' +import { + useAiVaultSessionRefresh, + resetAiVaultForcedRescanThrottleForTest +} from '../../src/renderer/src/components/right-sidebar/ai-vault-session-refresh' +import { resetInputQuietSchedulerForTest } from '../../src/renderer/src/lib/input-quiet-scheduler' +import { useAppStore } from '../../src/renderer/src/store' +import { aiVaultSearchHitToSession } from '../../src/renderer/src/components/right-sidebar/ai-vault-search-session' +import { searchHit } from '../../src/shared/ai-vault-search-test-fixture' + +const { scan } = vi.hoisted(() => ({ scan: vi.fn<() => Promise>() })) +vi.mock('node:crypto', async (original) => { + const actual = await original() + return { ...actual, createHash: vi.fn(actual.createHash) } +}) +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + realpath: async (path: string) => path +})) +vi.mock('../../src/main/ai-vault/session-scanner-service-spawn', async (original) => ({ + ...(await original()), + scanAiVaultSessionsInService: scan +})) +vi.mock('../../src/main/claude-accounts/claude-profile-installed-router', () => ({ + claudeProfileHistoryDirs: () => [] +})) +vi.mock('../../src/main/ai-vault/opencode-wsl-runtime-preparation', () => ({ + prepareOpenCodeWslReaders: async () => [] +})) + +function transcript(index: number): AiVaultSession { + return { + ...aiVaultSearchHitToSession( + { ...searchHit(), sessionId: `session-${index}`, cwd: '/retained' }, + 'local' + ), + previewMessages: [ + { role: 'user', text: `question-${index}`, timestamp: null }, + { role: 'assistant', text: `answer-${index}`, timestamp: null } + ] + } +} + +async function mountedHistory(count: number) { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + resetAiVaultForcedRescanThrottleForTest() + resetInputQuietSchedulerForTest() + resetAiVaultSessionListCacheForTests() + const initial = useAppStore.getInitialState() + useAppStore.setState(initial, true) + const directory = await fs.mkdtemp(join(tmpdir(), 'sta10541-publication-')) + const dataFile = join(directory, 'state.json') + const state = getDefaultPersistedState(directory) + state.repos = ['a', 'b', 'c'].map((id, index) => ({ + id, + path: `/clones/${id}`, + addedAt: index + 1, + displayName: id, + badgeColor: '' + })) + writePersistedStateJson(dataFile, JSON.stringify(state)) + const store = createSqliteTestStore(Store, { dataFile }) + const target = hostWorktreeTarget({ id: 'a::/retained', hostId: 'local' }) + const setOwner = (owner: string) => + store.setWorktreeMeta(target, { + projectId: owner, + projectHostSetupId: owner.slice('repo:'.length) + }) + setOwner('repo:a') + const capture = vi.fn(() => sessionSearchScopeCatalogFromStore(store, 'local')) + scan.mockResolvedValue({ + sessions: Array.from({ length: count }, (_, index) => transcript(index)), + issues: [], + scannedAt: 'same-scan' + }) + const compile = vi.spyOn(ownership, 'compileSessionSearchProjectOwnership') + const reconcile = vi.spyOn(identity, 'reuseAiVaultListResult') + const compare = vi.spyOn(equality, 'structuralValuesEqualIgnoringUndefined') + const hashes = vi.mocked(crypto.createHash) + let listHashCalls = 0 + const finalize = fingerprints.conditionalAiVaultListResult + const conditional = vi + .spyOn(fingerprints, 'conditionalAiVaultListResult') + .mockImplementation((...args) => { + const before = hashes.mock.calls.length + const result = finalize(...args) + listHashCalls += hashes.mock.calls.length - before + return result + }) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue(undefined) + configureAiVaultSessionSources({ getProjectOwnershipCatalog: capture }) + hashes.mockClear() + const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') + if (!method) { + throw new Error('Missing History RPC') + } + const outputs: AiVaultListResult[] = [] + let payloadReads = 0 + const api = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + aiVault: { + listSessions: async (args: AiVaultListArgs) => { + const params = AiVaultListSessionsParams.parse({ + ...args, + executionHostId: 'runtime:one' + }) + // The real handler performs structured projection, runtime restamping and conditional transfer. + const result = structuredClone(await method.handler(params, { runtime })) + outputs.push(result) + // Observe nested reads by the real comparator after the transport clone. + for (const row of result.sessions) { + for (const message of row.previewMessages) { + const text = message.text + Object.defineProperty(message, 'text', { + enumerable: true, + get: () => { + payloadReads += 1 + return text + } + }) + } + } + return result + }, + cancelListSessions: async () => {}, + onWindowFocused: () => () => {} + } + } + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + let latest: ReturnType | undefined + function Probe({ limit }: { limit: number | 'unlimited' }) { + latest = useAiVaultSessionRefresh([], 'runtime:one', limit) + return null + } + const settle = async () => { + await act(async () => vi.advanceTimersByTimeAsync(1200)) + } + return { + capture, + compile, + reconcile, + compare, + conditional, + listHashCalls: () => listHashCalls, + outputs, + payloadReads: () => payloadReads, + setOwner, + current: () => { + if (!latest) { + throw new Error('History is not mounted') + } + return latest + }, + mount: async (limit: number | 'unlimited' = 'unlimited') => { + await act(async () => root.render(createElement(Probe, { limit }))) + await settle() + }, + refresh: async () => { + await act(async () => latest?.refresh({ background: true })) + await settle() + }, + close: async () => { + await act(async () => root.unmount()) + container.remove() + if (api) { + Object.defineProperty(window, 'api', api) + } else { + Reflect.deleteProperty(window, 'api') + } + useAppStore.setState(initial, true) + resetAiVaultSessionListCacheForTests() + configureAiVaultSessionSources({}) + resetAiVaultForcedRescanThrottleForTest() + resetInputQuietSchedulerForTest() + scan.mockReset() + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() + await closeTestStores() + await fs.rm(directory, { recursive: true, force: true }) + } + } +} + +it('publishes post-cache ownership at an unchanged stamp through Unlimited RPC and keeps nested unchanged identities', async () => { + const history = await mountedHistory(1) + try { + await history.mount() + expect(history.current().sessions[0].projectOwnerKey).toBe('repo:a') + history.setOwner('repo:b') + await history.refresh() + expect(history.outputs.at(-1)?.sessions[0].projectOwnerKey).toBe('repo:b') + expect(history.outputs.at(-1)?.scannedAt).toBe(history.outputs[0].scannedAt) + expect(history.outputs.at(-1)?.fingerprint).toBeUndefined() + expect(history.current().sessions[0].projectOwnerKey).toBe('repo:b') + expect(history.current().sessions[0].executionHostId).toBe('runtime:one') + expect(scan).toHaveBeenCalledTimes(1) + const applied = history.current().scanResult + const sessions = history.current().sessions + const row = sessions[0] + await history.refresh() + expect(history.outputs.at(-1)?.sessions[0].previewMessages).not.toBe(row.previewMessages) + expect(history.current().scanResult).toBe(applied) + expect(history.current().sessions).toBe(sessions) + expect(history.current().sessions[0]).toBe(row) + expect(scan).toHaveBeenCalledTimes(1) + expect(history.capture).toHaveBeenCalledTimes(3) + expect(history.compile).toHaveBeenCalledTimes(3) + expect(history.listHashCalls()).toBe(0) + expect(history.conditional.mock.calls.every(([, after]) => after === undefined)).toBe(true) + + await history.mount(250) + expect(history.listHashCalls()).toBe(1) + history.setOwner('repo:c') + await history.refresh() + expect(history.outputs.at(-1)?.fingerprint).toMatch(/^[0-9a-f]{64}$/) + expect(history.current().sessions[0].projectOwnerKey).toBe('repo:c') + expect(history.listHashCalls()).toBe(2) + } finally { + await history.close() + } +}) + +it.each([20, 200])( + 'bounds repeated actual full refresh work for %i unique nested rows', + async (count) => { + const history = await mountedHistory(count) + try { + await history.mount() + const applied = history.current().scanResult + const sessions = history.current().sessions + const before = history.payloadReads() + history.reconcile.mockClear() + history.compare.mockClear() + for (let repeat = 0; repeat < 3; repeat++) { + await history.refresh() + expect(history.current().scanResult).toBe(applied) + expect(history.current().sessions).toBe(sessions) + expect(history.current().sessions.every((row, index) => row === sessions[index])).toBe(true) + } + expect(history.outputs).toHaveLength(4) + expect(history.reconcile).toHaveBeenCalledTimes(3) + expect(history.compare).toHaveBeenCalledTimes(3 * count) + expect(history.payloadReads() - before).toBe(3 * count * 4) + expect(history.capture).toHaveBeenCalledTimes(4) + expect(history.compile).toHaveBeenCalledTimes(4) + expect(history.listHashCalls()).toBe(0) + expect(history.conditional).toHaveBeenCalledTimes(4) + expect(history.conditional.mock.calls.every(([, after]) => after === undefined)).toBe(true) + expect(history.outputs.every((result) => result.fingerprint === undefined)).toBe(true) + expect(scan).toHaveBeenCalledTimes(1) + } finally { + await history.close() + } + } +) diff --git a/tests/e2e/ai-vault-project-native.unit.test.ts b/tests/e2e/ai-vault-project-native.unit.test.ts new file mode 100644 index 000000000000..b7cb6bdecc93 --- /dev/null +++ b/tests/e2e/ai-vault-project-native.unit.test.ts @@ -0,0 +1,321 @@ +// @vitest-environment happy-dom +import { mkdtemp, realpath, mkdir, symlink, rm } from 'node:fs/promises' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { Repo } from '../../src/shared/repo-types' +import type { SqliteBindings, SqliteStatement } from '../../src/main/sqlite/sqlite-statement' +import SyncDatabase from '../../src/main/sqlite/sync-database' +import { NodeSqliteStatement } from '../../src/main/sqlite/node-sqlite-statement' +import * as ownership from '../../src/main/ai-vault-search/session-search-project-ownership' +import * as claims from '../../src/shared/project-checkout-claim' +import * as projection from '../../src/shared/project-catalog-canonical-projection' +import { projectHostSetupCompatibilityFromRepos } from '../../src/shared/project-host-setup-projection' +import { sessionSearchScopeCatalogFromStore } from '../../src/main/ai-vault-search/session-search-store-scope-catalog' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from '../../src/main/ai-vault-search/session-search-scope-catalog' +import { createSessionSearchService } from '../../src/main/ai-vault-search/session-search-service' +import { setSessionSearchService } from '../../src/main/ai-vault-search/session-search-service-registry' +import { + openSessionSearchHarness, + addSyntheticSession, + markFork, + type SessionSearchHarness +} from '../../src/main/ai-vault-search/session-search-engine-test-fixture' +import { registerAiVaultSearchHandlers } from '../../src/main/ipc/ai-vault-search' +import { aiVaultApi } from '../../src/preload/api/ai-vault-bridge' +import { useAiVaultPanelSearch } from '../../src/renderer/src/components/right-sidebar/use-ai-vault-search' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map Promise>() +})) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn(actual.realpath) } +}) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, handler: (...args: unknown[]) => Promise) => + handlers.set(name, handler) + }, + ipcRenderer: { invoke: (name: string, ...args: unknown[]) => handlers.get(name)!(null, ...args) } +})) +vi.mock('../../src/main/ipc/ai-vault', () => ({ + getActiveRuntimeAiVaultHostInfosResult: () => ({ hostInfos: [] }) +})) +vi.mock('../../src/main/ai-vault/session-scanner-service-spawn', () => ({ + clearSessionSearchInService: async () => undefined +})) + +let harness: SessionSearchHarness | undefined +let root: string | undefined +const agents = ['claude'] as const + +afterEach(async () => { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + vi.restoreAllMocks() + await harness?.close() + if (root) { + await rm(root, { recursive: true, force: true }) + } + harness = undefined + root = undefined +}) + +it('bounds actual panel-to-IPC canonical preparation and native cwd seeks at N and 10N', async () => { + const observations: { + size: number + catalog: number + admissions: number + compilations: number + projections: number + claims: number + claimFacts: number + aliases: number + namespace: number + preparations: number + executions: number + intervalBytes: number + }[] = [] + for (const size of [8, 80]) { + root = await realpath(await mkdtemp(join(tmpdir(), 'sta10541-native-trigger-'))) + const repos: Repo[] = [] + for (let index = 0; index < size; index++) { + const actual = join(root, `real-${index}`) + const path = join(root, `linked-${index}`) + await mkdir(actual) + await symlink(actual, path, 'junction') + repos.push({ + id: `repo-${index}`, + path, + displayName: 'app', + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' } + }) + } + repos.push({ ...repos[0], id: 'independent', path: join(root, 'independent'), addedAt: 1000 }) + const raw = projectHostSetupCompatibilityFromRepos(repos) + raw.setups = raw.setups.map((setup, index) => + index > 0 && index < size ? { ...setup, setupMethod: 'provisioned' } : setup + ) + const source = { + getRepos: () => repos, + getProjects: () => raw.projects, + getProjectHostSetups: () => raw.setups, + getAllWorktreeMeta: () => ({}), + getSettings: () => ({ workspaceDir: '', nestWorkspaces: false }) + } + harness = await openSessionSearchHarness('sta10541-trigger-scaling', { + sessionCandidateLimit: 1000 + }) + for (let id = 1; id <= size * 3; id++) { + addSyntheticSession(harness.db, { + id, + cwd: join(root, `real-${(id - 1) % size}`), + text: 'ownership needle', + updatedAt: '2026-10-20' + }) + } + for (let id = size * 3 + 1; id <= size * 3 + 100; id++) { + addSyntheticSession(harness.db, { + id, + cwd: repos.at(-1)!.path, + text: 'ownership needle', + updatedAt: '2026-10-21' + }) + } + markFork(harness.db, [1, 2, size * 3 + 1], 'same-opening') + const native = createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + const admissions = vi.spyOn(native, 'search') + setSessionSearchService(native) + const catalogRead = vi.fn(() => sessionSearchScopeCatalogFromStore(source, 'local')) + installSessionSearchScopeCatalogSource(catalogRead) + const compile = vi.spyOn(ownership, 'compileSessionSearchProjectOwnership') + const namespace = vi.spyOn(ownership, 'sessionSearchProjectOwns') + const claim = vi.spyOn(claims, 'getOutclaimedProjectSetups') + const project = vi.spyOn(projection, 'resolveProjectHostSetupProjection') + const aliases = vi.mocked(fs.realpath) + aliases.mockClear() + const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare') + const sqlByStatement = new WeakMap() + const prepared = SyncDatabase.prototype.prepare + vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function ( + this: SyncDatabase, + sql + ) { + const statement = prepared.call(this, sql) + sqlByStatement.set(statement, sql) + return statement + }) + const statements: { sql: string; bindings: SqliteBindings }[] = [] + const all = NodeSqliteStatement.prototype.all + vi.spyOn(NodeSqliteStatement.prototype, 'all').mockImplementation(function ( + this: NodeSqliteStatement, + ...bindings + ) { + const sql = sqlByStatement.get(this) + if (sql?.includes('canonical_project_owns')) { + statements.push({ sql, bindings }) + } + return all.apply(this, bindings) + }) + registerAiVaultSearchHandlers() + Object.defineProperty(window, 'api', { configurable: true, value: { aiVault: aiVaultApi } }) + const projectKey = `project:${raw.projects[0].id}` + const within = { kind: 'project', projectKey } as const + const hook = renderHook( + ({ query }) => useAiVaultPanelSearch(query, agents, within, 'all', 'relevance', projectKey), + { initialProps: { query: 'ownership needle' } } + ) + await waitFor(() => expect(hook.result.current.hits).toHaveLength(20)) + act(() => hook.result.current.loadMore()) + await waitFor(() => expect(hook.result.current.hits).toHaveLength(Math.min(40, size * 3 - 1))) + expect(hook.result.current.hits.every((hit) => Number(hit.sessionId) <= size * 3)).toBe(true) + hook.rerender({ query: 'path:real-' }) + await waitFor(() => expect(hook.result.current.hits).toHaveLength(20)) + expect(hook.result.current.hits.every((hit) => Number(hit.sessionId) <= size * 3)).toBe(true) + hook.unmount() + const selections = await Promise.all(compile.mock.results.map((result) => result.value)) + observations.push({ + size, + catalog: catalogRead.mock.calls.length, + admissions: admissions.mock.calls.length, + compilations: compile.mock.calls.length, + projections: project.mock.calls.length, + claims: claim.mock.calls.length, + claimFacts: claim.mock.calls.reduce((sum, [rows]) => sum + rows.length, 0), + aliases: aliases.mock.calls.length, + namespace: namespace.mock.calls.length, + preparations: prepare.mock.calls.length, + executions: statements.length, + intervalBytes: selections.reduce((sum, value) => sum + JSON.stringify(value.ranges).length, 0) + }) + expect(statements.some(({ sql }) => sql.includes('WITH matched AS MATERIALIZED'))).toBe(true) + expect( + statements.some(({ sql }) => sql.includes('ORDER BY updated_at DESC, id DESC LIMIT')) + ).toBe(true) + expect(statements.some(({ sql }) => sql.includes('WHERE id IN ('))).toBe(true) + vi.restoreAllMocks() + for (const { sql, bindings } of statements) { + const plan = harness.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...bindings) + expect(plan.map((row) => row.detail).join('\n')).toMatch( + /SEARCH scoped USING (?:COVERING )?INDEX sessions_cwd_key/ + ) + } + await harness.close() + harness = undefined + await rm(root, { recursive: true, force: true }) + root = undefined + } + const [small, large] = observations + expect(large.admissions).toBe(small.admissions) + expect(large.compilations).toBe(small.compilations) + expect(large.catalog).toBe(small.catalog) + expect(large.claims).toBe(small.claims) + expect(large.aliases).toBeLessThanOrEqual(small.aliases * 10) + expect(large.claimFacts).toBeLessThanOrEqual(small.claimFacts * 10) + expect(large.intervalBytes).toBeLessThanOrEqual(small.intervalBytes * 11) + expect(large.namespace).toBeLessThanOrEqual(small.namespace * 12) + expect(large.preparations).toBeLessThanOrEqual(small.preparations + 2) + expect(large.executions).toBeLessThanOrEqual(small.executions + 2) + console.info('native-trigger-operation-counts', JSON.stringify(observations)) +}) + +it('does not revisit one SQL cwd range per WSL namespace at N and 10N', async () => { + const counts: number[] = [] + for (const size of [2, 20]) { + const repos: Repo[] = Array.from({ length: size }, (_, index) => ({ + id: `wsl-${index}`, + path: `//wsl$/D${index}/home/ada/app`, + displayName: 'app', + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' } + })) + const raw = projectHostSetupCompatibilityFromRepos(repos) + raw.setups = raw.setups.map((setup, index) => + index ? { ...setup, setupMethod: 'provisioned' } : setup + ) + harness = await openSessionSearchHarness('sta10541-namespace-union') + for (let id = 1; id <= 20; id++) { + addSyntheticSession(harness.db, { + id, + cwd: '/home/ada/app/topic', + sessionFilePath: `//wsl$/D${id <= 10 ? 0 : 'outside'}/home/ada/.claude/session${id}` + }) + } + const projectKey = `project:${raw.projects[0].id}` + const selection = await ownership.compileSessionSearchProjectOwnership( + { + repos, + projects: raw.projects, + projectHostSetups: raw.setups, + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + }, + projectKey + ) + const predicate = vi.spyOn(ownership, 'sessionSearchProjectOwns') + const sqlByStatement = new WeakMap() + const prepare = SyncDatabase.prototype.prepare + vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function ( + this: SyncDatabase, + sql + ) { + const statement = prepare.call(this, sql) + sqlByStatement.set(statement, sql) + return statement + }) + const statements: { sql: string; bindings: SqliteBindings }[] = [] + const all = NodeSqliteStatement.prototype.all + vi.spyOn(NodeSqliteStatement.prototype, 'all').mockImplementation(function ( + this: NodeSqliteStatement, + ...bindings + ) { + const sql = sqlByStatement.get(this) + if (sql?.includes('canonical_project_owns')) { + statements.push({ sql, bindings }) + } + return all.apply(this, bindings) + }) + for (const query of ['needle', 'path:app']) { + const result = harness.engine.search({ query, limit: 20, filters: { ownership: selection } }) + expect(result.hits.map((hit) => Number(hit.sessionId)).sort((a, b) => a - b)).toEqual( + Array.from({ length: 10 }, (_, i) => i + 1) + ) + expect(result.page.hasMore).toBe(false) + } + counts.push(predicate.mock.calls.length) + vi.restoreAllMocks() + expect(statements.length).toBeGreaterThan(0) + for (const { sql, bindings } of statements) { + expect( + harness.db + .prepare(`EXPLAIN QUERY PLAN ${sql}`) + .all(...bindings) + .map((row) => row.detail) + .join('\n') + ).toMatch(/SEARCH scoped USING (?:COVERING )?INDEX sessions_cwd_key/) + } + await harness.close() + harness = undefined + } + expect(counts[0]).toBeGreaterThan(0) + expect(counts[1]).toBe(counts[0]) + console.info('namespace-union-scalar-visits', JSON.stringify(counts)) +}) diff --git a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts new file mode 100644 index 000000000000..edef9d91ccc2 --- /dev/null +++ b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts @@ -0,0 +1,495 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { getRepoExecutionHostId } from '../../src/shared/execution-host' +import { hostWorktreeTarget } from '../../src/shared/worktree/host-qualified-identity' +import { + closeTestStores, + createSqliteTestStore, + writePersistedStateJson +} from '../../src/main/persistence/persistence-test-harness' +import { Store } from '../../src/main/persistence/loading-store/store' +import { getDefaultPersistedState } from '../../src/shared/constants' +import type { Repo } from '../../src/shared/repo-types' +import { projectHostSetupCompatibilityFromRepos } from '../../src/shared/project-host-setup-projection' +import { resolveProjectHostSetupProjection as legacyProjection } from '../../src/shared/project-host-setup-hydration' +import { getProjectHostSetupProjectionFromState } from '../../src/renderer/src/store/project-host-setup-selector' +import { resolveWorktreeMetaWithDiscoveryBackfill } from '../../src/main/ipc/worktrees/listing/worktree-discovery-metadata' +import { + getProjectHostSetupForRepoHost, + withRepoHostOwnership +} from '../../src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership' +import { makeWorktree } from '../../src/renderer/src/store/slices/worktrees-slice-test-fixtures' +import { buildAiVaultSessionProjectById } from '../../src/renderer/src/components/right-sidebar/ai-vault-session-projects' +import { + buildSetupByRepoId, + resolveActiveProjectKey, + resolveLegacyActiveProjectKey +} from '../../src/renderer/src/components/right-sidebar/ai-vault-project-key' +import { getProjectHostSetupLookup } from '../../src/shared/project-host-setup-lookup' +import { + adoptEndpointRepo, + adoptEndpointProjectCatalog +} from '../../src/shared/endpoint-catalog-adoption' +import { searchHit } from '../../src/shared/ai-vault-search-test-fixture' +import { aiVaultSearchHitToSession } from '../../src/renderer/src/components/right-sidebar/ai-vault-search-session' +import { sessionSearchScopeCatalogFromStore } from '../../src/main/ai-vault-search/session-search-store-scope-catalog' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from '../../src/main/ai-vault-search/session-search-scope-catalog' +import { + searchSessionService, + setSessionSearchService +} from '../../src/main/ai-vault-search/session-search-service-registry' +import { createSessionSearchService } from '../../src/main/ai-vault-search/session-search-service' +import { + openSessionSearchHarness, + addSyntheticSession, + type SessionSearchHarness +} from '../../src/main/ai-vault-search/session-search-engine-test-fixture' +import { AiVaultSearchRequestSchema } from '../../src/shared/ai-vault-search-contract' +import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../../src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning' +import { + configureAiVaultSessionSources, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from '../../src/main/ai-vault/cached-session-list' +import { parseAiVaultListResult } from '../../src/main/ai-vault/session-list-result-validation' +import { + restampAiVaultListResult, + mergeAiVaultListResults +} from '../../src/main/ai-vault/session-list-results' +import { filterAiVaultSessions } from '../../src/shared/ai-vault-session-filters' +import type { AiVaultListResult } from '../../src/shared/ai-vault-types' +import { getLocalWorktreeScanGeneration } from '../../src/main/local-worktree-scan-generation' + +const { scanTranscripts } = vi.hoisted(() => ({ + scanTranscripts: vi.fn<() => Promise>() +})) +vi.mock('../../src/main/ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService: scanTranscripts +})) +vi.mock('../../src/main/claude-accounts/claude-profile-installed-router', () => ({ + claudeProfileHistoryDirs: () => [] +})) +vi.mock('../../src/main/ai-vault/opencode-wsl-runtime-preparation', () => ({ + prepareOpenCodeWslReaders: async () => [] +})) + +const a: Repo = { + id: 'a', + path: '/clones/a/app', + displayName: 'app', + badgeColor: '', + addedAt: 1, + upstream: { owner: 'acme', repo: 'app' } +} +const b: Repo = { ...a, id: 'b', path: '/clones/b/app', addedAt: 2 } +let root: string | undefined +let harness: SessionSearchHarness | undefined + +afterEach(async () => { + resetAiVaultSessionListCacheForTests() + scanTranscripts.mockReset() + vi.restoreAllMocks() + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + await harness?.close() + await closeTestStores() + if (root) { + await rm(root, { recursive: true, force: true }) + } + harness = undefined + root = undefined +}) + +async function seededStore( + name: string, + repos: Repo[] = [a, b], + provisioned: readonly string[] = [] +) { + root ??= await realpath(await mkdtemp(join(tmpdir(), 'sta10541-source-'))) + const path = join(root, name, 'state.json') + const state = getDefaultPersistedState(root) + state.repos = repos + const raw = projectHostSetupCompatibilityFromRepos(state.repos) + state.projects = [...raw.projects] + state.projectHostSetups = raw.setups.map((setup) => + provisioned.includes(setup.repoId) ? { ...setup, setupMethod: 'provisioned' } : setup + ) + state.settings.workspaceDir = join(root, 'workspaces') + state.settings.nestWorkspaces = false + writePersistedStateJson(path, JSON.stringify(state)) + return createSqliteTestStore(Store, { dataFile: path }) +} + +function caller(store: Store, repo: Repo, path: string) { + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, `${repo.id}::${path}`) + const readState = { + repos: store.getRepos(), + endpointProjects: store.getProjects(), + endpointProjectHostSetups: store.getProjectHostSetups() + } + const worktree = withRepoHostOwnership( + makeWorktree({ id: `${repo.id}::${path}`, repoId: repo.id, path, ...meta }), + getRepoExecutionHostId(repo), + getProjectHostSetupForRepoHost(readState, repo.id, getRepoExecutionHostId(repo)) + ) + const old = legacyProjection( + readState.repos, + readState.endpointProjects, + readState.endpointProjectHostSetups + ) + return { + meta, + worktree, + legacy: resolveLegacyActiveProjectKey(repo, worktree, buildSetupByRepoId(old.setups)), + canonical: resolveActiveProjectKey( + repo, + worktree, + getProjectHostSetupLookup(getProjectHostSetupProjectionFromState(readState).setups) + ) + } +} + +// The integrated-base within schema: unknown Project properties are stripped. +const baseWithin = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('workspace'), worktreeId: z.string().min(1).max(8192) }), + z.object({ kind: z.literal('project'), projectKey: z.string().min(1).max(1024) }) +]) +const baseRequest = AiVaultSearchRequestSchema.safeExtend({ within: baseWithin.optional() }) + +it('pairs old discovery with canonical persistence, compatibility backfill, selected callers and actual legacy/native results', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + const oldStore = await seededStore('old-source') + const newStore = await seededStore('new-source') + const raw = projectHostSetupCompatibilityFromRepos([a, b]) + const projectKey = `project:${raw.projects[0].id}` + const oldSetups = vi.spyOn(oldStore, 'getProjectHostSetups').mockReturnValue([...raw.setups]) + harness = await openSessionSearchHarness('sta10541-source-results') + addSyntheticSession(harness.db, { id: 1, cwd: a.path }) + addSyntheticSession(harness.db, { id: 2, cwd: '/external/b' }) + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + const lastActivityAt = Date.now() + for (const foreign of [false, true]) { + const id = 'b::/external/b' + oldStore.setWorktreeMeta(hostWorktreeTarget({ id, hostId: 'local' }), { + projectId: foreign ? 'saved:S' : raw.projects[0].id, + projectHostSetupId: foreign ? 'foreign-setup' : 'b', + instanceId: 'existing-b', + lastActivityAt + }) + newStore.setWorktreeMeta(hostWorktreeTarget({ id, hostId: 'local' }), { + projectId: foreign ? 'saved:S' : 'repo:b', + projectHostSetupId: foreign ? 'foreign-setup' : 'b', + instanceId: 'existing-b', + lastActivityAt + }) + const before = caller(oldStore, b, '/external/b') + const after = caller(newStore, b, '/external/b') + expect(after.legacy).toBe(before.legacy) + expect(after.legacy).toBe(foreign ? 'project:saved:S' : projectKey) + expect(after.meta.lastActivityAt).toBe(lastActivityAt) + expect(after.meta.projectHostSetupId).toBe(foreign ? 'foreign-setup' : 'b') + expect(after.canonical).toBe(foreign ? 'project:saved:S' : 'repo:b') + installSessionSearchScopeCatalogSource(() => + sessionSearchScopeCatalogFromStore(newStore, 'local') + ) + const request = { + query: 'needle', + within: { + kind: 'project' as const, + projectKey: after.legacy!, + canonicalProjectKey: after.canonical! + } + } + const stripped = baseRequest.parse(request) + expect(stripped.within).toEqual({ kind: 'project', projectKey: before.legacy }) + const legacy = await searchSessionService(stripped, 'ipc') + expect(legacy.kind === 'results' && legacy.hits.map((hit) => hit.sessionId).sort()).toEqual( + foreign ? ['2'] : ['1', '2'] + ) + const exact = await searchSessionService(request, 'ipc') + expect(exact.kind === 'results' && exact.hits.map((hit) => hit.sessionId)).toEqual(['2']) + } + oldSetups.mockRestore() + newStore.flushOrThrow() + await newStore.freezeWritesAsync() + const reloaded = createSqliteTestStore(Store, { + dataFile: join(root!, 'new-source', 'state.json') + }) + expect(caller(reloaded, b, '/external/b').legacy).toBe('project:saved:S') +}) + +it('retains browser nested execution facts, omitted exact setup reconstruction and global first-setup ordering', () => { + const target = { kind: 'environment', environmentId: 'hub' } as const + const nested: Repo = { ...b, id: a.id, connectionId: 'jump', executionHostId: 'runtime:hub' } + const repos = [a, adoptEndpointRepo(target, nested)] + const raw = projectHostSetupCompatibilityFromRepos([ + a, + { ...nested, executionHostId: 'ssh:jump' } + ]) + const adopted = adoptEndpointProjectCatalog( + target, + raw.projects, + raw.setups.filter((setup) => setup.connectionId) + ) + const state = { + repos, + endpointProjects: adopted.projects, + endpointProjectHostSetups: adopted.setups + } + const canonical = getProjectHostSetupProjectionFromState(state) + const selected = resolveActiveProjectKey( + repos[1], + null, + getProjectHostSetupLookup(canonical.setups) + ) + expect(selected).toBe(`project:${raw.projects[0].id}`) + expect(resolveActiveProjectKey(a, null, getProjectHostSetupLookup(canonical.setups))).toBe( + selected + ) + const compatibility = legacyProjection(repos, adopted.projects, adopted.setups) + const first = buildSetupByRepoId(compatibility.setups) + expect(first.get(a.id)).toBe(compatibility.setups.find((setup) => setup.repoId === a.id)) + const foreign = makeWorktree({ + id: 'a::/foreign', + repoId: a.id, + path: '/foreign', + projectId: 'saved:S', + projectHostSetupId: 'foreign' + }) + expect(resolveLegacyActiveProjectKey(a, foreign, first)).toBe('project:saved:S') +}) + +it('keeps erased never-opened external history unscoped after authoritative prune while retaining an empty-tab owner', async () => { + const store = await seededStore('erased-source') + store.updateSettings({ nestWorkspaces: true }) + const repo = store.getRepos().find((row) => row.id === a.id)! + const erased = `${a.id}::${root}/workspaces/app/topic` + const retained = `${a.id}::${root}/workspaces/app/retained` + // Actual discovery creates locator/instance metadata; no tab is ever opened for topic. + resolveWorktreeMetaWithDiscoveryBackfill(store, repo, erased) + resolveWorktreeMetaWithDiscoveryBackfill(store, repo, retained) + store.setWorkspaceSession({ ...store.getWorkspaceSession(), tabsByWorktree: { [retained]: [] } }) + const scan = store.captureNativeLocalWorktreeMetadataScanExpectation(repo) + const pruned = await pruneMetadataMissingFromAuthoritativeLocalScan({ + store, + repo, + scan, + scanGeneration: getLocalWorktreeScanGeneration(repo.id), + gitWorktrees: [ + { path: repo.path, head: 'same-commit', branch: '', isBare: false, isMainWorktree: true } + ], + pathsExistOrAreUnverifiable: async (paths) => new Map(paths.map((path) => [path, false])) + }) + expect(pruned.removedWorktreeIds).toContain(erased) + expect(pruned.removedWorktreeIds).not.toContain(retained) + expect(store.getWorktreeMeta(erased)).toBeUndefined() + harness = await openSessionSearchHarness('sta10541-erased-source') + addSyntheticSession(harness.db, { id: 1, cwd: `${root}/workspaces/app/topic` }) + addSyntheticSession(harness.db, { id: 2, cwd: `${root}/workspaces/app/retained` }) + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => sessionSearchScopeCatalogFromStore(store, 'local')) + const projectKey = `project:${projectHostSetupCompatibilityFromRepos([a, b]).projects[0].id}` + const shared = await searchSessionService( + { query: 'needle', within: { kind: 'project', projectKey, canonicalProjectKey: projectKey } }, + 'ipc' + ) + const independent = await searchSessionService( + { query: 'needle', within: { kind: 'project', projectKey, canonicalProjectKey: 'repo:b' } }, + 'ipc' + ) + const all = await searchSessionService({ query: 'needle' }, 'ipc') + expect(shared.kind === 'results' && shared.hits.map((hit) => hit.sessionId)).toEqual(['2']) + expect(independent.kind === 'results' && independent.hits).toEqual([]) + expect(all.kind === 'results' && all.hits.map((hit) => hit.sessionId).sort()).toEqual(['1', '2']) + const read = { + repos: store.getRepos(), + endpointProjects: store.getProjects(), + endpointProjectHostSetups: store.getProjectHostSetups() + } + const transcriptRows = ['topic', 'retained'].map((name) => + aiVaultSearchHitToSession( + { ...searchHit(), sessionId: name, cwd: `${root}/workspaces/app/${name}` }, + 'local' + ) + ) + scanTranscripts.mockResolvedValue({ + sessions: transcriptRows, + issues: [], + scannedAt: 'same-scan' + }) + const capture = vi.fn(() => sessionSearchScopeCatalogFromStore(store, 'local')) + configureAiVaultSessionSources({ getProjectOwnershipCatalog: capture }) + // Non-opted list shares the parse cache but neither captures nor annotates ownership. + const legacy = await listAiVaultSessions({ limit: 2 }) + expect(legacy.sessions).toEqual(transcriptRows) + expect(capture).not.toHaveBeenCalled() + const opted = await listAiVaultSessions({ limit: 2, historyOwnership: {} }) + expect(opted.sessions.map((row) => row.projectOwnerKey)).toEqual([null, projectKey]) + expect(opted.fingerprint).toBeUndefined() + expect(scanTranscripts).toHaveBeenCalledTimes(1) + const render = (result: AiVaultListResult) => + buildAiVaultSessionProjectById({ + repos: read.repos, + projectHostSetupProjection: getProjectHostSetupProjectionFromState(read), + worktrees: [], // Successful Git listing has no retained actionable workspace. + sessions: result.sessions + }) + const decoded = parseAiVaultListResult(restampAiVaultListResult(opted, 'runtime:answering')) + const merged = mergeAiVaultListResults([decoded], 2) + const rows = render(merged) + expect([...rows.values()].map((row) => row.key)).toEqual([ + expect.stringMatching(/^folder:/), + projectKey + ]) + const filter = (activeProjectKey: string) => + filterAiVaultSessions(merged.sessions, { + query: '', + agents: ['codex'], + scope: 'project', + sort: 'updated', + activeWorktreePaths: [], + activeProjectKey, + sessionProjectById: rows, + hideEmptySessions: false + }) + expect(filter(projectKey).map((row) => row.sessionId)).toEqual(['retained']) + expect(filter('repo:b')).toEqual([]) + expect(merged.sessions[1]).toMatchObject({ + cwd: transcriptRows[1].cwd, + filePath: transcriptRows[1].filePath, + executionHostId: 'runtime:answering' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: retained, hostId: 'local' }), { + projectHostSetupId: 'b', + projectId: 'repo:b' + }) + const changed = await listAiVaultSessions({ limit: 2, historyOwnership: {} }) + expect(changed.scannedAt).toBe(opted.scannedAt) + expect(changed.fingerprint).toBeUndefined() + expect(opted.sessions[1].projectOwnerKey).toBe(projectKey) + expect(changed.sessions[1].projectOwnerKey).toBe('repo:b') + expect(scanTranscripts).toHaveBeenCalledTimes(1) + expect((await listAiVaultSessions({ limit: 2 })).sessions).toEqual(transcriptRows) + // A malformed optional owner costs its annotation, never the transcript. + const malformed = parseAiVaultListResult({ + ...opted, + sessions: [{ ...transcriptRows[0], projectOwnerKey: 42 }] + }) + expect(malformed.sessions).toHaveLength(1) + expect(malformed.sessions[0].projectOwnerKey).toBeUndefined() + const report = vi.spyOn(console, 'error').mockImplementation(() => undefined) + capture.mockImplementationOnce(() => { + throw new Error('ownership source unavailable') + }) + const fallback = await listAiVaultSessions({ limit: 2, historyOwnership: {} }) + expect(fallback.sessions).toEqual(transcriptRows) + expect(report).toHaveBeenCalledOnce() + + const explicitUnknown = render({ + ...opted, + sessions: [{ ...transcriptRows[0], cwd: a.path, projectOwnerKey: null }] + }) + expect([...explicitUnknown.values()][0].kind).toBe('folder') +}) + +it('ignores real Store activity during a held native reply while preserving governing changes', async () => { + const store = await seededStore('activity') + const target = hostWorktreeTarget({ id: `b::${b.path}`, hostId: 'local' }) + store.setWorktreeMeta(target, { lastActivityAt: 100 }) + let entered = () => {}, + release = () => {} + const reached = new Promise((resolve) => { + entered = resolve + }) + const held = new Promise((resolve) => { + release = resolve + }) + setSessionSearchService({ + reconcile: async () => {}, + status: async () => { + throw new Error('unused') + }, + search: async () => { + entered() + await held + return { + kind: 'results', + hits: [], + page: { cursor: null, hasMore: false }, + generation: 1, + durationMs: 0, + truncated: { candidates: false, snippets: 0, query: false, freshness: false } + } + } + }) + installSessionSearchScopeCatalogSource(() => sessionSearchScopeCatalogFromStore(store, 'local')) + const pending = searchSessionService( + { + query: 'needle', + within: { kind: 'project', projectKey: 'repo:b', canonicalProjectKey: 'repo:b' } + }, + 'ipc' + ) + await reached + store.setWorktreeMeta(target, { lastActivityAt: 101 }) + expect(store.getWorktreeMeta(target.id)?.lastActivityAt).toBe(101) + release() + expect(await pending).toMatchObject({ kind: 'results' }) +}) + +it('preserves actual discovered selected-source legacy keys across supported catalog ingress controls', async () => { + const cases: Repo[][] = [ + [a], + [a, b], + [a, b], + [{ ...a, kind: 'folder' }], + [{ ...a, upstream: undefined }], + [a, { ...b, projectGroupId: 'manual-group' }], + [a, { ...b, executionHostId: 'runtime:pending' }], + [a, { ...b, executionHostId: 'runtime:unresolved-owner' }] + ] + for (const [index, repos] of cases.entries()) { + const provisioned = index === 2 ? ['b'] : [] + const old = await seededStore(`old-ingress-${index}`, repos, provisioned) + const current = await seededStore(`new-ingress-${index}`, repos, provisioned) + const raw = projectHostSetupCompatibilityFromRepos(repos) + raw.setups = raw.setups.map((setup) => + provisioned.includes(setup.repoId) ? { ...setup, setupMethod: 'provisioned' } : setup + ) + const original = vi.spyOn(old, 'getProjectHostSetups').mockReturnValue([...raw.setups]) + const repo = repos.at(-1)! + const before = caller(old, repo, repo.path) + const after = caller(current, repo, repo.path) + expect(after.legacy, `ingress ${index}`).toBe(before.legacy) + expect(after.worktree.path).toBe(repo.path) + expect(after.worktree.repoId).toBe(repo.id) + original.mockRestore() + } +}) diff --git a/tests/e2e/helpers/host-split-and-sleep-journeys.ts b/tests/e2e/helpers/host-split-and-sleep-journeys.ts index 8c05602d316f..cbeaed7f15d5 100644 --- a/tests/e2e/helpers/host-split-and-sleep-journeys.ts +++ b/tests/e2e/helpers/host-split-and-sleep-journeys.ts @@ -41,6 +41,15 @@ export async function addHostCreatedSplitWorktree( activate: false, setupDecision: 'skip' }) + console.log( + '[host-split-created]', + JSON.stringify({ + repoId: added.result.repo.id, + worktreeId: created.result.worktree.id, + path: created.result.worktree.path, + branch: created.result.worktree.branch + }) + ) const terminals = await client.call('terminal.list', { worktree: `id:${created.result.worktree.id}` }) @@ -63,21 +72,86 @@ export async function addHostCreatedSplitWorktree( export async function openHostSplitWorktree(page: Page, userDataDir: string): Promise { let setupWorktreeId: string | undefined - await expect - .poll(async () => { - setupWorktreeId = await page.evaluate(async () => { - const store = window.__store! - await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) - for (const repo of store.getState().repos) { - await store.getState().fetchWorktrees(repo.id) - } - return Object.values(store.getState().worktreesByRepo) - .flat() - .find((worktree) => worktree.branch === 'refs/heads/parity-setup')?.id + let lastListing: unknown + try { + await expect + .poll(async () => { + const observed = await page.evaluate(async () => { + const store = window.__store! + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) + const listings: { repoId: string; fetchResult: boolean }[] = [] + for (const repo of store.getState().repos) { + listings.push({ + repoId: repo.id, + fetchResult: await store.getState().fetchWorktrees(repo.id) + }) + } + const state = store.getState() + const worktrees = Object.values(state.worktreesByRepo).flat() + return { + setupWorktreeId: worktrees.find( + (worktree) => worktree.branch === 'refs/heads/parity-setup' + )?.id, + listings, + repos: state.repos.map(({ id, path, executionHostId, connectionId }) => ({ + id, + path, + executionHostId, + connectionId + })), + endpointProjects: state.endpointProjects.map(({ id, sourceRepoIds }) => ({ + id, + sourceRepoIds + })), + endpointSetups: state.endpointProjectHostSetups.map( + ({ id, repoId, projectId, hostId, executionHostId }) => ({ + id, + repoId, + projectId, + hostId, + executionHostId + }) + ), + detected: Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => ({ + repoId, + authoritative: result.authoritative, + source: result.source, + catalogVersion: result.catalogVersion, + failureKind: result.failureKind, + worktrees: result.worktrees.map( + ({ id, branch, visible, ownership, hostId, projectId, projectHostSetupId }) => ({ + id, + branch, + visible, + ownership, + hostId, + projectId, + projectHostSetupId + }) + ) + })), + stored: worktrees.map( + ({ id, repoId, branch, hostId, projectId, projectHostSetupId }) => ({ + id, + repoId, + branch, + hostId, + projectId, + projectHostSetupId + }) + ), + catalogVersions: state.worktreeCatalogVersionByRepoHost + } + }) + setupWorktreeId = observed.setupWorktreeId + lastListing = observed + return setupWorktreeId }) - return setupWorktreeId - }) - .toBeDefined() + .toBeDefined() + } catch (error) { + console.log('[host-split-listing-failure]', JSON.stringify(lastListing)) + throw error + } const client = new RuntimeClient(userDataDir, 30_000) // Main has spawned the first terminal and its manual split before the user opens the worktree. let hostPtyIds: string[] = [] diff --git a/tests/e2e/helpers/wsl-golden-stub-agent.ts b/tests/e2e/helpers/wsl-golden-stub-agent.ts index 3294d6df3ecb..1560c11aea6a 100644 --- a/tests/e2e/helpers/wsl-golden-stub-agent.ts +++ b/tests/e2e/helpers/wsl-golden-stub-agent.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '../../../src/renderer/src/store/project-host-setup-selector' import { execFileSync } from 'node:child_process' import type { Page } from '@stablyai/playwright-test' import { buildWslExecArgs } from '../../../src/shared/wsl-login-shell-command' @@ -133,31 +134,46 @@ export function removeWslGoldenStubAgent(distro: string, stage: WslGoldenStubAge /** Retargets project agent detection and terminal spawning to WSL. */ export async function useWslRuntimeForActiveProject(page: Page, distro: string): Promise { - await page.evaluate(async (wslDistro) => { - const store = window.__store - if (!store) { + const catalog = await page.evaluate(() => { + const state = window.__store?.getState() + if (!state) { throw new Error('Orca store is unavailable') } - const state = store.getState() - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - throw new Error('No active worktree') + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups } - const activeWorktree = Object.values(state.worktreesByRepo) - .flat() - .find((worktree) => worktree.id === worktreeId) - const activeProject = state.projects.find((project) => - activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false - ) - if (!activeProject) { - throw new Error('No active project') - } - await state.updateProject( - activeProject.id, - { - localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } - }, - 'local' - ) - }, distro) + }) + const projects = selectCanonicalProjects(catalog) + await page.evaluate( + async ({ wslDistro, projects }) => { + const store = window.__store + if (!store) { + throw new Error('Orca store is unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') + } + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === worktreeId) + const activeProject = projects.find((project) => + activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false + ) + if (!activeProject) { + throw new Error('No active project') + } + await state.updateProject( + activeProject.id, + { + localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } + }, + 'local' + ) + }, + { wslDistro: distro, projects } + ) } diff --git a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts index a0c7b3d4b320..77e7e19fb8a5 100644 --- a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts +++ b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts @@ -1,5 +1,17 @@ -// Project switches must retain workspace-scoped Jira/Linear issues and clear repo-scoped GitHub/GitLab sources. -// Drive the real ProjectCombobox → handleProjectChange → pill render path, including main’s repo resolution. +import { selectCanonicalProjectHostSetups } from '../../src/renderer/src/store/project-host-setup-selector' +/** + * E2E: linked work item behavior when switching the composer project. + * + * Why: Jira/Linear issues are workspace-scoped context — switching the + * implementation project must keep them attached. They used to be dropped, + * leaving only the derived name in the smart field. GitHub/GitLab sources + * are repo-scoped and must still clear on a project switch. + * + * Why E2E: the preservation logic lives in useComposerState behind the real + * ProjectCombobox interaction and main-process repo resolution — a store + * slice unit test cannot reach the combobox → handleProjectChange → smart + * field pill re-render path. + */ import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -63,30 +75,42 @@ async function openComposerWithLinkedWorkItem( } async function getJiraSourceContext(page: Page): Promise { - return page.evaluate(() => { + const state = await page.evaluate(() => { const state = window.__store?.getState() - const activeWorktreeId = state?.activeWorktreeId - const setup = state?.projectHostSetups.find((candidate) => - state.worktreesByRepo[candidate.repoId]?.some((worktree) => worktree.id === activeWorktreeId) + return state + ? { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups, + activeWorktreeId: state.activeWorktreeId, + worktreesByRepo: state.worktreesByRepo + } + : null + }) + const setup = + state && + selectCanonicalProjectHostSetups(state).find((candidate) => + state.worktreesByRepo[candidate.repoId]?.some( + (worktree) => worktree.id === state.activeWorktreeId + ) ) - if (!setup) { - throw new Error('Active project host setup is unavailable') - } - return { - kind: 'task-source', + if (!setup) { + throw new Error('Active project host setup is unavailable') + } + return { + kind: 'task-source', + provider: 'jira', + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + providerIdentity: { provider: 'jira', - projectId: setup.projectId, - hostId: setup.hostId, - projectHostSetupId: setup.id, - repoId: setup.repoId, - providerIdentity: { - provider: 'jira', - siteId: 'e2e-jira-site', - siteUrl: 'https://example.atlassian.net', - projectKey: 'RDG' - } + siteId: 'e2e-jira-site', + siteUrl: 'https://example.atlassian.net', + projectKey: 'RDG' } - }) + } } async function switchComposerProject(page: Page, projectName: string): Promise { diff --git a/tests/e2e/project-group-creation-visibility.spec.ts b/tests/e2e/project-group-creation-visibility.spec.ts index 710b201ac611..9177e559b7dc 100644 --- a/tests/e2e/project-group-creation-visibility.spec.ts +++ b/tests/e2e/project-group-creation-visibility.spec.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '../../src/renderer/src/store/project-host-setup-selector' import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -122,7 +123,7 @@ for (const delayCreateResponse of [false, true]) { if (!createdGroup) { throw new Error('Group creation failed') } - await orcaPage.evaluate( + const catalog = await orcaPage.evaluate( async ({ repoIds, groupId }) => { const store = window.__store! for (const repoId of repoIds.slice(0, 2)) { @@ -132,14 +133,22 @@ for (const delayCreateResponse of [false, true]) { } await store.getState().moveProjectToGroup(repo, groupId) } - const collapsedGroups = store - .getState() - .projectHostSetups.map((setup) => `project:${setup.projectId}`) - await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) - store.setState({ collapsedGroups: new Set(collapsedGroups) }) + const state = store.getState() + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } }, { repoIds, groupId: createdGroup.id } ) + const collapsedGroups = selectCanonicalProjectHostSetups(catalog).map( + (setup) => `project:${setup.projectId}` + ) + await orcaPage.evaluate(async (collapsedGroups) => { + await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) + window.__store!.setState({ collapsedGroups: new Set(collapsedGroups) }) + }, collapsedGroups) const scroller = orcaPage.locator('[data-worktree-sidebar]') const group = scroller.locator(`[data-project-group-header-id="${createdGroup.id}"]`) diff --git a/tests/e2e/project-runtime-handoff.unit.test.ts b/tests/e2e/project-runtime-handoff.unit.test.ts new file mode 100644 index 000000000000..2293863919fc --- /dev/null +++ b/tests/e2e/project-runtime-handoff.unit.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + closeTestStores, + createSqliteTestStore, + withPlatform, + writePersistedStateJson +} from '../../src/main/persistence/persistence-test-harness' +import { Store } from '../../src/main/persistence/loading-store/store' +import { rebuildRepoBackedProjectState } from '../../src/main/orca-profiles/profile-project-state-file' +import { resolveLocalWorkspaceRuntime } from '../../src/main/local-project-runtime-resolution' +import { getDefaultPersistedState } from '../../src/shared/constants' +import type { PersistedState } from '../../src/shared/persisted-state-types' +import type { Repo } from '../../src/shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../src/shared/project-host-setup-projection' +import { getLocalRepoProjectExecutionRuntimeContext } from '../../src/renderer/src/lib/local-preflight-context' +import { useAppStore } from '../../src/renderer/src/store' + +it('keeps the exact surviving project runtime through a three-local-checkout handoff, profile rebuild and reload', async () => { + const root = await mkdtemp(join(tmpdir(), 'sta10541-runtime-handoff-')) + const dataFile = join(root, 'state.json') + const exported = join(root, 'export.json') + const projectId = 'github:acme/app' + const repo = (id: string, path: string, addedAt: number): Repo => ({ + id, + path, + addedAt, + displayName: 'app', + badgeColor: '', + upstream: { owner: 'acme', repo: 'app' } + }) + const repos = [ + repo('a', 'C:/app-a', 1), + repo('b', 'C:/app-b', 2), + repo('c', '\\\\wsl.localhost\\Ubuntu\\home\\me\\app-wsl', 3) + ] + const projection = projectHostSetupProjectionFromRepos(repos) + const state = getDefaultPersistedState(root) + state.repos = repos + state.projects = [...projection.projects] + state.projectHostSetups = [ + ...projection.setups, + { + id: 'pending-b', + projectId: 'repo:b', + repoId: '', + hostId: 'ssh:extra', + path: '', + displayName: 'extra', + setupState: 'not-set-up', + setupMethod: 'provisioned', + createdAt: 1, + updatedAt: 1 + } + ] + writePersistedStateJson(dataFile, JSON.stringify(state)) + try { + let store = createSqliteTestStore(Store, { dataFile }) + expect(store.getProjects().map((row) => [row.id, row.sourceRepoIds])).toEqual([ + [projectId, ['a', 'c']], + ['repo:b', ['b']] + ]) + store.updateProject(projectId, { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + store.updateProject('repo:b', { localWindowsRuntimePreference: { kind: 'windows-host' } }) + store.writeProfileStateJsonExport(exported) + await closeTestStores() + store = createSqliteTestStore(Store, { dataFile }) + expect( + store.getProjects().find((row) => row.id === 'repo:b')?.localWindowsRuntimePreference + ).toEqual({ kind: 'windows-host' }) + const before: PersistedState = JSON.parse(await readFile(exported, 'utf8')) + const concrete = store.getRepos().filter((row) => row.id !== 'a') + store.removeProjectForHost({ id: 'a', executionHostId: 'local' }) + const remaining = repos.slice(1) + const rebuilt = rebuildRepoBackedProjectState({ ...before, repos: remaining }) + expect(store.getProjects()).toEqual(rebuilt.projects) + expect(store.getProjectHostSetups()).toEqual(rebuilt.projectHostSetups) + const projects = store.getProjects() + const setups = store.getProjectHostSetups() + expect(projects).toEqual([ + expect.objectContaining({ + id: projectId, + sourceRepoIds: ['b', 'c'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ]) + expect(setups.filter((row) => row.id === 'pending-b')).toEqual([ + expect.objectContaining({ + projectId, + repoId: '', + hostId: 'ssh:extra', + path: '', + setupState: 'not-set-up' + }) + ]) + expect(store.getRepos()).toEqual(concrete) + expect( + setups.filter((row) => row.repoId).map((row) => [row.repoId, row.path, row.hostId]) + ).toEqual(remaining.map((row) => [row.id, row.path, 'local'])) + store.writeProfileStateJsonExport(exported) + await closeTestStores() + store = createSqliteTestStore(Store, { dataFile }) + expect(store.getProjects()).toEqual(projects) + expect(store.getProjectHostSetups()).toEqual(setups) + expect(store.getRepos()).toEqual(concrete) + const saved: PersistedState = JSON.parse(await readFile(exported, 'utf8')) + expect(rebuildRepoBackedProjectState(saved).projects).toEqual(projects) + await withPlatform('win32', async () => { + const renderer = { + ...useAppStore.getInitialState(), + repos: store.getRepos(), + settings: store.getSettings(), + endpointProjects: store.getProjects(), + endpointProjectHostSetups: store.getProjectHostSetups() + } + for (const target of store.getRepos()) { + const main = resolveLocalWorkspaceRuntime(store, target, target.path) + expect(main).toMatchObject({ + status: 'resolved', + runtime: { kind: 'wsl', distro: 'Ubuntu', projectId, reason: 'project-override' } + }) + expect(getLocalRepoProjectExecutionRuntimeContext(renderer, target.id, 'win32')).toEqual( + main + ) + } + }) + } finally { + await closeTestStores() + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/tests/e2e/sidebar-measured-row-scroll.spec.ts b/tests/e2e/sidebar-measured-row-scroll.spec.ts index bd6481b177fb..7ab77f9edf38 100644 --- a/tests/e2e/sidebar-measured-row-scroll.spec.ts +++ b/tests/e2e/sidebar-measured-row-scroll.spec.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '../../src/renderer/src/store/project-host-setup-selector' import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -103,7 +104,7 @@ test('keeps a collapsed group visible when an idle card below it grows', async ( } } - const fixture = await orcaPage.evaluate(async (repoPaths): Promise => { + const fixture = await orcaPage.evaluate(async (repoPaths) => { const store = window.__store! for (const repoPath of repoPaths) { await window.api.repos.add({ path: repoPath }) @@ -131,21 +132,35 @@ test('keeps a collapsed group visible when an idle card below it grows', async ( const state = store.getState() state.setGroupBy('repo') state.setProjectOrderBy('manual') - const collapsedSetup = state.projectHostSetups.find((setup) => setup.repoId === collapsed.id) const worktree = state.worktreesByRepo[expanded.id]?.find((entry) => entry.isMainWorktree) - if (!collapsedSetup || !worktree) { + if (!worktree) { throw new Error('Fixture project setup or worktree is missing') } - const collapsedGroups = [`project-group:${group.id}`, `project:${collapsedSetup.projectId}`] - await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) - store.setState({ collapsedGroups: new Set(collapsedGroups) }) return { groupId: group.id, collapsedRepoId: collapsed.id, expandedRepoId: expanded.id, - worktreeId: worktree.id - } + worktreeId: worktree.id, + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } satisfies SidebarFixture & + Pick }, paths) + const collapsedSetup = selectCanonicalProjectHostSetups(fixture).find( + (setup) => setup.repoId === fixture.collapsedRepoId + ) + if (!collapsedSetup) { + throw new Error('Fixture project setup or worktree is missing') + } + const collapsedGroups = [ + `project-group:${fixture.groupId}`, + `project:${collapsedSetup.projectId}` + ] + await orcaPage.evaluate(async (collapsedGroups) => { + await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) + window.__store!.setState({ collapsedGroups: new Set(collapsedGroups) }) + }, collapsedGroups) const sidebar = orcaPage.locator('[data-worktree-sidebar]') const group = sidebar.locator(`[data-project-group-header-id="${fixture.groupId}"]`) diff --git a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts index 0016079ccc3a..8b10227e5c23 100644 --- a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts +++ b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts @@ -218,6 +218,7 @@ async function verifyRunningRelayUpgrade( .toMatchObject({ kind: 'relay', reason: 'refused', canLeaveBehind: true }) // The refusal toast offers it too; this is the pane's own button. const move = page + .locator('[data-terminal-ssh-reconnect-banner]') .getByRole('button', { name: 'Move anyway…', exact: true }) .and(page.locator(':not([data-sonner-toast] button)')) await expect(move).toBeVisible() diff --git a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts index e7b5014e5ac1..02497d99d840 100644 --- a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts +++ b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '../../src/renderer/src/store/project-host-setup-selector' import { randomUUID } from 'node:crypto' import { execFileSync } from 'node:child_process' import { rmSync, writeFileSync } from 'node:fs' @@ -114,7 +115,19 @@ async function createWindowsDefaultShellTerminalTab( } async function configureActiveProjectWslRuntime(page: Page): Promise { - return page.evaluate(async () => { + const catalog = await page.evaluate(() => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Store unavailable') + } + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } + }) + const projects = selectCanonicalProjects(catalog) + return page.evaluate(async (projects) => { const store = window.__store if (!store) { throw new Error('Store unavailable') @@ -131,7 +144,7 @@ async function configureActiveProjectWslRuntime(page: Page): Promise worktree.id === worktreeId) - const activeProject = state.projects.find((project) => + const activeProject = projects.find((project) => activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false ) if (!activeProject) { @@ -148,7 +161,7 @@ async function configureActiveProjectWslRuntime(page: Page): Promise { const wslDistro = wsl.distros[0]! const wslRepoPath = await createGitRepo('orca-e2e-project-runtime-', 'wsl-runtime-project') - const smoke = await orcaPage.evaluate( - async ({ hostRepoPath, wslRepoPath, wslDistro }) => { + const catalog = await orcaPage.evaluate( + async ({ hostRepoPath, wslRepoPath }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -109,42 +110,61 @@ test.describe('Windows project runtime smoke', () => { throw new Error('Expected host and WSL smoke repos to be loaded') } - const hostProject = state.projects.find((project) => - project.sourceRepoIds.includes(hostRepo.id) - ) - const wslProject = state.projects.find((project) => - project.sourceRepoIds.includes(wslRepo.id) - ) - if (!hostProject || !wslProject) { - throw new Error('Expected host and WSL smoke projects to be loaded') + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups, + hostRepo, + wslRepo } - + }, + { hostRepoPath: testRepoPath, wslRepoPath } + ) + const projects = selectCanonicalProjects(catalog) + const hostProject = projects.find((project) => + project.sourceRepoIds.includes(catalog.hostRepo.id) + ) + const wslProject = projects.find((project) => + project.sourceRepoIds.includes(catalog.wslRepo.id) + ) + if (!hostProject || !wslProject) { + throw new Error('Expected host and WSL smoke projects to be loaded') + } + const smoke = await orcaPage.evaluate( + async ({ hostProjectId, wslProjectId, hostRepoId, wslRepoId, wslDistro }) => { + const state = window.__store!.getState() await state.updateProject( - hostProject.id, + hostProjectId, { localWindowsRuntimePreference: { kind: 'windows-host' } }, 'local' ) await state.updateProject( - wslProject.id, + wslProjectId, { localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } }, 'local' ) - const hostWorktrees = await window.api.worktrees.listDetected({ repoId: hostRepo.id }) - const wslWorktrees = await window.api.worktrees.listDetected({ repoId: wslRepo.id }) + const hostWorktrees = await window.api.worktrees.listDetected({ repoId: hostRepoId }) + const wslWorktrees = await window.api.worktrees.listDetected({ repoId: wslRepoId }) return { - hostRepoId: hostRepo.id, - wslRepoId: wslRepo.id, + hostRepoId, + wslRepoId, wslDistro, hostWorktreeCount: hostWorktrees.worktrees.length, wslWorktreeCount: wslWorktrees.worktrees.length } }, - { hostRepoPath: testRepoPath, wslRepoPath, wslDistro } + { + hostProjectId: hostProject.id, + wslProjectId: wslProject.id, + hostRepoId: catalog.hostRepo.id, + wslRepoId: catalog.wslRepo.id, + wslDistro + } ) expect(smoke.hostWorktreeCount).toBeGreaterThan(0) @@ -158,24 +178,32 @@ test.describe('Windows project runtime smoke', () => { await expect( hostSection.getByText(`This project runs in ${smoke.wslDistro} via WSL.`) ).toBeVisible() - const hostAfterWslUiSwitch = await orcaPage.evaluate((hostRepoId) => { + const afterWslCatalog = await orcaPage.evaluate(() => { const state = window.__store!.getState() - const hostProject = state.projects.find((project) => - project.sourceRepoIds.includes(hostRepoId) - ) - return hostProject?.localWindowsRuntimePreference - }, smoke.hostRepoId) + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } + }) + const hostAfterWslUiSwitch = selectCanonicalProjects(afterWslCatalog).find((project) => + project.sourceRepoIds.includes(smoke.hostRepoId) + )?.localWindowsRuntimePreference expect(hostAfterWslUiSwitch).toEqual({ kind: 'wsl', distro: smoke.wslDistro }) await chooseProjectRuntime(orcaPage, smoke.hostRepoId, 'Windows') await expect(hostSection.getByText('This project runs on Windows.')).toBeVisible() - const hostAfterWindowsUiSwitch = await orcaPage.evaluate((hostRepoId) => { + const afterWindowsCatalog = await orcaPage.evaluate(() => { const state = window.__store!.getState() - const hostProject = state.projects.find((project) => - project.sourceRepoIds.includes(hostRepoId) - ) - return hostProject?.localWindowsRuntimePreference - }, smoke.hostRepoId) + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } + }) + const hostAfterWindowsUiSwitch = selectCanonicalProjects(afterWindowsCatalog).find((project) => + project.sourceRepoIds.includes(smoke.hostRepoId) + )?.localWindowsRuntimePreference expect(hostAfterWindowsUiSwitch).toEqual({ kind: 'windows-host' }) await openRepoSettings(orcaPage, smoke.wslRepoId)