diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index 7668657f895..c9ca4f2d8c5 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -22,7 +22,7 @@ The target model is: - [x] Add a shared run-target type or selector for RunHostContext / WorkspaceRunContext so workspace creation, agent launch, automations, and remote execution use the same vocabulary. - [~] Audit user-facing copy so the product says **Host** for Local Mac, SSH hosts, remote servers, and future cloud VMs, while preserving technical labels only where useful. - [x] Document the invariant that task source context and run host are separate choices. -- [~] Add compatibility adapters for existing repo-id/path-based callers so old data can be read while new code moves to explicit contexts. +- [x] Add compatibility adapters for existing repo-id/path-based callers so old data can be read while new code moves to explicit contexts. - [x] Add schema migration notes for old automations, cached task selections, pending worktree requests, and any persisted task drawer state. Done means: a new agent can read the shared types and understand exactly where work item data comes from versus where execution happens. @@ -256,3 +256,4 @@ Done means: reviewers and future agents can understand the vision, implementatio - [x] 2026-06-12: Marked task drawer source-context persistence complete based on UI slice coverage that records GitHub, GitLab, Linear, and Jira task-detail history entries with their exact `TaskSourceContext`, plus direct-open prefetch coverage for source-scoped GitHub and Linear reads. - [x] 2026-06-12: Marked PR/MR base resolution complete based on `useComposerState-host-context-boundaries.test.ts`, which asserts GitHub PR and GitLab MR base lookup uses the selected run repo instead of the provider item's source repo while preserving source item metadata separately. - [x] 2026-06-12: Extracted and tested external automation source availability copy so local missing tooling, disconnected SSH sources, connecting SSH sources, and connected SSH hosts with missing remote tooling get distinct states; verified with focused automation availability tests, targeted oxlint, and full `pnpm run typecheck`. +- [x] 2026-06-12: Marked repo-id/path compatibility adapters complete after hardening the renderer project-host selector to merge repo-derived compatibility rows when hydrated project/setup arrays are empty or partial, alongside existing shared projection, persistence backfill/sync, automation context migration, and pending worktree context coverage. Verified with selector/projection tests, persistence tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`. diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index fd0f536c223..231c31e5be6 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { Repo, Worktree } from '../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import type { AppState } from './types' import { getAllWorktreesFromState, @@ -227,4 +228,86 @@ describe('store selectors', () => { setups: projectHostSetups }) }) + + it('falls back to repo compatibility projection when hydrated setup state is empty', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ] + + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects: [], + projectHostSetups: [] + }) + + expect(projection.projects).toEqual([ + expect.objectContaining({ + id: 'github:stablyai/orca', + sourceRepoIds: ['repo-1'] + }) + ]) + expect(projection.setups).toEqual([ + expect.objectContaining({ + id: 'repo-1', + projectId: 'github:stablyai/orca', + repoId: 'repo-1', + hostId: 'local', + path: '/Users/alice/orca' + }) + ]) + }) + + it('merges missing repo compatibility rows with independent hydrated setups', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca' + }) + ] + const projects = [ + { + id: 'cloud-project', + displayName: 'Cloud Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + } + ] + const projectHostSetups = [ + { + id: 'cloud-project::gpu-vm', + projectId: 'cloud-project', + hostId: toRuntimeExecutionHostId('gpu-vm'), + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM', + setupState: 'ready' as const, + setupMethod: 'provisioned' as const, + createdAt: 1, + updatedAt: 1 + } + ] + + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects, + projectHostSetups + }) + + expect(projection.projects.map((project) => project.id)).toEqual([ + 'repo:repo-1', + 'cloud-project' + ]) + expect(projection.setups.map((setup) => setup.id)).toEqual(['repo-1', 'cloud-project::gpu-vm']) + expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toBe( + projection + ) + }) }) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 245f4292001..53a03d2e3d9 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -40,6 +40,10 @@ const providedProjectHostSetupProjectionCache = new WeakMap< Project[], WeakMap >() +const mergedProjectHostSetupProjectionCache = new WeakMap< + AppState['repos'], + WeakMap> +>() let floatingVisibleTabCountCache: FloatingVisibleTabCountCache | null = null function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): WorktreeSnapshot { @@ -134,6 +138,54 @@ function getCachedProvidedProjectHostSetupProjection( return projection } +function mergeById(base: readonly T[], overlay: readonly T[]): T[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [entry.id, index])) + for (const entry of overlay) { + const index = indexById.get(entry.id) + if (index === undefined) { + indexById.set(entry.id, merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged +} + +function mergeProjectHostSetupProjection( + repos: AppState['repos'], + projects: Project[], + setups: ProjectHostSetup[] +): ProjectHostSetupProjection { + const cachedByProjects = mergedProjectHostSetupProjectionCache.get(repos) + const cachedBySetups = cachedByProjects?.get(projects) + const cachedProjection = cachedBySetups?.get(setups) + if (cachedProjection) { + return cachedProjection + } + const derived = getCachedProjectHostSetupProjection(repos) + // Why: older runtimes/profiles may hydrate empty or partial project/setup arrays + // beside legacy repos. Keep repo-backed compatibility rows visible in that case. + const projection = { + projects: mergeById(derived.projects, projects), + setups: mergeById(derived.setups, setups) + } + const nextCachedByProjects = + cachedByProjects ?? + new WeakMap>() + const nextCachedBySetups = + cachedBySetups ?? new WeakMap() + nextCachedBySetups.set(setups, projection) + if (!cachedBySetups) { + nextCachedByProjects.set(projects, nextCachedBySetups) + } + if (!cachedByProjects) { + mergedProjectHostSetupProjectionCache.set(repos, nextCachedByProjects) + } + return projection +} + export function selectFloatingVisibleTabCount(state: FloatingVisibleTabCountState): number { const terminalTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TABS const browserTabs = @@ -213,6 +265,24 @@ export function getProjectHostSetupProjectionFromState( state: Pick & Partial> ): ProjectHostSetupProjection { if (state.projects && state.projectHostSetups) { + const repoIds = new Set(state.repos.map((repo) => repo.id)) + const coveredRepoIds = new Set() + for (const setup of state.projectHostSetups) { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + if (repoIds.has(repoId)) { + coveredRepoIds.add(repoId) + } + if (repoIds.has(setup.id)) { + coveredRepoIds.add(setup.id) + } + } + if (state.repos.length > 0 && coveredRepoIds.size < repoIds.size) { + return mergeProjectHostSetupProjection( + state.repos, + state.projects as Project[], + state.projectHostSetups as ProjectHostSetup[] + ) + } return getCachedProvidedProjectHostSetupProjection( state.projects as Project[], state.projectHostSetups as ProjectHostSetup[]