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.
This commit is contained in:
Mohamed Boudra
2026-08-09 00:51:07 +02:00
committed by GitHub
parent ce7b3a90c0
commit 8f38d04919
6 changed files with 175 additions and 5 deletions
@@ -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);
});
});
@@ -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,
@@ -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" }],
});
});
});
@@ -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,
});
@@ -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);
});
});
@@ -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) =>