From 8f38d049191a49e48e01863dee2c5d4934617783 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 9 Aug 2026 00:51:07 +0200 Subject: [PATCH] Keep the active project when switching workspace hosts (#3051) * fix(app): preserve projects across host switches New workspace entry context retained only a host-local placement, so switching hosts could replace the selected project with the destination's first project. Hydrate that placement against canonical project identity while keeping exact clone placement separate. * fix(app): preserve manual project choice during hydration Keep the raw route placement key as stable selection context while hydrating the routed project to its canonical identity. --- ...orkspace-host-project-preservation.spec.ts | 32 ++++++++ .../app/src/projects/host-project-model.ts | 2 +- .../app/src/projects/host-projects.test.ts | 40 +++++++++ .../app/src/screens/new-workspace-screen.tsx | 17 +++- .../new-workspace/project-picker.test.tsx | 82 +++++++++++++++++++ .../screens/new-workspace/project-picker.ts | 7 +- 6 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/screens/new-workspace/project-picker.test.tsx diff --git a/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts b/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts index 13e752fd8..f3f1742c6 100644 --- a/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts +++ b/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts @@ -13,6 +13,7 @@ import { } from "../support/helpers/new-workspace"; import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; +import { addConnectedHostsAndReload } from "../support/helpers/hosts"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; import { createTempGitRepo } from "../support/helpers/workspace"; @@ -29,6 +30,7 @@ interface CreatedProject { interface CrossHostProjectScenario { contextWorkspaceId: string; + primarySharedWorkspaceId: string; selectedProjectViewKey: string; sharedProjectKey: string; secondaryHost: { @@ -149,6 +151,7 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({ await provide({ contextWorkspaceId: contextProject.workspaceId, + primarySharedWorkspaceId: primarySharedProject.workspaceId, selectedProjectViewKey: projectPlacementViewKey( getServerId(), primarySharedProject.projectId, @@ -196,4 +199,33 @@ test.describe("New workspace host project preservation", () => { await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME); }); + + test("keeps the active workspace project selected when switching hosts", async ({ + page, + crossHostProject, + }) => { + await openProjectDirectoryWithHosts(page, { + hosts: [crossHostProject.secondaryHost], + primaryLabel: PRIMARY_HOST_LABEL, + }); + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: crossHostProject.primarySharedWorkspaceId, + }); + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME); + + await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL); + + await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME); + + await addConnectedHostsAndReload(page, [crossHostProject.secondaryHost], { + primaryLabel: PRIMARY_HOST_LABEL, + }); + await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME); + await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL); + + await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME); + }); }); diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index 9a0c397ce..22f185238 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -71,7 +71,7 @@ export function hostProjectFromWorkspace(input: { serverId: input.serverId, projectId, }), - projectKey: null, + projectKey: input.workspace.project?.projectKey ?? null, projectName: input.workspace.projectDisplayName || projectId, projectKind: input.workspace.projectKind, iconWorkingDir, diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts index dc07b1726..798d08b08 100644 --- a/packages/app/src/projects/host-projects.test.ts +++ b/packages/app/src/projects/host-projects.test.ts @@ -6,7 +6,9 @@ import { getHostProjectSourceDirectory, getWorktreeSupportForHostProject, hostProjectFromRoute, + hostProjectFromWorkspace, } from "./host-project-model"; +import { normalizeWorkspaceDescriptor } from "@/stores/session-store"; function project(): HostProjectListItem { return { @@ -95,4 +97,42 @@ describe("host project lookups", () => { hosts: [{ serverId: "host-a", projectId: "prj_a" }], }); }); + + test("keeps canonical equivalence identity separate from host placement identity", () => { + const workspace = normalizeWorkspaceDescriptor({ + id: "workspace-a", + projectId: "project-a", + projectDisplayName: "App", + projectRootPath: "/repo/app", + workspaceDirectory: "/repo/app", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + project: { + projectKey: "remote:github.com/acme/app", + projectName: "App", + checkout: { + cwd: "/repo/app", + isGit: true, + currentBranch: "main", + remoteUrl: "https://github.com/acme/app.git", + worktreeRoot: "/repo/app", + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }); + + expect(hostProjectFromWorkspace({ serverId: "host-a", workspace })).toMatchObject({ + viewKey: JSON.stringify(["host-a", "project-a"]), + projectKey: "remote:github.com/acme/app", + hosts: [{ serverId: "host-a", projectId: "project-a" }], + }); + }); }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 41cbccd15..d887fa128 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -78,6 +78,7 @@ import { getWorktreeSupportForHostProject, hostProjectFromRoute, hostProjectFromWorkspace, + resolveHostProjectCandidate, useHostProjects, type HostProjectListItem, } from "@/projects/host-projects"; @@ -1201,6 +1202,7 @@ interface NewWorkspaceInitialContextState { openHostPicker: () => void; projects: HostProjectListItem[]; routeProject: HostProjectListItem | null; + routeProjectContextViewKey: string | null; lastActiveProject: HostProjectListItem | null; } @@ -1214,7 +1216,7 @@ function useNewWorkspaceInitialContext({ const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]); const projects = useHostProjects(allServerIds); const routeDisplayName = displayNameProp?.trim() ?? ""; - const routeProject = useMemo( + const routePlacement = useMemo( () => hostProjectFromRoute({ serverId, @@ -1224,6 +1226,16 @@ function useNewWorkspaceInitialContext({ }), [projectId, routeDisplayName, serverId, sourceDirectoryProp], ); + const routeProject = useMemo(() => { + if (!routePlacement) return null; + return ( + resolveHostProjectCandidate({ + candidate: routePlacement, + projects, + serverId, + }) ?? routePlacement + ); + }, [projects, routePlacement, serverId]); const lastWorkspaceSelection = useLastWorkspaceSelection(); const lastWorkspaceServerId = useMemo( () => @@ -1267,6 +1279,7 @@ function useNewWorkspaceInitialContext({ openHostPicker, projects, routeProject, + routeProjectContextViewKey: routePlacement?.viewKey ?? null, lastActiveProject, }; } @@ -1548,6 +1561,7 @@ export function NewWorkspaceScreen({ openHostPicker, projects, routeProject, + routeProjectContextViewKey, lastActiveProject, } = useNewWorkspaceInitialContext({ serverId, @@ -1628,6 +1642,7 @@ export function NewWorkspaceScreen({ selectedServerId, projects, routeProject, + routeProjectContextViewKey, lastActiveProject, allowAllProjects: supportsWorkspaceMultiplicity, }); diff --git a/packages/app/src/screens/new-workspace/project-picker.test.tsx b/packages/app/src/screens/new-workspace/project-picker.test.tsx new file mode 100644 index 000000000..5cf2ae691 --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-picker.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { HostProjectListItem } from "@/projects/host-projects"; +import { useNewWorkspaceProjectPicker } from "./project-picker"; + +function project(input: { + viewKey: string; + projectKey: string | null; + projectId: string; + projectName: string; +}): HostProjectListItem { + return { + ...input, + projectKind: "git", + iconWorkingDir: `/work/${input.projectId}`, + hosts: [ + { + serverId: "host", + projectId: input.projectId, + iconWorkingDir: `/work/${input.projectId}`, + worktreeSupport: "supported", + }, + ], + workspaceKeys: [], + }; +} + +describe("useNewWorkspaceProjectPicker", () => { + it("preserves a manual choice when the routed project hydrates", () => { + const routePlacement = project({ + viewKey: '["host","route-local"]', + projectKey: null, + projectId: "route-local", + projectName: "Route project", + }); + const hydratedRouteProject = project({ + viewKey: "remote:github.com/acme/route", + projectKey: "remote:github.com/acme/route", + projectId: "route-local", + projectName: "Route project", + }); + const manualProject = project({ + viewKey: "remote:github.com/acme/manual", + projectKey: "remote:github.com/acme/manual", + projectId: "manual-local", + projectName: "Manual project", + }); + const { result, rerender } = renderHook( + ({ routeProject, projects }) => + useNewWorkspaceProjectPicker({ + selectedServerId: "host", + projects, + routeProject, + routeProjectContextViewKey: routePlacement.viewKey, + lastActiveProject: null, + allowAllProjects: true, + }), + { + initialProps: { + routeProject: routePlacement, + projects: [routePlacement, manualProject], + }, + }, + ); + + const manualOption = result.current.projectPickerOptions.find( + (option) => option.label === manualProject.projectName, + ); + expect(manualOption).toBeDefined(); + act(() => result.current.handleSelectProjectOption(manualOption!.id)); + expect(result.current.selectedProject).toEqual(manualProject); + + rerender({ + routeProject: hydratedRouteProject, + projects: [hydratedRouteProject, manualProject], + }); + + expect(result.current.selectedProject).toEqual(manualProject); + }); +}); diff --git a/packages/app/src/screens/new-workspace/project-picker.ts b/packages/app/src/screens/new-workspace/project-picker.ts index 0d52b4dfa..736b0d3d7 100644 --- a/packages/app/src/screens/new-workspace/project-picker.ts +++ b/packages/app/src/screens/new-workspace/project-picker.ts @@ -24,6 +24,7 @@ interface NewWorkspaceProjectPickerInput { selectedServerId: string; projects: HostProjectListItem[]; routeProject: HostProjectListItem | null; + routeProjectContextViewKey: string | null; lastActiveProject: HostProjectListItem | null; allowAllProjects: boolean; } @@ -84,6 +85,7 @@ export function useNewWorkspaceProjectPicker({ selectedServerId, projects, routeProject, + routeProjectContextViewKey, lastActiveProject, allowAllProjects, }: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState { @@ -104,14 +106,13 @@ export function useNewWorkspaceProjectPicker({ [allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId], ); - const routeProjectViewKey = routeProject?.viewKey ?? null; const selectionContextKey = createProjectSelectionContextKey({ selectedServerId, - routeProjectViewKey, + routeProjectViewKey: routeProjectContextViewKey, allowAllProjects, }); const manualSelectionContextKey = createManualProjectSelectionContextKey({ - routeProjectViewKey, + routeProjectViewKey: routeProjectContextViewKey, }); const shouldPreserveMissingProject = useCallback( (project: HostProjectListItem) =>