diff --git a/.oxlintrc.json b/.oxlintrc.json index 0fb68f4f9e2..48e70a5366d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -153,14 +153,6 @@ { "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." } ] } @@ -215,39 +207,6 @@ "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 a6c8cf584cc..4de864fc047 100644 --- a/config/oxlint-js-plugins.json +++ b/config/oxlint-js-plugins.json @@ -38,7 +38,6 @@ { "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" } @@ -50,32 +49,6 @@ "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 71339d754bf..5a86aabe1f4 100644 --- a/config/oxlint-plugins/app-store-performance.mjs +++ b/config/oxlint-plugins/app-store-performance.mjs @@ -426,39 +426,12 @@ 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 ( - createVisitors !== noRawProjectCatalogComputedRule && - !STORE_HOOK_TEXT.test(context.sourceCode.text) && - !context.sourceCode.text.includes('\\') - ) { + if (!STORE_HOOK_TEXT.test(context.sourceCode.text) && !context.sourceCode.text.includes('\\')) { return {} } if ( @@ -479,7 +452,6 @@ 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-whole-hot-map-selector': { create: bindContext(noWholeHotMapSelectorRule) }, diff --git a/config/scripts/project-catalog-read-boundary.test.mjs b/config/scripts/project-catalog-read-boundary.test.mjs deleted file mode 100644 index 1e85163313f..00000000000 --- a/config/scripts/project-catalog-read-boundary.test.mjs +++ /dev/null @@ -1,102 +0,0 @@ -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 67f0700f739..809afab5c4c 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 { getRepoHostIdentity } from '../../../src/shared/repo-host-identity' +import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' 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,7 +22,6 @@ import { type NewWorktreeModalProps } from './new-worktree-modal-types' import { - getNewWorkspaceProjectIds, buildNewWorkspaceProjectOptions, buildNewWorkspaceRunTargetOptions, getNewWorkspaceRunTarget @@ -180,26 +179,13 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { })), [hostRepos] ) - const projectIds = useMemo(() => getNewWorkspaceProjectIds(repos), [repos]) - const projectPickerItems = useMemo( - () => buildNewWorkspaceProjectOptions(repos, projectIds), - [repos, projectIds] - ) - const selectedProjectId = selectedRepo - ? (projectIds.get(getRepoHostIdentity(selectedRepo)) ?? null) - : null + const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos]) + const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null const selectedProject = projectPickerItems.find((project) => project.id === selectedProjectId) ?? null const runTargetPickerItems = useMemo( - () => - buildNewWorkspaceRunTargetOptions( - repos, - selectedProjectId, - hostPlatform, - hostLabelById, - projectIds - ), - [hostLabelById, hostPlatform, repos, selectedProjectId, projectIds] + () => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform, hostLabelById), + [hostLabelById, hostPlatform, repos, selectedProjectId] ) 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 993a4927aee..bfccb682ef9 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('keeps each same-host clone selectable with its concrete target', () => { + it('shows one target per host when the project has multiple local worktrees', () => { const upstream = { owner: 'stablyai', repo: 'orca' } const repos = [ { id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream }, @@ -88,10 +88,7 @@ describe('new workspace project targets', () => { upstream } ] - 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 + const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([ expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }), @@ -102,13 +99,4 @@ 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 092f7c2e263..c3f5d436a40 100644 --- a/mobile/src/components/new-workspace-project-targets.ts +++ b/mobile/src/components/new-workspace-project-targets.ts @@ -1,42 +1,16 @@ import type { Repo } from '../../../src/shared/repo-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' import { - projectHostSetupProjectionFromRepos, + getProjectIdentityKey, 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< - Repo, - | 'connectionId' - | 'executionHostId' - | 'upstream' - | 'repoIcon' - | 'gitRemoteIdentity' - | 'addedAt' - | 'projectHostSetupMethod' - | 'kind' - > + Pick > -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 @@ -53,16 +27,12 @@ export type NewWorkspaceRunTargetOption = { } export function buildNewWorkspaceProjectOptions( - repos: readonly TRepo[], - projectIds = getNewWorkspaceProjectIds(repos) + repos: readonly TRepo[] ): NewWorkspaceProjectOption[] { const options = new Map>() const hostIdsByProject = new Map>() for (const repo of repos) { - if (!repo.path) { - continue - } - const id = projectIds.get(getRepoHostIdentity(repo))! + const id = getProjectIdentityKey(repo) const hostIds = hostIdsByProject.get(id) ?? new Set() hostIds.add(getRepoExecutionHostId(repo)) hostIdsByProject.set(id, hostIds) @@ -110,15 +80,14 @@ export function buildNewWorkspaceRunTargetOptions( repos: readonly TRepo[], projectId: string | null, localPlatform: NodeJS.Platform | null = null, - hostLabelById?: ReadonlyMap, - projectIds = getNewWorkspaceProjectIds(repos) + hostLabelById?: ReadonlyMap ): NewWorkspaceRunTargetOption[] { if (!projectId) { return [] } const options = new Map>() for (const repo of repos) { - if (!repo.path || projectIds.get(getRepoHostIdentity(repo)) !== projectId) { + if (getProjectIdentityKey(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 e6a67981ad6..cc57ae04532 100644 --- a/mobile/src/components/new-worktree-modal-types.ts +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -8,8 +8,6 @@ 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 ca0bae1eb1c..4f88a9f2f32 100644 --- a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts +++ b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts @@ -1,18 +1,8 @@ -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' @@ -84,36 +74,6 @@ 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' @@ -184,93 +144,3 @@ 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 deleted file mode 100644 index 367e74e5261..00000000000 --- a/src/main/ai-vault-search/session-search-current-owner.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -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 adac1903b22..308a211d4e2 100644 --- a/src/main/ai-vault-search/session-search-engine-types.ts +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -1,4 +1,3 @@ -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' @@ -20,7 +19,6 @@ 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 e0f11cff198..e64948a6698 100644 --- a/src/main/ai-vault-search/session-search-engine.ts +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -38,8 +38,6 @@ 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. */ @@ -62,22 +60,11 @@ 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) @@ -89,7 +76,6 @@ export class SessionSearchEngine { } search(request: SessionSearchRequest): SessionSearchResponse { - this.ownership = request.filters?.ownership const startedAt = performance.now() ensureSessionSearchQuerySchema(this.db) const generation = readIndexGeneration(this.db) @@ -108,13 +94,7 @@ export class SessionSearchEngine { // Reject invalid cursors before paying for retrieval. const pageKey = sessionSearchPageKey(request) const offset = request.cursor - ? decodeSessionSearchCursor( - request.cursor, - generation, - pageKey, - incarnation, - this.ownership?.fingerprint - ) + ? decodeSessionSearchCursor(request.cursor, generation, pageKey, incarnation) : 0 const plan = planSessionSearchQuery(split.text) @@ -141,13 +121,7 @@ export class SessionSearchEngine { page: { hasMore, cursor: hasMore - ? encodeSessionSearchCursor( - generation, - offset + limit, - pageKey, - incarnation, - this.ownership?.fingerprint - ) + ? encodeSessionSearchCursor(generation, offset + limit, pageKey, incarnation) : 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 deleted file mode 100644 index 691ff4999ec..00000000000 --- a/src/main/ai-vault-search/session-search-ownership-races.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 c6b791a14e2..38f02a37f4e 100644 --- a/src/main/ai-vault-search/session-search-page-cursor.ts +++ b/src/main/ai-vault-search/session-search-page-cursor.ts @@ -17,7 +17,6 @@ export class SessionSearchCursorError extends Error { type CursorPayload = { /** Database incarnation; changes when the index is rebuilt. */ - p?: string i: string /** Index generation. */ g: number @@ -36,8 +35,7 @@ export function sessionSearchPageKey(request: SessionSearchRequest): string { filters.sort ?? 'relevance', filters.since ?? null, [...(filters.agents ?? [])].sort(), - [...(filters.scopePaths ?? [])].sort(), - ...(filters.ownership ? [filters.ownership.projectKey] : []) + [...(filters.scopePaths ?? [])].sort() ]) return createHash('sha256').update(identity).digest('base64url').slice(0, 16) } @@ -46,16 +44,9 @@ export function encodeSessionSearchCursor( generation: number, offset: number, key: string, - incarnation: string, - ownership?: string + incarnation: string ): string { - const payload: CursorPayload = { - i: incarnation, - g: generation, - o: offset, - k: key, - ...(ownership ? { p: ownership } : {}) - } + const payload: CursorPayload = { i: incarnation, g: generation, o: offset, k: key } return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url') } @@ -64,8 +55,7 @@ export function decodeSessionSearchCursor( cursor: string, generation: number, key: string, - incarnation: string, - ownership?: string + incarnation: string ): number { let payload: CursorPayload try { @@ -94,9 +84,6 @@ 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 deleted file mode 100644 index d0b14363224..00000000000 --- a/src/main/ai-vault-search/session-search-project-ownership.ts +++ /dev/null @@ -1,284 +0,0 @@ -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 5f73cf9bc65..f3d047d47de 100644 --- a/src/main/ai-vault-search/session-search-row-filter.ts +++ b/src/main/ai-vault-search/session-search-row-filter.ts @@ -38,14 +38,6 @@ 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 9e1d35f7878..b47d407b90d 100644 --- a/src/main/ai-vault-search/session-search-scope-catalog.ts +++ b/src/main/ai-vault-search/session-search-scope-catalog.ts @@ -1,24 +1,20 @@ -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 Repo[] - projects: readonly Project[] - projectHostSetups: readonly ProjectHostSetup[] - executionHostId?: ExecutionHostId - /** Registered workspaces of this host, keyed by worktree id. */ - worktreeMeta: Readonly< - Record< - string, - Pick & - Partial> - > - > + repos: readonly Pick< + Repo, + 'id' | 'path' | 'kind' | 'worktreeBasePath' | 'connectionId' | 'executionHostId' + >[] + projects: readonly Pick[] + projectHostSetups: readonly Pick< + ProjectHostSetup, + 'projectId' | 'repoId' | 'path' | 'worktreeBasePath' + >[] + worktreeMeta: Readonly>> 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 4cbe4c73896..b877d80b30b 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,13 +7,12 @@ import { OrcaRuntimeService } from '../runtime/orca-runtime' import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault' import { installSessionSearchScopeCatalogSource, - resetSessionSearchScopeCatalogForTests, - type SessionSearchScopeCatalog + resetSessionSearchScopeCatalogForTests } from './session-search-scope-catalog' import { searchSessionService, setSessionSearchService } from './session-search-service-registry' -const CATALOG: SessionSearchScopeCatalog = { - repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }], +const CATALOG = { + repos: [{ id: 'repo-1', path: '/work/app' }], 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 2698447e622..401595c61f7 100644 --- a/src/main/ai-vault-search/session-search-scope-paths.ts +++ b/src/main/ai-vault-search/session-search-scope-paths.ts @@ -1,9 +1,16 @@ import { + getRuntimePathBasename, isPathInsideOrEqual, isRuntimePathAbsolute, - normalizeRuntimePathForComparison + normalizeRuntimePathForComparison, + resolveRuntimePath } from '../../shared/cross-platform-path' -export { managedWorktreeDirectories } from '../../shared/worktree/ownership' +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 class ScopePathSet { private readonly seen = new Set() @@ -30,3 +37,26 @@ 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 6a05767deae..0a7b7dae2ec 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,11 +1,10 @@ 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', displayName: 'app', badgeColor: '', addedAt: 0 }], + repos: [{ id: 'repo-1', path: '/work/app' }], projects: [], projectHostSetups: [], worktreeMeta: {}, @@ -83,18 +82,7 @@ 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', - displayName: 'notes', - badgeColor: '', - addedAt: 0 - } - ] - }) + catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] }) ) expect(paths(resolution)).toEqual(['/work/notes']) }) @@ -149,7 +137,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: [{ ...catalog().repos[0], worktreeBasePath: '/trees/app' }] }) + catalog({ repos: [{ id: 'repo-1', path: '/work/app', 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']) @@ -200,20 +188,10 @@ describe('project scope', () => { const resolution = resolveSessionSearchScope( { kind: 'project', projectKey: 'project:proj-1' }, catalog({ - repos: [{ ...catalog().repos[0], path: '/srv/app' }], - projects: [ - { - id: 'proj-1', - sourceRepoIds: ['repo-1'], - displayName: 'app', - badgeColor: '', - createdAt: 0, - updatedAt: 0 - } - ], + repos: [{ id: 'repo-1', path: '/srv/app' }], + projects: [{ id: 'proj-1', sourceRepoIds: ['repo-1'] }], projectHostSetups: [ { - ...projectHostSetupCompatibilityFromRepos(catalog().repos).setups[0], projectId: 'proj-1', repoId: 'repo-1', path: '/srv/app', @@ -228,18 +206,7 @@ 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', - displayName: 'notes', - badgeColor: '', - addedAt: 0 - } - ] - }) + catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] }) ) expect(paths(resolution)).toEqual(['/work/notes']) }) @@ -254,18 +221,7 @@ describe('project scope', () => { expect( resolveSessionSearchScope( { kind: 'project', projectKey: 'project:proj-1' }, - catalog({ - projects: [ - { - id: 'proj-1', - sourceRepoIds: ['repo-elsewhere'], - displayName: 'app', - badgeColor: '', - createdAt: 0, - updatedAt: 0 - } - ] - }) + catalog({ projects: [{ id: 'proj-1', sourceRepoIds: ['repo-elsewhere'] }] }) ) ).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 a7268baed5d..8a5e6566008 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,13 +3,12 @@ import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contrac import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' import { installSessionSearchScopeCatalogSource, - resetSessionSearchScopeCatalogForTests, - type SessionSearchScopeCatalog + resetSessionSearchScopeCatalogForTests } from './session-search-scope-catalog' import { searchSessionService, setSessionSearchService } from './session-search-service-registry' -const CATALOG: SessionSearchScopeCatalog = { - repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }], +const CATALOG = { + repos: [{ id: 'repo-1', path: '/work/app' }], 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 13c9b72fbaa..8e172e5f2c1 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,11 +4,6 @@ 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, @@ -17,8 +12,6 @@ import { } from './session-search-service-registry' afterEach(() => { - resetSessionSearchScopeCatalogForTests() - vi.restoreAllMocks() setSessionSearchService(null) vi.useRealTimers() }) @@ -139,33 +132,6 @@ 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 aad27dc224d..5f78a365cc3 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -6,12 +6,6 @@ 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 { @@ -66,60 +60,20 @@ export async function searchSessionService( agents: compatibleAgents } } - 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 hostScope = within + ? resolveSessionSearchScope(within, sessionSearchScopeCatalog()) + : 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 freshness = + request.freshness === 'wait-until-current' + ? await reconcileWithin(current, freshnessTimeoutMs) + : false 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 2a860baf4f9..28f7bdba8dc 100644 --- a/src/main/ai-vault-search/session-search-service.ts +++ b/src/main/ai-vault-search/session-search-service.ts @@ -1,4 +1,3 @@ -import type { SessionSearchProjectOwnership } from './session-search-project-ownership' import type { AiVaultSearchRequest, AiVaultSearchResponse, @@ -11,10 +10,6 @@ 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 = { @@ -47,15 +42,7 @@ export function createSessionSearchService({ try { const result = engine.search( hostScope - ? { - ...request, - filters: { - ...request.filters, - ...(hostScope.kind === 'canonical' - ? { ownership: hostScope.selection } - : { scopePaths: hostScope.paths }) - } - } + ? { ...request, filters: { ...request.filters, 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 7c213c1d682..98bac3f4667 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,10 +1,4 @@ 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' @@ -25,22 +19,16 @@ 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: () => repos, - getProjects: () => projection.projects, - getProjectHostSetups: () => projection.setups, + 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' } + ], getAllWorktreeMeta: () => ({ 'local-repo::/work/one': meta('local'), 'ssh-repo::/srv/one': meta('ssh:box') @@ -50,19 +38,16 @@ function store() { } describe('scope catalog from the profile store', () => { - it('captures every Repo/setup before projection and applies filesystem eligibility afterwards', async () => { + it('keeps only the rows the addressed execution host owns', () => { const local = sessionSearchScopeCatalogFromStore(store(), 'local') - 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(local.repos.map((repo) => repo.id)).toEqual(['local-repo']) + expect(local.projectHostSetups.map((setup) => setup.repoId)).toEqual(['local-repo']) 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(['local-repo', 'ssh-repo']) + expect(remote.repos.map((repo) => repo.id)).toEqual(['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 bfd19e0a642..0862148fe99 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,4 +1,8 @@ -import type { ExecutionHostId } from '../../shared/execution-host' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + 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' @@ -16,25 +20,22 @@ export type SessionSearchScopeStore = { })[] getAllWorktreeMeta(): Record getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record - getSettings(): Pick + getSettings(): Pick } -// Claims need the complete catalog; filesystem eligibility is applied after projection. +// The store includes other hosts’ paths; expose only the answering host’s registrations. export function sessionSearchScopeCatalogFromStore( store: SessionSearchScopeStore, executionHostId: ExecutionHostId ): SessionSearchScopeCatalog { const settings = store.getSettings() return { - repos: store.getRepos(), - executionHostId, + repos: store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === executionHostId), projects: store.getProjects(), - projectHostSetups: store.getProjectHostSetups(), + projectHostSetups: store + .getProjectHostSetups() + .filter((setup) => normalizeExecutionHostId(setup.hostId) === executionHostId), worktreeMeta: readAllWorktreeMetaForHost(store, executionHostId), - settings: { - workspaceDir: settings.workspaceDir, - nestWorkspaces: settings.nestWorkspaces, - workspaceDirHistory: settings.workspaceDirHistory - } + settings: { workspaceDir: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces } } } diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 1eb4e482b20..90751e31931 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,9 +1,4 @@ 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, @@ -52,7 +47,6 @@ 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[] } @@ -122,15 +116,12 @@ export async function listAiVaultSessions( cachedList.expiresAt > now && aiVaultCachedDepthServes(cachedList.depth, depth, args?.scopePaths) ) { - return annotateHistoryOwnership( - truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths), - args - ) + return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) } // Capture before the coordinator's start microtask so intervening invalidation still rejects the result. const startGeneration = cacheGeneration let progress: LocalAiVaultScanProgressHandle | null = null - const result = await scanCoordinator.run({ + return scanCoordinator.run({ key: scanKey, force: args?.force, signal: options.signal, @@ -178,37 +169,6 @@ 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 cd8062a7e77..cd4ba27203d 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -59,7 +59,6 @@ describe('runtime AI Vault session scanner', () => { { limit: 25, force: true, - historyOwnership: {}, scopePaths: ['/srv/app'] }, { timeoutMs: 3000 } @@ -72,7 +71,6 @@ 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 f82c891be46..93375304375 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -77,7 +77,6 @@ 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 f1e32983422..d7013544859 100644 --- a/src/main/ai-vault/runtime-session-search-call.test.ts +++ b/src/main/ai-vault/runtime-session-search-call.test.ts @@ -1,15 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { callRuntimeEnvironment, resolveEnvironment } = vi.hoisted(() => ({ - callRuntimeEnvironment: vi.fn(), - resolveEnvironment: vi.fn() -})) -vi.mock('../../shared/runtime-environment-store', () => ({ resolveEnvironment })) +const callRuntimeEnvironment = vi.hoisted(() => vi.fn()) vi.mock('../ipc/runtime-environments/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment })) -import { callRuntimeSessionSearch, bindRuntimeSessionSearch } from './runtime-session-search-call' +import { callRuntimeSessionSearch } from './runtime-session-search-call' beforeEach(() => { callRuntimeEnvironment.mockReset() @@ -41,36 +37,3 @@ 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 036d8cad297..4dfe7d66f31 100644 --- a/src/main/ai-vault/runtime-session-search-call.ts +++ b/src/main/ai-vault/runtime-session-search-call.ts @@ -1,4 +1,3 @@ -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. @@ -6,33 +5,11 @@ export async function callRuntimeSessionSearch( userDataPath: string, environmentId: string, method: string, - params: Record, - expected?: { pairingRevision: number; runtimeId?: string } + params: Record ): Promise { - const response = expected - ? await callRuntimeEnvironment( - userDataPath, - environmentId, - method, - params, - undefined, - expected.pairingRevision, - undefined, - { expectedEnvironmentRuntimeId: expected.runtimeId } - ) - : await callRuntimeEnvironment(userDataPath, environmentId, method, params) + const response = 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 deleted file mode 100644 index a54e8df71bc..00000000000 --- a/src/main/ai-vault/runtime-session-search-lifetime.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 a8f657c65b9..9c6b49f5a86 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { isAiVaultProjectKey } from '../../shared/ai-vault-project-key' import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT, @@ -59,7 +58,6 @@ 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 deleted file mode 100644 index 343728f0c40..00000000000 --- a/src/main/ai-vault/session-scanner-service-search-process.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 328c76d6cb7..7d6883d967a 100644 --- a/src/main/ai-vault/session-scanner-service-search.test.ts +++ b/src/main/ai-vault/session-scanner-service-search.test.ts @@ -1,4 +1,3 @@ -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' @@ -133,30 +132,6 @@ 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 61e479320a2..7dfd122e797 100644 --- a/src/main/ai-vault/session-scope-real-paths.ts +++ b/src/main/ai-vault/session-scope-real-paths.ts @@ -32,11 +32,8 @@ export async function resolveAiVaultScopeRealPaths( } } const scanScopePaths = [...scopePaths] - const seen = new Set(scopePaths.map(normalizeRuntimePathForComparison)) for (const { realPath } of links) { - const key = normalizeRuntimePathForComparison(realPath) - if (!seen.has(key)) { - seen.add(key) + if (!scanScopePaths.some((path) => sameComparisonPath(path, realPath))) { scanScopePaths.push(realPath) } } diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts index 244b53d295c..721de555dba 100644 --- a/src/main/ipc/ai-vault-runtime-scan.ts +++ b/src/main/ipc/ai-vault-runtime-scan.ts @@ -66,9 +66,6 @@ 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 c545c982729..d4e470fb457 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.test.ts +++ b/src/main/ipc/ai-vault-search-all-hosts.test.ts @@ -127,29 +127,20 @@ it('answers an empty merge when no host is reachable to ask', async () => { }) }) -it('asks every leg for the merged order and passes both Project keys and freshness unchanged', async () => { +it('asks every leg for the merged order and passes freshness through 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(), remote.leg()] + [host.leg()] ) - expect(remote.lastRequests[0]).toEqual(host.lastRequests[0]) expect(host.lastRequests[0]).toEqual({ query: 'needle', freshness: 'wait-until-current', - within, filters: { sort: 'newest' } }) }) @@ -241,76 +232,37 @@ it('names an unavailable host by its reason without losing the other hosts', asy expect(response.page.hasMore).toBe(false) }) -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() +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 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 } }, [ + 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! }, [ healthy.leg(), - { - executionHostId: 'ssh:down', - search: async () => { - throw new Error('connection refused') - } - } + recovered.leg() ]) ) - 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.hosts).toEqual([{ executionHostId: 'ssh:slow', outcome: 'searched' }]) expect(second.hits.map((hit) => hit.sessionId)).toEqual(['s-0', 's-1']) - expect(second.page.hasMore).toBe(false) + } finally { + vi.useRealTimers() } -) +}) 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 c1530678045..3ee0d6ac746 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.ts +++ b/src/main/ipc/ai-vault-search-all-hosts.ts @@ -50,8 +50,7 @@ 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[], - isActive: () => boolean = () => true + legs: readonly SessionSearchHostLeg[] ): Promise { const startedAt = Date.now() const limit = resolveSessionSearchLimit(request.limit) @@ -75,13 +74,9 @@ 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) => - isActive() - ? fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null) - : Promise.resolve() - ) + walks.map((walk) => fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null)) ) - const hits = await drainMergedPage(walks, limit, sort, isActive) + const hits = await drainMergedPage(walks, limit, sort) return mergedSearchResponse(walks, { limit, sort }, hits, Date.now() - startedAt) } @@ -140,12 +135,8 @@ async function fetchHostPage(walk: HostWalk, entry: MergedSearchCursorEntry | nu endHostWalk(walk, 'stale', false) return } - // `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 - ) { + // Emitted offsets are generation-specific; fence only after emitting hits. + if (walk.emitted > 0 && walk.generation !== response.generation) { endHostWalk(walk, 'stale', false) return } @@ -167,8 +158,8 @@ function endHostWalk(walk: HostWalk, outcome: HostOutcome, carry: boolean): void walk.carry = carry } -async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise { - while (isActive() && walk.pending.length === 0 && walk.nextCursor !== null) { +async function advanceHostWalk(walk: HostWalk): Promise { + while (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 @@ -181,15 +172,14 @@ async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise async function drainMergedPage( walks: readonly HostWalk[], limit: number, - sort: MergedSort, - isActive: () => boolean + sort: MergedSort ): Promise { const hits: AiVaultSearchHit[] = [] let turn = 0 - while (isActive() && hits.length < limit) { + while (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, isActive) + await advanceHostWalk(walk) } 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 6a8ae1c7bc3..cfb76ba3cf1 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -1,18 +1,13 @@ -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, 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[]>(() => []) - })) +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() +})) vi.mock('../ai-vault/session-scanner-service-spawn', () => ({ clearSessionSearchInService: clearSearch })) @@ -23,23 +18,11 @@ 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' @@ -59,7 +42,6 @@ beforeEach(() => { sshHostInfos.mockReset() sshHostInfos.mockReturnValue([]) runtimeSearch.mockReset() - runtimeHostInfos.mockReset().mockReturnValue([]) clearSearch.mockReset() registerAiVaultSearchHandlers({ callRuntimeSearch: runtimeSearch @@ -294,70 +276,3 @@ 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 be3670958eb..9f1a1a87873 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -43,9 +43,6 @@ export type RuntimeSessionSearchCall = ( export type AiVaultSearchHandlerOptions = { callRuntimeSearch?: RuntimeSessionSearchCall - bindRuntimeSearch?: ( - environmentId: string - ) => (method: string, params: Record) => Promise ensureStructuredSessionOwnership?: () => Promise } @@ -169,23 +166,14 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] { } function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg { - 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 - } - } + 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' }) } } @@ -226,9 +214,8 @@ function remoteSearchClient( ): ReturnType | null { if (host.kind === 'runtime' && call) { const { environmentId } = host - const bound = handlerOptions.bindRuntimeSearch?.(environmentId) return createSessionSearchClient( - bound ?? ((method, params) => call(environmentId, method, params)), + (method, params) => call(environmentId, method, params), 'relay' ) } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 267d4f364f1..5a73496a65a 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -117,11 +117,13 @@ async function listAiVaultSessions( ? [...new Set(scopePaths)].sort() : scopePaths, executionHostScope, - historyOwnership: args?.historyOwnership, includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true }) const depth = requestedAiVaultSessionDepth(args) - const afterFingerprint = parsedHost?.kind === 'runtime' ? args?.afterFingerprint : undefined + const afterFingerprint = + parseRoutableExecutionHostId(executionHostScope)?.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 02ff0bc0a33..c5ea208ea6e 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -1,6 +1,4 @@ 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' @@ -104,10 +102,7 @@ import { resolveRuntimeAiVaultSessionTitles, scanRuntimeAiVaultSessions } from '../../ai-vault/runtime-session-scanner' -import { - callRuntimeSessionSearch, - bindRuntimeSessionSearch -} from '../../ai-vault/runtime-session-search-call' +import { callRuntimeSessionSearch } 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' @@ -275,15 +270,11 @@ 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: () => @@ -301,7 +292,9 @@ export function registerCoreHandlers( registerNativeChatHandlers() registerClipboardHandlers(store, { localChatAttachmentStore: async () => { - await ensureStructuredSessionOwnership() + await ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ) 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 e386155d75e..6d03ff335e3 100644 --- a/src/main/orca-profiles/profile-project-state-file.test.ts +++ b/src/main/orca-profiles/profile-project-state-file.test.ts @@ -3,8 +3,6 @@ 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 = { @@ -112,81 +110,3 @@ 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 8d065ab65c2..57977e33c4e 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -1,4 +1,3 @@ -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' @@ -220,12 +219,16 @@ 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, { - previous: state.projectHostSetups, - current: projection.setups - }) + const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects) 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 44d0e2fb88e..06d8c2e0d6e 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -182,15 +182,16 @@ 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), - ...orcadAiVaultSessionSources(profileStore, codexAccountHomes, accountLaunch), - // Why absent on a WSL host: a resolver overrides the desktop-pushed CODEX_HOME. + getAdditionalAiVaultCodexHomePaths: () => [ + ...codexAccountHomes.listSessionHomes(), + ...(accountLaunch?.listCodexSessionHomes() ?? []) + ], + // WSL must retain desktop-pushed CODEX_HOME instead of host-selected resolvers. ...accountLaunch?.runtimeDeps, ...agentHookRuntimeDeps(agentHookServer, observedPaneIdentities, () => profileStore.getSettings() @@ -219,6 +220,7 @@ 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 bba252f26e1..3ac0d2c3f53 100644 --- a/src/main/orcad/orcad-session-search.ts +++ b/src/main/orcad/orcad-session-search.ts @@ -1,8 +1,3 @@ -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' @@ -23,21 +18,3 @@ 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 fded84d3a58..f461bf44c6b 100644 --- a/src/main/persistence/tracking-repos/project-host-compatibility.ts +++ b/src/main/persistence/tracking-repos/project-host-compatibility.ts @@ -53,8 +53,7 @@ export function mergeProjectHostSetupCompatibilityState( const projection = projectHostSetupProjectionFromRepos(repos) const succession = carryProjectStateThroughIdentityChange( projection.projects, - state.projects ?? [], - { previous: state.projectHostSetups ?? [], current: projection.setups } + state.projects ?? [] ) 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 43667acb12d..36dc14599b6 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -197,10 +197,6 @@ 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 5b77847455e..198d5cd6ab2 100644 --- a/src/main/runtime/construction/runtime-construction-dependencies.ts +++ b/src/main/runtime/construction/runtime-construction-dependencies.ts @@ -1,4 +1,3 @@ -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' @@ -60,7 +59,6 @@ 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 a2c01394925..7b712e933b4 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -232,14 +232,11 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo this.dropAgentStatusForRemovedWorktreeFn = deps?.dropAgentStatusForRemovedWorktree ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null) - // 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 - }) + // Headless serve skips registerCoreHandlers but still needs managed-Codex/WSL AiVault sources. 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 de934643a96..bf9ec2ce431 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,18 +1,3 @@ -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' @@ -56,12 +41,8 @@ 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( @@ -147,40 +128,6 @@ 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: { @@ -197,10 +144,7 @@ describe('desktop relay E2EE integration', () => { credentialKind: 'invite' }) receivedText.resolve(plaintext) - expect(JSON.parse(plaintext)).toEqual(request) - void dispatcher - .dispatch(request, { clientKind: 'mobile' }) - .then((result) => reply(JSON.stringify(result))) + reply(JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } })) sendBinary(new Uint8Array([4, 5, 6])) }, onBinary: (_socket, bytes) => receivedBinary.resolve(new Uint8Array(bytes)), @@ -282,16 +226,16 @@ describe('desktop relay E2EE integration', () => { }) phone.send(JSON.stringify(phoneSession.hello)) await authenticated.promise - phone.send(phoneSession.sealText(JSON.stringify(request))) + phone.send(phoneSession.sealText(JSON.stringify({ id: 'rpc-1', method: 'status.get' }))) phone.send(phoneSession.sealBinary(new Uint8Array([1, 2, 3]))) - await expect(receivedText.promise).resolves.toBe(JSON.stringify(request)) + await expect(receivedText.promise).resolves.toBe( + JSON.stringify({ id: 'rpc-1', method: 'status.get' }) + ) await expect(receivedBinary.promise).resolves.toEqual(new Uint8Array([1, 2, 3])) - expect(JSON.parse(await phoneText.promise)).toMatchObject({ - id: 'rpc-1', - ok: true, - result: { kind: 'results', hits: [{ sessionId: '1' }] } - }) + await expect(phoneText.promise).resolves.toBe( + JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } }) + ) 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 df098ecc26e..6399217f576 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -30,7 +30,6 @@ 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' @@ -663,68 +662,6 @@ 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 9bc03b51636..97d7c9dba03 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -106,7 +106,6 @@ 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 958926d35a4..17b7fea336d 100644 --- a/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts +++ b/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts @@ -1,14 +1,4 @@ -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 { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import nacl from 'tweetnacl' @@ -60,7 +50,7 @@ function keyedServer() { const reply: unknown = JSON.parse(replies.at(-1)!) return reply } - return { server, registry, keyed, legacy, send, userDataPath } + return { server, registry, keyed, legacy, send } } describe('requests on a key-authenticated socket', () => { @@ -145,46 +135,3 @@ 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 6460afc21c8..19bb23352bd 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,11 +1,5 @@ import { existsSync } from 'node:fs' -import type { - backup, - BackupOptions, - DatabaseSync, - FunctionOptions, - SQLInputValue -} from 'node:sqlite' +import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite' import { hasNodeSqliteReaderApi } from './node-sqlite-reader-api' import { NodeSqliteStatement } from './node-sqlite-statement' import type { SqliteStatement } from './sqlite-statement' @@ -97,14 +91,6 @@ 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 167f00b6868..4776a806c32 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -115,9 +115,7 @@ 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, - // 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), + // Provide managed Codex homes here for desktop and headless session discovery; core IPC registration is window-only. 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 c3f8a774e4d..280cd31602e 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -33,8 +33,7 @@ export type AiVaultApi = { /** Omitted host means this host; `all` is merged by this desktop across every enumerated host. */ searchSessions: ( request: AiVaultSearchRequest, - executionHostScope?: ExecutionHostScope, - isActive?: () => boolean + executionHostScope?: ExecutionHostScope ) => 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 b3430379422..c28de00481e 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -34,11 +34,8 @@ function searchClient( } export const aiVaultApi = { - searchSessions: ( - request: AiVaultSearchRequest, - executionHostScope?: ExecutionHostScope, - isActive?: () => boolean - ) => searchClient(executionHostScope).searchSessions(request, isActive), + searchSessions: (request: AiVaultSearchRequest, executionHostScope?: ExecutionHostScope) => + searchClient(executionHostScope).searchSessions(request), 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 67b76ebbc49..09c44839db9 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.endpointProjects !== previousState.endpointProjects || - state.endpointProjectHostSetups !== previousState.endpointProjectHostSetups || + state.projects !== previousState.projects || + state.projectHostSetups !== previousState.projectHostSetups || 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 054a6264f21..c1092a5294b 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx @@ -16,8 +16,7 @@ vi.mock('@/runtime/runtime-file-import-client', () => ({ vi.mock('@/store', () => ({ useAppStore: Object.assign( - (selector: (state: unknown) => unknown) => - selector({ settings: {}, endpointProjects: [], endpointProjectHostSetups: [], repos: [] }), + (selector: (state: unknown) => unknown) => selector({ settings: {}, projects: [], 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 a6e092dd429..1fe178fa703 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', - endpointProjects: [], + projects: [], projectGroups: [], - endpointProjectHostSetups: [], + projectHostSetups: [], 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 a0422f0be46..e3f3280641a 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx @@ -30,8 +30,7 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], 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 82a76f51bdd..f5c613a6067 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -25,8 +25,7 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], 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 f644a6e92c7..7fa8caef83b 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx @@ -17,8 +17,7 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], repos: [], worktreesByRepo: {} }), diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index eeba0aaff83..9e1470260e2 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -32,8 +32,7 @@ vi.mock('@/store', () => ({ activeModal: 'none', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], repos: [] }), { diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 441b260d788..712f38da399 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1,4 +1,3 @@ -import { selectCanonicalProjects } from '@/store/project-host-setup-selector' import React from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -97,7 +96,7 @@ export default function NewWorkspaceComposerCard( (state) => state.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS ) const updateSettings = useAppStore((state) => state.updateSettings) - const projects = useAppStore((state) => selectCanonicalProjects(state)) + const projects = useAppStore((state) => state.projects) 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 924408ff486..9bb05e7efc1 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -63,21 +63,9 @@ function addSshProject(): void { } mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] mocks.repoMap.set(SSH_REPO_ID, repo) - // 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, + // The host needs a ready setup or save refuses the project before choosing a destination. + mocks.state.projectHostSetups = [ + ...mocks.state.projectHostSetups, { id: 'setup-ssh', projectId: 'project-ssh', @@ -105,19 +93,8 @@ function addCollidingRuntimeProject(): void { executionHostId: `runtime:${RUNTIME_ID}` } mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] - 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, + mocks.state.projectHostSetups = [ + ...mocks.state.projectHostSetups, { 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 92d760ac9d3..4dd4c2a6c39 100644 --- a/src/renderer/src/components/automations/automation-project-groups.ts +++ b/src/renderer/src/components/automations/automation-project-groups.ts @@ -1,6 +1,5 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' -import { getRepoHostIdentity } from '../../../../shared/repo-host-identity' +import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection' import type { Repo } from '../../../../shared/repo-types' export type AutomationProjectGroup = { @@ -13,15 +12,9 @@ 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 = projectIds.get(getRepoHostIdentity(repo))! + const projectKey = getProjectIdentityKey(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 795124fa34f..497f876451d 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -6,7 +6,8 @@ import type { AutomationRunUsage, ExternalAutomationManager } from '../../../../shared/automations-types' -import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { AppState } from '@/store/types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { @@ -218,11 +219,11 @@ export function makeWorktree(overrides: Partial = {}): Worktree { } } +export type AutomationsPageFixtureState = Record & + Pick + export type AutomationsPageStoreFixtures = { - state: Record & { - endpointProjects: Project[] - endpointProjectHostSetups: ProjectHostSetup[] - } + state: AutomationsPageFixtureState repoMap: Map worktreeMap: Map } @@ -235,17 +236,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures { return { state: { repos: [repo], - endpointProjects: [ - { - id: 'project-1', - displayName: repo.displayName, - badgeColor: repo.badgeColor, - sourceRepoIds: [repo.id], - createdAt: 1, - updatedAt: 1 - } - ], - endpointProjectHostSetups: [makeProjectHostSetup()], + projectHostSetups: [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 8f1635d4601..0bfebc6c500 100644 --- a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts @@ -1,3 +1,4 @@ +import type { AutomationsPageFixtureState } from './automations-page-fixtures' import type { Automation } from '../../../../shared/automations-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' @@ -19,7 +20,7 @@ export function selfScopedList(automations: Automation[]): Record & { endpointProjectHostSetups: ProjectHostSetup[] } + state: AutomationsPageFixtureState repoMap: Map worktreeMap: Map } @@ -55,10 +56,10 @@ export function addRuntimeProject(mocks: RuntimeFixtureMocks, runtimeId: string) updatedAt: 1 } const repos = mocks.state.repos as Repo[] - const setups = mocks.state.endpointProjectHostSetups + const setups = mocks.state.projectHostSetups const worktreesByRepo = mocks.state.worktreesByRepo as Record mocks.state.repos = [...repos, repo] - mocks.state.endpointProjectHostSetups = [...setups, setup] + mocks.state.projectHostSetups = [...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 0cbae2b23ac..66c7814828a 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -1,4 +1,3 @@ -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' @@ -90,10 +89,7 @@ export type DeleteDialogProps = { } type AutomationsPageMocks = { - state: Record & { - endpointProjects: Project[] - endpointProjectHostSetups: ProjectHostSetup[] - } + state: AutomationsPageFixtureState repoMap: Map worktreeMap: Map listPanel: ListPanelProps | null @@ -111,7 +107,7 @@ type AutomationsPageMocks = { // Mock factories only close over this exported binding; vi.hoisted bindings cannot be exported. export const mocks: AutomationsPageMocks = { - state: { endpointProjects: [], endpointProjectHostSetups: [] }, + state: { repos: [], projectHostSetups: [], worktreesByRepo: {} }, repoMap: new Map(), worktreeMap: new Map(), listPanel: null, @@ -248,7 +244,12 @@ vi.mock('./AutomationDeleteDialogs', () => ({ })) import AutomationsPage from './AutomationsPage' -import { makeAutomation, makeRun, makeStoreState } from './automations-page-fixtures' +import { + makeAutomation, + makeRun, + makeStoreState, + type AutomationsPageFixtureState +} from './automations-page-fixtures' function mockGroup(...names: K[]): Record { return Object.fromEntries(names.map((name) => [name, vi.fn()])) as Record 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 fef24234d12..d817789bf76 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,4 +1,3 @@ -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' @@ -19,7 +18,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) => selectCanonicalProjectHostSetups(s)) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) 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 7d19545a857..a3f6f7b86c0 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -516,6 +516,25 @@ 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 eaa39897f5a..7bf660a702a 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -95,8 +95,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState { activeWorktreeId: null, activeWorkspaceExecutionHostId: null, activeRepoId: null, - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], removedRuntimeEnvironmentIds: new Set() } } @@ -287,8 +286,6 @@ 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 bc093176233..70a0603be7a 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 AgentLaunchRouteWatchState, + type AgentLaunchRouteView, agentLaunchRouteInputsChanged, selectAgentLaunchRouteView } from '@/lib/agent-launch-route-input' @@ -37,7 +37,7 @@ export function repoIconsUnchanged( type DashboardSnapshotWatchState = DashboardSnapshotState & Pick & - AgentLaunchRouteWatchState + AgentLaunchRouteView 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 40840c031a1..daab9c32d6b 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts @@ -226,11 +226,7 @@ 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 [ - { endpointProjects: [] }, - { endpointProjectHostSetups: [] }, - { activeWorkspaceExecutionHostId: 'local' as const } - ]) { + for (const route of [{ projects: [] }, { 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 565e4ad33b2..fe77acc5562 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,9 +121,6 @@ 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 9b2368a6ce7..600d01faf21 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,9 +54,6 @@ 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 e499881bbbe..ec0342be274 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, - endpointProjects: [], + projects: [], 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 fb233399af1..a5ad59f2e20 100644 --- a/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx +++ b/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx @@ -8,8 +8,6 @@ type StoreState = { activeRepoId: string | null activeWorktreeId: string | null worktreesByRepo: Record - endpointProjects: never[] - endpointProjectHostSetups: never[] repos: unknown[] settings: { activeRuntimeEnvironmentId?: string | null } preflightStatus: PreflightStatus | null @@ -92,8 +90,6 @@ 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 d1b41b7e99b..434d393786d 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,7 +1,3 @@ -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' @@ -29,6 +25,7 @@ export type NativeChatSkillStateInputs = Pick< | 'floatingWorkspacePath' | 'folderWorkspaces' | 'projectGroups' + | 'projects' | 'repos' | 'restoredRuntimeHostIdByWorkspaceSessionKey' | 'settings' @@ -36,8 +33,7 @@ export type NativeChatSkillStateInputs = Pick< | 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'worktreesByRepo' -> & - ProjectCatalogReadState +> type NativeChatSkillTab = { id: string; startupCwd?: string } @@ -63,7 +59,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki floatingWorkspacePath: state.floatingWorkspacePath, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, - projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), + projects: state.projects, 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 5b4611202c6..9fe278addac 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 @@ -137,7 +137,7 @@ function corpus(scale: number, dense = false) { worktreesByRepo: {}, detectedWorktreesByRepo: {}, projectGroups: [], - endpointProjects: [], + projects: [], 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 f473b46eeb3..967b15967b8 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,8 +35,7 @@ function stateForHost(hostId: string) { activeWorktreeId: 'worktree-1', folderWorkspaces: [], projectGroups: [], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], 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 1aa2a77e843..9de50cd5f01 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 @@ -103,8 +103,7 @@ describe('floating workspace skill discovery', () => { floatingWorkspacePath: '/home/me/scratch', folderWorkspaces: [], projectGroups: [], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], 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 d9dea9661a0..29a4ba1b2d9 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 { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { @@ -17,12 +17,10 @@ import { SetProjectLocationDialog } from './SetProjectLocationDialog' const storeMock = vi.hoisted(() => { const repos: Repo[] = [] - const endpointProjects: Project[] = [] - const endpointProjectHostSetups: ProjectHostSetup[] = [] + const projectHostSetups: ProjectHostSetup[] = [] return { repos, - endpointProjects, - endpointProjectHostSetups, + projectHostSetups, setupProjectExistingFolder: vi.fn(), setupProjectClone: vi.fn() } @@ -127,14 +125,12 @@ function createInput(): Parameters[0] { beforeEach(() => { storeMock.repos = [localRepo] - storeMock.endpointProjects = [...localProjection.projects] - storeMock.endpointProjectHostSetups = [...localProjection.setups] + storeMock.projectHostSetups = [...localProjection.setups] storeMock.setupProjectExistingFolder.mockReset() storeMock.setupProjectClone.mockReset() const publishRuntimeSetup = async () => { storeMock.repos = [localRepo, runtimeRepo] - storeMock.endpointProjects = [...localProjection.projects, ...runtimeProjection.projects] - storeMock.endpointProjectHostSetups = [...localProjection.setups, ...runtimeProjection.setups] + storeMock.projectHostSetups = [...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 a5478eecff6..aa82e46ea46 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,6 +1,5 @@ -import { expect, it } from 'vitest' +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 { @@ -18,34 +17,44 @@ function worktree(partial: Partial): Worktree { } as Worktree } -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 +describe('orderProjectIdsByRecency', () => { + it('orders projects by their newest workspace, newest first', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha', createdAt: 10 }), + worktree({ projectId: 'beta', createdAt: 30 }), + worktree({ projectId: 'gamma', createdAt: 20 }) + ]) + expect(ids.filter((id) => !id.includes(':'))).toEqual(['beta', 'gamma', 'alpha']) + }) + + it('uses the newest workspace per project, not the oldest', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha', createdAt: 1 }), + worktree({ projectId: 'alpha', createdAt: 99 }), + worktree({ projectId: 'beta', createdAt: 50 }) + ]) + expect(ids[0]).toBe('alpha') + }) + + it('skips legacy repo-only workspaces that carry no project identity', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: undefined, createdAt: 99 }), + worktree({ projectId: 'beta', createdAt: 1 }) + ]) + expect(ids).toContain('beta') + expect(ids).not.toContain(undefined) + }) + + it('emits the folder-group option id too, so grouped targets resolve', () => { + const ids = orderProjectIdsByRecency([worktree({ projectId: 'alpha', createdAt: 5 })]) + expect(ids).toContain('project-group:alpha') + }) + + it('treats a missing createdAt as oldest rather than crashing', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha' }), + worktree({ projectId: 'beta', createdAt: 5 }) + ]) + expect(ids[0]).toBe('beta') }) - 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 b226e35e290..bd0c3d3f3fe 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,25 +1,13 @@ 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' -/** - * 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) +/** 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[] { const newestByProject = new Map() for (const worktree of worktrees) { - const projectId = owners.projectId(worktree) - // Legacy repo-only workspaces have no projectId and can't be attributed. + const projectId = worktree.projectId if (projectId === undefined || projectId === '') { continue } @@ -40,6 +28,5 @@ export function orderProjectIdsByRecency( export function useRecentProjectIds(): string[] { const worktrees = useAllWorktrees() - const { setups } = useProjectHostSetupProjection() - return useMemo(() => orderProjectIdsByRecency(worktrees, setups), [worktrees, setups]) + return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees]) } 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 b63bf3a744e..49238cc4bea 100644 --- a/src/renderer/src/components/pull-request-page/checks/tab.tsx +++ b/src/renderer/src/components/pull-request-page/checks/tab.tsx @@ -1,4 +1,3 @@ -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' @@ -69,7 +68,7 @@ export function ChecksTab({ targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null ) const repos = useAppStore((s) => s.repos) - const projects = useAppStore((s) => selectCanonicalProjects(s)) + const projects = useAppStore((s) => s.projects) const [fixingChecks, setFixingChecks] = useState(false) const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState(null) const mountedRef = useMountedRef() @@ -137,7 +136,7 @@ export function ChecksTab({ { activeRepoId: null, activeWorktreeId: null, - projectHostSetupProjection: { projects, setups: [] }, + projects, 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 baf0dffc218..4ba56f3072e 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,8 +11,6 @@ const mockState: { folderWorkspaces: never[] projectGroups: never[] repos: never[] - endpointProjects: never[] - endpointProjectHostSetups: never[] worktreesByRepo: Record } = { settings: {}, @@ -20,8 +18,6 @@ const mockState: { folderWorkspaces: [], projectGroups: [], repos: [], - endpointProjects: [], - endpointProjectHostSetups: [], worktreesByRepo: {} } const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boolean } }) => { @@ -160,11 +156,7 @@ 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.any(Function) - ) + expect(searchSessions).toHaveBeenCalledWith(expect.objectContaining({ query: 'foo' }), 'local') ) 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 deleted file mode 100644 index 7ae5a0bd73d..00000000000 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -// @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 ae15c615afe..cff14480cab 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -1,4 +1,3 @@ -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' @@ -64,15 +63,14 @@ export default function AiVaultPanel({ const { workspaceId: activeWorktreeId, worktree: activeWorktree, - repo: activeRepo, - legacyProjectKey + repo: activeRepo } = useAiVaultPanelWorkspace(workspace) const repos = useRepos() const allWorktrees = useAllWorktrees() const projectHostSetupProjection = useProjectHostSetupProjection() const resumeTargetState = useAppStore( useShallow((state) => ({ - projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), + projects: state.projects, settings: state.settings, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, @@ -156,18 +154,11 @@ export default function AiVaultPanel({ aiVaultSearchScopeIdentity({ scope, activeWorktreeId: activeWorktree?.id, - activeProjectKey: legacyProjectKey + activeProjectKey }), - [legacyProjectKey, activeWorktree?.id, scope] - ) - const search = useAiVaultPanelSearch( - query, - agents, - searchWithin, - executionHostScope, - searchSort, - activeProjectKey ?? undefined + [activeProjectKey, activeWorktree?.id, scope] ) + const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope, searchSort) const { searching, searchHits } = search const searchSessions = useMemo( () => withAiVaultHistoryPreviews(search.sessions, history), @@ -189,10 +180,9 @@ export default function AiVaultPanel({ repos, worktrees: allWorktrees, projectHostSetupProjection, - settings: settings ?? undefined, sessions }), - [allWorktrees, projectHostSetupProjection, repos, sessions, settings] + [allWorktrees, projectHostSetupProjection, repos, sessions] ) 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 48bf33c5234..a6a576e1113 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 @@ -50,8 +50,6 @@ function stateForWorktree(args: { return { folderWorkspaces: [], projectGroups: [], - endpointProjects: [], - endpointProjectHostSetups: [], repos: [ { ...TEST_REPO, @@ -116,8 +114,6 @@ describe('useAiVaultExecutionHostScope', () => { folderWorkspaces: [], projectGroups: [], repos: [], - endpointProjects: [], - endpointProjectHostSetups: [], worktreesByRepo: {} } }) @@ -133,8 +129,6 @@ describe('useAiVaultExecutionHostScope', () => { folderWorkspaces: [], projectGroups: [], repos: [], - endpointProjects: [], - endpointProjectHostSetups: [], worktreesByRepo: {} } }) 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 e89bd1c048c..0d5561ff39e 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,36 +1,9 @@ -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 @@ -48,15 +21,3 @@ export function resolveLegacyActiveProjectKey( 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 deleted file mode 100644 index 3d60a3362e7..00000000000 --- a/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -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 ba91caff3c9..43957af1cc9 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,11 +20,7 @@ const AGENTS = ['codex' as const] const WORKSPACE = { kind: 'workspace', worktreeId: 'repo-1::/work/app' } as const const searchSessions = vi.fn< - ( - request: AiVaultSearchRequest, - scope?: ExecutionHostScope, - isCurrent?: () => boolean - ) => Promise + (request: AiVaultSearchRequest, scope?: ExecutionHostScope) => Promise >() beforeEach(() => { @@ -94,13 +90,9 @@ describe('the panel hook under a scope identity', () => { await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { query: 'needle', filters: { agents: ['codex'] }, within, cursor: undefined }, - 'ssh:build-box', - expect.any(Function) + 'ssh:build-box' ) - 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 be9edd2e821..5f60f3a0a5e 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,20 +337,6 @@ 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 8e36481923f..a3d93b9fbe0 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,7 +22,6 @@ 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 1de92931e54..0b2e545b431 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,17 +1,4 @@ -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 { sessionFileExecutionNamespace } from '../../../../shared/ai-vault-scope-cwd-match' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -64,49 +51,3 @@ 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 3a6a1516de8..84573a4aaec 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,6 +5,7 @@ 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 @@ -38,6 +39,14 @@ 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' }) @@ -248,7 +257,7 @@ describe('buildAiVaultProjectContext', () => { expect(context.sessionProjectById.get(secondSession.id)?.label).toBe('Canonical Project') }) - it('keeps the most specific workspace path while using its current checkout affiliation', () => { + it('chooses the most specific nested path and lets worktrees win equal-length ties', () => { 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({ @@ -263,7 +272,7 @@ describe('buildAiVaultProjectContext', () => { repos: [repo, childRepo], worktrees: [worktree], projectHostSetupProjection: makeProjection({ - projects: [makeProject({ id: 'setup-child', displayName: 'Child Project' })], + projects: [makeProject({ id: 'child-project', displayName: 'Child Project' })], setups: [ makeSetup({ repoId: repo.id, projectId: 'root-project', path: repo.path }), makeSetup({ repoId: childRepo.id, projectId: 'setup-child', path: childRepo.path }) @@ -275,7 +284,7 @@ describe('buildAiVaultProjectContext', () => { }) expect(context.sessionProjectById.get(session.id)).toMatchObject({ - key: 'project:setup-child', + key: 'project:child-project', 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 788aeacf1f2..833c7f45477 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,33 +1,27 @@ -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 { createNormalizedAiVaultScopeCwdMatcher } from '../../../../shared/ai-vault-scope-cwd-match' import { resolveActiveProjectKey } from './ai-vault-project-key' -import { isAiVaultProjectKey, toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' +import { 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 { 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 { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { - folderProject, aiVaultSessionProjectCwdKey, - matchAiVaultProjectCandidates, - makeProjectCandidate, - resolveCandidateHostId, - hasCandidatePath, - type SessionProjectCandidate + matchAiVaultProjectCandidates } from './ai-vault-session-project-candidates' -import type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' +import { + folderGroupKey, + folderLabel, + type AiVaultSessionProject +} from '../../../../shared/ai-vault-session-filters' export type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' @@ -38,6 +32,16 @@ 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[] @@ -45,7 +49,6 @@ type ProjectResolverArgs = { activeRepo: Repo | null activeWorktree: Worktree | null sessions: readonly AiVaultSession[] - settings?: Pick } export function buildAiVaultProjectContext({ @@ -56,10 +59,10 @@ export function buildAiVaultProjectContext({ activeWorktree, sessions }: ProjectResolverArgs): AiVaultProjectContext { - const owners = getProjectHostSetupLookup(projectHostSetupProjection.setups) + const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) return { - activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, owners), + activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId), activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null, projectLabelByKey: buildProjectLabelByKey(repos, projectHostSetupProjection), sessionProjectById: buildAiVaultSessionProjectById({ @@ -76,18 +79,16 @@ export function buildAiVaultSessionProjectById({ repos, worktrees, projectHostSetupProjection, - sessions, - settings + sessions }: Omit): Map { - const repoById = new Map(repos.map((repo) => [getRepoHostIdentity(repo), repo])) - const owners = getProjectHostSetupLookup(projectHostSetupProjection.setups) + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection) const candidates = buildProjectCandidates( worktrees, projectHostSetupProjection, repoById, - owners, - settings + setupByRepoId ) const sessionProjectById = new Map() const projectsByHostAndCwd = new Map< @@ -95,18 +96,6 @@ 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) { @@ -124,6 +113,18 @@ 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 @@ -158,53 +159,36 @@ function buildProjectCandidates( worktrees: readonly Worktree[], projection: ProjectHostSetupProjection, repoById: ReadonlyMap, - owners: ReturnType, - settings?: ProjectResolverArgs['settings'] + setupByRepoId: ReadonlyMap ): 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( - getRepoHostIdentityForParts(worktree.repoId, worktree.hostId ?? 'local') + 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 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(getRepoHostIdentityForParts(setup.repoId, getRepoExecutionHostId(setup))) + setupRepoIds.add(setup.repoId) } } if (!hasCandidatePath(setup.path)) { @@ -222,21 +206,7 @@ function buildProjectCandidates( } for (const repo of repoById.values()) { - 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))) { + if (setupRepoIds.has(repo.id)) { continue } if (!hasCandidatePath(repo.path)) { @@ -256,6 +226,34 @@ 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[], @@ -282,15 +280,7 @@ function resolveSessionProject( } const bestCandidate = hostMatches[0] - if ( - !bestCandidate || - hostMatches.some( - (candidate) => - compareCandidates(candidate, bestCandidate) === 0 && - toAiVaultProjectKey(candidate.projectId, candidate.repoId) !== - toAiVaultProjectKey(bestCandidate.projectId, bestCandidate.repoId) - ) - ) { + if (!bestCandidate) { return folderProject(cwd) } @@ -319,3 +309,11 @@ 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 f80094a52ca..ce832c055c9 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 - // Repeated stamp and equal content reuse the snapshot already on screen. + // Same scannedAt = the snapshot already on screen was replayed. listSessionsMock.mockResolvedValueOnce({ ...EMPTY_RESULT }) await advance(THROTTLE_MS + 1) await fireWindowFocused() @@ -435,9 +435,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(latest?.scanResult).toBe(firstResult) }) - // 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. + // Merged stamps use the newest host clock; lagging-host changes require structural reconciliation. it('applies a changed all-host result whose merged scannedAt stood still', async () => { const pinned = '2026-07-01T00:00:00.000Z' listSessionsMock.mockResolvedValueOnce({ @@ -461,8 +459,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(latest?.sessions[1]?.id).toBe('session-2') }) - // The main process routes an empty or unrecognized scope to the same all-host - // merge; its changed content must also reach reconciliation. + // Empty/unknown scopes also use the main-process all-host merge and cannot trust its stamp. it('treats a scope that normalizes to all-host as merged', async () => { const pinned = '2026-07-01T00:00:00.000Z' listSessionsMock.mockResolvedValueOnce({ @@ -498,7 +495,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { const applied = latest?.scanResult const appliedSessions = latest?.sessions - // Reconciliation recognises an independently cloned identical body. + // Structural reconciliation must still skip identical IPC clones after bypassing the all-host stamp guard. 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 77872aec724..d4610439879 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,7 +189,6 @@ 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 1f18ee2b742..2807664c2b0 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,14 +40,7 @@ function resumeInChat(session: AiVaultSession = codexSession) { session, resumeState: { blocked: true, worktreeId: null, usesSessionWorktree: false }, activeWorkspaceId: PAIRED_WORKSPACE, - targetState: { - folderWorkspaces: [], - projectGroups: [], - repos: [], - endpointProjects: [], - endpointProjectHostSetups: [], - worktreesByRepo: {} - }, + targetState: { folderWorkspaces: [], projectGroups: [], repos: [], 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 301de2e2a8d..46d1973d98a 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,8 +53,6 @@ function makeTargetState( folderWorkspaces: [], projectGroups: [], repos: [], - endpointProjects: [], - endpointProjectHostSetups: [], worktreesByRepo: {}, ...overrides } 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 4bc3e6346e6..8b8d499efd3 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,8 +10,6 @@ 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' @@ -24,9 +22,11 @@ import { } from './ai-vault-session-worktree' import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' -export type AiVaultSessionResumeTargetState = ProjectCatalogReadState & - Pick & - Partial> +export type AiVaultSessionResumeTargetState = Pick< + AppState, + 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo' +> & + Partial> export type AiVaultSessionResumeState = { blocked: boolean @@ -227,12 +227,10 @@ function resolveAiVaultResumeTargetState(args: { for (const worktree of args.worktrees) { worktreesByRepo[worktree.repoId] = [...(worktreesByRepo[worktree.repoId] ?? []), worktree] } - const repos = [...args.repos] return { folderWorkspaces: [], projectGroups: [], - repos, - projectHostSetupProjection: projectHostSetupProjectionFromRepos(repos), + repos: [...args.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 8c3c3f8d780..72120cb84d8 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,8 +303,6 @@ 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 deleted file mode 100644 index 8c2c4338098..00000000000 --- a/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// @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 65b4583424e..90e937a37fd 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,6 +1,3 @@ -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' @@ -14,25 +11,14 @@ const NO_ROWS: AiVaultPanelWorkspaceRows = { worktree: null, repo: null } export function useAiVaultPanelWorkspace( workspace: AiVaultPanelWorkspace | undefined -): AiVaultPanelWorkspaceRows & { workspaceId: string | null; legacyProjectKey: string | null } { +): AiVaultPanelWorkspaceRows & { workspaceId: string | null } { const activeWorktreeId = useActiveWorktreeId() const activeWorktree = useActiveWorktree() const activeRepo = useActiveRepo() const bound = useAppStore( useShallow((state) => (workspace ? selectAiVaultPanelWorkspace(state, workspace) : NO_ROWS)) ) - const selected = workspace + return 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 c3a8cb2590e..abe879cdd4d 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,6 +1,3 @@ -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' @@ -16,17 +13,11 @@ import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-w import { useAiVaultPanelSearch, useAiVaultSearch } from './use-ai-vault-search' const mockSettings: { aiVaultSearch?: { enabled: boolean } } = {} -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 } - ) - } -}) +vi.mock('@/store', () => ({ + useAppStore: ( + select: (state: { settings: typeof mockSettings; runtimeEnvironments: never[] }) => unknown + ) => select({ settings: mockSettings, runtimeEnvironments: [] }) +})) const feeds = vi.hoisted(() => ({ subscribe: vi.fn(), @@ -95,8 +86,7 @@ it('debounces, skips empty/disabled requests, and never substitutes local for an await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { query: 'latest', cursor: undefined }, - 'ssh:remote', - expect.any(Function) + 'ssh:remote' ) unmount() }) @@ -142,12 +132,8 @@ 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.any(Function) - ]) - expect(searchSessions.mock.calls[2]).toEqual([request, 'runtime:owner', expect.any(Function)]) + expect(searchSessions.mock.calls[1]).toEqual([{ ...request, cursor: 'page-2' }, 'runtime:owner']) + expect(searchSessions.mock.calls[2]).toEqual([request, 'runtime:owner']) expect(searchSessions).toHaveBeenCalledTimes(3) expect(result.current.response?.kind).toBe('stale-cursor') unmount() @@ -197,11 +183,7 @@ 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.any(Function) - ]) + expect(searchSessions.mock.calls.at(-1)).toEqual([{ ...request, cursor: undefined }, 'local']) unmount() }) @@ -247,11 +229,7 @@ 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.any(Function) - ]) + expect(searchSessions.mock.calls.at(-1)).toEqual([{ ...request, cursor: undefined }, 'local']) unmount() }) @@ -286,8 +264,7 @@ 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', - expect.any(Function) + 'all' ) expect(result.current.sessions.map((session) => session.executionHostId)).toEqual([ 'ssh:build-box', @@ -333,12 +310,8 @@ 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.any(Function) - ]) - expect(searchSessions.mock.calls[2]).toEqual([ALL_REQUEST, 'all', expect.any(Function)]) + expect(searchSessions.mock.calls[1]).toEqual([{ ...ALL_REQUEST, cursor: 'merged' }, 'all']) + expect(searchSessions.mock.calls[2]).toEqual([ALL_REQUEST, 'all']) expect(result.current.sessions.map((session) => session.executionHostId)).toEqual(['local']) unmount() }) @@ -365,8 +338,7 @@ it('searches the local index with the same query once consent is on', async () = await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { ...ALL_REQUEST, cursor: undefined }, - 'local', - expect.any(Function) + 'local' ) expect(result.current.searching).toBe(true) expect(result.current.hasQuery).toBe(true) @@ -419,33 +391,6 @@ 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 41c97a861d2..f8dcb2a1ad7 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,8 +59,6 @@ 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) { @@ -79,14 +77,14 @@ export function useAiVaultSearch( loading: true })) try { - let response = await search({ ...request, cursor }, scope) + let response = await window.api.aiVault.searchSessions({ ...request, cursor }, scope) let append = Boolean(cursor) if (!isCurrent()) { return } if (response.kind === 'stale-cursor') { append = false - response = await search(request, scope) + response = await window.api.aiVault.searchSessions(request, scope) } if (!isCurrent()) { return @@ -172,8 +170,7 @@ export function useAiVaultPanelSearch( /** Which scope the host resolves; undefined searches everything it has. */ within: AiVaultSearchScopeIdentity | undefined, executionHostScope: ExecutionHostScope, - sort: AiVaultSearchSort, - canonicalProjectKey?: string + sort: AiVaultSearchSort ) { const settings = useAppStore((state) => state.settings?.aiVaultSearch) const policy = resolveAiVaultSearchSettings({ aiVaultSearch: settings }) @@ -196,17 +193,10 @@ export function useAiVaultPanelSearch( ? { query: trimmed, filters: { agents: [...agents], ...(sort === 'relevance' ? {} : { sort }) }, - ...(within - ? { - within: - within.kind === 'project' && canonicalProjectKey - ? { ...within, canonicalProjectKey } - : within - } - : {}) + ...(within ? { within } : {}) } : null, - [searching, scope, agents, trimmed, within, sort, canonicalProjectKey] + [searching, scope, agents, trimmed, within, sort] ) 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 230845736ee..543db2dc88e 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx @@ -24,8 +24,7 @@ const mockStoreState = { activeWorktreeId: null, openModal: storeMocks.openModal, recordFeatureInteraction: vi.fn(), - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], 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 deleted file mode 100644 index f06863eb56d..00000000000 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx +++ /dev/null @@ -1,203 +0,0 @@ -// @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/repository/RepositoryPane.tsx b/src/renderer/src/components/settings/repository/RepositoryPane.tsx index 2aa84096753..673309616fe 100644 --- a/src/renderer/src/components/settings/repository/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/repository/RepositoryPane.tsx @@ -15,6 +15,7 @@ import { SparsePresetSettingsSection } from '../SparsePresetSettingsSection' import { RepositorySourceControlAiSection } from './source-control/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' @@ -57,6 +58,9 @@ type RepositoryPaneProps = { removeProject: (repoId: string) => void project?: Project | null selectedProjectSetupId?: string + settingsSelectionKey?: string + settingsEntryRepoIds?: ReadonlySet + removalScope?: SettingsProjectRemovalScope isLocalWindowsProject?: boolean wslAvailable?: boolean wslDistros?: string[] @@ -77,6 +81,9 @@ export function RepositoryPane({ removeProject, project = null, selectedProjectSetupId, + settingsSelectionKey, + settingsEntryRepoIds, + removalScope = 'project', isLocalWindowsProject = false, wslAvailable = false, wslDistros = EMPTY_WSL_DISTROS, @@ -239,6 +246,7 @@ export function RepositoryPane({ @@ -301,6 +309,8 @@ export function RepositoryPane({ void }) { @@ -32,10 +54,7 @@ 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 24858f96955..4cbd8e13025 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], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -178,8 +178,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo, remoteRepo], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -230,10 +230,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [remoteRepo], - endpointProjects: [ - makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] }) - ], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })], + projectHostSetups: [ makeSetup({ id: 'direct-setup', projectId: 'github:stablyai/orca', @@ -332,10 +330,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [remoteRepo], - endpointProjects: [ - makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] }) - ], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })], + projectHostSetups: [ makeSetup({ id: 'hub-local-setup', projectId: 'github:stablyai/orca', @@ -375,8 +371,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -446,8 +442,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -523,8 +519,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -586,8 +582,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -637,8 +633,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -706,8 +702,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -780,8 +776,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], - endpointProjectHostSetups: [ + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ 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 4d3ff2a603a..d7032bb0c9d 100644 --- a/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.tsx @@ -1,7 +1,6 @@ import { useMemo, useState } from 'react' import { getExecutionHostLabel, - getRepoExecutionHostId, parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId @@ -20,7 +19,6 @@ 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' @@ -36,6 +34,8 @@ import { type RepositoryHostSetupsSectionProps = { repo: Repo selectedProjectSetupId?: string + settingsSelectionKey?: string + settingsEntryRepoIds?: ReadonlySet forceVisible: boolean searchQuery: string searchEntries: SettingsSearchEntry[] @@ -62,6 +62,8 @@ function setupsByOwnedExecutionHost( export function RepositoryHostSetupsSection({ repo, selectedProjectSetupId, + settingsSelectionKey, + settingsEntryRepoIds, forceVisible, searchQuery, searchEntries @@ -109,7 +111,7 @@ export function RepositoryHostSetupsSection({ getProjectHostSetupProjectionFromState(state) ) const repoProjectHostSetup = projectHostSetupProjection.setups.find( - (setup) => setup.repoId === repo.id && setup.hostId === getRepoExecutionHostId(repo) + (setup) => setup.repoId === repo.id ) const selectedProjectHostSetup = projectHostSetupProjection.setups.find( @@ -123,8 +125,12 @@ 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, + allProjectHostSetups.filter( + (setup) => + !settingsEntryRepoIds || !setup.repoId.trim() || settingsEntryRepoIds.has(setup.repoId) + ), selectedProjectHostSetup?.id ?? '' ) const openableProjectHostSetups = projectHostSetups.filter((setup) => setup.repoId.trim()) @@ -133,17 +139,14 @@ 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) - 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. + // 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 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 1bf74259820..3ccb5de66b8 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], - endpointProjects: [project], - endpointProjectHostSetups: [setup], + projects: [project], + projectHostSetups: [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], - endpointProjects: [project], - endpointProjectHostSetups: [setup], + projects: [project], + projectHostSetups: [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 3070c123567..f431c25576f 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,16 +45,11 @@ 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 6f295731ff8..7d668a67084 100644 --- a/src/renderer/src/components/settings/settings-project-list.test.ts +++ b/src/renderer/src/components/settings/settings-project-list.test.ts @@ -1,11 +1,15 @@ 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, @@ -62,6 +66,7 @@ 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', () => { @@ -77,21 +82,112 @@ describe('buildSettingsProjectList', () => { expect(projects[0].representativeRepoId).toBe('folder-x') }) - it('gives each same-host clone of one remote its own entry; other hosts join the first (#20861)', () => { + it('gives each same-host clone of one remote its own entry, like the sidebar (#20861)', () => { const repos: Repo[] = [ - makeRepo({ id: 'clone-b', displayName: 'app-b', addedAt: 2, gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-a', displayName: 'app', addedAt: 1, gitRemoteIdentity: gitRemote }), + 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: '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'], - ['clone-a', 'remote-9'] + ['remote-9'] + ]) + expect(projects[2].checkoutLabel).toBeUndefined() + expect(projects.map(getSettingsProjectRemovalScope)).toEqual([ + 'checkout', + 'checkout', + 'split-project' ]) - 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', () => { @@ -172,7 +268,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({ - projectId: projects[0].projectId, + selectionKey: projects[0].selectionKey, hostId: 'runtime:home-mac', setupId: 'remote-9' }) @@ -192,7 +288,7 @@ describe('deep-link resolution', () => { expect(getSettingsTargetHostSelection(sameIdProjects, 'same-repo', 'ssh:server')).toEqual( expect.objectContaining({ - projectId: sameIdProjects[0].projectId, + selectionKey: sameIdProjects[0].selectionKey, 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 e44999e7682..261c40eb700 100644 --- a/src/renderer/src/components/settings/settings-project-list.ts +++ b/src/renderer/src/components/settings/settings-project-list.ts @@ -1,4 +1,3 @@ -import type { ProjectGroupingModel } from '../../../../shared/project-section-grouping' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { @@ -7,30 +6,67 @@ 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 } -/** - * The host (and setup) an entry's pane shows: the stored pick, while that host - * still has a setup in this entry. - */ +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. */ export function getSettingsEntryHostSelection( settingsProject: SettingsProject, hostSelection: Readonly>, setupSelection: Readonly> ): { hostId: ExecutionHostId | undefined; setupId: string | undefined } { - 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 } + 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' } /** Prefer local, then lowest repo ID, to keep navigation and pane identity stable until that repo is removed. */ @@ -53,45 +89,73 @@ export function getSettingsProjectRepresentativeRepoId( return lowest } -/** - * One entry per project. Entries are derived from repos alone so the nav and - * pane lists agree exactly. - */ +/** Match sidebar clone splitting; derive navigation and panes from the same repo list. */ export function buildSettingsProjectList( repos: readonly Repo[], - grouping?: ProjectGroupingModel + projectGrouping?: ProjectGroupingModel ): SettingsProject[] { - const projection = grouping - ? { projects: grouping.projects, setups: grouping.projectHostSetups } - : projectHostSetupProjectionFromRepos(repos) + const projection = projectHostSetupProjectionFromRepos(repos) const projectById = new Map(projection.projects.map((project) => [project.id, project])) - // 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>() + 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) + ) for (const setup of projection.setups) { const project = projectById.get(setup.projectId) if (!project) { continue } - const entry = entriesByProjectId.get(project.id) + const checkoutScoped = checkoutRepoIds.has(setup.repoId) + const key = checkoutScoped ? getCheckoutSelectionKey(project.id, setup.repoId) : project.id + const entry = entriesByKey.get(key) if (entry) { entry.setups.push(setup) } else { - entriesByProjectId.set(project.id, { projectId: project.id, project, setups: [setup] }) + entriesByKey.set(key, { + projectId: project.id, + selectionKey: key, + project, + setups: [setup], + ...(checkoutScoped ? { checkoutLabel: setup.displayName } : {}) + }) } } - return [...entriesByProjectId.values()].map((entry) => ({ + 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) => ({ ...entry, - representativeRepoId: getSettingsProjectRepresentativeRepoId(entry.setups) + representativeRepoId: getSettingsProjectRepresentativeRepoId(entry.setups), + ...((entryCountByProjectId.get(entry.projectId) ?? 0) > 1 ? { splitProject: true } : {}) })) } -/** - * 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. - */ +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. */ export function resolveEffectiveProjectHost( setups: readonly ProjectHostSetup[], selectedHostId: ExecutionHostId | undefined @@ -127,7 +191,7 @@ export function buildRepoIdToRepresentative( /** Which entry, host and setup row a Settings deep link opens. */ export type SettingsProjectHostSelection = { - projectId: string + selectionKey: string hostId: ExecutionHostId setupId: string } @@ -141,7 +205,7 @@ export function buildRepoIdToHostSelection( for (const setup of settingsProject.setups) { if (setup.repoId.trim().length > 0 && !map.has(setup.repoId)) { map.set(setup.repoId, { - projectId: settingsProject.projectId, + selectionKey: settingsProject.selectionKey, hostId: setup.hostId, setupId: setup.id }) @@ -161,7 +225,7 @@ export function getSettingsTargetHostSelection( (candidate) => candidate.repoId === repoId && candidate.hostId === hostId ) if (setup) { - return { projectId: settingsProject.projectId, hostId, setupId: setup.id } + return { selectionKey: settingsProject.selectionKey, hostId, setupId: setup.id } } } return null @@ -190,11 +254,7 @@ export function resolveSettingsTargetRepoId( return best } -/** - * 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. - */ +/** Remove sequentially to avoid interleaved teardown/projection updates; planned hosts have no repo 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 d0b88109dc7..e2557dc0779 100644 --- a/src/renderer/src/components/settings/settings-project-regrouping.test.ts +++ b/src/renderer/src/components/settings/settings-project-regrouping.test.ts @@ -27,6 +27,7 @@ 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, @@ -37,7 +38,9 @@ function pickRepo( if (!target) { throw new Error(`Missing Settings target for ${repo.id}`) } - store.getState().setSettingsProjectHostSelection(target.projectId, target.hostId, target.setupId) + store + .getState() + .setSettingsProjectHostSelection(target.selectionKey, target.hostId, target.setupId) } function selectedRepo(store: ReturnType, repos: Repo[], repo: Repo): Repo { @@ -69,12 +72,12 @@ describe('Settings project selection across local checkout regrouping', () => { expect(selectedRepo(store, [nativeA, wslA], nativeA).path).toBe(nativeA.path) }) - it('keeps the shared project setup pick when another native checkout arrives', () => { + it('keeps a newer project-level WSL pick when an older checkout selection still exists', () => { 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(wslA.path) + expect(selectedRepo(store, [nativeA, nativeB, wslA], nativeA).path).toBe(nativeA.path) expect(selectedRepo(store, [nativeA, nativeB, wslA], wslA).path).toBe(wslA.path) expect(selectedRepo(store, [nativeA, wslA], nativeA).path).toBe(wslA.path) }) @@ -111,36 +114,49 @@ describe('Settings project selection across local checkout regrouping', () => { }) describe('Settings provided local setup grouping', () => { - it('uses current project membership for both Settings and sidebar, including independent setups', () => { + it('keeps provided provisioned metadata aligned with the sidebar', () => { 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, projectId: sharedId, setupMethod: 'provisioned' as const } - : setup + setup.repoId === provisioned.id ? { ...setup, 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 entry = entries.find((candidate) => - candidate.setups.some((setup) => setup.repoId === provisioned.id) + 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 ) - 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((candidate) => candidate.projectId === `repo:${nativeB.id}`) - ?.setups.map((setup) => setup.repoId) - ).toEqual([nativeB.id]) + entries.find((entry) => entry.representativeRepoId === wslB.id)?.checkoutLabel + ).toBeUndefined() }) }) 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 ae36e4a0e0d..42608160b5f 100644 --- a/src/renderer/src/components/settings/settings-project-section-renderer.tsx +++ b/src/renderer/src/components/settings/settings-project-section-renderer.tsx @@ -2,7 +2,11 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../sha import { getRepoHostIdentity } from '../../store/slices/repo-host-identity' import { RepositoryPane } from './repository/RepositoryPane' import { SettingsSection } from './SettingsSection' -import { getSettingsEntryHostSelection, getSettingsProjectHostRepo } from './settings-project-list' +import { + getSettingsEntryHostSelection, + getSettingsProjectHostRepo, + getSettingsProjectRemovalScope +} from './settings-project-list' import { translate } from '@/i18n/i18n' import type { SettingsRenderContext } from './settings-render-context' @@ -36,7 +40,7 @@ export function renderProjectSettingsSections(context: SettingsRenderContext): R title={translate( 'auto.components.settings.Settings.3bf149e873', 'Project Settings > {{value0}}', - { value0: project.displayName } + { value0: settingsProject.checkoutLabel ?? project.displayName } )} description={repo.path} searchEntries={navigation.getSectionSearchEntries(repoSectionId)} @@ -54,6 +58,13 @@ 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 ee8e6e4876f..d571f189abf 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,9 +12,6 @@ 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 }[] @@ -62,9 +59,6 @@ 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 d524531fb41..de9928abcd8 100644 --- a/src/renderer/src/components/settings/use-settings-navigation-model.ts +++ b/src/renderer/src/components/settings/use-settings-navigation-model.ts @@ -7,6 +7,7 @@ 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' @@ -156,7 +157,11 @@ export function useSettingsNavigationModel( [visibleNavSections] ) const projectByRepoId = useMemo(() => { - const projection = { projects: model.projects, setups: model.projectHostSetups } + const projection = getProjectHostSetupProjectionFromState({ + repos: model.repos, + projects: model.projects, + projectHostSetups: 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 bc69ada8cdf..299411a6b04 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.projectId, + hostSelection.selectionKey, 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 acef8b23410..57fcee2fd5d 100644 --- a/src/renderer/src/components/settings/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/use-settings-store-model.ts @@ -1,12 +1,9 @@ -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' @@ -24,7 +21,6 @@ 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' @@ -55,8 +51,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) => selectCanonicalProjects(s)) - const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) const updateProject = useAppStore((s) => s.updateProject) const updateRepo = useAppStore((s) => s.updateRepo) const removeProject = useAppStore((s) => s.removeProject) @@ -72,15 +68,15 @@ export function useSettingsStoreModel() { const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) - // Why: same inputs as nav metadata so both lists agree — the source of truth for the pane list. - const projection = useProjectHostSetupProjection() + const projectHostSetupProjection = useProjectHostSetupProjection() + // Use navigation’s grouping inputs so pane and navigation lists agree. const settingsProjectList = useMemo( () => buildSettingsProjectList(repos, { - projects: projection.projects, - projectHostSetups: projection.setups + projects: projectHostSetupProjection.projects, + projectHostSetups: projectHostSetupProjection.setups }), - [repos, projection] + [repos, projectHostSetupProjection] ) 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 48c4ee941c5..30d0e1f604e 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[], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], + projectHostSetups: [], 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.endpointProjects = [] - mocks.storeState.endpointProjectHostSetups = [] + mocks.storeState.projects = [] + mocks.storeState.projectHostSetups = [] 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 aa60c655fa4..45b48419326 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -18,8 +18,6 @@ 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 a24a7f76493..f97265cc415 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx @@ -1,7 +1,3 @@ -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' @@ -36,8 +32,8 @@ export function DeleteWorktreeLineageNotice({ dirtyChangePreviewsByWorktreeId, changeCheckStatesByWorktreeId }: DeleteWorktreeLineageNoticeProps): JSX.Element | null { - const projects = useAppStore((s) => selectCanonicalProjects(s)) - const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) 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 a154973a037..a12be595421 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[], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], + projectHostSetups: [], 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.endpointProjects = [] - mocks.state.endpointProjectHostSetups = [] + mocks.state.projects = [] + mocks.state.projectHostSetups = [] 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 9d865281e5e..71236d95b41 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -33,9 +33,6 @@ 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 ae5686e45a3..27b8c527a89 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,9 +24,6 @@ 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 34885ba9a5f..29589aba6ca 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,8 +213,6 @@ 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 0100059d4e0..54db86d6dcb 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 @@ -65,8 +65,6 @@ 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 4f0f4bdbee0..8491fb76a6e 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,8 +124,6 @@ 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 cf50c62890f..f302fe14113 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts @@ -83,8 +83,6 @@ 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 36fee6d3728..4cec2697ff7 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,8 +307,6 @@ 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 99618185450..80b1ae17b01 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,8 +308,6 @@ 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 11d9ef8f76d..1de297e5928 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,8 +275,6 @@ 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 0629cc4aedf..6f20999c0ee 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, - endpointProjects: projection.projects, - endpointProjectHostSetups: projection.setups + projects: projection.projects, + projectHostSetups: 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 8906b7a354f..a3cb16e27be 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,8 +20,6 @@ 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 f0bf33cdba9..fa61812f383 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: {}, - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], + projectHostSetups: [], // 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 6c889f189e0..2116d500744 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) }, - endpointProjects: projects, - endpointProjectHostSetups: [setup(orca, 'local'), setup(orcaSsh, 'ssh:gpu-vm')], + projects, + projectHostSetups: [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 5883974bf78..eb6193115bf 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,10 +51,7 @@ export function windowCatalogFromServerAnswers(base: Partial): Partial SAVED_REPO_ORDER ) const rows = mergeFetchedHostProjectCatalog({ - previous: { - projects: base.endpointProjects ?? [], - projectHostSetups: base.endpointProjectHostSetups ?? [] - }, + previous: { projects: base.projects ?? [], projectHostSetups: base.projectHostSetups ?? [] }, // An older server answers no project.list, so both sides derive its projection. fetched: projectHostSetupProjectionFromRepos(fetched), repos, @@ -62,8 +59,8 @@ export function windowCatalogFromServerAnswers(base: Partial): Partial }) return { repos, - endpointProjects: [...rows.projects], - endpointProjectHostSetups: [...rows.projectHostSetups], + projects: [...rows.projects], + projectHostSetups: [...rows.projectHostSetups], manualRepoOrder: SAVED_REPO_ORDER, projectGroups: [ ...(base.projectGroups ?? []), @@ -101,10 +98,10 @@ export async function projectCatalogFromWindowState(state: AppState) { { repos: own(state.repos), projectGroups: own(state.projectGroups ?? []), - projects: (state.endpointProjects ?? []).filter((project) => + projects: (state.projects ?? []).filter((project) => project.sourceRepoIds.some((id) => own(state.repos).some((entry) => entry.id === id)) ), - setups: (state.endpointProjectHostSetups ?? []).filter((setup) => !isServerHost(setup.hostId)) + setups: (state.projectHostSetups ?? []).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 1ee5c301291..38ef53e739c 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[], - endpointProjects: [], - endpointProjectHostSetups: [] + projects: [], + projectHostSetups: [] }, 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.endpointProjects = [] - mocks.storeState.endpointProjectHostSetups = [] + mocks.storeState.projects = [] + mocks.storeState.projectHostSetups = [] 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 b7bcc35ab68..58677cbd3a1 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[], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], + projectHostSetups: [], 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.endpointProjects = [] - mocks.storeState.endpointProjectHostSetups = [] + mocks.storeState.projects = [] + mocks.storeState.projectHostSetups = [] 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.endpointProjects).toEqual( + expect(mocks.storeState.projects).toEqual( expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) ) - expect(mocks.storeState.endpointProjectHostSetups).toEqual( + expect(mocks.storeState.projectHostSetups).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 2835355bf8d..3f24166c42a 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,14 +1,10 @@ -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, - getProjectGroupingForRepo + buildProjectSections } from '../../../../shared/project-section-grouping' import { getLogicalRepoOrderRankById, @@ -111,11 +107,7 @@ async function desktopCatalog(): Promise<{ repos: readonly Repo[]; rows: Project async function desktopHeaders(projectOrderBy: ProjectOrderBy) { const { repos, rows } = await desktopCatalog() - const projection = getProjectHostSetupProjectionFromState({ - repos, - endpointProjects: rows.projects, - endpointProjectHostSetups: rows.projectHostSetups - }) + const projection = getProjectHostSetupProjectionFromState({ repos, ...rows }) const worktrees: Worktree[] = PROJECT_SECTION_PARITY_ROWS.map((row) => ({ ...worktreeFixture, id: row.id, @@ -149,12 +141,7 @@ 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, - endpointProjects: rows.projects, - endpointProjectHostSetups: rows.projectHostSetups, - projectOrderBy - }) + const published = getProjectSectionCatalogFromState({ repos, ...rows, projectOrderBy }) if (!published) { throw new Error('the renderer published no catalog') } @@ -200,42 +187,3 @@ 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 fb7c9c7cb90..ea3a9834f54 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('keeps a split checkout under its current project identity', () => { + it('splits same-host checkouts of one project into separate per-setup groups', () => { const repoB: Repo = { ...repo, id: 'repo-2', path: '/tmp/orca-2', displayName: 'orca-2' } const worktreeB: Worktree = { ...worktree, @@ -383,7 +383,6 @@ 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 @@ -413,15 +412,7 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [ - project, - { - ...project, - id: `repo:${repoB.id}`, - displayName: repoB.displayName, - sourceRepoIds: [repoB.id] - } - ], + projects: [{ ...project, sourceRepoIds: [repo.id, repoB.id] }], projectHostSetups: [projectHostSetups[0]!, localSetupB] } ) @@ -431,18 +422,18 @@ describe('buildRows with pinned worktrees', () => { expect(headers).toEqual( expect.arrayContaining([ expect.objectContaining({ - key: 'project:github:stablyai/orca', - label: 'Orca' + key: 'project:github:stablyai/orca::setup:repo-1', + label: 'orca' }), expect.objectContaining({ - key: 'project:repo:repo-2', + key: 'project:github:stablyai/orca::setup:repo-2', label: 'orca-2' }) ]) ) }) - it('keeps shared-host association while grouping an outclaimed checkout independently', () => { + it('splits only the surface with duplicate checkouts', () => { const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -459,7 +450,6 @@ 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 @@ -491,23 +481,16 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [ - project, - { - ...project, - id: `repo:${localRepoB.id}`, - displayName: localRepoB.displayName, - sourceRepoIds: [localRepoB.id] - } - ], + projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, remoteRepo.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', - 'project:repo:repo-local-b' + 'project:github:stablyai/orca::setup:repo-1', + 'project:github:stablyai/orca::setup:repo-local-b', + 'project:github:stablyai/orca' ]) }) @@ -703,11 +686,8 @@ describe('buildRows with pinned worktrees', () => { }) }) - 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. + 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. const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -724,7 +704,6 @@ 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 @@ -777,15 +756,7 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [ - { ...project, sourceRepoIds: [repo.id, runtimeRepoB.id] }, - { - ...project, - id: `repo:${localRepoB.id}`, - displayName: localRepoB.displayName, - sourceRepoIds: [localRepoB.id] - } - ], + projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, runtimeRepoB.id] }], projectHostSetups: [projectHostSetups[0]!, localSetupB, runtimeSetupB] } ) @@ -793,11 +764,16 @@ 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:repo:repo-local-b' + 'project:github:stablyai/orca::setup:repo-1', + 'project:github:stablyai/orca::setup: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: 2 + count: 1 }) }) }) 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 bd646665426..6b122a41ada 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,8 +35,6 @@ 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 6c12acc55ea..bad2645ee05 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,5 +1,4 @@ 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' @@ -51,7 +50,7 @@ const worktreeMap = new Map([ [child.id, child] ]) -const endpoint = { +const grouping = { projects: [project], projectHostSetups: [repo, childRepo].map((checkout) => ({ ...projectHostSetups[0]!, @@ -62,13 +61,6 @@ const endpoint = { })) } -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' } @@ -136,8 +128,7 @@ describe('cross-project sidebar lineage', () => { } } const worktrees = [grandchild, child, pinnedParent] - const parentProjectId = canonical.setups.find((setup) => setup.repoId === repo.id)!.projectId - const parentSection = `project:${parentProjectId}` + const parentSection = `project:${project.id}::setup:${repo.id}` 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 f6514aa46f2..2220d38d4d7 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,7 +7,9 @@ function getProjectIdFromHeaderRowKey(rowKey: string): string | null { if (!rowKey.startsWith('project:')) { return null } - return rowKey.slice('project:'.length) + const withoutPrefix = rowKey.slice('project:'.length) + const setupSeparator = withoutPrefix.indexOf('::setup:') + return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) } function getRepoIdsFromHeaderRowKey( @@ -18,6 +20,11 @@ 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 80fa9cc1632..8b2c58b5b33 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,8 +35,6 @@ 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 @@ -53,8 +51,6 @@ const useAppStoreMock = vi.fn( activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, gitStatusByWorktree: {}, - endpointProjects: [], - endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, @@ -99,8 +95,6 @@ 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 adb5e24b72f..68ffa7d5224 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,27 +109,13 @@ 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.endpointProjects = [ + appStoreSnapshot.projects = [ { 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': [ @@ -191,27 +177,13 @@ describe('TabBar PowerShell launch wiring', () => { it('shows only the WSL terminal row for local WSL-runtime projects', async () => { appStoreSnapshot.activeRepoId = 'repo-1' - appStoreSnapshot.endpointProjects = [ + appStoreSnapshot.projects = [ { 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 cc08d544a80..e0aced6d566 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,12 +66,7 @@ describe('TabBar worktree-write gate (non-Windows client)', () => { probeWindowsCapabilities.hostPlatform = 'darwin' tabBarSurfaceRenders.count = 0 localProjectRuntimeSpy.mockClear() - ;(await createTabBarProbeStore()).setState({ - worktreesByRepo: {}, - endpointProjects: [], - endpointProjectHostSetups: [], - repos: [] - }) + ;(await createTabBarProbeStore()).setState({ worktreesByRepo: {}, projects: [], 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 37052fccbe5..7cdecadb4cf 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,12 +52,7 @@ describe('TabBar worktree-write gate (Windows client)', () => { probeWindowsCapabilities.hostPlatform = null tabBarSurfaceRenders.count = 0 localProjectRuntimeSpy.mockClear() - ;(await createTabBarProbeStore()).setState({ - worktreesByRepo: {}, - endpointProjects: [], - endpointProjectHostSetups: [], - repos: [] - }) + ;(await createTabBarProbeStore()).setState({ worktreesByRepo: {}, projects: [], 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 5a98511fcf0..aec83754864 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,4 +1,3 @@ -import type { ProjectHostSetup } from '../../../../shared/project-types' import { vi } from 'vitest' export const appStoreSnapshot: { @@ -7,8 +6,7 @@ export const appStoreSnapshot: { activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null activeRuntimeEnvironmentId: string | null activeWorktreeId: string | null - endpointProjectHostSetups: readonly ProjectHostSetup[] - endpointProjects: { + projects: { id: string localWindowsRuntimePreference?: | { kind: 'inherit-global' | 'windows-host' } @@ -46,8 +44,7 @@ export const appStoreSnapshot: { activeTabType: null, activeRuntimeEnvironmentId: null, activeWorktreeId: null, - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], repos: [], sshConnectionStates: new Map(), worktreesByRepo: {}, @@ -72,8 +69,7 @@ const useAppStoreMock = vi.fn( activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null activeWorktreeId: string | null gitStatusByWorktree: Record - endpointProjectHostSetups: readonly ProjectHostSetup[] - endpointProjects: typeof appStoreSnapshot.endpointProjects + projects: typeof appStoreSnapshot.projects repos: { id: string; connectionId?: string | null }[] sshConnectionStates: Map worktreesByRepo: typeof appStoreSnapshot.worktreesByRepo @@ -102,8 +98,7 @@ const useAppStoreMock = vi.fn( activeTabType: appStoreSnapshot.activeTabType, activeWorktreeId: appStoreSnapshot.activeWorktreeId, gitStatusByWorktree: {}, - endpointProjects: appStoreSnapshot.endpointProjects, - endpointProjectHostSetups: appStoreSnapshot.endpointProjectHostSetups, + projects: appStoreSnapshot.projects, repos: appStoreSnapshot.repos, sshConnectionStates: appStoreSnapshot.sshConnectionStates, worktreesByRepo: appStoreSnapshot.worktreesByRepo, @@ -135,8 +130,7 @@ useAppStoreExport.getState = vi.fn(() => ({ activeTabType: appStoreSnapshot.activeTabType, activeWorktreeId: appStoreSnapshot.activeWorktreeId, gitStatusByWorktree: {}, - endpointProjects: appStoreSnapshot.endpointProjects, - endpointProjectHostSetups: appStoreSnapshot.endpointProjectHostSetups, + projects: appStoreSnapshot.projects, repos: appStoreSnapshot.repos, sshConnectionStates: appStoreSnapshot.sshConnectionStates, worktreesByRepo: appStoreSnapshot.worktreesByRepo, @@ -165,8 +159,7 @@ export function resetAppStoreSnapshot(): void { appStoreSnapshot.activeTabType = null appStoreSnapshot.activeRuntimeEnvironmentId = null appStoreSnapshot.activeWorktreeId = null - appStoreSnapshot.endpointProjects = [] - appStoreSnapshot.endpointProjectHostSetups = [] + appStoreSnapshot.projects = [] 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 dbb45e535c7..0dfd6677eed 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,7 +679,6 @@ 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 268c0ed39dd..e126d142984 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,8 +11,7 @@ export type TabBarProbeState = { activeGroupIdByWorktree: Record activeRepoId: string | null activeWorktreeId: string | null - endpointProjects: unknown[] - endpointProjectHostSetups: unknown[] + projects: unknown[] repos: unknown[] worktreesByRepo: Record sshConnectionStates: Map @@ -44,8 +43,7 @@ export async function createTabBarProbeStore(): Promise { activeGroupIdByWorktree: {}, activeRepoId: null, activeWorktreeId: null, - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], 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 b9174a1d9ec..4251a8bd4f3 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,7 +1,3 @@ -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' @@ -39,7 +35,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['endpointProjects'] = [] +const EMPTY_PROJECTS: AppStoreState['projects'] = [] const EMPTY_REPOS: AppStoreState['repos'] = [] const EMPTY_WORKTREES_BY_REPO: AppStoreState['worktreesByRepo'] = {} @@ -175,10 +171,7 @@ 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 projectHostSetups = useAppStore(selectCanonicalProjectHostSetups) - const projects = useAppStore((s) => - needsLocalProjectRuntime ? selectCanonicalProjects(s) : EMPTY_PROJECTS - ) + const projects = useAppStore((s) => (needsLocalProjectRuntime ? s.projects : EMPTY_PROJECTS)) const repos = useAppStore((s) => (needsLocalProjectRuntime ? s.repos : EMPTY_REPOS)) const worktreesByRepo = useAppStore((s) => needsLocalProjectRuntime ? selectWorktreesByRepo(s) : EMPTY_WORKTREES_BY_REPO @@ -188,14 +181,7 @@ export function useTabBarRuntimeModel({ return undefined } return getLocalProjectExecutionRuntimeContext( - { - activeRepoId, - activeWorktreeId, - projectHostSetupProjection: { projects, setups: projectHostSetups }, - repos, - settings, - worktreesByRepo - }, + { activeRepoId, activeWorktreeId, projects, repos, settings, worktreesByRepo }, worktreeId, 'win32', { @@ -212,7 +198,6 @@ 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 ac31f269569..1e8177bfe43 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,12 +1,10 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { - projectHostSetupProjectionFromRepos, + getProjectIdentityKey, 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 = { @@ -25,10 +23,9 @@ 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, setups) + const projectKey = getTaskRepoProjectKey(repo) const current = selectedByProject.get(projectKey) if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { selectedByProject.set(projectKey, repo) @@ -41,10 +38,9 @@ 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, setups) + const projectKey = getTaskRepoProjectKey(repo) const current = groupsByProject.get(projectKey) if (!current) { groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) @@ -65,14 +61,13 @@ 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, setups) + const projectKey = getTaskRepoProjectKey(repo) const current = selectedByProject.get(projectKey) if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { selectedByProject.set(projectKey, repo) @@ -84,13 +79,8 @@ export function normalizeTaskRepoSelection( return new Set([...selectedByProject.values()].map((repo) => repo.id)) } -export function getTaskRepoProjectKey(repo: Repo, setups: readonly ProjectHostSetup[]): string { - return ( - getProjectHostSetupLookup(setups).projectId({ - repoId: repo.id, - hostId: getRepoExecutionHostId(repo) - }) ?? `repo:${repo.id}` - ) +export function getTaskRepoProjectKey(repo: Repo): string { + return getProjectIdentityKey(repo) } 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 699666b95c0..b06113fa213 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,4 +1,3 @@ -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' @@ -221,10 +220,7 @@ export function useTaskPageComposerActions(model: TaskPageJiraListEffectsModel) taskSourceContext, initialRepoId: mappedRepo?.id, initialWorkspaceTarget: mappedRepo - ? getRepoWorkspaceCreationTargetHint( - mappedRepo, - selectCanonicalProjectHostSetups(useAppStore.getState()) - ) + ? getRepoWorkspaceCreationTargetHint(mappedRepo, useAppStore.getState().projectHostSetups) : 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 08e6da4a2a9..910b01ad031 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,9 +64,6 @@ 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 f7bf1b042cc..72bfdcd5892 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,9 +41,6 @@ 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 8d5db348a56..dc1d743ba4e 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,7 +1,5 @@ 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' @@ -238,7 +236,6 @@ 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: { @@ -247,22 +244,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - 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' } }] + projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'windows-host' } }] } connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) @@ -316,7 +298,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { { id: 'tab-floating-agent', ptyId: null, launchAgent: 'codex' } ] }, - endpointProjects: [ + projects: [ { 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 c33e0438101..9038166329e 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,8 +29,7 @@ export function createInitialStoreState(getState: () => StoreState): StoreState }, runtimeStatusByEnvironmentId: new Map(), repos: [{ id: 'repo1', connectionId: null, displayName: 'orca' }], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], 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 71d3477a0dd..c89990da68e 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,8 +55,7 @@ export type StoreState = { displayName?: string executionHostId?: string | null }[] - endpointProjectHostSetups: AppState['endpointProjectHostSetups'] - endpointProjects: { + projects: { 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 bd0c51538b5..8093a1550b3 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,9 +79,6 @@ 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 b188f2f26a5..59b15af0a77 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,7 +1,4 @@ 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' @@ -16,14 +13,18 @@ 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 projects: { + id: string + sourceRepoIds: string[] + localWindowsRuntimePreference?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } +}[] = [] + +const repos: { id: string; kind?: 'git' | 'folder'; connectionId?: string | null }[] = [] + const store = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', - endpointProjects, - endpointProjectHostSetups, + projects, repos, settings: {} as { localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } @@ -81,19 +82,13 @@ describe('forkAgentSessionFromPane', () => { vi.clearAllMocks() store.activeRepoId = 'repo-1' store.activeWorktreeId = 'wt-1' - store.repos = [ + store.projects = [ { id: 'repo-1', - path: 'C:\\repo', - displayName: 'Repo', - badgeColor: '', - addedAt: 0, - kind: 'git' + sourceRepoIds: ['repo-1'] } ] - const catalog = projectHostSetupProjectionFromRepos(store.repos) - store.endpointProjects = [...catalog.projects] - store.endpointProjectHostSetups = [...catalog.setups] + store.repos = [{ id: 'repo-1', kind: 'git' }] store.settings = { localWindowsRuntimeDefault: { kind: 'windows-host' } } store.worktreesByRepo = { 'repo-1': [ @@ -290,7 +285,7 @@ describe('forkAgentSessionFromPane', () => { ) it('uses Linux startup quoting for SSH workspaces', async () => { - store.repos = [{ ...store.repos[0], connectionId: 'ssh-1' }] + store.repos = [{ id: 'repo-1', kind: 'git', connectionId: 'ssh-1' }] store.agentStatusByPaneKey = { [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } } @@ -347,10 +342,13 @@ describe('forkAgentSessionFromPane', () => { }) it('uses Linux startup quoting when a Windows-path project is forced to WSL', async () => { - store.endpointProjects = store.endpointProjects.map((project) => ({ - ...project, - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - })) + store.projects = [ + { + id: 'repo-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] store.agentStatusByPaneKey = { [`tab-1:${LEAF_ID}`]: { agentType: 'pi' } } @@ -488,7 +486,7 @@ describe('forkAgentSessionFromPane', () => { }) it('does not create a worktree from a folder-only source workspace', async () => { - store.repos = [{ ...store.repos[0], kind: 'folder' }] + store.repos = [{ id: 'repo-1', 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 6f6aca04f6d..6b009d3711c 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,13 +8,19 @@ const mocks = vi.hoisted(() => { > = { repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo', hostId: 'runtime:env-1' }] } - const endpointProjects: { + const projects: { id: string localWindowsRuntimePreference: | { kind: 'inherit-global' } | { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } - }[] = [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] + }[] = [ + { + id: 'repo1', + localWindowsRuntimePreference: { kind: 'inherit-global' as const } + } + ] + return { toastLoading: vi.fn(() => 'toast-1'), toastDismiss: vi.fn(), @@ -26,21 +32,7 @@ const mocks = vi.hoisted(() => { activeRepoId: 'repo1', activeWorktreeId: 'wt-1', settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, - endpointProjects, - endpointProjectHostSetups: [ - { - id: 'repo1', - 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 - } - ], + projects, repos: [ { id: 'repo1', @@ -117,7 +109,7 @@ describe('handleTerminalFileDrop', () => { } }) mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } - mocks.storeState.endpointProjects = [ + mocks.storeState.projects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'inherit-global' } @@ -384,7 +376,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.endpointProjects = [ + mocks.storeState.projects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } @@ -442,7 +434,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.endpointProjects = [ + mocks.storeState.projects = [ { 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 790f069bfea..953e7adf8ee 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,9 +50,6 @@ 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 93e158007ad..3a36fee0f52 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,6 +1,5 @@ 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 = { @@ -9,10 +8,7 @@ export type TerminalLinkStoreSettings = { activeRuntimeEnvironmentId?: string | null } -export type TerminalLinkStoreState = Pick< - AppState, - 'repos' | 'endpointProjects' | 'endpointProjectHostSetups' -> & { +export type TerminalLinkStoreState = { settings: TerminalLinkStoreSettings | undefined setActiveWorktree: Mock createBrowserTab: Mock @@ -100,9 +96,6 @@ 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 28b20403a9a..e992047716d 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,9 +103,6 @@ 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 933fb2d5fc1..574d9c441fc 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,9 +169,6 @@ 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 68c9a04a3c6..ebde32ea15d 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,9 +23,6 @@ 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() @@ -134,9 +131,6 @@ 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() @@ -205,9 +199,6 @@ 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 4a2764ce841..39358c99491 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,7 +1,3 @@ -import { - selectCanonicalProjects, - selectCanonicalProjectHostSetups -} from '@/store/project-host-setup-selector' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' @@ -50,8 +46,8 @@ export function useWorktreeJumpPaletteStoreState({ ) const repos = useAppStore((state) => state.repos) const projectGroups = useAppStore((state) => state.projectGroups) - const projects = useAppStore((state) => selectCanonicalProjects(state)) - const projectHostSetups = useAppStore((state) => selectCanonicalProjectHostSetups(state)) + const projects = useAppStore((state) => state.projects) + const projectHostSetups = useAppStore((state) => state.projectHostSetups) 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 500c530dc11..7d4d735bb36 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,7 +1,3 @@ -import { - selectCanonicalProjects, - selectCanonicalProjectHostSetups -} from '@/store/project-host-setup-selector' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { @@ -43,8 +39,8 @@ function getComposerDefaultWorkspaceTarget(state: ReturnType { useAppStore.setState({ settings: null, repos, - endpointProjects: [...projection.projects], - endpointProjectHostSetups: [...projection.setups], + projects: [...projection.projects], + projectHostSetups: [...projection.setups], projectGroups: [], newWorkspaceDraft: null, worktreesByRepo: {}, @@ -158,7 +158,6 @@ function renderUnavailableMappedComposer( includeSetupHint?: boolean emptyRepoOverride?: boolean missingSshSetup?: boolean - missingSshRepo?: boolean canonicalSameHostSetup?: boolean } = {} ) { @@ -180,9 +179,9 @@ function renderUnavailableMappedComposer( } useAppStore.setState({ settings: null, - repos: options.missingSshRepo ? repos.filter((repo) => repo !== mappedRepo) : repos, - endpointProjects: [...projection.projects], - endpointProjectHostSetups: projection.setups.filter( + repos, + projects: [...projection.projects], + projectHostSetups: projection.setups.filter( (setup) => !options.missingSshSetup || setup.hostId !== 'ssh:builder' ), projectGroups: [], @@ -246,7 +245,7 @@ describe('unavailable mapped Jira destinations', () => { }) it('recovers the intended host when it becomes configured', () => { - const { result, mappedRepo } = renderUnavailableMappedComposer({ missingSshSetup: true }) + const { result, mappedRepo } = renderUnavailableMappedComposer() act(() => useAppStore.setState({ sshTargetLabels: new Map([['builder', 'Builder']]) })) expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toMatchObject({ @@ -276,10 +275,7 @@ describe('unavailable mapped Jira destinations', () => { act(() => useAppStore.setState((state) => ({ - endpointProjectHostSetups: state.endpointProjectHostSetups.filter( - (setup) => setup.hostId !== 'ssh:builder' - ), - repos: state.repos.filter((repo) => repo !== mappedRepo) + projectHostSetups: state.projectHostSetups.filter((setup) => setup.hostId !== 'ssh:builder') })) ) @@ -292,8 +288,7 @@ describe('unavailable mapped Jira destinations', () => { const { result } = renderUnavailableMappedComposer({ repeatedId: true, configured: true, - missingSshSetup: true, - missingSshRepo: true + missingSshSetup: 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 8782bf86697..4fe09d7a805 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-store.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-store.ts @@ -1,7 +1,3 @@ -import { - selectCanonicalProjects, - selectCanonicalProjectHostSetups -} from '@/store/project-host-setup-selector' import { useMemo } from 'react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' @@ -98,11 +94,11 @@ export function useComposerTargetStore(options: ComposerStateInput, decisions: C const repos = useAppStore((s) => s.repos) - const projects = useAppStore((s) => selectCanonicalProjects(s)) + const projects = useAppStore((s) => s.projects) const projectGroups = useAppStore((s) => s.projectGroups) - const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) 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 b2b25e1401f..9c5200feb07 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, - endpointProjects: [...projection.projects], - endpointProjectHostSetups: [...projection.setups], + projects: [...projection.projects], + projectHostSetups: [...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 32d8c8223f2..6304f8faa72 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, - projectHostSetupProjection: { projects, setups: projectHostSetups }, + projects, repos, settings, worktreesByRepo @@ -178,7 +178,6 @@ 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 3f6a5006aca..c1b0c3c2a98 100644 --- a/src/renderer/src/hooks/composer-state/target-change-actions.ts +++ b/src/renderer/src/hooks/composer-state/target-change-actions.ts @@ -1,4 +1,3 @@ -import { selectCanonicalProjectHostSetups } from '@/store/project-host-setup-selector' import type { ComposerModel } from './composer-model' type TargetChangeActionsInput = Pick< @@ -246,7 +245,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { ? option : buildProjectHostSetupOptions({ projectId: selectedRepoProjectId, - projectHostSetups: selectCanonicalProjectHostSetups(useAppStore.getState()), + projectHostSetups: useAppStore.getState().projectHostSetups, 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 170f8d7de76..3470a1b7acd 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 } = settingsProject + const { project, representativeRepoId, setups, checkoutLabel } = 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: project.displayName, + title: checkoutLabel ?? 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 15f8e3bdfc6..d7799b84324 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, - endpointProjects: [], + projects: [], repos: [], worktreesByRepo: {}, folderWorkspaces: [], diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts index cd3e42d8f93..028089ab13b 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -1,4 +1,3 @@ -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' @@ -78,7 +77,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { useShallow((state) => ({ activeRepoId: state.activeRepoId, activeWorktreeId: state.activeWorktreeId, - projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), + projects: state.projects, 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 b165e669828..0c4b738953e 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -1,20 +1,9 @@ // @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, renderHook } 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' @@ -95,8 +84,8 @@ describe('useComposerState integrated lifecycle', () => { const projection = projectHostSetupProjectionFromRepos([localRepo, runtimeRepo]) useAppStore.setState({ repos: [localRepo, runtimeRepo], - endpointProjects: [...projection.projects], - endpointProjectHostSetups: projection.setups.map((setup) => ({ + projects: [...projection.projects], + projectHostSetups: projection.setups.map((setup) => ({ ...setup, id: `${setup.hostId}:setup` })), @@ -134,205 +123,12 @@ 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: [], - endpointProjects: [], + projects: [], projectGroups: [], - endpointProjectHostSetups: [], + projectHostSetups: [], 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 e2df80f3be1..1eb266a675c 100644 --- a/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx @@ -6,7 +6,6 @@ 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() @@ -61,19 +60,21 @@ 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 = { - ...catalog.projects[0], + id: TEST_REPO.id, + displayName: TEST_REPO.displayName, + badgeColor: TEST_REPO.badgeColor, + sourceRepoIds: [TEST_REPO.id], + createdAt: 0, + updatedAt: 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, - endpointProjects: [project], - endpointProjectHostSetups: [...catalog.setups], + repos: [{ ...TEST_REPO, path: worktree.path }], + projects: [project], 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 92a366d693b..4be7dff1a38 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx @@ -7,7 +7,6 @@ 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' @@ -21,8 +20,6 @@ const testState = vi.hoisted(() => ({ type MockedSettingsNavState = { settings: GlobalSettings | null repos: Repo[] - endpointProjects: Project[] - endpointProjectHostSetups: ProjectHostSetup[] runtimeEnvironments: typeof testState.runtimeEnvironments runtimeStatusByEnvironmentId: Map } @@ -32,8 +29,6 @@ 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 f9d1b70c5a7..c76b3a67285 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,11 +114,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const { i18n } = useTranslation() const activeLocale = i18n.language const repos = useAppStore((state) => state.repos) - const projection = useProjectHostSetupProjection() - const projectGrouping = useMemo( - () => ({ projects: projection.projects, projectHostSetups: projection.setups }), - [projection] - ) + const projectHostSetupProjection = useProjectHostSetupProjection() const settings = useAppStore((state) => state.settings) const [managedBrowserCreationEnabled, mobileEmulatorCreationEnabled] = useAppStore( useShallow((state) => { @@ -180,7 +176,10 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isDev: import.meta.env.DEV, isLinearConnected, repos, - projectGrouping + projectGrouping: { + projects: projectHostSetupProjection.projects, + projectHostSetups: projectHostSetupProjection.setups + } }), // 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. [ @@ -196,7 +195,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isLinearConnected, repos, - projectGrouping, + projectHostSetupProjection, activeLocale ] ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6963a27c67d..9a3294a5bfb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7958,6 +7958,8 @@ "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 0eb028154af..31a0c4696eb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7624,6 +7624,8 @@ "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 64cdb55e5c7..34e55bfb01c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7839,6 +7839,8 @@ "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 fc1c6186ef6..4dc24ece62f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7630,6 +7630,8 @@ "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 7fec07132b3..c161a95db84 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7593,6 +7593,8 @@ "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 ae058cd381e..c4b41c2dac5 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -4256,7 +4256,9 @@ "show": "Mostrar", "hide": "Ocultar", "externalWorktreesGlobal": "Padrão global: {{value0}}", - "useGlobal": "Usar global" + "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." }, "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 d2283b47330..5c0b7b5dece 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7593,6 +7593,8 @@ "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 b048d488037..d58f590e619 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 } - endpointProjects: { + projects: { id: string localWindowsRuntimePreference: | { kind: 'inherit-global' } @@ -69,20 +69,22 @@ export function createAgentBackgroundSessionTestState(mocks: { setTabLayout: TestMock registerAgentLaunchConfig: TestMock }): AgentBackgroundSessionTestState { - const endpointProjects: AgentBackgroundSessionTestState['endpointProjects'] = [ - { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } - ] - const state = { + const state: AgentBackgroundSessionTestState = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', lastTerminalInputAtByPaneKey: {}, settings: { agentCmdOverrides: {}, - activeRuntimeEnvironmentId: null as string | null, - terminalMainSideEffectAuthority: undefined as boolean | undefined + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined }, - endpointProjects, - repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' } + } + ], + repos: [{ id: 'repo-1', connectionId: null, path: '/repo' }], worktreesByRepo: { 'repo-1': [ { @@ -94,16 +96,13 @@ export function createAgentBackgroundSessionTestState(mocks: { } ] }, - folderWorkspaces: [] as AgentBackgroundSessionTestState['folderWorkspaces'], - projectGroups: [] as AgentBackgroundSessionTestState['projectGroups'], - tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] }, - terminalLayoutsByTabId: {} as Record< - string, - { ptyIdsByLeafId?: Record } - >, - ptyIdsByTabId: {} as Record, + folderWorkspaces: [], + projectGroups: [], + tabsByWorktree: { 'wt-1': [] }, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, sshConnectionStates: new Map(), - transientClearedAgentStatusConnectionIds: {} as Record, + transientClearedAgentStatusConnectionIds: {}, allWorktrees: () => state.worktreesByRepo['repo-1'], getKnownWorktreeById: (worktreeId: string) => state.worktreesByRepo['repo-1']?.find((worktree) => worktree.id === worktreeId), @@ -130,9 +129,7 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi activeRuntimeEnvironmentId: null, terminalMainSideEffectAuthority: undefined } - state.endpointProjects = [ - { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } - ] + state.projects = [{ 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 f4ccce9f352..b0cab494308 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -1,6 +1,4 @@ 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, @@ -43,7 +41,6 @@ vi.mock('@/lib/structured-agent-launch-settlement', () => ({ import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { buildAgentLaunchRouteInput, - selectAgentLaunchRouteView, workspaceKindForWorktreeId, type AgentLaunchRouteArgs, type AgentLaunchRouteStore @@ -495,24 +492,3 @@ 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 b93be6bc4be..4b91ba97de3 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -5,10 +5,6 @@ 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' @@ -74,26 +70,21 @@ const AGENT_LAUNCH_ROUTE_KEYS = [ 'runtimeEnvironments', 'runtimeEnvironmentCatalogHydrated', 'removedRuntimeEnvironmentIds', - 'activeRepoId' -] as const satisfies readonly (keyof AppState)[] + 'activeRepoId', + 'projects' +] as const satisfies readonly (keyof AgentLaunchRouteStore)[] 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 AgentLaunchRouteWatchState = Pick & - ProjectCatalogReadState -export type AgentLaunchRouteView = Pick & { - projectHostSetupProjection: ReturnType -} +export type AgentLaunchRouteView = { [K in AgentLaunchRouteKey]: AppState[K] } export const agentLaunchRouteInputsChanged = ( - next: AgentLaunchRouteWatchState, - previous: AgentLaunchRouteWatchState -): boolean => - AGENT_LAUNCH_ROUTE_KEYS.some((key) => next[key] !== previous[key]) || - getProjectHostSetupProjectionFromState(next) !== getProjectHostSetupProjectionFromState(previous) + next: AgentLaunchRouteView, + previous: AgentLaunchRouteView +): boolean => AGENT_LAUNCH_ROUTE_KEYS.some((key) => next[key] !== previous[key]) let lastRouteView: AgentLaunchRouteView | undefined export function selectAgentLaunchRouteView(state: AppState): AgentLaunchRouteView { @@ -113,7 +104,7 @@ export function selectAgentLaunchRouteView(state: AppState): AgentLaunchRouteVie runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds, activeRepoId: state.activeRepoId, - projectHostSetupProjection: getProjectHostSetupProjectionFromState(state) + projects: state.projects } } 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 9d6e9d71e0a..16df933a70d 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,10 +21,7 @@ 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 -): Extract { +function stateFor(host: ExecutionHostId, distro?: string): State { const worktree = makeWorktree({ id: 'repo::workspace', repoId: 'repo', @@ -48,21 +45,7 @@ function stateFor( executionHostId: host } ], - endpointProjectHostSetups: [ - { - id: 'repo', - projectId: 'repo', - repoId: 'repo', - hostId: host, - path: worktree.path, - displayName: 'project', - setupMethod: 'legacy-repo', - setupState: 'ready', - createdAt: 0, - updatedAt: 0 - } - ], - endpointProjects: [ + projects: [ { id: 'repo', displayName: 'project', @@ -156,7 +139,7 @@ describe('Antigravity transcript reference ownership', () => { const state = stateFor('local') state.activeRepoId = null state.repos = [] - state.endpointProjects = [] + state.projects = [] state.activeWorktreeId = 'folder:folder-1' state.folderWorkspaces = [ makeFolderWorkspace({ folderPath: '//wsl.localhost/Debian/home/example/project' }) @@ -325,7 +308,7 @@ describe('Antigravity transcript reference ownership', () => { it('refuses local references while the project runtime requires repair', () => { const state = stateFor('local') - state.endpointProjects[0].localWindowsRuntimePreference = { kind: 'inherit-global' } + state.projects[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 58ffcec99eb..e1ee263f6ff 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,6 +1,7 @@ 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', () => ({ @@ -10,7 +11,17 @@ 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 = Parameters[0]['state'] +type DropRepinState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projectGroups' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> function makeState(): DropRepinState { return { @@ -19,14 +30,13 @@ function makeState(): DropRepinState { folderWorkspaces: [], projectGroups: [], repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], - endpointProjectHostSetups: [], - endpointProjects: [ + projects: [ { - id: 'repo-1', displayName: 'repo', badgeColor: '', createdAt: 0, updatedAt: 0, + id: 'repo-1', sourceRepoIds: ['repo-1'] } ], 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 471f26b1926..c53302607c6 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -1,6 +1,7 @@ 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, @@ -24,44 +25,38 @@ afterEach(() => { type RuntimePreference = { kind: 'windows-host' } | { kind: 'wsl'; distro: string } -type AiVaultResumeCommandState = Parameters< - typeof buildAiVaultResumeCopyCommandForWorktree ->[0]['state'] +type AiVaultResumeCommandState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projectGroups' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> function makeState(args: { worktreePath: string localWindowsRuntimePreference?: RuntimePreference terminalWindowsShell?: string -}): Extract & { - settings: NonNullable -} { +}): AiVaultResumeCommandState { + const settings = getDefaultSettings('/tmp') + Reflect.deleteProperty(settings, 'agentPermissionDefault') return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], repos: [{ ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\repo' }], - endpointProjectHostSetups: [ + projects: [ { - 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, + id: 'repo-1', sourceRepoIds: ['repo-1'], ...(args.localWindowsRuntimePreference ? { localWindowsRuntimePreference: args.localWindowsRuntimePreference } @@ -69,11 +64,10 @@ function makeState(args: { } ], settings: { - ...getDefaultSettings('/tmp'), + ...settings, localWindowsRuntimeDefault: { kind: 'windows-host' }, ...(args.terminalWindowsShell ? { terminalWindowsShell: args.terminalWindowsShell } : {}), agentDefaultArgs: { claude: '', codex: '' }, - agentPermissionDefault: 'own', agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { @@ -220,7 +214,6 @@ 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 c3039d61a11..717dfa01e41 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -1,4 +1,3 @@ -import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import { assertAntigravityReferenceTarget, buildAntigravityReferenceStartup @@ -34,11 +33,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 50c5c8baa25..9676172a737 100644 --- a/src/renderer/src/lib/ai-vault-resume-platform.ts +++ b/src/renderer/src/lib/ai-vault-resume-platform.ts @@ -1,4 +1,3 @@ -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' @@ -16,11 +15,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 5112490c65c..417c06be65e 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.test.ts @@ -10,29 +10,25 @@ import { buildAiVaultResumeCopyCommandForWorktree } from './ai-vault-resume-comm type ResumeShellState = Parameters[0]['state'] -function makeState( - worktreeHostId?: Parameters[0]['hostId'] -): ResumeShellState { +function makeState(worktreeHostId?: `ssh:${string}`): ResumeShellState { return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], repos: [{ ...TEST_REPO, id: 'repo-1', path: '/home/alice/repo' }], - endpointProjectHostSetups: [], - endpointProjects: [ + projects: [ { - id: 'repo-1', displayName: 'repo', badgeColor: '', createdAt: 0, updatedAt: 0, + id: 'repo-1', sourceRepoIds: ['repo-1'] } ], settings: { ...getDefaultSettings('/tmp'), - agentPermissionOverrides: { codex: 'own' }, agentDefaultArgs: { codex: '' }, agentDefaultEnv: { codex: {} } }, diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts index acc934429cb..df4c7c53f8b 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.ts @@ -1,7 +1,3 @@ -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' @@ -23,11 +19,11 @@ type AiVaultResumeShellState = Pick< | 'activeRepoId' | 'activeWorktreeId' | 'folderWorkspaces' + | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' -> & - ProjectCatalogReadState +> export function resolveAiVaultResumeStartupShell(args: { state: AiVaultResumeShellState @@ -83,8 +79,12 @@ export function getAiVaultResumeWorkspacePath( export function getAiVaultResumeWorkspaceWslDistro( state: Pick & - Partial> & - ProjectCatalogReadState, + Partial< + Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'settings' | 'projectGroups' + > + >, 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, - projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), + projects: state.projects ?? [], settings: state.settings ?? null, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups ?? [], 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 697a804b5ad..15c0c7d585b 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.test.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts @@ -15,8 +15,7 @@ type LaneState = Pick< | 'activeRepoId' | 'activeWorktreeId' | 'folderWorkspaces' - | 'endpointProjects' - | 'endpointProjectHostSetups' + | 'projects' | 'repos' | 'runtimeEnvironments' | 'settings' @@ -58,21 +57,7 @@ function laneState(args?: { terminalWindowsWslDistro: args?.terminalWindowsWslDistro ?? null }, repos: [{ id: 'repo1', path: 'C:\\code\\app' }], - 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 + projects: 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 e7f5d548268..581a40702fe 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -1,4 +1,3 @@ -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/remote-runtime-pty-id' @@ -32,12 +31,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 9af5fe59977..46824937b48 100644 --- a/src/renderer/src/lib/execution-host-facts.test.ts +++ b/src/renderer/src/lib/execution-host-facts.test.ts @@ -37,8 +37,7 @@ function state(): State { detectedWorktreesByRepo: {}, folderWorkspaces: [], projectGroups: [], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], 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 6637de834be..8236cd26b36 100644 --- a/src/renderer/src/lib/execution-host-facts.ts +++ b/src/renderer/src/lib/execution-host-facts.ts @@ -1,4 +1,3 @@ -import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import { getRepoExecutionHostId, parseExecutionHostId, @@ -36,11 +35,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 7e2583ea531..dd84c0bf440 100644 --- a/src/renderer/src/lib/http-link-localhost-route.ts +++ b/src/renderer/src/lib/http-link-localhost-route.ts @@ -1,8 +1,3 @@ -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, @@ -17,7 +12,10 @@ export type LocalhostLinkRepo = { export type LocalhostLinkProject = LocalhostLinkRepo -export type LocalhostLinkWorktree = { id: string } & Partial +export type LocalhostLinkWorktree = { + id: string + projectId?: string +} export function localhostLabelRouteForHttpLink( url: string, @@ -75,14 +73,10 @@ function localhostLabelRouteForTerminalLink( return null } const worktree = findWorktreeById(state, port.owner.worktreeId) - 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 project = + worktree?.projectId && state.projects + ? (state.projects.find((entry) => entry.id === worktree.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 268d8295e7b..3a3fe793ed0 100644 --- a/src/renderer/src/lib/http-link-modifier-routing.test.ts +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -11,9 +11,6 @@ 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 0eeda499ba0..e2c7084c260 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -9,8 +9,6 @@ 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()) @@ -32,9 +30,6 @@ function noActiveWorkspace(): { return { activeWorktreeId: null, activeWorkspaceExecutionHostId: null } } -const endpointProjects: Project[] = [] -const endpointProjectHostSetups: ProjectHostSetup[] = [] - type LinkTestSettings = { openLinksInApp?: boolean openLinksInAppModifierInverts?: boolean @@ -46,6 +41,10 @@ type LinkTestSettings = { const noSettings = (): LinkTestSettings | undefined => undefined const terminalView = (): 'terminal' | 'activity' => 'terminal' +const repos: { id: string; displayName: string; repoIcon?: null; badgeColor?: string }[] = [] + +const projects: { id: string; displayName: string; repoIcon?: null; badgeColor?: string }[] = [] + const storeState = { settings: noSettings(), setActiveWorktree: setActiveWorktreeMock, @@ -53,9 +52,8 @@ const storeState = { setActiveView: setActiveViewMock, ...noActiveWorkspace(), createBrowserTab: createBrowserTabMock, - repos: [] as Repo[], - endpointProjects, - endpointProjectHostSetups, + repos, + projects, worktreesByRepo: {} as Record< string, { id: string; projectId?: string; repoId?: string; displayName?: string }[] @@ -396,8 +394,8 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true } storeState.repos = [ - { id: 'repo-local', displayName: 'Local', path: '/local', badgeColor: '', addedAt: 0 }, - { id: 'repo-remote', displayName: 'Remote', path: '/remote', badgeColor: '', addedAt: 0 } + { id: 'repo-local', displayName: 'Local' }, + { id: 'repo-remote', displayName: 'Remote' } ] storeState.worktreesByRepo = { 'repo-local': [{ id: 'wt-local', projectId: 'repo-local' }], @@ -481,8 +479,6 @@ describe('openHttpLink', () => { storeState.repos = [ { id: 'repo-1', - path: '/repo', - addedAt: 0, displayName: 'snapstudio', repoIcon: null, badgeColor: '#f97316' @@ -545,8 +541,6 @@ describe('openHttpLink', () => { storeState.repos = [ { id: 'repo-1', - path: '/repo', - addedAt: 0, displayName: 'snapstudio', repoIcon: null, badgeColor: '#f97316' @@ -638,9 +632,7 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true, activeRuntimeEnvironmentId: null } - storeState.repos = [ - { id: 'repo-1', path: '/repo', addedAt: 0, badgeColor: '', displayName: 'snapstudio' } - ] + storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }] storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] } storeState.workspacePortScan = { result: { @@ -678,9 +670,7 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true, activeRuntimeEnvironmentId: 'env-other' } - storeState.repos = [ - { id: 'repo-1', path: '/repo', addedAt: 0, badgeColor: '', displayName: 'snapstudio' } - ] + storeState.repos = [{ id: 'repo-1', 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 11914e9dab3..1b2c8433b13 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -1,7 +1,8 @@ -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' @@ -50,29 +51,30 @@ export type HttpLinkSourceOwner = | { kind: 'ssh'; connectionId: string } | { kind: 'unknown' } -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 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 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 4896500c6be..9098f8b9a26 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.endpointProjects = [ + state.projects = [ { 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 bdcf7a28783..ae8c9eb019f 100644 --- a/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts +++ b/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts @@ -1,7 +1,5 @@ // 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 { @@ -67,7 +65,7 @@ export function launchWorkspaceState( settings: Record ): Record { const repoPath = workspace.kind === 'repo' ? workspace.path : workspace.repoPath - const repo: Repo | null = repoPath + const repo = repoPath ? { id: LAUNCH_REPO_ID, path: repoPath, @@ -98,17 +96,24 @@ export function launchWorkspaceState( sortOrder: 0, lastActivityAt: 0 } - const catalog = projectHostSetupProjectionFromRepos(repo ? [repo] : []) return { settings, repos: repo ? [repo] : [], - endpointProjects: catalog.projects.map((project) => ({ - ...project, - ...(workspace.projectRuntime - ? { localWindowsRuntimePreference: workspace.projectRuntime } - : {}) - })), - endpointProjectHostSetups: catalog.setups, + projects: repo + ? [ + { + id: LAUNCH_REPO_ID, + displayName: 'repo', + badgeColor: '#000000', + sourceRepoIds: [LAUNCH_REPO_ID], + createdAt: 0, + updatedAt: 0, + ...(workspace.projectRuntime + ? { localWindowsRuntimePreference: workspace.projectRuntime } + : {}) + } + ] + : [], 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 deleted file mode 100644 index 8e76fe5fe7d..00000000000 --- a/src/renderer/src/lib/launch-work-item-direct-catalog.test-fixture.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 a560c8cb27b..10deaf7a9a9 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,6 +1,4 @@ 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' @@ -85,7 +83,7 @@ function sshState(remotePlatform: 'linux' | 'win32'): SshConnectionState { type HostCase = { name: string client: NodeJS.Platform - repo: Pick + repo: { path: string; connectionId?: string; executionHostId?: string } ssh?: Map runtime?: Map expected: NodeJS.Platform @@ -93,15 +91,11 @@ 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, + repos: [{ id: 'repo-1', displayName: 'Repo', addedAt: 1, ...hostCase.repo }], activeRepoId: null, activeWorktreeId: null, - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], projectGroups: [], folderWorkspaces: [], worktreesByRepo: {}, @@ -233,21 +227,7 @@ 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.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 = [ + mocks.store.projects = [ { id: 'repo-1', displayName: 'Repo', @@ -257,7 +237,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 a2b2085abc5..d900111310e 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -4,7 +4,6 @@ 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' @@ -172,7 +171,26 @@ describe('launchWorkItemDirect', () => { mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'tab-1' }) mocks.pasteDraftWhenAgentReady.mockResolvedValue(true) mocks.store = { - ...directWorkItemCatalogFixture(), + 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 + } + ], worktreesByRepo: {}, settings: { defaultTuiAgent: 'codex', 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 17b9850814b..b99d0c2ea57 100644 --- a/src/renderer/src/lib/local-preflight-context-cache.test.ts +++ b/src/renderer/src/lib/local-preflight-context-cache.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' +import { createTestStore } from '../store/slices/store-test-helpers' +import { getDefaultSettings } from '../../../shared/constants' import { _getProjectRuntimePreflightContextCacheSizeForTest, _getWslPreflightContextCacheSizeForTest, @@ -9,6 +11,8 @@ import { resetLocalPreflightContextCachesForTests } from './local-preflight-context' +const baseState = createTestStore().getState() + const WSL_CACHE_LIMIT = 128 const PROJECT_RUNTIME_CACHE_LIMIT = 2048 @@ -20,28 +24,23 @@ function makeRepos(id: string, path: string): AppState['repos'] { return [{ id, path, displayName: id, badgeColor: 'blue', addedAt: 1 }] } -function makeWslState(distro: string): Parameters[0] { +function makeWslState(distro: string): AppState { return { - endpointProjects: [], - endpointProjectHostSetups: [], + ...baseState, activeRepoId: 'repo-1', activeWorktreeId: null, repos: makeRepos('repo-1', `\\\\wsl.localhost\\${distro}\\home\\alice\\repo`), - settings: null, worktreesByRepo: {} } } -function makeWindowsProjectState( - projectId: string -): Parameters[0] { +function makeWindowsProjectState(projectId: string): AppState { return { - endpointProjects: [], - endpointProjectHostSetups: [], + ...baseState, activeRepoId: projectId, activeWorktreeId: null, repos: makeRepos(projectId, `C:\\Users\\alice\\${projectId}`), - settings: null, + settings: getDefaultSettings('/tmp'), worktreesByRepo: {} } } diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 5deaa67935f..594f29e877f 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -1,11 +1,12 @@ -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 { createTestStore } from '../store/slices/store-test-helpers' +import { makeDetectedResult } from '../store/slices/worktrees-detected-listing-fixtures' +import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures' import { getLocalAgentPreflightContext, getLocalPreflightContext, @@ -15,21 +16,19 @@ import { localPreflightContextKey } from './local-preflight-context' -function legacySettings() { +function legacySettings(): NonNullable { const settings = getDefaultSettings('/tmp') Reflect.deleteProperty(settings, 'localWindowsRuntimeDefault') return settings } -type PreflightState = Parameters[0] & - Pick function project(overrides: Pick & Partial): Project { return { displayName: '', badgeColor: '', + sourceRepoIds: [], createdAt: 0, updatedAt: 0, - sourceRepoIds: [], ...overrides } } @@ -39,7 +38,7 @@ function makeState(args: { worktreePath?: string repo?: Partial worktree?: Partial -}): PreflightState { +}): AppState { const repoId = 'repo-1' const worktreeId = `${repoId}::worktree-1` const repos: AppState['repos'] = @@ -55,11 +54,8 @@ 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 })), + ...createTestStore().getState(), activeRepoId: repoId, activeWorktreeId: args.worktreePath === undefined ? null : worktreeId, repos, @@ -136,7 +132,7 @@ describe('local preflight context', () => { }) it('keys preflight by active runtime before local WSL or host context', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` }), @@ -152,13 +148,13 @@ describe('local preflight context', () => { }) it('keys preflight by the focused worktree host over the default host', () => { - const localFocused: PreflightState = { + const localFocused: AppState = { ...makeState({ repoPath: '/Users/alice/repo', worktreePath: '/Users/alice/repo', repo: { executionHostId: 'local' } }), - settings: { ...legacySettings(), activeRuntimeEnvironmentId: 'runtime-1' } + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' } } expect(localPreflightContextKey(getLocalPreflightContext(localFocused))).toBe('host') @@ -217,7 +213,7 @@ describe('local preflight context', () => { }) it('returns repair context for Windows local preflight when the selected WSL distro is missing', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -246,7 +242,7 @@ describe('local preflight context', () => { }) it('uses the migrated WSL runtime for local agent checks when WSL is the default shell', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -275,7 +271,7 @@ describe('local preflight context', () => { }) it('migrates explicit legacy agent location to the project runtime default', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -303,7 +299,7 @@ describe('local preflight context', () => { }) it('lets explicit agent location choose a WSL distro independent of the terminal shell', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -333,7 +329,7 @@ describe('local preflight context', () => { }) it('ignores stale terminal WSL settings when no Windows project runtime is available', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -346,7 +342,7 @@ describe('local preflight context', () => { }) it('ignores stale explicit agent runtime settings on non-Windows hosts', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -359,7 +355,7 @@ describe('local preflight context', () => { }) it('returns repair context for explicit WSL agent location without a selected distro', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -386,7 +382,7 @@ describe('local preflight context', () => { }) it('uses the global WSL runtime for local agent checks without an active project', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: undefined }), activeRepoId: null, activeWorktreeId: null, @@ -416,7 +412,7 @@ describe('local preflight context', () => { }) it('uses the global runtime default over stale legacy agent location without an active project', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: undefined }), activeRepoId: null, activeWorktreeId: null, @@ -434,7 +430,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: PreflightState = { + const state: AppState = { ...makeState({ repoPath: '/home/alice/repo', worktreePath: '/home/alice/repo', @@ -454,7 +450,7 @@ describe('local preflight context', () => { }) it('uses the project override over legacy agent location for local agent checks', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -462,9 +458,7 @@ describe('local preflight context', () => { localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' }, - endpointProjects: [ - project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }) - ] + projects: [project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } })] } const context = getLocalAgentPreflightContext(state, 'win32') @@ -484,16 +478,15 @@ describe('local preflight context', () => { }) it('uses the target worktree runtime for local agent checks', () => { - 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 = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\active', worktreePath: 'C:\\Users\\alice\\active' }), - repos, + repos: [ + { ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\active' }, + { ...TEST_REPO, id: 'repo-2', path: 'C:\\Users\\alice\\target' } + ], worktreesByRepo: { 'repo-1': [ makeWorktree({ @@ -510,23 +503,19 @@ describe('local preflight context', () => { }) ] }, - endpointProjects: [ + projects: [ project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }), project({ id: 'repo-2', localWindowsRuntimePreference: { kind: 'windows-host' } }) ] } - state.endpointProjectHostSetups = projectHostSetupProjectionFromRepos(state.repos).setups.map( - (setup) => ({ ...setup, projectId: setup.repoId }) - ) - const context = getLocalAgentPreflightContext(state, 'win32', {}, 'repo-2::worktree-1') expect(localPreflightContextKey(context)).toBe('repo-2:windows-host') }) it('resolves a project host override for a specific worktree over a WSL default', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -534,9 +523,7 @@ describe('local preflight context', () => { terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - endpointProjects: [ - project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }) - ] + projects: [project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } })] } expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32')).toEqual({ @@ -552,9 +539,9 @@ describe('local preflight context', () => { }) it('does not assign the active project runtime to the floating workspace', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), - endpointProjects: [ + projects: [ project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) ] } @@ -565,7 +552,7 @@ describe('local preflight context', () => { }) it('keeps Floating on the host despite global and explicit WSL agent settings', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -583,16 +570,14 @@ describe('local preflight context', () => { }) it('keeps the active project runtime fallback for a detected-only worktree', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), detectedWorktreesByRepo: { - 'repo-1': { - worktrees: [ - makeWorktree({ id: 'repo-1::detected', repoId: 'repo-1', path: 'C:\\detected' }) - ] - } + 'repo-1': makeDetectedResult('repo-1', [ + makeWorktree({ id: 'repo-1::detected', repoId: 'repo-1', path: 'C:\\detected' }) + ]) }, - endpointProjects: [ + projects: [ project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) ] } @@ -624,9 +609,9 @@ describe('local preflight context', () => { }) it('resolves a local repo project WSL override before a worktree exists', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: String.raw`C:\Users\alice\repo` }), - endpointProjects: [ + projects: [ project({ id: 'project-1', sourceRepoIds: ['repo-1'], @@ -678,27 +663,21 @@ describe('local preflight context', () => { }) it('uses the requested worktree repo owner before resolving a local project runtime', () => { - 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 = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), - repos, + 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' + } + ], worktreesByRepo: { 'repo-1': [ makeWorktree({ @@ -724,13 +703,13 @@ describe('local preflight context', () => { }) it('returns repair when the resolved project WSL distro is unavailable', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), localWindowsRuntimeDefault: { kind: 'windows-host' } }, - endpointProjects: [ + projects: [ project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) ] } @@ -753,7 +732,7 @@ describe('local preflight context', () => { }) it('defers WSL repair while capability loading leaves distro availability unknown', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { ...legacySettings(), @@ -780,7 +759,7 @@ describe('local preflight context', () => { }) it('resolves the requested worktree instead of the active worktree', () => { - const state: PreflightState = { + const state: AppState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 39982ee598f..3611e91b2df 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -1,5 +1,3 @@ -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' @@ -16,11 +14,7 @@ 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 { - getLocalPreflightProjectId, - getLocalProjectRuntimeWorkspace, - type LocalProjectRuntimeState -} from './local-project-runtime-workspace' +import { getLocalProjectRuntimeWorkspace } from './local-project-runtime-workspace' import { getActiveWorkspaceOwnerTarget } from './resolved-worktree-execution-host' import { getEnvironmentRuntimeContextKey } from './provider-runtime-context' import { getRendererAppPlatform } from './renderer-app-platform' @@ -44,8 +38,13 @@ export { resetLocalPreflightContextCachesForTests } from './local-preflight-context-cache' -// Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}` -// or `[]` fallback would miss the cache on every read. +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. const EMPTY_REPOS: AppState['repos'] = [] type LocalProjectRuntimeWslContext = { @@ -154,7 +153,7 @@ export function getLocalRepoProjectExecutionRuntimeContext( } export function getLocalPreflightContext( - state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, + state: AppState, appPlatform: NodeJS.Platform = getRendererAppPlatform(), wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext() ): LocalPreflightContext { @@ -177,7 +176,7 @@ export function getLocalPreflightContext( } export function getLocalAgentPreflightContext( - state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, + state: AppState, appPlatform: NodeJS.Platform = getRendererAppPlatform(), wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext(), worktreeId?: string | null @@ -255,9 +254,7 @@ export function getLocalAgentPreflightContext( return undefined } -function isActiveWorkspaceOnAnotherHost( - state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState -): boolean { +function isActiveWorkspaceOnAnotherHost(state: AppState): boolean { const activeWorktree = getLocalProjectRuntimeWorkspace(state, undefined) const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree) return Boolean(repo) && (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) @@ -275,10 +272,7 @@ function getCachedLocalProjectRuntimeWslContext(): LocalProjectRuntimeWslContext } } -function getLocalPreflightWslDistro( - state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, - worktreeId?: string | null -): string | null { +function getLocalPreflightWslDistro(state: AppState, worktreeId?: string | null): string | null { const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree) if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) { @@ -317,9 +311,17 @@ function getLocalRuntimeProject( projectId: string, repoId: string ) { - const { projects } = getProjectHostSetupProjectionFromState(state) + return state.projects?.find( + (entry) => entry.id === projectId || projectMatchesRepo(entry, repoId) + ) +} + +function getLocalPreflightProjectId( + state: LocalProjectRuntimeState, + worktreeId?: string | null +): string { + const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) return ( - projects.find((entry) => entry.id === projectId) ?? - projects.find((entry) => projectMatchesRepo(entry, repoId)) + activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project' ) } diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts index 8e4f89b7119..5d006191211 100644 --- a/src/renderer/src/lib/local-project-runtime-workspace.ts +++ b/src/renderer/src/lib/local-project-runtime-workspace.ts @@ -1,8 +1,3 @@ -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' @@ -17,28 +12,12 @@ 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< - Worktree, - | 'id' - | 'repoId' - | 'projectId' - | 'path' - | 'hostId' - | 'projectHostSetupId' - | 'runtimeOwnerEnvironmentId' -> | null { +): Pick | null { const targetWorktreeId = worktreeId ?? state.activeWorktreeId if (!targetWorktreeId) { return null @@ -79,20 +58,3 @@ 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 dd353ac188a..d893d76afe9 100644 --- a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts +++ b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts @@ -1,23 +1,17 @@ -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 { - getProjectHostSetupLookup, - type WorkspaceProjectOwner -} from '../../../shared/project-host-setup-lookup' +import type { Project } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' import type { WorkspacePort } from '../../../shared/workspace-ports' import { useAppStore } from '@/store' import { localhostWorktreeLabelRouteForPort } from './workspace-port-localhost-label' -// 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 & { +// Share port → repo → workspace → project routing across reactive and imperative surfaces. +type LocalhostLabelLookupState = { settings?: Pick | null - getKnownWorktreeById?: (worktreeId: string) => Partial | null | undefined + repos?: readonly Repo[] + projects?: readonly Project[] + getKnownWorktreeById?: (worktreeId: string) => { projectId?: string | null } | null | undefined } export function resolveLocalhostLabelRouteForPort( @@ -29,16 +23,8 @@ export function resolveLocalhostLabelRouteForPort( } const repo = (state.repos ?? []).find((entry) => entry.id === port.owner.repoId) ?? null const worktree = state.getKnownWorktreeById?.(port.owner.worktreeId) ?? 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) + const project = worktree?.projectId + ? ((state.projects ?? []).find((entry) => entry.id === worktree.projectId) ?? null) : null return localhostWorktreeLabelRouteForPort({ port, repo, project, settings: state.settings }) } @@ -55,19 +41,9 @@ 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) => - projectId - ? (getProjectHostSetupProjectionFromState(s).projects.find( - (entry) => entry.id === projectId - ) ?? null) + worktree?.projectId + ? ((s.projects ?? []).find((entry) => entry.id === worktree.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 32ca36eafa7..4c8b812c789 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], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], + projectHostSetups: [], 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 b902d1278af..e375e04d9ea 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,10 +17,7 @@ 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< - AppState, - 'repos' | 'endpointProjects' | 'endpointProjectHostSetups' | 'projectOrderBy' - > + state: Pick ): 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 884ab48c833..cf1c3890c97 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: s, + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, 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 596505cd7ae..c6de8f65820 100644 --- a/src/renderer/src/store/project-host-setup-selector.test.ts +++ b/src/renderer/src/store/project-host-setup-selector.test.ts @@ -22,11 +22,9 @@ function countCollectionReads(items: readonly T[]): { counters[property] += 1 return array[property].bind(array) } - if (property === Symbol.iterator || property === 'entries') { + if (property === Symbol.iterator) { counters.iterator += 1 - return property === 'entries' - ? array.entries.bind(array) - : array[Symbol.iterator].bind(array) + return array[Symbol.iterator].bind(array) } if (property === 'length') { counters.length += 1 @@ -85,14 +83,14 @@ describe('project host setup selector', () => { const countedSetups = countCollectionReads(setups) const state = { repos: countedRepos.value, - endpointProjects: countedProjects.value, - endpointProjectHostSetups: countedSetups.value + projects: countedProjects.value, + projectHostSetups: countedSetups.value } const first = getProjectHostSetupProjectionFromState(state) expect(countedRepos.counters.map).toBeGreaterThan(0) expect(countedRepos.counters.iterator).toBeGreaterThan(0) - expect(countedProjects.counters.map).toBeGreaterThan(0) + expect(countedProjects.counters.flatMap).toBeGreaterThan(0) expect(countedSetups.counters.map).toBeGreaterThan(0) countedRepos.counters.map = 0 countedRepos.counters.flatMap = 0 @@ -123,15 +121,15 @@ describe('project host setup selector', () => { const setups = [makeSetup(repo.id, 'repo:repo-1')] const first = getProjectHostSetupProjectionFromState({ repos, - endpointProjects: projects, - endpointProjectHostSetups: setups + projects, + projectHostSetups: setups }) const replacementSetups = [...setups] const next = getProjectHostSetupProjectionFromState({ repos, - endpointProjects: projects, - endpointProjectHostSetups: replacementSetups + projects, + projectHostSetups: replacementSetups }) expect(next).not.toBe(first) @@ -144,17 +142,13 @@ 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, - endpointProjects: projects, - endpointProjectHostSetups: setups - }) + getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups: setups }) const replacementRepos = [makeRepo('repo-2')] const next = getProjectHostSetupProjectionFromState({ repos: replacementRepos, - endpointProjects: projects, - endpointProjectHostSetups: setups + projects, + projectHostSetups: 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 950aebf7cc0..ea9be95434d 100644 --- a/src/renderer/src/store/project-host-setup-selector.ts +++ b/src/renderer/src/store/project-host-setup-selector.ts @@ -3,8 +3,7 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../shared/project-host-setup-projection' -import { resolveProjectHostSetupProjection } from '../../../shared/project-catalog-canonical-projection' -import { resolveProjectHostSetupProjection as resolveCompatibility } from '../../../shared/project-host-setup-hydration' +import { resolveProjectHostSetupProjection } from '../../../shared/project-host-setup-hydration' import type { AppState } from './types' const derivedProjectionByRepos = new WeakMap() @@ -24,21 +23,14 @@ function getDerivedProjection(repos: AppState['repos']): ProjectHostSetupProject return projection } -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. +// Object.is selector results must be stable per repos/projects/setups identities or every render retriggers. export function getProjectHostSetupProjectionFromState( - state: ProjectCatalogReadState + state: Pick & Partial> ): ProjectHostSetupProjection { - if ('projectHostSetupProjection' in state) { - return state.projectHostSetupProjection + const { repos, projects, projectHostSetups: setups } = state + if (!projects || !setups) { + return getDerivedProjection(repos) } - const { repos, endpointProjects: projects, endpointProjectHostSetups: setups } = state let byProjects = resolvedProjectionByCatalog.get(repos) if (!byProjects) { byProjects = new WeakMap() @@ -62,35 +54,3 @@ 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 73b5e826318..3b67ccfcdc5 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, - endpointProjects: [...ingested.projects], - endpointProjectHostSetups: [...ingested.setups] + projects: [...ingested.projects], + projectHostSetups: [...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, - endpointProjects: [...ingested.projects], - endpointProjectHostSetups: [...ingested.setups] + projects: [...ingested.projects], + projectHostSetups: [...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 fe0572cd027..83c84a616b9 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -1,4 +1,3 @@ -import { selectCanonicalProjects } from '../project-host-setup-selector' import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' @@ -46,8 +45,7 @@ export function createProjectHostSetupActions( await assertProjectHostSetupMutationRuntimeCapabilities(target) const projectProviderIdentity = args.projectProviderIdentity ?? - selectCanonicalProjects(get()).find((project) => project.id === args.projectId) - ?.providerIdentity + get().projects.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 = @@ -79,14 +77,14 @@ export function createProjectHostSetupActions( repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry ) : [...s.repos, repo] - 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) + 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) return { repos: nextRepos, - endpointProjects: nextProjects, - endpointProjectHostSetups: nextSetups + projects: nextProjects, + projectHostSetups: nextSetups } }) toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { @@ -125,10 +123,10 @@ export function createProjectHostSetupActions( }) const project = normalizeProjectRow(result.project) set((s) => ({ - 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) + 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) })) return { project, setup } } catch (err) { @@ -145,7 +143,7 @@ export function createProjectHostSetupActions( updateProjectHostSetup: async (args) => { try { const { target, hostArgs } = await prepareProjectHostSetupMutation( - get().endpointProjectHostSetups, + get().projectHostSetups, args ) const result = @@ -176,10 +174,10 @@ export function createProjectHostSetupActions( ) : [...s.repos, repo] : s.repos, - 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) + 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) })) return { ...result, project, repo, setup } } catch (err) { @@ -196,7 +194,7 @@ export function createProjectHostSetupActions( deleteProjectHostSetup: async (args) => { try { const { target, hostArgs } = await prepareProjectHostSetupMutation( - get().endpointProjectHostSetups, + get().projectHostSetups, args ) const result = @@ -219,7 +217,7 @@ export function createProjectHostSetupActions( row: result.setup }) set((s) => { - const projectHostSetups = s.endpointProjectHostSetups.filter( + const projectHostSetups = s.projectHostSetups.filter( (setup) => !isSameProjectHostSetup(setup, removedSetup) ) const repos = @@ -228,14 +226,14 @@ export function createProjectHostSetupActions( : s.repos const projects = repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) - ? s.endpointProjects.filter((project) => project.id !== result.project.id) - : s.endpointProjects + ? s.projects.filter((project) => project.id !== result.project.id) + : s.projects 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, - endpointProjects: projects, - endpointProjectHostSetups: projectHostSetups, + projects, + 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 19ea419b67c..8855c8cfeb9 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -6,7 +6,6 @@ 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( @@ -17,23 +16,15 @@ 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: endpointProjectId, updates }) + ? await window.api.projects.update({ projectId, updates }) : ( await callRuntimeRpc<{ project: Project }>( target, 'project.update', - { projectId: endpointProjectId, updates }, + { projectId, updates }, { timeoutMs: 15_000 } ) ).project @@ -44,8 +35,8 @@ export function createProjectUpdateActions( const normalizedProject = normalizeProjectRow(updatedProject) const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ - endpointProjects: state.endpointProjects.map((project) => - project.id === endpointProjectId + projects: state.projects.map((project) => + project.id === projectId ? 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 b855c9a15c3..a13785267e8 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 @@ -68,9 +68,9 @@ export function createAllHostRepoCatalogActions( const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { - projects: s.endpointProjects, + projects: s.projects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.endpointProjectHostSetups, + s.projectHostSetups, result.repos, finalizedRepos ) @@ -86,8 +86,7 @@ export function createAllHostRepoCatalogActions( repos: finalizedRepos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileFetchedCatalogWorktreeState(s, catalog, finalizedRepos), - endpointProjects: mergedProjectCompatibility.projects, - endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, + ...mergedProjectCompatibility, ...(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 279060d8881..f48282f6a3b 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -1,4 +1,3 @@ -import { selectCanonicalProjects } from '../project-host-setup-selector' import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' @@ -122,10 +121,7 @@ export function createRepoAddActions( return { repos: nextRepos, ...mergeProjectCompatibilityForHostRepoChange({ - previous: { - endpointProjects: s.endpointProjects, - endpointProjectHostSetups: s.endpointProjectHostSetups - }, + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, nextRepos, hostId }), @@ -148,11 +144,7 @@ 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, - selectCanonicalProjects(get()), - get().settings - ) + warnIfProjectCrossesWslFilesystemBoundary(repo, get().projects, 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 e94a19dbc57..be3d86963bb 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -55,26 +55,22 @@ export function createRepoCatalogActions( const reconciliation = reconcileReadoptedSshRepoRows(s.repos, pendingSshRepoReadoptions) const repos = reconciliation.repos const worktreeState = reconcileReadoptedSshWorktreeState(s, pendingSshRepoReadoptions) - const remainingSetups = filterSetupsForPrunedRepoRows( - s.endpointProjectHostSetups, - s.repos, - repos - ) + const remainingSetups = filterSetupsForPrunedRepoRows(s.projectHostSetups, s.repos, repos) const compatibility = mergeProjectHostSetupCompatibility( projectCompatibilityFromRepos(repos), { - projects: s.endpointProjects, + projects: s.projects, 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.endpointProjects, + s.projects, compatibility.projects, (project) => project.id ) const projectHostSetups = reconcileCatalogRows( - s.endpointProjectHostSetups, + s.projectHostSetups, compatibility.projectHostSetups, getProjectHostSetupOwnerKey ) @@ -82,8 +78,8 @@ export function createRepoCatalogActions( repos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...worktreeState, - endpointProjects: projects, - endpointProjectHostSetups: projectHostSetups + projects, + projectHostSetups } }), @@ -115,9 +111,9 @@ export function createRepoCatalogActions( const validRepoHostIdentities = new Set(prunedRepos.map(getRepoHostIdentity)) const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { - projects: s.endpointProjects, + projects: s.projects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.endpointProjectHostSetups, + s.projectHostSetups, result.repos, prunedRepos ) @@ -133,8 +129,7 @@ export function createRepoCatalogActions( repos: prunedRepos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileFetchedCatalogWorktreeState(s, catalog, prunedRepos), - endpointProjects: mergedProjectCompatibility.projects, - endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, + ...mergedProjectCompatibility, ...(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 10d08c30396..1f0a215cd93 100644 --- a/src/renderer/src/store/repos/repo-catalog-identity.ts +++ b/src/renderer/src/store/repos/repo-catalog-identity.ts @@ -30,18 +30,14 @@ export function mergeProjectCompatibilityForHostRepoChange({ nextRepos, hostId }: { - previous: Pick + previous: Pick nextRepos: readonly Repo[] hostId: string -}): Pick { - const merged = mergeFetchedProjectCompatibilityForHost({ - previous: { - projects: previous.endpointProjects, - projectHostSetups: previous.endpointProjectHostSetups - }, +}): Pick { + return mergeFetchedProjectCompatibilityForHost({ + previous, 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 f8a1e12dbf3..9753234b32c 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -285,7 +285,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: s, + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, nextRepos, hostId: ownerHostId }), diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 9aa1301eb11..7d4b4a9f8d5 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -164,8 +164,8 @@ export type RuntimeCatalogFetchOptions = { export type RepoSlice = { repos: readonly Repo[] - endpointProjects: readonly Project[] - endpointProjectHostSetups: readonly ProjectHostSetup[] + projects: readonly Project[] + projectHostSetups: 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 a8cd6751cb1..be72d383efb 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -205,10 +205,7 @@ export function createRepoUpdateActions( return { repos: nextRepos, ...mergeProjectCompatibilityForHostRepoChange({ - previous: { - endpointProjects: s.endpointProjects, - endpointProjectHostSetups: s.endpointProjectHostSetups - }, + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, 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 df93403ec8e..0b1a8c6f8d5 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.endpointProjects, + projects: s.projects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.endpointProjectHostSetups, + s.projectHostSetups, result.repos, finalizedRepos ) @@ -106,8 +106,7 @@ export function createRuntimeRepoCatalogActions( : {}), pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileFetchedCatalogWorktreeState(s, catalog, finalizedRepos), - endpointProjects: mergedProjectCompatibility.projects, - endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, + ...mergedProjectCompatibility, 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 01a8d9effeb..4be4d5af415 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -264,9 +264,7 @@ describe('store selectors', () => { displayName: 'orca' }) ] - const endpointProjects: AppState['endpointProjects'] = [] - const endpointProjectHostSetups: AppState['endpointProjectHostSetups'] = [] - const state = { repos, endpointProjects, endpointProjectHostSetups } + const state = { repos } const projection = getProjectHostSetupProjectionFromState(state) @@ -276,10 +274,8 @@ describe('store selectors', () => { projectId: 'repo:repo-1', hostId: 'local' }) - expect(getProjectHostSetupProjectionFromState({ ...state, repos })).toBe(projection) - expect(getProjectHostSetupProjectionFromState({ ...state, repos: [...repos] })).not.toBe( - projection - ) + expect(getProjectHostSetupProjectionFromState({ repos })).toBe(projection) + expect(getProjectHostSetupProjectionFromState({ repos: [...repos] })).not.toBe(projection) }) it('does not fall back to a same-ID local repo when the active runtime row is absent', () => { @@ -492,13 +488,7 @@ describe('store selectors', () => { } ] - expect( - getProjectHostSetupProjectionFromState({ - repos, - endpointProjects: projects, - endpointProjectHostSetups: projectHostSetups - }) - ).toEqual({ + expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toEqual({ projects, setups: projectHostSetups }) @@ -567,8 +557,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - endpointProjects: projects, - endpointProjectHostSetups: projectHostSetups + projects, + projectHostSetups }) expect(projection.projects.map((project) => project.id)).toEqual(['github:stablyai/orca']) @@ -592,8 +582,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - endpointProjects: [], - endpointProjectHostSetups: [] + projects: [], + projectHostSetups: [] }) expect(projection.projects).toEqual([ @@ -648,8 +638,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - endpointProjects: projects, - endpointProjectHostSetups: projectHostSetups + projects, + projectHostSetups }) expect(projection.projects.map((project) => project.id)).toEqual([ @@ -657,13 +647,9 @@ describe('store selectors', () => { 'cloud-project' ]) expect(projection.setups.map((setup) => setup.id)).toEqual(['repo-1', 'cloud-project::gpu-vm']) - expect( - getProjectHostSetupProjectionFromState({ - repos, - endpointProjects: projects, - endpointProjectHostSetups: projectHostSetups - }) - ).toBe(projection) + expect(getProjectHostSetupProjectionFromState({ repos, projects, 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 0c11ed0e359..b30320e7a3f 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -2,7 +2,6 @@ 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 { @@ -14,7 +13,7 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { getDefaultSettings, FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const detectAgents = vi.fn() const refreshAgents = vi.fn() @@ -46,14 +45,12 @@ function createTestStore(initial?: Partial) { }) as AppState ) store.setState({ - endpointProjects: [], - endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, activeRepoId: null, activeWorktreeId: null, ...initial - }) + } as Partial) return store } @@ -135,10 +132,10 @@ describe('createDetectedAgentsSlice WSL context', () => { runtime: { kind: 'wsl', hostPlatform: 'wsl', - projectId: 'repo:repo-1', + projectId: 'repo-1', distro: 'Ubuntu', reason: 'project-override', - cacheKey: 'repo:repo-1:wsl:Ubuntu' + cacheKey: 'repo-1:wsl:Ubuntu' } } }) @@ -376,16 +373,14 @@ 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: { + ...getDefaultSettings('/tmp'), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' - } as AppState['settings'], - endpointProjectHostSetups: setups.map((setup) => ({ ...setup, projectId: 'repo-1' })), - endpointProjects: [ + }, + projects: [ { id: 'repo-1', displayName: 'repo-1', @@ -396,7 +391,7 @@ describe('createDetectedAgentsSlice WSL context', () => { localWindowsRuntimePreference: { kind: 'windows-host' } } ], - repos, + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], activeRepoId: 'repo-1', activeWorktreeId: null }) 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 3a1a0b9a473..8a2121e4ab7 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,8 +32,7 @@ function createTestStore(repos: Repo[]) { ) store.setState({ repos, - endpointProjectHostSetups: [], - endpointProjects: repos.map((repo) => ({ + projects: repos.map((repo) => ({ id: repo.id, displayName: repo.displayName, badgeColor: repo.badgeColor, @@ -97,7 +96,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().endpointProjects + const projects = store.getState().projects detectAgents.mockResolvedValueOnce(['grok']).mockResolvedValueOnce([]) await store.getState().ensureNativeDetectedAgents() expect(detectAgents).toHaveBeenNthCalledWith(1, undefined) @@ -107,7 +106,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({ endpointProjects: [] }) + store.setState({ projects: [] }) refreshAgents.mockRejectedValueOnce(new Error('native refresh failed')) await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['grok']) expect(readNativeDetectedAgents(store.getState())).toEqual(['grok']) @@ -123,14 +122,14 @@ describe('local detected agent context lifecycle', () => { finish = resolve }) ) - store.setState({ endpointProjects: projects }) + store.setState({ 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({ endpointProjects: [] }) + store.setState({ projects: [] }) 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 50e8e9cc2f0..354ca49f635 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -235,10 +235,10 @@ describe('createPreflightSlice', () => { runtime: { kind: 'wsl', hostPlatform: 'wsl', - projectId: 'repo:repo-1', + projectId: 'repo-1', distro: 'Ubuntu', reason: 'project-override', - cacheKey: 'repo:repo-1:wsl:Ubuntu' + cacheKey: '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 22a50fd2c50..851ec2d1e83 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 } ], - endpointProjectHostSetups: [ + projectHostSetups: [ 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.endpointProjectHostSetups.map((x) => x.id).sort()).toEqual(['s-local', 's-serving']) + expect(s.projectHostSetups.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], - endpointProjectHostSetups: [setup({ id: 's-local', repoId: 'repo1', hostId: 'local' })], + projectHostSetups: [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.endpointProjectHostSetups + const setupsRef = before.projectHostSetups 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.endpointProjectHostSetups).toBe(setupsRef) + expect(s.projectHostSetups).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: [], - endpointProjectHostSetups: [setup({ id: 's-a', repoId: 'repoA', hostId: RUNTIME_A })], + projectHostSetups: [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.endpointProjectHostSetups).toEqual([]) + expect(s.projectHostSetups).toEqual([]) expect(s.worktreesByRepo.repoA).toEqual([]) expect(s.tabsByWorktree[worktreeId]).toBeUndefined() }) @@ -436,7 +436,7 @@ describe('purgeStaleRuntimeHostState', () => { executionHostId: RUNTIME_A } ], - endpointProjectHostSetups: [setup({ id: 's-b', repoId: 'shared', hostId: RUNTIME_B })], + projectHostSetups: [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.endpointProjectHostSetups).toHaveLength(1) + expect(s.projectHostSetups).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 bac2547944e..762d6558d17 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().endpointProjects).toContainEqual(localProject) - expect(store.getState().endpointProjectHostSetups).toContainEqual(localProjectHostSetup) + expect(store.getState().projects).toContainEqual(localProject) + expect(store.getState().projectHostSetups).toContainEqual(localProjectHostSetup) }) it('preserves runtime-owned project metadata during a local-only repo refresh', async () => { @@ -440,12 +440,10 @@ describe('fetchReposForAllHosts', () => { expect( store .getState() - .endpointProjects.map((project) => project.id) + .projects.map((project) => project.id) .sort() ).toEqual(['local-project', 'repo:remote-repo']) - expect( - store.getState().endpointProjects.find((project) => project.id === runtimeProject.id) - ).toEqual( + expect(store.getState().projects.find((project) => project.id === runtimeProject.id)).toEqual( expect.objectContaining({ displayName: runtimeProject.displayName, badgeColor: runtimeProject.badgeColor @@ -462,10 +460,10 @@ describe('fetchReposForAllHosts', () => { expect( store .getState() - .endpointProjects.map((project) => project.id) + .projects.map((project) => project.id) .sort() ).toEqual(['local-project', 'repo:remote-repo']) - expect(store.getState().endpointProjects).toContainEqual(localProject) + expect(store.getState().projects).toContainEqual(localProject) }) it('preserves shared project metadata when the same project id is fetched from multiple hosts', async () => { @@ -475,8 +473,8 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() - expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) - const setups = store.getState().endpointProjectHostSetups + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) expect(setups).toEqual( expect.arrayContaining([ @@ -504,7 +502,7 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() expect( - store.getState().endpointProjects.find((project) => project.id === sharedProjectId) + store.getState().projects.find((project) => project.id === sharedProjectId) ?.localWindowsRuntimePreference ).toEqual({ kind: 'windows-host' }) }) @@ -517,7 +515,7 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() await store.getState().fetchRuntimeEnvironmentRepos('env-1') - expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) }) it('keeps the local side of a shared project when a runtime refresh removes its repos', async () => { @@ -563,10 +561,10 @@ describe('fetchReposForAllHosts', () => { const sharedProject = store .getState() - .endpointProjects.find((project) => project.id === sharedProjectId) + .projects.find((project) => project.id === sharedProjectId) expect(sharedProject?.sourceRepoIds).toEqual(['local-repo']) expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) - expect(store.getState().endpointProjectHostSetups).toEqual([ + expect(store.getState().projectHostSetups).toEqual([ expect.objectContaining({ projectId: sharedProjectId, hostId: 'local', @@ -583,7 +581,7 @@ describe('fetchReposForAllHosts', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) await store.getState().fetchReposForAllHosts() - expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { if (args.method === 'repo.list') { return { @@ -619,12 +617,11 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') - const { endpointProjects, endpointProjectHostSetups } = store.getState() - expect(endpointProjects.map((project) => project.id)).toEqual([sharedProjectId]) + expect(store.getState().projects.map((project) => project.id)).toEqual([sharedProjectId]) expect( - endpointProjects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds ).toEqual(['local-repo']) - expect(endpointProjectHostSetups).toEqual([ + expect(store.getState().projectHostSetups).toEqual([ expect.objectContaining({ projectId: sharedProjectId, hostId: 'local', @@ -674,15 +671,16 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') - const { endpointProjects, endpointProjectHostSetups } = store.getState() expect( - endpointProjects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds ).toEqual(['local-repo']) - expect(endpointProjects.map((project) => project.id).sort()).toEqual([ - 'github:stablyai/orca', - 'repo:remote-repo' - ]) - expect(endpointProjectHostSetups).toEqual( + expect( + store + .getState() + .projects.map((project) => project.id) + .sort() + ).toEqual(['github:stablyai/orca', 'repo:remote-repo']) + expect(store.getState().projectHostSetups).toEqual( expect.arrayContaining([ expect.objectContaining({ projectId: sharedProjectId, @@ -740,8 +738,7 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') expect( - store.getState().endpointProjects.find((project) => project.id === sharedProjectId) - ?.sourceRepoIds + store.getState().projects.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 9b3bbb3afc2..92af0e9c61b 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().endpointProjects.find((project) => project.id === SHARED_PROJECT_ID) + return store.getState().projects.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 4cc33297020..6a837d6f916 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'], - endpointProjects: [ + projects: [ { id: 'repo:same-repo', displayName: 'Same Repo', @@ -324,7 +324,7 @@ describe('repo slice host identity routing', () => { updatedAt: 1 } satisfies Project ], - endpointProjectHostSetups: [ + projectHostSetups: [ 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().endpointProjectHostSetups).toEqual([ + expect(store.getState().projectHostSetups).toEqual([ expect.objectContaining({ hostId: 'runtime:env-1', repoId: 'same-repo' }) ]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [remoteWorktree.id]: 20 }) - expect(store.getState().endpointProjects).toEqual([ + expect(store.getState().projects).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 434d9c39de3..8016f354648 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().endpointProjectHostSetups + const setups = store.getState().projectHostSetups 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().endpointProjectHostSetups).toEqual([ + expect(store.getState().projectHostSetups).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 7efa0018478..6ca81212ce2 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().endpointProjectHostSetups).toEqual([ + expect(store.getState().projectHostSetups).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 1eb0746d589..8ffcdc5487f 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().endpointProjects).toEqual([project]) - expect(store.getState().endpointProjectHostSetups).toEqual([setup]) + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) expect(projectsCreateHostSetup).toHaveBeenCalledWith({ projectId: project.id, hostId: 'local', @@ -117,7 +117,7 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - endpointProjectHostSetups: [runtimeSetup], + projectHostSetups: [runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -158,8 +158,8 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - endpointProjects: [project], - endpointProjectHostSetups: [runtimeSetup], + projects: [project], + projectHostSetups: [runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -171,8 +171,8 @@ export function registerReposProjectHostLifecycleTests(): void { repo: undefined }) - expect(store.getState().endpointProjects).toEqual([project]) - expect(store.getState().endpointProjectHostSetups).toEqual([]) + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([]) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'projectHostSetup.delete', @@ -194,7 +194,7 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - endpointProjectHostSetups: [localSetup, runtimeSetup], + projectHostSetups: [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().endpointProjectHostSetups).toEqual([updatedLocalSetup, runtimeSetup]) + expect(store.getState().projectHostSetups).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({ - endpointProjects: [project], - endpointProjectHostSetups: [localSetup, runtimeSetup], + projects: [project], + projectHostSetups: [localSetup, runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -234,15 +234,15 @@ export function registerReposProjectHostLifecycleTests(): void { hostId: 'local' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().endpointProjectHostSetups).toEqual([runtimeSetup]) + expect(store.getState().projectHostSetups).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({ - endpointProjects: [project], - endpointProjectHostSetups: [localSetup, runtimeSetup], + projects: [project], + projectHostSetups: [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().endpointProjectHostSetups).toEqual([localSetup, runtimeSetup]) + expect(store.getState().projectHostSetups).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({ - endpointProjects: [project], - endpointProjectHostSetups: [localSetup], + projects: [project], + projectHostSetups: [localSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -276,7 +276,7 @@ export function registerReposProjectHostLifecycleTests(): void { ).resolves.toBeNull() expect(projectsDeleteHostSetup).not.toHaveBeenCalled() - expect(store.getState().endpointProjectHostSetups).toEqual([localSetup]) + expect(store.getState().projectHostSetups).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({ - endpointProjects: [project], - endpointProjectHostSetups: [localSetup, sshSetup], + projects: [project], + projectHostSetups: [localSetup, sshSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -296,7 +296,7 @@ export function registerReposProjectHostLifecycleTests(): void { setupId: sshSetup.id, hostId: 'ssh:box' }) - expect(store.getState().endpointProjectHostSetups).toEqual([localSetup]) + expect(store.getState().projectHostSetups).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().endpointProjectHostSetups).toEqual( + expect(store.getState().projectHostSetups).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 2bd4078467f..508d06dfb92 100644 --- a/src/renderer/src/store/slices/repos-project-runtime.spec.ts +++ b/src/renderer/src/store/slices/repos-project-runtime.spec.ts @@ -6,8 +6,6 @@ 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 { @@ -89,7 +87,7 @@ export function registerReposProjectRuntimeTests(): void { }) const store = createTestStore() store.setState({ - endpointProjects: [project], + projects: [project], detectedAgentIds: ['claude'], isDetectingAgents: true, isRefreshingAgents: true @@ -109,68 +107,6 @@ 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', @@ -199,8 +135,8 @@ export function registerReposProjectRuntimeTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjects).toEqual([project]) - expect(store.getState().endpointProjectHostSetups).toEqual([setup]) + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) expect(projectsList).toHaveBeenCalled() expect(window.api.projects.listHostSetups).toHaveBeenCalled() }) @@ -239,11 +175,11 @@ export function registerReposProjectRuntimeTests(): void { window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) reposList.mockResolvedValue([localRepo]) const store = createTestStore() - store.setState({ endpointProjects: [staleProject], endpointProjectHostSetups: [setup] }) + store.setState({ projects: [staleProject], projectHostSetups: [setup] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() }) it('updates local project runtime preferences through the projects API', async () => { @@ -260,7 +196,7 @@ export function registerReposProjectRuntimeTests(): void { localWindowsRuntimePreference: { kind: 'windows-host' } }) const store = createTestStore() - store.setState({ endpointProjects: [project] }) + store.setState({ projects: [project] }) await store.getState().updateProject( project.id, @@ -270,7 +206,7 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) expect(projectsUpdate).toHaveBeenCalledWith({ @@ -296,8 +232,8 @@ export function registerReposProjectRuntimeTests(): void { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - endpointProjects: [project], - endpointProjectHostSetups: [ + projects: [project], + projectHostSetups: [ { id: 'setup-1', projectId: project.id, @@ -351,8 +287,8 @@ export function registerReposProjectRuntimeTests(): void { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - endpointProjects: [project], - endpointProjectHostSetups: [ + projects: [project], + projectHostSetups: [ { id: 'setup-1', projectId: project.id, @@ -376,7 +312,7 @@ export function registerReposProjectRuntimeTests(): void { 'runtime:env-1' ) - expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) @@ -410,7 +346,7 @@ export function registerReposProjectRuntimeTests(): void { localWindowsRuntimePreference: { kind: 'windows-host' } }) const store = createTestStore() - store.setState({ endpointProjects: [project] }) + store.setState({ projects: [project] }) await store.getState().updateProject( project.id, @@ -420,8 +356,8 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().endpointProjects[0]?.createdAt).toBe(100) - expect(store.getState().endpointProjects[0]?.updatedAt).toBe(100) + expect(store.getState().projects[0]?.createdAt).toBe(100) + expect(store.getState().projects[0]?.updatedAt).toBe(100) }) it('preserves shared project source repos when updating local runtime preferences', async () => { @@ -440,7 +376,7 @@ export function registerReposProjectRuntimeTests(): void { updatedAt: 3 }) const store = createTestStore() - store.setState({ endpointProjects: [project] }) + store.setState({ projects: [project] }) await store.getState().updateProject( project.id, @@ -450,11 +386,8 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().endpointProjects[0]?.sourceRepoIds).toEqual([ - 'local-repo', - 'remote-repo' - ]) - expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) }) @@ -478,7 +411,7 @@ export function registerReposProjectRuntimeTests(): void { updatedAt: 3 }) const store = createTestStore() - store.setState({ endpointProjects: [project] }) + store.setState({ projects: [project] }) await store.getState().updateProject( project.id, @@ -488,11 +421,8 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().endpointProjects[0]?.sourceRepoIds).toEqual([ - 'local-repo', - 'remote-repo' - ]) - expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects[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 ca94efac77a..14438834697 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().endpointProjects - const setups = store.getState().endpointProjectHostSetups + const projects = store.getState().projects + const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) await store.getState().fetchRepos(catalogOwner(store)) - 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]) + 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]) }) it('lets a nested hookSettings change through', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().endpointProjectHostSetups + const setups = store.getState().projectHostSetups mockRepos({ ...repo, @@ -104,20 +104,20 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjectHostSetups).not.toBe(setups) - expect(store.getState().endpointProjectHostSetups[0]?.hookSettings?.mode).toBe('override') + expect(store.getState().projectHostSetups).not.toBe(setups) + expect(store.getState().projectHostSetups[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().endpointProjects + const projects = store.getState().projects mockRepos({ ...repo, displayName: 'Renamed' }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjects).not.toBe(projects) - expect(store.getState().endpointProjects[0]?.displayName).toBe('Renamed') + expect(store.getState().projects).not.toBe(projects) + expect(store.getState().projects[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().endpointProjects + const projects = store.getState().projects mockRepos({ ...repo, @@ -143,39 +143,39 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjects).not.toBe(projects) - expect(store.getState().endpointProjects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') + expect(store.getState().projects).not.toBe(projects) + expect(store.getState().projects[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().endpointProjects[0]!.id + const projectId = store.getState().projects[0]!.id const withPreference: Project = { - ...store.getState().endpointProjects[0]!, + ...store.getState().projects[0]!, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } - store.setState({ endpointProjects: [withPreference] }) + store.setState({ projects: [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().endpointProjects[0]).not.toBe(withPreference) - expect(store.getState().endpointProjects[0]?.id).toBe(projectId) - expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().projects[0]).not.toBe(withPreference) + expect(store.getState().projects[0]?.id).toBe(projectId) + expect(store.getState().projects[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().endpointProjectHostSetups + const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) mockRepos(repo, { ...secondRepo, displayName: 'Repo 2 renamed' }) await store.getState().fetchRepos(catalogOwner(store)) - const next = store.getState().endpointProjectHostSetups + const next = store.getState().projectHostSetups 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().endpointProjectHostSetups + const setups = store.getState().projectHostSetups 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().endpointProjectHostSetups + const next = store.getState().projectHostSetups 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().endpointProjectHostSetups + const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) expect(new Set(setups.map((setup) => setup.id)).size).toBe(1) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjectHostSetups).toBe(setups) - expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) - expect(store.getState().endpointProjectHostSetups[1]).toBe(setups[1]) + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().projectHostSetups[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().endpointProjects + const projects = store.getState().projects mockRepos(repo) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjects).not.toBe(projects) - expect(store.getState().endpointProjects).toHaveLength(1) - expect(store.getState().endpointProjectHostSetups).toHaveLength(1) + expect(store.getState().projects).not.toBe(projects) + expect(store.getState().projects).toHaveLength(1) + expect(store.getState().projectHostSetups).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({ - endpointProjects: [...store.getState().endpointProjects, sshOwned], - endpointProjectHostSetups: [] + projects: [...store.getState().projects, sshOwned], + projectHostSetups: [] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjects.map((project) => project.id)).toContain('ssh-owned') + expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned') }) it('keeps a project whose repo id is cloned onto a second host', async () => { @@ -266,36 +266,34 @@ export function registerReposRefreshIdentityTests(): void { updatedAt: 1 } store.setState({ - endpointProjects: [...store.getState().endpointProjects, dualHostOwned], - endpointProjectHostSetups: [] + projects: [...store.getState().projects, dualHostOwned], + projectHostSetups: [] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjects.map((project) => project.id)).toContain( - 'dual-host-owned' - ) + expect(store.getState().projects.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().endpointProjects + const projects = store.getState().projects mockRepos(repo, secondRepo) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().endpointProjects).not.toBe(projects) - expect(store.getState().endpointProjects).toHaveLength(2) - expect(store.getState().endpointProjectHostSetups).toHaveLength(2) + expect(store.getState().projects).not.toBe(projects) + expect(store.getState().projects).toHaveLength(2) + expect(store.getState().projectHostSetups).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().endpointProjects - const setups = store.getState().endpointProjectHostSetups + const projects = store.getState().projects + const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) expect(projects[0]?.createdAt).toBe(0) @@ -303,18 +301,18 @@ export function registerReposRefreshIdentityTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - 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]) + 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]) }) 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().endpointProjects - const setups = store.getState().endpointProjectHostSetups + const projects = store.getState().projects + const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) expect(projects[0]?.createdAt).toBe(0) @@ -322,17 +320,17 @@ export function registerReposRefreshIdentityTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - 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]) + 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]) }) 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().endpointProjectHostSetups + const setups = store.getState().projectHostSetups mockRepos({ ...repo, @@ -341,9 +339,9 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - 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') + 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') }) }) @@ -414,8 +412,8 @@ export function registerReposRefreshIdentityTests(): void { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) const before = store.getState() - const projects = before.endpointProjects - const setups = before.endpointProjectHostSetups + const projects = before.projects + const setups = before.projectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) @@ -424,26 +422,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().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]) + 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]) }) 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().endpointProjects - const setups = store.getState().endpointProjectHostSetups + const projects = store.getState().projects + const setups = store.getState().projectHostSetups const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } store.getState().recordSshRepoReadoptions([readoption]) expect(store.getState().pendingSshRepoReadoptions).toEqual([readoption]) - 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]) + 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]) }) it('replaces the moved setup on a real prune/rehome', async () => { @@ -460,7 +458,7 @@ export function registerReposRefreshIdentityTests(): void { mockRepos(oldHostRepo, newHostRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().endpointProjectHostSetups + const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) const oldSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-old') const newSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-new') @@ -473,7 +471,7 @@ export function registerReposRefreshIdentityTests(): void { { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } ]) - const next = store.getState().endpointProjectHostSetups + const next = store.getState().projectHostSetups 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 401185a1a81..b954ca79aa2 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,8 +131,7 @@ export function registerReposSharedProjectBadgeColorTests(): void { }) function badgeColor(store: ReturnType): string | undefined { - return store.getState().endpointProjects.find((project) => project.id === SHARED_PROJECT_ID) - ?.badgeColor + return store.getState().projects.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 1fd4d043ef6..58d694c84d0 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], - endpointProjectHostSetups: [directSshSetup('ssh-old')], + projectHostSetups: [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().endpointProjectHostSetups).toEqual([directSshSetup('ssh-new')]) + expect(store.getState().projectHostSetups).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')], - endpointProjectHostSetups: [directSshSetup('ssh-old'), runtimeSetup] + projectHostSetups: [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().endpointProjectHostSetups).toEqual( + expect(store.getState().projectHostSetups).toEqual( expect.arrayContaining([directSshSetup('ssh-new'), runtimeSetup]) ) - expect(store.getState().endpointProjectHostSetups).toHaveLength(2) + expect(store.getState().projectHostSetups).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], endpointProjectHostSetups: [directSshSetup('ssh-old')] }) + store.setState({ repos: [staleRepo], projectHostSetups: [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().endpointProjectHostSetups).toEqual([directSshSetup('ssh-new')]) + expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')]) }) it('rejects an old-host worktree response that resolves after re-adoption', async () => { @@ -387,17 +387,14 @@ describe('SSH repo host reconciliation', () => { projectsList.mockResolvedValue([project]) setupsList.mockResolvedValue([directSshSetup('ssh-old')]) const store = createTestStore() - store.setState({ - repos: [localRepo, oldRepo], - endpointProjectHostSetups: [directSshSetup('ssh-old')] - }) + store.setState({ repos: [localRepo, oldRepo], projectHostSetups: [directSshSetup('ssh-old')] }) await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }, oldRepo]) - expect(store.getState().endpointProjectHostSetups).toEqual( + expect(store.getState().projectHostSetups).toEqual( expect.arrayContaining([directSshSetup('ssh-old')]) ) - expect(store.getState().endpointProjectHostSetups).toHaveLength(2) + expect(store.getState().projectHostSetups).toHaveLength(2) }) }) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 2d7504003a6..9cf3fb9917a 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().endpointProjects).toEqual([ + expect(store.getState().projects).toEqual([ expect.objectContaining({ id: 'repo:local-repo', sourceRepoIds: ['local-repo'] }) ]) - expect(store.getState().endpointProjectHostSetups).toEqual([ + expect(store.getState().projectHostSetups).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().endpointProjects).toEqual([ + expect(store.getState().projects).toEqual([ expect.objectContaining({ id: 'repo:remote-repo', sourceRepoIds: ['remote-repo'] }) ]) - expect(store.getState().endpointProjectHostSetups).toEqual([ + expect(store.getState().projectHostSetups).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({ endpointProjects: [project] }) + store.setState({ projects: [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().endpointProjects).toEqual([project]) - expect(store.getState().endpointProjectHostSetups).toEqual([setup]) + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).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().endpointProjects).toBe(before.endpointProjects) - expect(store.getState().endpointProjectHostSetups).toBe(before.endpointProjectHostSetups) + expect(store.getState().projects).toBe(before.projects) + expect(store.getState().projectHostSetups).toBe(before.projectHostSetups) 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 bddfec29106..543607cd7a3 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: [], - endpointProjects: [], - endpointProjectHostSetups: [], + projects: [], + projectHostSetups: [], 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 5e12e1cb5cb..4cb4d02ee0a 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 } ], - endpointProjectHostSetups: [ + projectHostSetups: [ { 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.endpointProjectHostSetups).toEqual([]) + expect(s.projectHostSetups).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 83767d90cb6..7a099cabed8 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,21 +267,7 @@ describe('setActiveWorktree', () => { terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian' }, - 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: [ + projects: [ { id: 'project-1', displayName: 'Project', @@ -326,21 +312,7 @@ describe('setActiveWorktree', () => { seedStore(store, { settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'powershell.exe' }, - 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: [ + projects: [ { 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 fb13c9ba42f..d13c58951ef 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,8 +275,6 @@ 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 ace14540a71..b1f5109c6c1 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,10 +161,7 @@ describe('fetchWorktrees', () => { }) ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().worktreesByRepo['same-repo']).toEqual([ - remote, - { ...local, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' } - ]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([remote, local]) expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( expect.arrayContaining([ expect.objectContaining({ id: remote.id, hostId: 'runtime:env-1' }), @@ -210,12 +207,7 @@ 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', - projectId: 'repo:repo-ssh', - projectHostSetupId: 'repo-ssh' - } + { ...sshWorktree, hostId: 'ssh:ssh-1' } ]) }) @@ -260,9 +252,7 @@ describe('fetchWorktrees', () => { }) ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().worktreesByRepo['same-repo']).toEqual([ - { ...localWorktree, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' } - ]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) }) it('honors an explicit runtime owner before the repo catalog is hydrated', async () => { @@ -276,7 +266,7 @@ describe('fetchWorktrees', () => { store.setState({ settings: { activeRuntimeEnvironmentId: null } as never, repos: [] - } as Partial) + }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-missing-repo', ok: true, @@ -297,11 +287,7 @@ 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' } ]) }) @@ -438,7 +424,7 @@ describe('fetchWorktrees', () => { executionHostId: 'runtime:env-1' } ] - } as Partial) + }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', ok: true, @@ -449,13 +435,7 @@ 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', - projectId: 'repo:repo-remote', - projectHostSetupId: 'repo-remote' - } + { ...remote, hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } ]) }) @@ -516,13 +496,7 @@ describe('fetchWorktrees', () => { await expect(staleRefresh).resolves.toBe(false) expect(store.getState().worktreesByRepo['repo-remote']).toEqual([ - { - ...fresh, - hostId: 'runtime:env-1', - runtimeOwnerEnvironmentId: 'env-1', - projectId: 'repo:repo-remote', - projectHostSetupId: 'repo-remote' - } + { ...fresh, hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } ]) expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() }) @@ -547,17 +521,7 @@ describe('fetchWorktrees', () => { executionHostId: 'runtime:env-1' } ], - endpointProjects: [ - { - id: 'github:stablyai/orca', - displayName: 'orca', - badgeColor: '#000', - sourceRepoIds: ['repo-remote'], - createdAt: 1, - updatedAt: 1 - } - ], - endpointProjectHostSetups: [ + projectHostSetups: [ { id: 'repo-remote', projectId: 'github:stablyai/orca', 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 7b0c3351e02..ca4cb4584b3 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', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + { ...queued, hostId: 'ssh:ssh-1' } ]) 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', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + { ...queued, hostId: 'ssh:ssh-1' } ]) }) @@ -187,12 +187,7 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ sshExisting, - { - ...queued, - hostId: 'ssh:ssh-1', - projectId: 'repo:repo-shared', - projectHostSetupId: 'repo-shared' - }, + { ...queued, hostId: 'ssh:ssh-1' }, localExisting ]) }) @@ -291,16 +286,14 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(sshRepo.id) expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...stale, hostId: 'ssh:ssh-1', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + { ...stale, hostId: 'ssh:ssh-1' } ]) worktreeListMock.mockResolvedValueOnce([live]) store.setState({ sshConnectionStates: connectedStates } as Partial) await store.getState().fetchWorktrees(sshRepo.id) - expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...live, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } - ]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) }) it('keeps the repo detection entry authoritative while appending metadata rows', async () => { @@ -388,30 +381,21 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(sshRepo.id) expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { - ...deletedOnRemote, - hostId: 'ssh:ssh-1', - projectId: 'repo:repo-ssh', - projectHostSetupId: 'repo-ssh' - } + { ...deletedOnRemote, hostId: 'ssh:ssh-1' } ]) // 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, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } - ]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) // 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, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } - ]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) }) it('retires persisted metadata for worktrees an authoritative SSH scan proved gone', async () => { @@ -490,9 +474,7 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(localRepo.id) - expect(store.getState().worktreesByRepo[localRepo.id]).toEqual([ - { ...survivor, projectId: 'repo:repo-local', projectHostSetupId: 'repo-local' } - ]) + expect(store.getState().worktreesByRepo[localRepo.id]).toEqual([survivor]) // 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 63a8c3b5c20..a9ed1993a08 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, projectId: 'repo:repo1', projectHostSetupId: 'repo1' }, - { ...sshWorktree, hostId: 'ssh:ssh-1', projectId: 'repo:repo1', projectHostSetupId: 'repo1' } + localWorktree, + { ...sshWorktree, hostId: 'ssh:ssh-1' } ]) expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(2) }) @@ -397,12 +397,7 @@ describe('fetchWorktrees', () => { expect(visibleResult).toBe(true) expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { - ...sshWorktree, - hostId: 'ssh:ssh-1', - projectId: 'repo:repo-ssh', - projectHostSetupId: 'repo-ssh' - } + { ...sshWorktree, hostId: 'ssh:ssh-1' } ]) expect(store.getState().detectedWorktreesByRepo['repo-ssh']?.worktrees).toEqual([ expect.objectContaining({ id: sshWorktree.id, hostId: 'ssh:ssh-1' }) @@ -454,12 +449,7 @@ describe('fetchWorktrees', () => { expect(visibleResult).toBe(true) expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { - ...sshWorktree, - hostId: 'ssh:ssh-1', - projectId: 'repo:repo-ssh', - projectHostSetupId: 'repo-ssh' - } + { ...sshWorktree, hostId: 'ssh:ssh-1' } ]) 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 f71743aea7a..284f4198358 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,9 +82,7 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('repo1') - expect(store.getState().worktreesByRepo.repo1).toEqual([ - { ...refreshed, projectId: 'repo:repo1', projectHostSetupId: 'repo1' } - ]) + expect(store.getState().worktreesByRepo.repo1).toEqual([refreshed]) expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) expect(store.getState().sortEpoch).toBe(8) }) @@ -118,9 +116,7 @@ describe('fetchWorktrees', () => { path: '/remote/wt1', branch: 'refs/heads/remote', hostId: 'runtime:env-1', - runtimeOwnerEnvironmentId: 'env-1', - projectId: 'repo:repo1', - projectHostSetupId: 'repo1' + runtimeOwnerEnvironmentId: 'env-1' }) const staleLineage = makeLineage({ worktreeId: worktree.id, @@ -532,8 +528,6 @@ 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 4e2a3f14728..42bdea60462 100644 --- a/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts +++ b/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts @@ -161,12 +161,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { - ...queued, - hostId: 'ssh:ssh-1', - projectId: 'repo:repo-ssh', - projectHostSetupId: 'repo-ssh' - } + { ...queued, hostId: 'ssh:ssh-1' } ]) expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ repoId: sshRepo.id, @@ -539,11 +534,9 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(refreshed).toHaveLength(2) expect(refreshed).toEqual( expect.arrayContaining([ - { ...localWorktree, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' }, + localWorktree, { ...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 051e42c06a1..9c322acb3e3 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,8 +178,6 @@ 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 27d69bb59e8..6b193ac8748 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,25 +15,6 @@ 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', () => ({ @@ -193,18 +174,34 @@ 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 = Object.assign(makeOrphanedSetup(), { + const orphanedSetup: AppState['projectHostSetups'][number] = { id: 'setup-runtime-ssh', - hostId: 'ssh:runtime-ssh-orca-1' - }) - const userSshSetup = Object.assign(makeOrphanedSetup(), { + hostId: 'ssh:runtime-ssh-orca-1', + projectId: 'project-runtime-ssh', + repoId: 'repo-runtime-ssh', + path: '/runtime/repo', + displayName: 'Runtime repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + const userSshSetup: AppState['projectHostSetups'][number] = { id: 'setup-user-ssh', - hostId: 'ssh:my-server' - }) + hostId: 'ssh:my-server', + projectId: 'project-user-ssh', + repoId: 'repo-user-ssh', + path: '/user/repo', + displayName: 'User repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } const deleteProjectHostSetup = vi.fn().mockResolvedValue(null) store.setState({ worktreesByRepo: { repo1: [wt] }, - endpointProjectHostSetups: [orphanedSetup, userSshSetup], + projectHostSetups: [orphanedSetup, userSshSetup], deleteProjectHostSetup }) mockApi.ephemeralVm.listRuntimes.mockResolvedValueOnce([ 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 7bc8e74068e..ca727c6662b 100644 --- a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts +++ b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts @@ -150,8 +150,7 @@ export function createTestStore() { ] ]), repos: [], - endpointProjects: [], - endpointProjectHostSetups: [], + projectHostSetups: [], 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 1d256dcb73c..748efb01ae9 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,4 +1,3 @@ -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' @@ -123,11 +122,11 @@ export function toVisibleWorktrees( } export function getProjectHostSetupForRepoHost( - state: Parameters[0], + state: Partial>, repoId: string, hostId: ExecutionHostId ): ProjectHostSetup | undefined { - return getProjectHostSetupCompatibilityFromState(state).setups.find( + return state.projectHostSetups?.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 5aeb1d82583..8aa1f039e6a 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() - .endpointProjectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId)) + .projectHostSetups.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 129961cf682..580bff2797e 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.endpointProjectHostSetups) { + for (const setup of s.projectHostSetups) { if (isRemovedRuntimeHostId(setup.hostId, removed)) { if (setup.repoId) { repoIdsWithRemovedOwners.add(setup.repoId) @@ -68,7 +68,7 @@ export function createPurgeStaleRuntimeHostState( } } } - const setupsChanged = survivingSetups.length !== s.endpointProjectHostSetups.length + const setupsChanged = survivingSetups.length !== s.projectHostSetups.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 ? { endpointProjectHostSetups: survivingSetups } : {}), + ...(setupsChanged ? { projectHostSetups: 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 855c64c5bd4..3999a685339 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,8 +22,7 @@ import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/exe import { assertEnvironmentPairing, callRuntimeEnvelope, - callRuntimeResult, - callEnvironmentEnvelope + callRuntimeResult } from './web-runtime-calls' import { readRuntimeAiVaultDeleteSessionResponse, @@ -34,42 +33,19 @@ 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, 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) - }, + searchSessions: (request, executionHostScope) => + addressesOwnRuntime(executionHostScope) + ? search.searchSessions(request) + : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }), searchStatus: (executionHostScope) => addressesOwnRuntime(executionHostScope) - ? createSessionSearchClient( - (method, params) => callRuntimeResult(method, params), - 'relay' - ).searchStatus() + ? search.searchStatus() : Promise.resolve(unavailableSessionSearchStatus()), // Host-index consent requires the desktop operator’s settings surface. setSearchEnabled: () => Promise.reject(new Error('unsupported')), @@ -93,7 +69,6 @@ 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 cf84faa4cf1..6e0ff9520c0 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,13 +4,7 @@ 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, - callEnvironmentEnvelope: async (_selector: string, method: string, params: unknown) => ({ - ok: true, - result: await callRuntimeResult(method, params) - }) -})) +vi.mock('./web-runtime-calls', () => ({ callRuntimeResult })) vi.mock('./web-runtime-session', () => ({ requireActiveEnvironment: () => ({ id: 'owning-host' }) })) @@ -39,23 +33,6 @@ 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 3fead54be41..2a02f200d85 100644 --- a/src/renderer/src/web/preload-api/web-runtime-calls.ts +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -120,8 +120,7 @@ export async function callEnvironmentEnvelope( params?: unknown, timeoutMs?: number, expectedEnvironmentPairingRevision?: number, - expectedEnvironmentRuntimeId?: string, - isActive: () => boolean = () => true + expectedEnvironmentRuntimeId?: string ): Promise> { const environment = resolveEnvironment(selector) const assertPairing = () => assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision) @@ -130,9 +129,6 @@ 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 64f36aca070..c47608bdca0 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,99 +275,3 @@ 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 cf43593b24a..bf665e4e1c3 100644 --- a/src/shared/ai-vault-project-key.ts +++ b/src/shared/ai-vault-project-key.ts @@ -9,9 +9,3 @@ 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 66419dbd4be..10e78b3034d 100644 --- a/src/shared/ai-vault-runtime-capabilities.ts +++ b/src/shared/ai-vault-runtime-capabilities.ts @@ -2,16 +2,12 @@ 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 bdb497398cb..3ca5184ed1f 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -42,17 +42,11 @@ export function createSessionSearchClient( call: (method: string, params: Record) => Promise, transport: SessionSearchTransport ): { - searchSessions( - request: AiVaultSearchRequest, - isActive?: () => boolean - ): Promise + searchSessions(request: AiVaultSearchRequest): Promise searchStatus(): Promise } { return { - searchSessions: async (request, isActive = () => true) => { - if (!isActive()) { - throw new Error('Session search retired') - } + searchSessions: async (request) => { const parsed = AiVaultSearchRequestSchema.parse(request) let hostRequest = parsed let raw: unknown @@ -66,9 +60,6 @@ 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 0f513ab504e..1ffe4501043 100644 --- a/src/shared/ai-vault-search-scope.ts +++ b/src/shared/ai-vault-search-scope.ts @@ -3,11 +3,7 @@ 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), - canonicalProjectKey: z.string().min(1).max(1024).optional() - }) + z.object({ kind: z.literal('project'), projectKey: z.string().min(1).max(1024) }) ]) export type AiVaultSearchScopeIdentity = z.infer diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index e005e950e4a..6ffcaf6d2ef 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -115,8 +115,6 @@ 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 @@ -218,7 +216,6 @@ 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 a1ece2eb1b9..081310b9d1d 100644 --- a/src/shared/git-remote-identity.ts +++ b/src/shared/git-remote-identity.ts @@ -136,13 +136,3 @@ 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 58d2125a63f..49d6896e501 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: `repo:${LAUNCH_REPO_ID}`, reason: runtime.reason } + const base = { projectId: 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: `repo:${LAUNCH_REPO_ID}:wsl:${runtime.distro}` + cacheKey: `${LAUNCH_REPO_ID}:wsl:${runtime.distro}` } : { ...base, kind: 'windows-host', hostPlatform: 'win32', - cacheKey: `repo:${LAUNCH_REPO_ID}:windows-host` + cacheKey: `${LAUNCH_REPO_ID}:windows-host` } } } diff --git a/src/shared/project-catalog-canonical-projection.ts b/src/shared/project-catalog-canonical-projection.ts deleted file mode 100644 index 4ccbc7d5e8e..00000000000 --- a/src/shared/project-catalog-canonical-projection.ts +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 13af343f3fc..00000000000 --- a/src/shared/project-checkout-claim.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index c39df74cd38..00000000000 --- a/src/shared/project-host-setup-hydration.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -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 75f40a794ba..50096689dca 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, - projectHostSetupCompatibilityFromRepos, + projectHostSetupProjectionFromRepos, 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 = projectHostSetupCompatibilityFromRepos(repos) + derived: ProjectHostSetupProjection = projectHostSetupProjectionFromRepos(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 deleted file mode 100644 index 7dc42a7b1d2..00000000000 --- a/src/shared/project-host-setup-lookup.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 e9c7681c554..d7d59f4cef9 100644 --- a/src/shared/project-host-setup-lookup.ts +++ b/src/shared/project-host-setup-lookup.ts @@ -1,107 +1,8 @@ -import { - getRepoExecutionHostId, - normalizeExecutionHostId, - toRuntimeExecutionHostId -} from './execution-host' +import { getRepoExecutionHostId } 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[], @@ -120,20 +21,10 @@ export function getProjectHostSetupWorktreeMeta( setups: readonly ProjectHostSetup[], repo: Repo ): Pick { - const setup = getProjectHostSetupForRepo( - resolveProjectHostSetupProjection([repo], [], setups).setups, - repo - ) + const setup = getProjectHostSetupForRepo(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 ed4e89f476d..aade8b16611 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -1,13 +1,11 @@ import { describe, expect, it } from 'vitest' import { projectHostSetupProjectionFromRepos, + getProjectHostSetupsForProject, getProjectIdForProviderIdentity, isProjectRemoteIdentityPending } from './project-host-setup-projection' -import { - getProjectHostSetupsForProject, - getProjectHostSetupWorktreeMeta -} from './project-host-setup-lookup' +import { getProjectHostSetupWorktreeMeta } from './project-host-setup-lookup' import type { Repo } from './repo-types' function repo(overrides: Partial & Pick): Repo { @@ -20,53 +18,6 @@ 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', @@ -349,9 +300,8 @@ describe('project host setup projection', () => { }), repo({ id: 'old-branch-checkout', - path: '/home/alice/orca/workspaces/orca/re-enable-webgl-for-remote-runtime-terminals', + path: '/Users/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', @@ -671,7 +621,6 @@ 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 d936326156b..eb641768895 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -1,4 +1,3 @@ -import { normalizeSavedGitRemoteIdentity } from './git-remote-identity' import { getRepoExecutionHostId } from './execution-host' import { normalizeProjectHostSetupRow, @@ -8,8 +7,6 @@ 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. */ @@ -71,9 +68,18 @@ export function getGitRemoteProviderIdentity( ) } -/** 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. */ +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. */ export function isGitHubBackedRepo( repo: Pick ): boolean { @@ -83,10 +89,7 @@ export function isGitHubBackedRepo( export function hasProjectRemoteIdentity( repo: Pick ): boolean { - return ( - getProjectProviderIdentity(repo) !== null || - normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) !== null - ) + return getProjectProviderIdentity(repo) !== null || getProjectGitRemoteIdentity(repo) !== null } /** Undefined remote identity is pending/unreachable, distinct from resolved null (checked, unusable). @@ -108,7 +111,7 @@ export function getProjectIdentityKey( if (identity) { return getProjectIdForProviderIdentity(identity) } - const gitRemoteIdentity = normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) + const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) if (gitRemoteIdentity) { return `${GIT_REMOTE_PROJECT_ID_PREFIX}${gitRemoteIdentity.canonicalKey}` } @@ -124,6 +127,12 @@ 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 @@ -237,7 +246,7 @@ export function mergeCatalogUpdatedAt(left: number, right: number): number { function createProjectFromRepo(repo: Repo, projectId: string): Project { const identity = getProjectProviderIdentity(repo) - const gitRemoteIdentity = normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) + const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) const addedAt = catalogTimestampFromAddedAt(repo.addedAt) return { id: projectId, @@ -292,53 +301,22 @@ function createSetupFromRepo(repo: Repo, projectId: string): ProjectHostSetup { export function projectHostSetupProjectionFromRepos( repos: readonly Repo[], - _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 + _now?: number ): ProjectHostSetupProjection { const projectById = new Map() const setups: ProjectHostSetup[] = [] - // 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) + for (const repo of repos) { + const projectId = getProjectId(repo) + const existing = projectById.get(projectId) if (existing) { mergeProjectRepo(existing, repo) } else { - const project = createProjectFromRepo(repo, setup.projectId) - projectById.set(setup.projectId, { project, sourceRepoIds: new Set(project.sourceRepoIds) }) + const project = createProjectFromRepo(repo, projectId) + projectById.set(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) } @@ -347,3 +325,10 @@ function projectHostSetupProjection( 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 ac2458173e2..25968ae7558 100644 --- a/src/shared/project-identity-succession.ts +++ b/src/shared/project-identity-succession.ts @@ -1,6 +1,4 @@ -import { getRepoExecutionHostId } from './execution-host' -import type { Project, ProjectHostSetup } from './project-types' -import { getRepoSetupOwnerScope } from './repo-host-identity' +import type { Project } from './project-types' export type ProjectIdentitySuccession = { /** Projected projects with user-set state carried forward from their predecessor row. */ @@ -23,18 +21,12 @@ function compareStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } -/** - * 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. - */ +/** 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. */ export function carryProjectStateThroughIdentityChange( projectedProjects: readonly Project[], - previousProjects: readonly Project[], - membership?: { previous: readonly ProjectHostSetup[]; current: readonly ProjectHostSetup[] } + previousProjects: readonly Project[] ): ProjectIdentitySuccession { const previousById = new Map(previousProjects.map((project) => [project.id, project])) const projectedIds = new Set(projectedProjects.map((project) => project.id)) @@ -102,82 +94,5 @@ 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 deleted file mode 100644 index 3c604c01246..00000000000 --- a/src/shared/project-section-catalog-claims.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 7a2b0799100..d26a16abfb1 100644 --- a/src/shared/project-section-grouping.ts +++ b/src/shared/project-section-grouping.ts @@ -2,8 +2,11 @@ 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 @@ -29,6 +32,7 @@ export type ProjectSectionModel = { export type ProjectGroupingIndex = { projectById: Map setupByRepoId: Map + surfaceKeysRequiringSetupGroups: Set } /** The repo fields section grouping, labelling and ordering read. */ @@ -53,6 +57,40 @@ 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 { @@ -69,14 +107,45 @@ 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])) + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), + surfaceKeysRequiringSetupGroups } 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 @@ -99,8 +168,16 @@ 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 b115a1e5c87..0f8dfad7097 100644 --- a/src/shared/repo-host-identity.ts +++ b/src/shared/repo-host-identity.ts @@ -1,8 +1,4 @@ -import { - getRepoExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from './execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from './execution-host' import type { Repo } from './repo-types' declare const hostScopedRepoKeyBrand: unique symbol @@ -32,13 +28,3 @@ 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 bc2dd92f1cf..eeca524e47d 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -30,7 +30,6 @@ 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 bcf6241e08f..cb2983e96dc 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-catalog-canonical-projection' +import { resolveProjectHostSetupProjection } from '../project-host-setup-hydration' 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 999a973b65d..77b97b0cbe5 100644 --- a/src/shared/worktree/ownership.ts +++ b/src/shared/worktree/ownership.ts @@ -1,10 +1,4 @@ -import { isFolderRepo } from '../repo-kind' -import { - getRuntimePathBasename, - resolveRuntimePath, - normalizeRuntimePathForComparison, - relativePathInsideRoot -} from '../cross-platform-path' +import { normalizeRuntimePathForComparison, relativePathInsideRoot } from '../cross-platform-path' import { parseWslUncPath } from '../wsl-paths' import { isRuntimePathAbsoluteForRepo, @@ -271,25 +265,3 @@ 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 deleted file mode 100644 index e23a740c29f..00000000000 --- a/tests/e2e/ai-vault-history-publication.unit.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -// @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 deleted file mode 100644 index b7cb6bdecc9..00000000000 --- a/tests/e2e/ai-vault-project-native.unit.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -// @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 deleted file mode 100644 index edef9d91ccc..00000000000 --- a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -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 cbeaed7f15d..8c05602d316 100644 --- a/tests/e2e/helpers/host-split-and-sleep-journeys.ts +++ b/tests/e2e/helpers/host-split-and-sleep-journeys.ts @@ -41,15 +41,6 @@ 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}` }) @@ -72,86 +63,21 @@ export async function addHostCreatedSplitWorktree( export async function openHostSplitWorktree(page: Page, userDataDir: string): Promise { let setupWorktreeId: string | undefined - 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 + 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 }) - .toBeDefined() - } catch (error) { - console.log('[host-split-listing-failure]', JSON.stringify(lastListing)) - throw error - } + return setupWorktreeId + }) + .toBeDefined() 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 1560c11aea6..3294d6df3ec 100644 --- a/tests/e2e/helpers/wsl-golden-stub-agent.ts +++ b/tests/e2e/helpers/wsl-golden-stub-agent.ts @@ -1,4 +1,3 @@ -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' @@ -134,46 +133,31 @@ 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 { - const catalog = await page.evaluate(() => { - const state = window.__store?.getState() - if (!state) { + await page.evaluate(async (wslDistro) => { + const store = window.__store + if (!store) { throw new Error('Orca store is unavailable') } - return { - repos: state.repos, - endpointProjects: state.endpointProjects, - endpointProjectHostSetups: state.endpointProjectHostSetups + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') } - }) - 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 } - ) + 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) } 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 77e7e19fb8a..a0c7b3d4b32 100644 --- a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts +++ b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts @@ -1,17 +1,5 @@ -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. - */ +// 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 { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -75,42 +63,30 @@ async function openComposerWithLinkedWorkItem( } async function getJiraSourceContext(page: Page): Promise { - const state = await page.evaluate(() => { + return page.evaluate(() => { const state = window.__store?.getState() - 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 - ) + const activeWorktreeId = state?.activeWorktreeId + const setup = state?.projectHostSetups.find((candidate) => + state.worktreesByRepo[candidate.repoId]?.some((worktree) => worktree.id === activeWorktreeId) ) - 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', - siteId: 'e2e-jira-site', - siteUrl: 'https://example.atlassian.net', - projectKey: 'RDG' + 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', + 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 9177e559b7d..710b201ac61 100644 --- a/tests/e2e/project-group-creation-visibility.spec.ts +++ b/tests/e2e/project-group-creation-visibility.spec.ts @@ -1,4 +1,3 @@ -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' @@ -123,7 +122,7 @@ for (const delayCreateResponse of [false, true]) { if (!createdGroup) { throw new Error('Group creation failed') } - const catalog = await orcaPage.evaluate( + await orcaPage.evaluate( async ({ repoIds, groupId }) => { const store = window.__store! for (const repoId of repoIds.slice(0, 2)) { @@ -133,22 +132,14 @@ for (const delayCreateResponse of [false, true]) { } await store.getState().moveProjectToGroup(repo, groupId) } - const state = store.getState() - return { - repos: state.repos, - endpointProjects: state.endpointProjects, - endpointProjectHostSetups: state.endpointProjectHostSetups - } + const collapsedGroups = store + .getState() + .projectHostSetups.map((setup) => `project:${setup.projectId}`) + await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) + store.setState({ collapsedGroups: new Set(collapsedGroups) }) }, { 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 deleted file mode 100644 index 2293863919f..00000000000 --- a/tests/e2e/project-runtime-handoff.unit.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 7ab77f9edf3..bd6481b177f 100644 --- a/tests/e2e/sidebar-measured-row-scroll.spec.ts +++ b/tests/e2e/sidebar-measured-row-scroll.spec.ts @@ -1,4 +1,3 @@ -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' @@ -104,7 +103,7 @@ test('keeps a collapsed group visible when an idle card below it grows', async ( } } - const fixture = await orcaPage.evaluate(async (repoPaths) => { + const fixture = await orcaPage.evaluate(async (repoPaths): Promise => { const store = window.__store! for (const repoPath of repoPaths) { await window.api.repos.add({ path: repoPath }) @@ -132,35 +131,21 @@ 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 (!worktree) { + if (!collapsedSetup || !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, - repos: state.repos, - endpointProjects: state.endpointProjects, - endpointProjectHostSetups: state.endpointProjectHostSetups - } satisfies SidebarFixture & - Pick + worktreeId: worktree.id + } }, 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 8b10227e5c2..0016079ccc3 100644 --- a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts +++ b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts @@ -218,7 +218,6 @@ 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 02497d99d84..e7b5014e5ac 100644 --- a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts +++ b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts @@ -1,4 +1,3 @@ -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' @@ -115,19 +114,7 @@ async function createWindowsDefaultShellTerminalTab( } async function configureActiveProjectWslRuntime(page: Page): Promise { - 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) => { + return page.evaluate(async () => { const store = window.__store if (!store) { throw new Error('Store unavailable') @@ -144,7 +131,7 @@ async function configureActiveProjectWslRuntime(page: Page): Promise worktree.id === worktreeId) - const activeProject = projects.find((project) => + const activeProject = state.projects.find((project) => activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false ) if (!activeProject) { @@ -161,7 +148,7 @@ async function configureActiveProjectWslRuntime(page: Page): Promise { const wslDistro = wsl.distros[0]! const wslRepoPath = await createGitRepo('orca-e2e-project-runtime-', 'wsl-runtime-project') - const catalog = await orcaPage.evaluate( - async ({ hostRepoPath, wslRepoPath }) => { + const smoke = await orcaPage.evaluate( + async ({ hostRepoPath, wslRepoPath, wslDistro }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -110,61 +109,42 @@ test.describe('Windows project runtime smoke', () => { throw new Error('Expected host and WSL smoke repos to be loaded') } - return { - repos: state.repos, - endpointProjects: state.endpointProjects, - endpointProjectHostSetups: state.endpointProjectHostSetups, - hostRepo, - wslRepo + 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') } - }, - { 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( - hostProjectId, + hostProject.id, { localWindowsRuntimePreference: { kind: 'windows-host' } }, 'local' ) await state.updateProject( - wslProjectId, + wslProject.id, { localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } }, 'local' ) - const hostWorktrees = await window.api.worktrees.listDetected({ repoId: hostRepoId }) - const wslWorktrees = await window.api.worktrees.listDetected({ repoId: wslRepoId }) + const hostWorktrees = await window.api.worktrees.listDetected({ repoId: hostRepo.id }) + const wslWorktrees = await window.api.worktrees.listDetected({ repoId: wslRepo.id }) return { - hostRepoId, - wslRepoId, + hostRepoId: hostRepo.id, + wslRepoId: wslRepo.id, wslDistro, hostWorktreeCount: hostWorktrees.worktrees.length, wslWorktreeCount: wslWorktrees.worktrees.length } }, - { - hostProjectId: hostProject.id, - wslProjectId: wslProject.id, - hostRepoId: catalog.hostRepo.id, - wslRepoId: catalog.wslRepo.id, - wslDistro - } + { hostRepoPath: testRepoPath, wslRepoPath, wslDistro } ) expect(smoke.hostWorktreeCount).toBeGreaterThan(0) @@ -178,32 +158,24 @@ test.describe('Windows project runtime smoke', () => { await expect( hostSection.getByText(`This project runs in ${smoke.wslDistro} via WSL.`) ).toBeVisible() - const afterWslCatalog = await orcaPage.evaluate(() => { + const hostAfterWslUiSwitch = await orcaPage.evaluate((hostRepoId) => { const state = window.__store!.getState() - return { - repos: state.repos, - endpointProjects: state.endpointProjects, - endpointProjectHostSetups: state.endpointProjectHostSetups - } - }) - const hostAfterWslUiSwitch = selectCanonicalProjects(afterWslCatalog).find((project) => - project.sourceRepoIds.includes(smoke.hostRepoId) - )?.localWindowsRuntimePreference + const hostProject = state.projects.find((project) => + project.sourceRepoIds.includes(hostRepoId) + ) + return hostProject?.localWindowsRuntimePreference + }, smoke.hostRepoId) 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 afterWindowsCatalog = await orcaPage.evaluate(() => { + const hostAfterWindowsUiSwitch = await orcaPage.evaluate((hostRepoId) => { const state = window.__store!.getState() - return { - repos: state.repos, - endpointProjects: state.endpointProjects, - endpointProjectHostSetups: state.endpointProjectHostSetups - } - }) - const hostAfterWindowsUiSwitch = selectCanonicalProjects(afterWindowsCatalog).find((project) => - project.sourceRepoIds.includes(smoke.hostRepoId) - )?.localWindowsRuntimePreference + const hostProject = state.projects.find((project) => + project.sourceRepoIds.includes(hostRepoId) + ) + return hostProject?.localWindowsRuntimePreference + }, smoke.hostRepoId) expect(hostAfterWindowsUiSwitch).toEqual({ kind: 'windows-host' }) await openRepoSettings(orcaPage, smoke.wslRepoId)