diff --git a/packages/app/e2e/browser/sidebar-project-grouping.spec.ts b/packages/app/e2e/browser/sidebar-project-grouping.spec.ts index bd9b934aad..6243d52fdf 100644 --- a/packages/app/e2e/browser/sidebar-project-grouping.spec.ts +++ b/packages/app/e2e/browser/sidebar-project-grouping.spec.ts @@ -9,6 +9,8 @@ import { expectProjectWorkspaceCountForHost, expectSeparateProjects, openGroupedProjectSettings, + openGroupedProjectWorkspace, + expectWorkspaceHost, openProjectsForSettingsHost, openProjectDirectory, openProjectDirectoryWithHosts, @@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => { count: 2, }); }); + test("creates grouped project workspaces on the active workspace host", async ({ + page, + crossHostProject, + }) => { + await openScenario(page, crossHostProject); + await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace"); + await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME); + await expectWorkspaceHost(page, SECONDARY_HOST_LABEL); + await createWorkspaceWithoutAgent(page); + await expectProjectWorkspaceCountForHost(page, { + projectName: GROUPED_PROJECT_NAME, + hostName: SECONDARY_HOST_LABEL, + count: 2, + }); + + await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace"); + await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME); + await expectWorkspaceHost(page, PRIMARY_HOST_LABEL); + await createWorkspaceWithoutAgent(page); + await expectProjectWorkspaceCountForHost(page, { + projectName: GROUPED_PROJECT_NAME, + hostName: PRIMARY_HOST_LABEL, + count: 2, + }); + }); }); diff --git a/packages/app/e2e/support/helpers/project-grouping.ts b/packages/app/e2e/support/helpers/project-grouping.ts index fc56d4bf9a..49c2f1c9b4 100644 --- a/packages/app/e2e/support/helpers/project-grouping.ts +++ b/packages/app/e2e/support/helpers/project-grouping.ts @@ -134,3 +134,19 @@ export async function expectProjectSettingsName(page: Page, projectName: string) timeout: PROJECT_VISIBILITY_TIMEOUT, }); } + +export async function openGroupedProjectWorkspace( + page: Page, + projectName: string, + workspaceName: string, +): Promise { + await projectGroup(page, projectName) + .getByRole("button") + .filter({ hasText: workspaceName }) + .click(); + await expect(page).toHaveURL(/\/workspace\//); +} + +export async function expectWorkspaceHost(page: Page, hostName: string): Promise { + await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName); +} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c750121368..9ce91b5a10 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -41,7 +41,11 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; -import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + useHosts, + useHostRuntimeConnectionStatus, +} from "@/runtime/host-runtime"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { useSidebarWorkspacePinController, @@ -1596,14 +1600,18 @@ function ProjectBlock({ canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, } = useLimitedSidebarGroup(project.workspaces); + const activeServerId = activeWorkspaceSelection?.serverId ?? null; + const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? ""); + const preferredServerId = activeHostStatus === "online" ? activeServerId : null; const rowModel = useMemo( () => buildSidebarProjectRowModel({ project, collapsed, supportsMultiplicityByServerId, + preferredServerId, }), - [collapsed, project, supportsMultiplicityByServerId], + [collapsed, preferredServerId, project, supportsMultiplicityByServerId], ); // Collapsed rows hide their workspace rows, so the project row carries the most urgent @@ -1848,6 +1856,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.useNestable === next.useNestable && previous.dragGestureHostActive === next.dragGestureHostActive && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && + previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId && areProjectBlockSelectionsEqual(previous, next) ); } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index de23319c97..a2978a669c 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -65,6 +65,45 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry { } describe("buildSidebarProjectRowModel", () => { + it("prefers the active host when the grouped project can create there", () => { + const result = buildSidebarProjectRowModel({ + project: project({ + hosts: [ + { serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" }, + { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" }, + ], + }), + collapsed: false, + preferredServerId: "host-b", + }); + + expect(result.trailingAction).toEqual({ + kind: "new_workspace", + target: { serverId: "host-b", projectId: "project-host-b", iconWorkingDir: "/repo/b" }, + }); + }); + + it.each(["host-b", "another-host"])( + "keeps the first eligible placement when preferred host %s cannot create here", + (preferredServerId) => { + const result = buildSidebarProjectRowModel({ + project: project({ + hosts: [ + { serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" }, + { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "unsupported" }, + ], + }), + collapsed: false, + preferredServerId, + }); + + expect(result.trailingAction).toEqual({ + kind: "new_workspace", + target: { serverId: "host-a", projectId: "project-host-a", iconWorkingDir: "/repo/a" }, + }); + }, + ); + it("renders a non-git single-workspace project as an expandable section", () => { const result = buildSidebarProjectRowModel({ project: project({ diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 619e08dc4f..d5504d08bb 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -85,7 +85,9 @@ export function resolveSidebarProjectLocalPath( function resolveNewWorkspaceTarget( project: SidebarProjectEntry, supportsMultiplicityByServerId: ReadonlyMap, + preferredServerId: string | null | undefined, ): SidebarProjectHostTarget | null { + let fallbackTarget: SidebarProjectHostTarget | null = null; for (const host of project.hosts) { if ( host.worktreeSupport === "unsupported" && @@ -94,22 +96,30 @@ function resolveNewWorkspaceTarget( continue; } const target = hostTarget(host); - if (target) return target; + if (!target) continue; + if (host.serverId === preferredServerId) return target; + fallbackTarget ??= target; } - return null; + return fallbackTarget; } function projectTrailingAction( project: SidebarProjectEntry, supportsMultiplicityByServerId: ReadonlyMap, + preferredServerId: string | null | undefined, ): SidebarProjectTrailingAction { - const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId); + const target = resolveNewWorkspaceTarget( + project, + supportsMultiplicityByServerId, + preferredServerId, + ); return target ? { kind: "new_workspace", target } : { kind: "none" }; } export function buildSidebarProjectRowModel(input: { project: SidebarProjectEntry; collapsed: boolean; + preferredServerId?: string | null; supportsMultiplicityByServerId?: ReadonlyMap; }): SidebarProjectRowModel { return { @@ -118,6 +128,7 @@ export function buildSidebarProjectRowModel(input: { trailingAction: projectTrailingAction( input.project, input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP, + input.preferredServerId, ), }; }