From 807d0d6d695b71aa961e07f91c0c7829844046ee Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Thu, 2 Jul 2026 15:15:10 +0200 Subject: [PATCH] fix(app): show New workspace action on non-git sidebar projects (#1857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app): show New workspace action on non-git sidebar projects The per-project "New workspace" affordance in the sidebar (the project- header + button and the empty-project ghost row) was gated on host.canCreateWorktree, i.e. projectKind === "git". Non-git projects (non_git / directory) therefore showed no way to add a workspace, even though a host with the workspaceMultiplicity capability can create additional local workspaces for them. Thread the per-host workspaceMultiplicity flag into the sidebar project row model and show the affordance when canCreateWorktree || supportsWorkspaceMultiplicity, matching the gate already used by the global "New workspace" button and the Cmd+N handler. Rename the internal trailing-action kind new_worktree -> new_workspace (a client-only UI model, not a wire type) to reflect that it now also covers non-git workspaces. Co-Authored-By: Claude Opus 4.8 * test(app): expect the per-row New workspace icon on non-git projects The sidebar "+" now shows for non-git projects on a multiplicity-capable host, so the Model B sidebar spec no longer asserts the non-git project has zero new-worktree icons — it now expects the icon, like git projects. Also refresh the now-stale "no new-worktree icon" comments in the entry-points spec (the picker is still one valid entry point). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- packages/app/e2e/new-workspace-entry.spec.ts | 14 ++-- packages/app/e2e/sidebar-model-b.spec.ts | 13 ++-- .../src/components/sidebar-workspace-list.tsx | 19 ++++- .../utils/sidebar-project-row-model.test.ts | 71 +++++++++++++++++-- .../src/utils/sidebar-project-row-model.ts | 32 +++++++-- 5 files changed, 120 insertions(+), 29 deletions(-) diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 6bd79af19..90a7e4623 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -13,12 +13,12 @@ import { getServerId } from "./helpers/server-id"; import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; -// Model B entry points into the New Workspace screen. The per-project -// "+ New workspace" sidebar row is gone; the surviving entries are the global -// button (universal) and each git project's own new-worktree icon (preselects -// that project). These specs prove the global entry opens the screen, the -// project icon preselects the right project across the reused 'new' screen, and -// non-git projects never offer the worktree Isolation control. +// Model B entry points into the New Workspace screen. The surviving entries are +// the global button (universal) and each project's per-row New workspace icon +// (preselects that project) — shown for git projects and for non-git projects on +// a multiplicity-capable host. These specs prove the global entry opens the +// screen, the project icon preselects the right project across the reused 'new' +// screen, and non-git projects never offer the worktree Isolation control. function projectRow(page: import("@playwright/test").Page, projectKey: string) { return page.getByTestId(`sidebar-project-row-${projectKey}`); @@ -215,7 +215,7 @@ test.describe("New workspace entry points", () => { await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 }); // Open New Workspace for the non-git project via the global button, then - // select it in the picker (its row has no new-worktree icon). + // select it in the picker (the per-row icon would preselect it too). await openGlobalNewWorkspaceComposer(page); const trigger = page.getByTestId("new-workspace-project-picker-trigger"); await expect(trigger).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index 4363dd62e..30ebb767b 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom test.describe("Model B sidebar shape", () => { test.describe.configure({ timeout: 180_000 }); - test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({ + test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({ page, }) => { const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" }); @@ -62,14 +62,17 @@ test.describe("Model B sidebar shape", () => { await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 }); await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 }); - // The per-project "+ New workspace" row is gone. The git project keeps a - // per-row new-worktree icon (revealed on hover); the non-git project has - // none, since worktree creation needs a git checkout. + // Both projects show a per-row New workspace icon (revealed on hover): the + // git project can branch off a worktree, and the non-git project can add + // another workspace because the host supports workspaceMultiplicity. await projectRow(page, gitProject.projectId).hover(); await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({ timeout: 30_000, }); - await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0); + await projectRow(page, nonGitProject.projectId).hover(); + await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({ + timeout: 30_000, + }); // The global new-workspace button is the universal entry — present for both // kinds regardless of their per-row affordance. diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 4086cd384..6c1891d97 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -56,6 +56,7 @@ 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 { useHostFeatureMap } from "@/runtime/host-features"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { @@ -1881,6 +1882,7 @@ function ProjectBlock({ activeWorkspaceSelection, hostLabelByServerId, showHostLabels, + supportsMultiplicityByServerId, }: { project: SidebarProjectEntry; collapsed: boolean; @@ -1902,14 +1904,16 @@ function ProjectBlock({ activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; + supportsMultiplicityByServerId: ReadonlyMap; }) { const rowModel = useMemo( () => buildSidebarProjectRowModel({ project, collapsed, + supportsMultiplicityByServerId, }), - [collapsed, project], + [collapsed, project, supportsMultiplicityByServerId], ); const active = isProjectSelectedByRoute({ @@ -2062,7 +2066,7 @@ function ProjectBlock({ containerStyle={styles.workspaceListContainer} /> ); - } else if (rowModel.trailingAction.kind === "new_worktree") { + } else if (rowModel.trailingAction.kind === "new_workspace") { projectChildren = ( [0]; +// oxlint-disable-next-line complexity function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { return ( previous.project === next.project && @@ -2117,6 +2122,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && previous.hostLabelByServerId === next.hostLabelByServerId && previous.showHostLabels === next.showHostLabels && + previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId && previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && @@ -2183,6 +2189,8 @@ export function SidebarWorkspaceList({ } return labels; }, [hosts]); + const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); + const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity"); const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]); const content = @@ -2208,6 +2216,7 @@ export function SidebarWorkspaceList({ pathname={pathname} hostLabelByServerId={hostLabelByServerId} showHostLabels={showHostLabels} + supportsMultiplicityByServerId={supportsMultiplicityByServerId} /> ); @@ -2256,6 +2265,7 @@ function ProjectModeList({ pathname, hostLabelByServerId, showHostLabels, + supportsMultiplicityByServerId, }: Omit< SidebarWorkspaceListProps, "statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh" @@ -2263,6 +2273,7 @@ function ProjectModeList({ pathname: string; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; + supportsMultiplicityByServerId: ReadonlyMap; }) { const { t } = useTranslation(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); @@ -2451,6 +2462,7 @@ function ProjectModeList({ activeWorkspaceSelection={activeWorkspaceSelection} hostLabelByServerId={hostLabelByServerId} showHostLabels={showHostLabels} + supportsMultiplicityByServerId={supportsMultiplicityByServerId} /> ); }, @@ -2461,6 +2473,7 @@ function ProjectModeList({ handleWorkspaceReorder, hostLabelByServerId, showHostLabels, + supportsMultiplicityByServerId, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, 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 2bb644bef..37548071e 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -66,7 +66,7 @@ describe("buildSidebarProjectRowModel", () => { }); }); - it("renders a single-workspace git project as an expandable section with the new worktree action", () => { + it("renders a single-workspace git project as an expandable section with the new workspace action", () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: "git", @@ -79,13 +79,49 @@ describe("buildSidebarProjectRowModel", () => { kind: "project_section", chevron: "expand", trailingAction: { - kind: "new_worktree", + kind: "new_workspace", target: { serverId: "srv", iconWorkingDir: "/repo" }, }, }); }); - it("targets the project host, not route state, for new worktree actions", () => { + it("shows the new workspace action for a non-git project when the host supports workspace multiplicity", () => { + const result = buildSidebarProjectRowModel({ + project: project({ projectKind: "directory", workspaces: [] }), + collapsed: false, + supportsMultiplicityByServerId: new Map([["srv", true]]), + }); + + expect(result.trailingAction).toEqual({ + kind: "new_workspace", + target: { serverId: "srv", iconWorkingDir: "/repo" }, + }); + }); + + it("hides the new workspace action for a non-git project when the host lacks workspace multiplicity", () => { + const result = buildSidebarProjectRowModel({ + project: project({ projectKind: "directory", workspaces: [] }), + collapsed: false, + supportsMultiplicityByServerId: new Map([["srv", false]]), + }); + + expect(result.trailingAction).toEqual({ kind: "none" }); + }); + + it("still shows the new workspace action for a git project regardless of multiplicity", () => { + const result = buildSidebarProjectRowModel({ + project: project({ projectKind: "git" }), + collapsed: false, + supportsMultiplicityByServerId: new Map([["srv", false]]), + }); + + expect(result.trailingAction).toEqual({ + kind: "new_workspace", + target: { serverId: "srv", iconWorkingDir: "/repo" }, + }); + }); + + it("targets the project host, not route state, for new workspace actions", () => { const result = buildSidebarProjectRowModel({ project: project({ hosts: [ @@ -98,13 +134,34 @@ describe("buildSidebarProjectRowModel", () => { expect(result).toMatchObject({ trailingAction: { - kind: "new_worktree", + kind: "new_workspace", target: { serverId: "host-b", iconWorkingDir: "/repo/b" }, }, }); }); - it("renders a multi-workspace git project as an expandable section with a new worktree action", () => { + it("targets the first multiplicity-capable host for a non-git project", () => { + const result = buildSidebarProjectRowModel({ + project: project({ + projectKind: "directory", + hosts: [ + { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }, + { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false }, + ], + }), + collapsed: false, + supportsMultiplicityByServerId: new Map([["host-b", true]]), + }); + + expect(result).toMatchObject({ + trailingAction: { + kind: "new_workspace", + target: { serverId: "host-b", iconWorkingDir: "/repo/b" }, + }, + }); + }); + + it("renders a multi-workspace git project as an expandable section with a new workspace action", () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: "git", @@ -120,7 +177,7 @@ describe("buildSidebarProjectRowModel", () => { kind: "project_section", chevron: "expand", trailingAction: { - kind: "new_worktree", + kind: "new_workspace", target: { serverId: "srv", iconWorkingDir: "/repo" }, }, }); @@ -149,7 +206,7 @@ describe("buildSidebarProjectRowModel", () => { kind: "project_section", chevron: "collapse", trailingAction: { - kind: "new_worktree", + kind: "new_workspace", target: { serverId: "srv", iconWorkingDir: "/repo" }, }, }); diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 2fe5b740f..ee03b6767 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -6,7 +6,7 @@ export interface SidebarProjectHostTarget { } export type SidebarProjectTrailingAction = - | { kind: "new_worktree"; target: SidebarProjectHostTarget } + | { kind: "new_workspace"; target: SidebarProjectHostTarget } | { kind: "none" }; export interface SidebarProjectSectionRowModel { @@ -17,6 +17,8 @@ export interface SidebarProjectSectionRowModel { export type SidebarProjectRowModel = SidebarProjectSectionRowModel; +const EMPTY_MULTIPLICITY_MAP: ReadonlyMap = new Map(); + function hostTarget(input: { serverId: string; iconWorkingDir: string; @@ -40,9 +42,18 @@ export function resolveSidebarProjectIconTarget( return null; } -function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectHostTarget | null { +// A project can host a brand-new workspace on a host when that host can create a +// git worktree (git projects) OR the host supports running multiple independent +// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git +// directories add a second workspace. Mirrors the gate used by the global "New +// workspace" affordances (use-global-new-workspace-action.ts and left-sidebar's +// SidebarNewWorkspaceHeaderRow): `canCreateWorktree || supportsMultiplicity`. +function resolveNewWorkspaceTarget( + project: SidebarProjectEntry, + supportsMultiplicityByServerId: ReadonlyMap, +): SidebarProjectHostTarget | null { for (const host of project.hosts) { - if (!host.canCreateWorktree) { + if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) { continue; } const target = hostTarget(host); @@ -53,18 +64,25 @@ function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectH return null; } -function projectTrailingAction(project: SidebarProjectEntry): SidebarProjectTrailingAction { - const target = resolveNewWorktreeTarget(project); - return target ? { kind: "new_worktree", target } : { kind: "none" }; +function projectTrailingAction( + project: SidebarProjectEntry, + supportsMultiplicityByServerId: ReadonlyMap, +): SidebarProjectTrailingAction { + const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId); + return target ? { kind: "new_workspace", target } : { kind: "none" }; } export function buildSidebarProjectRowModel(input: { project: SidebarProjectEntry; collapsed: boolean; + supportsMultiplicityByServerId?: ReadonlyMap; }): SidebarProjectRowModel { return { kind: "project_section", chevron: input.collapsed ? "expand" : "collapse", - trailingAction: projectTrailingAction(input.project), + trailingAction: projectTrailingAction( + input.project, + input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP, + ), }; }