From 8d908764dcd9c155e46bcbcab051804868ca2b30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:38:35 -0700 Subject: [PATCH] Revert "Keep same-machine folders with matching Git remotes in separate projects (#32272)" (#34662) * Revert "Keep same-machine folders with matching Git remotes in separate projects (#32272)" This reverts commit 29a5f89b8ac02c5f0406cc729ffdf75c580c978b. On a machine with several clones of one repo, #32272 moved the oldest clone into the shared cross-host project. Shared Windows QA machines have several orca clones, so everyone's sidebar "orca" project suddenly gained that machine's main checkout and its worktrees. Revert until the intended grouping is decided. Conflicts resolved against later main: Settings files moved into settings/repository (imports adjusted), and two test files later deleted on main stay deleted. * Use checked types in restored test fixtures * Combine restored fixture type imports --- .oxlintrc.json | 41 -- config/oxlint-js-plugins.json | 27 - .../oxlint-plugins/app-store-performance.mjs | 30 +- .../project-catalog-read-boundary.test.mjs | 102 ---- mobile/src/components/NewWorktreeModal.tsx | 24 +- .../new-workspace-project-targets.test.ts | 16 +- .../new-workspace-project-targets.ts | 43 +- .../components/new-worktree-modal-types.ts | 2 - .../src/worktree/project-section-catalog.ts | 2 - ...space-list-sections-desktop-parity.test.ts | 130 ----- .../session-search-current-owner.test.ts | 414 --------------- .../session-search-engine-types.ts | 2 - .../ai-vault-search/session-search-engine.ts | 30 +- .../session-search-ownership-races.test.ts | 131 ----- .../session-search-page-cursor.ts | 21 +- .../session-search-project-ownership.ts | 284 ---------- .../session-search-row-filter.ts | 8 - .../session-search-scope-catalog.ts | 24 +- .../session-search-scope-entry-points.test.ts | 7 +- .../session-search-scope-paths.ts | 34 +- .../session-search-scope-resolution.test.ts | 58 +- .../session-search-scope-service.test.ts | 7 +- .../session-search-service-registry.test.ts | 34 -- .../session-search-service-registry.ts | 60 +-- .../ai-vault-search/session-search-service.ts | 15 +- ...session-search-store-scope-catalog.test.ts | 41 +- .../session-search-store-scope-catalog.ts | 23 +- src/main/ai-vault/cached-session-list.ts | 44 +- .../ai-vault/runtime-session-scanner.test.ts | 2 - src/main/ai-vault/runtime-session-scanner.ts | 1 - .../runtime-session-search-call.test.ts | 41 +- .../ai-vault/runtime-session-search-call.ts | 27 +- .../runtime-session-search-lifetime.test.ts | 69 --- .../session-list-result-validation.ts | 2 - ...ion-scanner-service-search-process.test.ts | 186 ------- .../session-scanner-service-search.test.ts | 25 - src/main/ai-vault/session-scope-real-paths.ts | 5 +- src/main/ipc/ai-vault-runtime-scan.ts | 3 - .../ipc/ai-vault-search-all-hosts.test.ts | 100 +--- src/main/ipc/ai-vault-search-all-hosts.ts | 30 +- src/main/ipc/ai-vault-search.test.ts | 99 +--- src/main/ipc/ai-vault-search.ts | 31 +- src/main/ipc/ai-vault.ts | 6 +- .../register-core-handlers.ts | 15 +- .../profile-project-state-file.test.ts | 80 --- .../profile-project-state-file.ts | 13 +- src/main/orcad/orcad-entry.ts | 10 +- src/main/orcad/orcad-session-search.ts | 23 - .../project-host-compatibility.ts | 3 +- .../runtime-composition-parity.test.ts | 4 - .../runtime-construction-dependencies.ts | 2 - .../construction/runtime-construction.ts | 11 +- .../mobile-relay-e2ee.integration.test.ts | 72 +-- src/main/runtime/rpc/methods/ai-vault.test.ts | 63 --- src/main/runtime/rpc/methods/ai-vault.ts | 1 - .../runtime-rpc-device-key-dispatch.test.ts | 57 +- src/main/sqlite/sync-database.ts | 16 +- .../startup/main-process-runtime-service.ts | 4 +- src/preload/api/ai-vault-api.ts | 3 +- src/preload/api/ai-vault-bridge.ts | 7 +- .../src/app-shell/use-runtime-graph-sync.ts | 4 +- ...ewWorkspaceComposerCard.file-drop.test.tsx | 3 +- ...spaceComposerCard.quick-file-drop.test.tsx | 4 +- ...aceComposerCard.set-location-warm.test.tsx | 3 +- ...orkspaceComposerCard.set-location.test.tsx | 3 +- ...wWorkspaceComposerCard.start-from.test.tsx | 3 +- .../NewWorkspaceComposerCard.test.tsx | 3 +- .../components/NewWorkspaceComposerCard.tsx | 3 +- ...utomationsPage.create-destination.test.tsx | 33 +- .../automations/automation-project-groups.ts | 11 +- .../automations/automations-page-fixtures.ts | 23 +- .../automations-page-runtime-fixtures.ts | 7 +- .../automations-page-test-harness.tsx | 15 +- .../use-automations-page-store-state.ts | 3 +- .../components/cmd-j/palette-results.test.ts | 19 + .../useDashboardPopoutBridge.test.tsx | 5 +- .../dashboard/useDashboardPopoutBridge.ts | 4 +- .../useLiveDashboardSnapshot.test.ts | 6 +- ...nPreview.link-routing.interaction.test.tsx | 3 - ...rich-markdown-editor-click-routing.test.ts | 3 - ...obile-emulator-agent-skill-target.test.tsx | 2 +- .../ConnectIntegrationsList.test.tsx | 4 - .../native-chat-skill-discovery-context.ts | 10 +- ...ve-chat-subscriptions.performance.test.tsx | 2 +- .../use-native-chat-skills.react.test.tsx | 3 +- .../use-native-chat-skills.test.ts | 3 +- ...ojectLocationDialog.host-identity.test.tsx | 14 +- .../use-recent-project-ids.test.ts | 71 +-- .../new-workspace/use-recent-project-ids.ts | 23 +- .../pull-request-page/checks/tab.tsx | 5 +- .../AiVaultPanel.legacy-filter.test.tsx | 10 +- .../AiVaultPanel.resume-catalog.test.tsx | 180 ------- .../components/right-sidebar/AiVaultPanel.tsx | 22 +- .../right-sidebar/ai-vault-host-scope.test.ts | 6 - .../right-sidebar/ai-vault-project-key.ts | 39 -- .../ai-vault-project-ownership.test.ts | 172 ------ .../ai-vault-search-scope-identity.test.tsx | 12 +- ...-session-conditional-refresh-hook.test.tsx | 14 - .../ai-vault-session-list-request.ts | 1 - .../ai-vault-session-project-candidates.ts | 61 +-- .../ai-vault-session-projects.test.ts | 15 +- .../ai-vault-session-projects.ts | 186 ++++--- .../ai-vault-session-refresh.test.ts | 11 +- .../right-sidebar/ai-vault-session-refresh.ts | 1 - ...t-session-resume-in-chat-workspace.test.ts | 9 +- .../ai-vault-session-resume.test.ts | 2 - .../right-sidebar/ai-vault-session-resume.ts | 14 +- .../ai-vault-session-worktree-map.test.tsx | 2 - .../project-search-lifetime.test.tsx | 89 ---- .../use-ai-vault-panel-workspace.ts | 18 +- .../use-ai-vault-search.test.tsx | 83 +-- .../right-sidebar/use-ai-vault-search.ts | 20 +- .../settings/EphemeralVmsPane.test.tsx | 3 +- ...RepositoryHostSetupsSection.claim.test.tsx | 203 ------- .../settings/repository/RepositoryPane.tsx | 10 + .../RepositoryRemoveProjectButton.tsx | 27 +- ...yHostSetupsSection.settings-entry.test.tsx | 166 ++++++ .../RepositoryHostSetupsSection.test.tsx | 48 +- .../execution/RepositoryHostSetupsSection.tsx | 23 +- ...ostSetupsSection.workspace-window.test.tsx | 8 +- .../repository-host-setup-options.ts | 5 - .../settings/settings-project-list.test.ts | 112 +++- .../settings/settings-project-list.ts | 142 +++-- .../settings-project-regrouping.test.ts | 54 +- .../settings-project-section-renderer.tsx | 15 +- .../task-tracker-integration-cards.test.tsx | 6 - .../settings/use-settings-navigation-model.ts | 7 +- .../settings/use-settings-page-effects.ts | 2 +- .../settings/use-settings-store-model.ts | 20 +- .../AddRepoSteps.default-checkout.test.ts | 8 +- .../sidebar/DeleteWorktreeDialog.test.tsx | 2 - .../sidebar/DeleteWorktreeLineageNotice.tsx | 8 +- .../sidebar/NonGitFolderDialog.test.tsx | 8 +- .../WorktreeCard.compact-hover.test.tsx | 3 - ....compact-ports-hover-independence.test.tsx | 3 - .../WorktreeList.card-memo-stability.test.tsx | 2 - .../WorktreeList.empty-project-rows.test.ts | 2 - ...WorktreeList.folder-workspace-rows.test.ts | 2 - .../WorktreeList.group-headers.test.ts | 2 - ....lineage-agent-expansion-coupling.test.tsx | 2 - ...ktreeList.lineage-child-real-card.test.tsx | 2 - ...treeList.status-lane-lineage-drop.test.tsx | 2 - .../sidebar/add-repo-store-upsert.ts | 4 +- ...e-worktree-dirty-change-hydration.test.tsx | 2 - ...bar-list-golden-name-order.test-fixture.ts | 4 +- .../sidebar-list-golden-store.test-fixture.ts | 4 +- ...ist-parity-project-catalog.test-fixture.ts | 13 +- .../sidebar/useAddRepoCloneFlow.test.ts | 8 +- .../useCreateRepo.default-checkout.test.ts | 12 +- ...worktree-list-groups-mobile-parity.test.ts | 58 +- ...ee-list-groups-project-host-setups.test.ts | 64 +-- ...worktree-list-lineage-card-test-harness.ts | 2 - .../grouping/cross-project-lineage.test.ts | 13 +- .../navigation/reveal-ancestors.ts | 9 +- .../tab-bar/TabBar.context-menu.test.ts | 6 - .../TabBar.windows-shell-launch.test.ts | 32 +- .../TabBar.worktree-write-gate.test.tsx | 7 +- ...abBar.worktree-write-gate.windows.test.tsx | 7 +- ...b-bar-windows-shell-launch-test-harness.ts | 19 +- .../tab-session-history-switch.test.ts | 1 - ...-bar-runtime-model-worktree-write-probe.ts | 6 +- .../tab-bar/use-tab-bar-runtime-model.ts | 21 +- .../task-page-default-repo-selection.ts | 22 +- .../use-task-page-composer-actions.ts | 6 +- .../edge-wrapped-terminal-http-links.test.ts | 3 - ...framed-overlay-terminal-http-links.test.ts | 3 - .../pty-connection-fresh-spawn-guards.spec.ts | 22 +- .../pty-connection-test-store-fixtures.ts | 3 +- .../pty-connection-test-store-state.ts | 3 +- .../repro-8832-url-next-line.test.ts | 3 - .../terminal-agent-session-fork.test.ts | 44 +- .../terminal-drop-handler.test.ts | 32 +- .../terminal-link-action-routing.test.ts | 3 - .../terminal-link-handlers-test-fixtures.ts | 9 +- ...terminal-runtime-host-link-routing.test.ts | 3 - .../terminal-url-link-click.test.ts | 3 - ...rapped-terminal-http-link-provider.test.ts | 9 - .../use-worktree-jump-palette-store-state.ts | 8 +- .../use-worktree-jump-palette-task-url.ts | 8 +- ...omposer-target-state.jira-mapping.test.tsx | 21 +- .../composer-state/composer-target-store.ts | 8 +- ...ira-mapped-owner-flow.integration.test.tsx | 4 +- .../runtime-target-selection.ts | 3 +- .../composer-state/target-change-actions.ts | 3 +- .../settings-navigation-remote-sections.ts | 4 +- .../useActiveProjectSkillRuntime.test.tsx | 2 +- .../src/hooks/useActiveProjectSkillRuntime.ts | 3 +- .../useComposerState.integration.test.ts | 214 +------- ...DetectedAgents.local-invalidation.test.tsx | 17 +- ...vigationMetadata.capability-owner.test.tsx | 5 - .../hooks/useSettingsNavigationMetadata.ts | 15 +- src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/pt.json | 4 +- src/renderer/src/i18n/locales/zh.json | 2 + .../agent-background-session-test-state.ts | 39 +- .../src/lib/agent-launch-route-input.test.ts | 24 - .../src/lib/agent-launch-route-input.ts | 25 +- ...i-vault-antigravity-reference-host.test.ts | 25 +- ...ai-vault-resume-command.drop-repin.test.ts | 18 +- .../src/lib/ai-vault-resume-command.test.ts | 43 +- .../src/lib/ai-vault-resume-command.ts | 5 +- .../src/lib/ai-vault-resume-platform.ts | 5 +- .../src/lib/ai-vault-resume-shell.test.ts | 10 +- src/renderer/src/lib/ai-vault-resume-shell.ts | 18 +- .../src/lib/codex-pane-selection-lane.test.ts | 19 +- .../src/lib/codex-pane-selection-lane.ts | 5 +- .../src/lib/execution-host-facts.test.ts | 3 +- src/renderer/src/lib/execution-host-facts.ts | 5 +- .../src/lib/http-link-localhost-route.ts | 22 +- .../lib/http-link-modifier-routing.test.ts | 3 - .../src/lib/http-link-routing.test.ts | 30 +- src/renderer/src/lib/http-link-routing.ts | 50 +- .../launch-agent-background-session.test.ts | 2 +- .../launch-parity-renderer.test-fixture.ts | 27 +- ...h-work-item-direct-catalog.test-fixture.ts | 41 -- ...nch-work-item-direct-host-platform.test.ts | 30 +- .../src/lib/launch-work-item-direct.test.ts | 22 +- .../lib/local-preflight-context-cache.test.ts | 19 +- .../src/lib/local-preflight-context.test.ts | 131 ++--- .../src/lib/local-preflight-context.ts | 44 +- .../lib/local-project-runtime-workspace.ts | 40 +- ...workspace-port-localhost-label-selector.ts | 46 +- ...ync-runtime-graph-project-sections.test.ts | 4 +- .../project-section-catalog.ts | 5 +- .../project-groups/project-group-mutations.ts | 2 +- .../store/project-host-setup-selector.test.ts | 30 +- .../src/store/project-host-setup-selector.ts | 52 +- .../project-catalog-null-field-ingest.test.ts | 8 +- .../projects/project-host-setup-actions.ts | 46 +- .../src/store/projects/project-update.ts | 17 +- .../repos/all-host-repo-catalog-actions.ts | 7 +- .../src/store/repos/repo-add-actions.ts | 12 +- .../src/store/repos/repo-catalog-actions.ts | 23 +- .../src/store/repos/repo-catalog-identity.ts | 12 +- src/renderer/src/store/repos/repo-removal.ts | 2 +- src/renderer/src/store/repos/repo-state.ts | 4 +- src/renderer/src/store/repos/repo-update.ts | 5 +- .../repos/runtime-repo-catalog-actions.ts | 7 +- src/renderer/src/store/selectors.test.ts | 40 +- .../src/store/slices/detected-agents.test.ts | 21 +- .../slices/local-detected-agent-state.test.ts | 11 +- .../src/store/slices/preflight.test.ts | 4 +- .../purge-stale-runtime-host-state.test.ts | 18 +- .../src/store/slices/repos-all-hosts.test.ts | 53 +- .../repos-cross-host-refresh-identity.spec.ts | 2 +- .../repos-host-identity-routing.test.ts | 8 +- .../repos-nested-ssh-projection.test.ts | 4 +- .../repos-project-host-capability.spec.ts | 2 +- .../repos-project-host-lifecycle.spec.ts | 44 +- .../slices/repos-project-runtime.spec.ts | 112 +--- .../slices/repos-refresh-identity.spec.ts | 148 +++--- .../repos-shared-project-badge-color.spec.ts | 3 +- .../repos-ssh-host-reconciliation.test.ts | 23 +- src/renderer/src/store/slices/repos.test.ts | 18 +- src/renderer/src/store/slices/repos.ts | 4 +- ...-runtime-environments-purge-wiring.test.ts | 4 +- ...-active-worktree-terminal-creation.test.ts | 32 +- .../worktrees-create-base-status.test.ts | 2 - .../worktrees-fetch-owner-routing.test.ts | 54 +- ...-fetch-persisted-metadata-fallback.test.ts | 36 +- ...worktrees-fetch-refresh-coalescing.test.ts | 18 +- .../worktrees-fetch-remote-lineage.test.ts | 10 +- .../slices/worktrees-hydration-purge.test.ts | 11 +- .../worktrees-remote-runtime-create.test.ts | 2 - .../worktrees-removal-state-cleanup.test.ts | 49 +- .../slices/worktrees-slice-test-harness.ts | 3 +- .../listing/worktree-host-ownership.ts | 5 +- .../orphaned-runtime-ssh-project-purge.ts | 2 +- .../purge-stale-runtime-host-state.ts | 6 +- .../src/web/preload-api/web-ai-vault-api.ts | 45 +- .../web-ai-vault-search-compat.test.ts | 25 +- .../src/web/preload-api/web-runtime-calls.ts | 6 +- .../web/web-preload-api-runtime-calls.test.ts | 96 ---- src/shared/ai-vault-project-key.ts | 6 - src/shared/ai-vault-runtime-capabilities.ts | 4 - src/shared/ai-vault-search-client.ts | 13 +- src/shared/ai-vault-search-scope.ts | 6 +- src/shared/ai-vault-types.ts | 3 - src/shared/git-remote-identity.ts | 10 - ...unch-parity-window-request.test-fixture.ts | 6 +- .../project-catalog-canonical-projection.ts | 92 ---- src/shared/project-checkout-claim.ts | 72 --- .../project-host-setup-hydration.test.ts | 91 ---- src/shared/project-host-setup-hydration.ts | 4 +- src/shared/project-host-setup-lookup.test.ts | 34 -- src/shared/project-host-setup-lookup.ts | 113 +--- .../project-host-setup-projection.test.ts | 57 +- src/shared/project-host-setup-projection.ts | 87 ++- src/shared/project-identity-succession.ts | 95 +--- src/shared/project-section-catalog-claims.ts | 39 -- src/shared/project-section-grouping.ts | 83 ++- .../project-section-parity.test-fixture.ts | 41 +- src/shared/repo-host-identity.ts | 16 +- src/shared/rpc-contract/ai-vault-params.ts | 1 - .../sidebar-list/sidebar-project-catalog.ts | 2 +- src/shared/worktree/ownership.ts | 30 +- .../ai-vault-history-publication.unit.test.ts | 297 ----------- .../e2e/ai-vault-project-native.unit.test.ts | 321 ------------ ...ault-project-source-lifecycle.unit.test.ts | 495 ------------------ .../helpers/host-split-and-sleep-journeys.ts | 102 +--- tests/e2e/helpers/wsl-golden-stub-agent.ts | 64 +-- ...rkspace-linked-item-project-switch.spec.ts | 70 +-- .../project-group-creation-visibility.spec.ts | 21 +- .../e2e/project-runtime-handoff.unit.test.ts | 135 ----- tests/e2e/sidebar-measured-row-scroll.spec.ts | 31 +- .../e2e/ssh-orcad-legacy-relay-bridge.spec.ts | 1 - ...inal-windows-shell-paste-ownership.spec.ts | 19 +- .../e2e/windows-project-runtime-smoke.spec.ts | 88 ++-- 312 files changed, 2054 insertions(+), 8018 deletions(-) delete mode 100644 config/scripts/project-catalog-read-boundary.test.mjs delete mode 100644 src/main/ai-vault-search/session-search-current-owner.test.ts delete mode 100644 src/main/ai-vault-search/session-search-ownership-races.test.ts delete mode 100644 src/main/ai-vault-search/session-search-project-ownership.ts delete mode 100644 src/main/ai-vault/runtime-session-search-lifetime.test.ts delete mode 100644 src/main/ai-vault/session-scanner-service-search-process.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx delete mode 100644 src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx create mode 100644 src/renderer/src/components/settings/repository/execution/RepositoryHostSetupsSection.settings-entry.test.tsx delete mode 100644 src/renderer/src/lib/launch-work-item-direct-catalog.test-fixture.ts delete mode 100644 src/shared/project-catalog-canonical-projection.ts delete mode 100644 src/shared/project-checkout-claim.ts delete mode 100644 src/shared/project-host-setup-hydration.test.ts delete mode 100644 src/shared/project-host-setup-lookup.test.ts delete mode 100644 src/shared/project-section-catalog-claims.ts delete mode 100644 tests/e2e/ai-vault-history-publication.unit.test.ts delete mode 100644 tests/e2e/ai-vault-project-native.unit.test.ts delete mode 100644 tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts delete mode 100644 tests/e2e/project-runtime-handoff.unit.test.ts 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)