Show New Workspace isolation controls immediately (#2702)

* fix(workspaces): show isolation without checkout delay

* fix(workspaces): trust hydrated project capability

* fix(workspaces): resolve branch before creation

* fix(workspaces): honor invalidated checkout status

* fix(workspaces): preserve isolation while projects load

* fix(workspaces): keep provisional project routes usable

Route-derived projects do not know their worktree capability. Represent that uncertainty at the placement boundary so remembered worktree intent survives hydration without disabling the New Workspace screen indefinitely.
This commit is contained in:
Mohamed Boudra
2026-07-31 17:50:35 +02:00
committed by GitHub
parent 35f985baff
commit de44c8cd66
20 changed files with 333 additions and 105 deletions
+25 -3
View File
@@ -190,6 +190,29 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
}); });
} }
export async function openMissingProjectNewWorkspaceComposer(
page: Page,
input: { serverId: string; projectId: string; sourceDirectory: string },
): Promise<void> {
const query = new URLSearchParams({
serverId: input.serverId,
projectId: input.projectId,
dir: input.sourceDirectory,
name: "Missing project",
});
await page.goto(`/new?${query.toString()}`);
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
}
export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Workspace project" })).toBeEnabled({
timeout: 30_000,
});
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
}
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> { export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P"); await page.keyboard.press("Control+P");
@@ -286,9 +309,8 @@ export async function selectWorkspaceIsolation(
await expect(trigger).toBeVisible({ timeout: 30_000 }); await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click(); await trigger.click();
// "New worktree" is only listed once the checkout status query confirms the // Isolation options are derived from project capability. Wait for the option
// selected project is a git repo, so wait for the option to appear before // so this helper also covers route-to-project reconciliation.
// clicking it.
const option = page.getByTestId(`workspace-create-isolation-${isolation}`); const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
await expect(option).toBeVisible({ timeout: 30_000 }); await expect(option).toBeVisible({ timeout: 30_000 });
await option.click(); await option.click();
+15 -1
View File
@@ -2,8 +2,10 @@ import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app"; import { gotoAppShell } from "./helpers/app";
import { import {
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected, expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer, openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer,
openNewWorkspaceComposer, openNewWorkspaceComposer,
openNewWorkspaceProjectPickerWithShortcut, openNewWorkspaceProjectPickerWithShortcut,
} from "./helpers/new-workspace"; } from "./helpers/new-workspace";
@@ -13,7 +15,7 @@ import { seedSavedSettingsHosts } from "./helpers/settings";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The surviving entries are // 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 // the global button (universal) and each project's per-row New workspace icon
@@ -108,6 +110,18 @@ test.describe("New workspace entry points", () => {
} }
}); });
test("a stale project route keeps the New Workspace controls enabled", async ({ page }) => {
await gotoAppShell(page);
await waitForNoProjectsInSidebar(page);
await openMissingProjectNewWorkspaceComposer(page, {
serverId: getServerId(),
projectId: "missing-project",
sourceDirectory: "/tmp/missing-project",
});
await expectNewWorkspaceControlsEnabled(page);
});
test("Ctrl+P opens the project picker with search focused", async ({ page }) => { test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" }); const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
@@ -45,7 +45,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt
serverId: "srv", serverId: "srv",
projectId: "project", projectId: "project",
iconWorkingDir: "/repo", iconWorkingDir: "/repo",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
workspaces, workspaces,
@@ -14,6 +14,7 @@ import {
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store"; import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
import { import {
applyCheckoutStatusUpdateFromEvent, applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus,
type CheckoutPrStatusPayload, type CheckoutPrStatusPayload,
type CheckoutStatusPayload, type CheckoutStatusPayload,
fetchCheckoutStatus, fetchCheckoutStatus,
@@ -122,6 +123,40 @@ describe("fetchCheckoutStatus", () => {
}); });
}); });
describe("ensureCheckoutStatus", () => {
it("awaits the canonical checkout-status query and reuses its cached result", async () => {
const queryClient = createQueryClient();
const fetched = checkoutStatus({ currentBranch: "feature/current" });
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
const first = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
const second = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
expect(first).toEqual(fetched);
expect(second).toEqual(fetched);
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
});
it("awaits a refetch when the canonical cached status was invalidated", async () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutStatusQueryKey(serverId, cwd),
checkoutStatus({ currentBranch: "feature/stale" }),
);
await queryClient.invalidateQueries({
queryKey: checkoutStatusQueryKey(serverId, cwd),
refetchType: "none",
});
const fetched = checkoutStatus({ currentBranch: "feature/current" });
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
const result = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
expect(result.currentBranch).toBe("feature/current");
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
});
});
describe("applyCheckoutStatusUpdateFromEvent", () => { describe("applyCheckoutStatusUpdateFromEvent", () => {
it("writes the checkout status to the cache using the cwd from the payload", () => { it("writes the checkout status to the cache using the cwd from the payload", () => {
const queryClient = createQueryClient(); const queryClient = createQueryClient();
@@ -35,6 +35,24 @@ export async function fetchCheckoutStatus({
return payload; return payload;
} }
export async function ensureCheckoutStatus({
queryClient,
client,
serverId,
cwd,
}: {
queryClient: QueryClient;
client: CheckoutStatusClient;
serverId: string;
cwd: string;
}): Promise<CheckoutStatusPayload> {
return await queryClient.fetchQuery({
queryKey: checkoutStatusQueryKey(serverId, cwd),
queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }),
staleTime: Infinity,
});
}
export function applyCheckoutStatusUpdateFromEvent({ export function applyCheckoutStatusUpdateFromEvent({
queryClient, queryClient,
serverId, serverId,
@@ -93,7 +93,7 @@ function project(input: {
{ {
serverId: "srv", serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey, iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }), (host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
@@ -251,7 +251,7 @@ describe("buildSidebarProjectsFromStructure", () => {
{ {
serverId: "relay:paseo-host", serverId: "relay:paseo-host",
iconWorkingDir: "/repo/project-1", iconWorkingDir: "/repo/project-1",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
workspaceKeys: ["relay:paseo-host:ws-main"], workspaceKeys: ["relay:paseo-host:ws-main"],
@@ -276,8 +276,16 @@ describe("shared sidebar workspace model", () => {
projectName: "getpaseo/paseo", projectName: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo",
hosts: [ hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true }, {
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true }, serverId: "host-a",
iconWorkingDir: "/repo/getpaseo/paseo",
worktreeSupport: "supported" as const,
},
{
serverId: "host-b",
iconWorkingDir: "/repo/getpaseo/paseo",
worktreeSupport: "supported" as const,
},
], ],
workspaceKeys: ["host-a:main", "host-b:feature"], workspaceKeys: ["host-a:main", "host-b:feature"],
}), }),
@@ -334,13 +342,13 @@ describe("shared sidebar workspace model", () => {
serverId: "host-a", serverId: "host-a",
projectId: "getpaseo/paseo", projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
{ {
serverId: "host-b", serverId: "host-b",
projectId: "getpaseo/paseo", projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
workspaces: [ workspaces: [
@@ -470,14 +478,22 @@ describe("shouldShowSidebarHostLabels", () => {
project({ project({
projectKey: "project-a", projectKey: "project-a",
hosts: [ hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/project-a", canCreateWorktree: true }, {
serverId: "host-a",
iconWorkingDir: "/repo/project-a",
worktreeSupport: "supported" as const,
},
], ],
workspaceKeys: ["host-a:ws-1"], workspaceKeys: ["host-a:ws-1"],
}), }),
project({ project({
projectKey: "project-b", projectKey: "project-b",
hosts: [ hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/project-b", canCreateWorktree: true }, {
serverId: "host-b",
iconWorkingDir: "/repo/project-b",
worktreeSupport: "supported" as const,
},
], ],
workspaceKeys: ["host-b:ws-2"], workspaceKeys: ["host-b:ws-2"],
}), }),
@@ -493,8 +509,16 @@ describe("shouldShowSidebarHostLabels", () => {
project({ project({
projectKey: "getpaseo/paseo", projectKey: "getpaseo/paseo",
hosts: [ hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/paseo", canCreateWorktree: true }, {
{ serverId: "host-b", iconWorkingDir: "/repo/paseo", canCreateWorktree: true }, serverId: "host-a",
iconWorkingDir: "/repo/paseo",
worktreeSupport: "supported" as const,
},
{
serverId: "host-b",
iconWorkingDir: "/repo/paseo",
worktreeSupport: "supported" as const,
},
], ],
workspaceKeys: ["host-a:main", "host-b:feature"], workspaceKeys: ["host-a:main", "host-b:feature"],
}), }),
@@ -23,7 +23,7 @@ export interface SidebarWorkspacePlacement {
projectName: string; projectName: string;
projectRootPath?: string; projectRootPath?: string;
workspaceDirectory?: string; workspaceDirectory?: string;
projectKind: WorkspaceDescriptor["projectKind"]; projectKind: WorkspaceStructureProject["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string; name: string;
} }
@@ -52,7 +52,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
export interface SidebarProjectEntry { export interface SidebarProjectEntry {
viewKey: string; viewKey: string;
projectName: string; projectName: string;
projectKind: WorkspaceDescriptor["projectKind"]; projectKind: WorkspaceStructureProject["projectKind"];
iconWorkingDir: string; iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[]; hosts: WorkspaceStructureHostPlacement[];
workspaces: SidebarWorkspacePlacement[]; workspaces: SidebarWorkspacePlacement[];
@@ -35,14 +35,14 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }), viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }),
projectKey: null, projectKey: null,
projectName: trimOptional(route.displayName) || projectId, projectName: trimOptional(route.displayName) || projectId,
projectKind: "git", projectKind: "unknown",
iconWorkingDir, iconWorkingDir,
hosts: [ hosts: [
{ {
serverId: route.serverId, serverId: route.serverId,
projectId, projectId,
iconWorkingDir, iconWorkingDir,
canCreateWorktree: true, worktreeSupport: "unknown",
}, },
], ],
workspaceKeys: [], workspaceKeys: [],
@@ -80,7 +80,7 @@ export function hostProjectFromWorkspace(input: {
serverId: input.serverId, serverId: input.serverId,
projectId: input.workspace.projectId, projectId: input.workspace.projectId,
iconWorkingDir, iconWorkingDir,
canCreateWorktree: canCreate, worktreeSupport: canCreate ? "supported" : "unsupported",
}, },
], ],
workspaceKeys: [`${input.serverId}:${input.workspace.id}`], workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
@@ -88,7 +88,7 @@ export function hostProjectFromWorkspace(input: {
} }
function projectCanCreateWorktree(project: HostProjectListItem): boolean { function projectCanCreateWorktree(project: HostProjectListItem): boolean {
return project.hosts.some((h) => h.canCreateWorktree); return project.hosts.some((host) => host.worktreeSupport !== "unsupported");
} }
function getHostProjectPlacement( function getHostProjectPlacement(
@@ -101,6 +101,13 @@ function getHostProjectPlacement(
return null; return null;
} }
export function getWorktreeSupportForHostProject(input: {
project: HostProjectListItem;
serverId: string;
}): WorkspaceStructureHostPlacement["worktreeSupport"] {
return getHostProjectPlacement(input.project, input.serverId)?.worktreeSupport ?? "unknown";
}
export function getHostProjectSourceDirectory( export function getHostProjectSourceDirectory(
project: HostProjectListItem, project: HostProjectListItem,
serverId: string, serverId: string,
@@ -121,7 +128,7 @@ export function canCreateWorkspaceForHostProject(input: {
if (!host) { if (!host) {
return false; return false;
} }
return input.allowAllProjects || host.canCreateWorktree; return input.allowAllProjects || host.worktreeSupport !== "unsupported";
} }
export function filterWorkspaceProjectsForHost(input: { export function filterWorkspaceProjectsForHost(input: {
@@ -4,6 +4,7 @@ import {
canCreateWorkspaceForHostProject, canCreateWorkspaceForHostProject,
getHostProjectId, getHostProjectId,
getHostProjectSourceDirectory, getHostProjectSourceDirectory,
getWorktreeSupportForHostProject,
hostProjectFromRoute, hostProjectFromRoute,
} from "./host-project-model"; } from "./host-project-model";
@@ -19,13 +20,13 @@ function project(): HostProjectListItem {
serverId: "host-a", serverId: "host-a",
projectId: "prj_a", projectId: "prj_a",
iconWorkingDir: "/repo/a", iconWorkingDir: "/repo/a",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
{ {
serverId: "host-b", serverId: "host-b",
projectId: "prj_b", projectId: "prj_b",
iconWorkingDir: "/repo/b", iconWorkingDir: "/repo/b",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
workspaceKeys: [], workspaceKeys: [],
@@ -49,6 +50,38 @@ describe("host project lookups", () => {
).toBe(true); ).toBe(true);
}); });
test("checks worktree capability against the selected host placement", () => {
const groupedProject = project();
groupedProject.hosts[1] = {
...groupedProject.hosts[1]!,
worktreeSupport: "unsupported" as const,
};
expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "host-a" })).toBe(
"supported",
);
expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "host-b" })).toBe(
"unsupported",
);
expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "missing" })).toBe(
"unknown",
);
});
test("marks route placeholder worktree support as unknown", () => {
const routeProject = hostProjectFromRoute({
serverId: "host-a",
projectId: "prj_a",
displayName: "App",
sourceDirectory: "/repo/a",
});
expect(routeProject).not.toBeNull();
expect(routeProject!.projectKind).toBe("unknown");
expect(getWorktreeSupportForHostProject({ project: routeProject!, serverId: "host-a" })).toBe(
"unknown",
);
});
test("builds an unhydrated route project around the routed project id", () => { test("builds an unhydrated route project around the routed project id", () => {
expect( expect(
hostProjectFromRoute({ hostProjectFromRoute({
@@ -7,6 +7,7 @@ export {
filterWorkspaceProjectsForHost, filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory, getHostProjectSourceDirectory,
getHostProjectId, getHostProjectId,
getWorktreeSupportForHostProject,
hostProjectFromRoute, hostProjectFromRoute,
hostProjectFromWorkspace, hostProjectFromWorkspace,
resolveHostProjectCandidate, resolveHostProjectCandidate,
@@ -5,14 +5,14 @@ export interface WorkspaceStructureHostPlacement {
serverId: string; serverId: string;
projectId: string; projectId: string;
iconWorkingDir: string; iconWorkingDir: string;
canCreateWorktree: boolean; worktreeSupport: "supported" | "unsupported" | "unknown";
} }
export interface WorkspaceStructureProject { export interface WorkspaceStructureProject {
viewKey: string; viewKey: string;
projectKey: string | null; projectKey: string | null;
projectName: string; projectName: string;
projectKind: WorkspaceDescriptor["projectKind"]; projectKind: WorkspaceDescriptor["projectKind"] | "unknown";
iconWorkingDir: string; iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[]; hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[]; workspaceKeys: string[];
@@ -157,7 +157,7 @@ function addProjectToView(input: {
serverId, serverId,
projectId: project.projectId, projectId: project.projectId,
iconWorkingDir: project.projectRootPath, iconWorkingDir: project.projectRootPath,
canCreateWorktree: project.projectKind === "git", worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
}; };
const draft = byProject.get(viewKey); const draft = byProject.get(viewKey);
if (!draft) { if (!draft) {
@@ -13,7 +13,14 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem {
projectName: key, projectName: key,
projectKind: "git", projectKind: "git",
iconWorkingDir: `/work/${key}`, iconWorkingDir: `/work/${key}`,
hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }], hosts: [
{
serverId,
projectId: key,
iconWorkingDir: `/work/${key}`,
worktreeSupport: "supported" as const,
},
],
workspaceKeys: [], workspaceKeys: [],
}; };
} }
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item"; import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
import {
pickerItemToCheckoutRequest,
resolveCheckoutRequest,
type PickerItem,
} from "./new-workspace-picker-item";
const prItem: ForgeSearchItem = { const prItem: ForgeSearchItem = {
kind: "change_request", kind: "change_request",
@@ -82,3 +87,31 @@ describe("pickerItemToCheckoutRequest", () => {
}); });
}); });
}); });
describe("resolveCheckoutRequest", () => {
const checkoutStatus = {
currentBranch: "feature/current",
} as CheckoutStatusPayload;
it("branches from the loaded current branch when nothing was picked", () => {
expect(resolveCheckoutRequest(null, checkoutStatus)).toEqual({
action: "branch-off",
refName: "feature/current",
});
});
it("prefers an explicit picker selection", () => {
expect(
resolveCheckoutRequest({ kind: "branch", name: "feature/picked" }, checkoutStatus),
).toEqual({
action: "branch-off",
refName: "feature/picked",
});
});
it("preserves the server-default intent for a detached checkout", () => {
expect(
resolveCheckoutRequest(null, { ...checkoutStatus, currentBranch: null }),
).toBeUndefined();
});
});
@@ -1,5 +1,6 @@
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
export type PickerItem = export type PickerItem =
| { kind: "branch"; name: string } | { kind: "branch"; name: string }
@@ -43,3 +44,16 @@ export function pickerItemToCheckoutRequest(
} }
} }
} }
export function resolveCheckoutRequest(
selectedItem: PickerItem | null,
checkoutStatus: CheckoutStatusPayload,
): PickerCheckoutRequest | undefined {
const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem);
if (selectedCheckoutRequest) return selectedCheckoutRequest;
if (!checkoutStatus.currentBranch) return undefined;
return {
action: "branch-off",
refName: checkoutStatus.currentBranch,
};
}
@@ -8,7 +8,7 @@ import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/composer"; import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { FileDropZone } from "@/components/file-drop/file-drop-zone";
@@ -31,6 +31,8 @@ import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useForgeSearchQuery } from "@/git/use-forge-search-query"; import { useForgeSearchQuery } from "@/git/use-forge-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { ensureCheckoutStatus } from "@/git/checkout-status-cache";
import { import {
useHostRuntimeClient, useHostRuntimeClient,
useHostRuntimeConnectionStatuses, useHostRuntimeConnectionStatuses,
@@ -67,6 +69,7 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp
import { import {
getHostProjectSourceDirectory, getHostProjectSourceDirectory,
getHostProjectId, getHostProjectId,
getWorktreeSupportForHostProject,
hostProjectFromRoute, hostProjectFromRoute,
hostProjectFromWorkspace, hostProjectFromWorkspace,
useHostProjects, useHostProjects,
@@ -87,7 +90,7 @@ import {
remapDraftCwdToWorkspace, remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context"; } from "./new-workspace-fork-context";
import { import {
pickerItemToCheckoutRequest, resolveCheckoutRequest,
type PickerCheckoutRequest, type PickerCheckoutRequest,
type PickerItem, type PickerItem,
} from "./new-workspace-picker-item"; } from "./new-workspace-picker-item";
@@ -109,19 +112,6 @@ const addProjectIcon = (
<ThemedFolderPlus size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} /> <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
); );
function resolveCheckoutRequest(
selectedItem: PickerItem | null,
currentBranch: string | null,
): PickerCheckoutRequest | undefined {
const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem);
if (selectedCheckoutRequest) return selectedCheckoutRequest;
if (!currentBranch) return undefined;
return {
action: "branch-off",
refName: currentBranch,
};
}
function useIsNewWorkspaceDraftHandoffActive(input: { function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined; draftId: string | undefined;
selectedServerId: string; selectedServerId: string;
@@ -564,7 +554,8 @@ function NewWorkspaceProjectPickerOption({
active={active} active={active}
disabled={ disabled={
isPending || isPending ||
(!supportsWorkspaceMultiplicity && !project.hosts.some((host) => host.canCreateWorktree)) (!supportsWorkspaceMultiplicity &&
!project.hosts.some((host) => host.worktreeSupport !== "unsupported"))
} }
onPress={onPress} onPress={onPress}
/> />
@@ -697,21 +688,20 @@ interface WorkspaceIsolationState {
showRefPicker: boolean; showRefPicker: boolean;
} }
// Worktree isolation only makes sense for a git checkout. The effective isolation // Preserve the user's worktree choice while route metadata is provisional. Once
// falls back to local whenever the selected directory isn't git so the flow // the authoritative placement arrives, unsupported projects fall back to local.
// never submits an impossible request.
function useWorkspaceIsolation(input: { function useWorkspaceIsolation(input: {
supportsMultiplicity: boolean; supportsMultiplicity: boolean;
selectedIsGit: boolean; worktreeSupport: "supported" | "unsupported" | "unknown";
}): WorkspaceIsolationState { }): WorkspaceIsolationState {
const { supportsMultiplicity, selectedIsGit } = input; const { supportsMultiplicity, worktreeSupport } = input;
// The last isolation choice is remembered alongside the other New Workspace // The last isolation choice is remembered alongside the other New Workspace
// form preferences (provider, model, mode). A manual in-screen pick overrides // form preferences (provider, model, mode). A manual in-screen pick overrides
// the remembered default until the screen remounts. // the remembered default until the screen remounts.
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null); const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null);
const isolation = manualIsolation ?? preferences.isolation ?? "local"; const isolation = manualIsolation ?? preferences.isolation ?? "local";
const canCreateWorktree = supportsMultiplicity && selectedIsGit; const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported";
const isWorktree = isolation === "worktree" && canCreateWorktree; const isWorktree = isolation === "worktree" && canCreateWorktree;
const setIsolation = useCallback( const setIsolation = useCallback(
@@ -812,8 +802,7 @@ async function createMultiplicityWorkspace(input: {
isolation: "local" | "worktree"; isolation: "local" | "worktree";
project: HostProjectListItem; project: HostProjectListItem;
sourceDirectory: string; sourceDirectory: string;
selectedItem: PickerItem | null; checkoutRequest: PickerCheckoutRequest | undefined;
currentBranch: string | null;
withInitialAgent: boolean; withInitialAgent: boolean;
prompt: string; prompt: string;
attachments: AgentAttachment[]; attachments: AgentAttachment[];
@@ -827,9 +816,6 @@ async function createMultiplicityWorkspace(input: {
const projectId = getHostProjectId(input.project, input.serverId); const projectId = getHostProjectId(input.project, input.serverId);
if (!projectId) throw new Error("Project is not available on the selected host"); if (!projectId) throw new Error("Project is not available on the selected host");
const isWorktree = input.isolation === "worktree"; const isWorktree = input.isolation === "worktree";
const checkoutRequest = isWorktree
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
: undefined;
const firstAgentContext = buildFirstAgentContext({ const firstAgentContext = buildFirstAgentContext({
prompt: input.prompt, prompt: input.prompt,
attachments: input.attachments, attachments: input.attachments,
@@ -841,7 +827,7 @@ async function createMultiplicityWorkspace(input: {
cwd: input.sourceDirectory, cwd: input.sourceDirectory,
projectId, projectId,
worktreeSlug: createNameId(), worktreeSlug: createNameId(),
...checkoutRequest, ...input.checkoutRequest,
} }
: { : {
kind: "directory", kind: "directory",
@@ -1518,6 +1504,7 @@ export function NewWorkspaceScreen({
displayName: displayNameProp, displayName: displayNameProp,
draftId, draftId,
}: NewWorkspaceScreenProps) { }: NewWorkspaceScreenProps) {
const queryClient = useQueryClient();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -1567,7 +1554,6 @@ export function NewWorkspaceScreen({
}, [pickerSearchQuery]); }, [pickerSearchQuery]);
const workspace = createdWorkspace; const workspace = createdWorkspace;
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
const client = useHostRuntimeClient(selectedServerId); const client = useHostRuntimeClient(selectedServerId);
const isConnected = useHostRuntimeIsConnected(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId);
const { const {
@@ -1646,27 +1632,20 @@ export function NewWorkspaceScreen({
const hasSelectedSourceDirectory = selectedSourceDirectory !== null; const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory; const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory;
const checkoutStatusQuery = useQuery({ const { status: checkoutStatus } = useCheckoutStatusQuery({
queryKey: ["checkout-status", selectedServerId, selectedSourceDirectory], serverId: selectedServerId,
queryFn: async () => { cwd: selectedSourceDirectory ?? "",
if (!selectedSourceDirectory) {
throw new Error("Choose a project");
}
const connectedClient = withConnectedClient();
return connectedClient.getCheckoutStatus(selectedSourceDirectory);
},
enabled: clientReady && hasSelectedSourceDirectory,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}); });
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; const currentBranch = checkoutStatus?.currentBranch ?? null;
const worktreeSupport = selectedProject
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
: "unsupported";
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } = const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
useWorkspaceIsolation({ useWorkspaceIsolation({
supportsMultiplicity: supportsWorkspaceMultiplicity, supportsMultiplicity: supportsWorkspaceMultiplicity,
selectedIsGit: checkoutStatusQuery.data?.isGit === true, worktreeSupport,
}); });
const branchSuggestionsQuery = useQuery({ const branchSuggestionsQuery = useQuery({
@@ -1885,6 +1864,7 @@ export function NewWorkspaceScreen({
cwd: string; cwd: string;
prompt: string; prompt: string;
attachments: AgentAttachment[]; attachments: AgentAttachment[];
checkoutRequest: PickerCheckoutRequest | undefined;
}): CreatePaseoWorktreeInput => { }): CreatePaseoWorktreeInput => {
if (!selectedProject) { if (!selectedProject) {
throw new Error("Choose a project"); throw new Error("Choose a project");
@@ -1892,7 +1872,6 @@ export function NewWorkspaceScreen({
if (!selectedSourceDirectory) { if (!selectedSourceDirectory) {
throw new Error("Choose a host for this project"); throw new Error("Choose a host for this project");
} }
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const firstAgentContext = buildFirstAgentContext(input); const firstAgentContext = buildFirstAgentContext(input);
const hostProjectId = getHostProjectId(selectedProject, selectedServerId); const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
if (!hostProjectId) { if (!hostProjectId) {
@@ -1904,10 +1883,10 @@ export function NewWorkspaceScreen({
projectId: hostProjectId, projectId: hostProjectId,
worktreeSlug: createNameId(), worktreeSlug: createNameId(),
...(firstAgentContext ? { firstAgentContext } : {}), ...(firstAgentContext ? { firstAgentContext } : {}),
...checkoutRequest, ...input.checkoutRequest,
}; };
}, },
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory], [selectedProject, selectedServerId, selectedSourceDirectory],
); );
const ensureWorkspace = useCallback( const ensureWorkspace = useCallback(
@@ -1926,14 +1905,26 @@ export function NewWorkspaceScreen({
if (!selectedSourceDirectory) { if (!selectedSourceDirectory) {
throw new Error("Choose a host for this project"); throw new Error("Choose a host for this project");
} }
const connectedClient = withConnectedClient();
const createsWorktree = !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree";
const checkoutRequest = createsWorktree
? resolveCheckoutRequest(
selectedItem,
await ensureCheckoutStatus({
queryClient,
client: connectedClient,
serverId: selectedServerId,
cwd: selectedSourceDirectory,
}),
)
: undefined;
const normalizedWorkspace = supportsWorkspaceMultiplicity const normalizedWorkspace = supportsWorkspaceMultiplicity
? await createMultiplicityWorkspace({ ? await createMultiplicityWorkspace({
client: withConnectedClient(), client: connectedClient,
isolation: effectiveIsolation, isolation: effectiveIsolation,
project: selectedProject, project: selectedProject,
sourceDirectory: selectedSourceDirectory, sourceDirectory: selectedSourceDirectory,
selectedItem, checkoutRequest,
currentBranch,
withInitialAgent: input.withInitialAgent, withInitialAgent: input.withInitialAgent,
prompt: input.prompt, prompt: input.prompt,
attachments: input.attachments, attachments: input.attachments,
@@ -1942,8 +1933,8 @@ export function NewWorkspaceScreen({
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
}) })
: await createAndMergeWorkspace({ : await createAndMergeWorkspace({
client: withConnectedClient(), client: connectedClient,
createInput: buildCreateWorktreeInput(input), createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }),
mergeWorkspaces, mergeWorkspaces,
serverId: selectedServerId, serverId: selectedServerId,
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
@@ -1954,9 +1945,9 @@ export function NewWorkspaceScreen({
[ [
buildCreateWorktreeInput, buildCreateWorktreeInput,
createdWorkspace, createdWorkspace,
currentBranch,
effectiveIsolation, effectiveIsolation,
mergeWorkspaces, mergeWorkspaces,
queryClient,
selectedItem, selectedItem,
selectedProject, selectedProject,
selectedServerId, selectedServerId,
@@ -166,7 +166,11 @@ export function useNewWorkspaceProjectPicker({
(id: string) => { (id: string) => {
const project = projectByOptionId.get(id); const project = projectByOptionId.get(id);
if (!project) return; if (!project) return;
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return; if (
!allowAllProjects &&
!project.hosts.some((host) => host.worktreeSupport !== "unsupported")
)
return;
setProjectSelection({ setProjectSelection({
contextKey: manualSelectionContextKey, contextKey: manualSelectionContextKey,
project, project,
@@ -23,7 +23,7 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem {
serverId, serverId,
projectId: projectKey, projectId: projectKey,
iconWorkingDir: `/work/${projectKey}`, iconWorkingDir: `/work/${projectKey}`,
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
workspaceKeys: [], workspaceKeys: [],
@@ -127,7 +127,7 @@ describe("reconcileProjectSelection", () => {
serverId: "host", serverId: "host",
projectId: "local-project-id", projectId: "local-project-id",
iconWorkingDir: "/work/project", iconWorkingDir: "/work/project",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
}; };
@@ -138,7 +138,7 @@ describe("reconcileProjectSelection", () => {
serverId: "host", serverId: "host",
projectId: "local-project-id", projectId: "local-project-id",
iconWorkingDir: "/work/project", iconWorkingDir: "/work/project",
canCreateWorktree: false, worktreeSupport: "unsupported" as const,
}, },
], ],
}; };
@@ -301,7 +301,7 @@ describe("reconcileProjectSelection", () => {
serverId: "host-b", serverId: "host-b",
projectId: "prj_a", projectId: "prj_a",
iconWorkingDir: "/work/a", iconWorkingDir: "/work/a",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
}; };
@@ -338,7 +338,7 @@ describe("reconcileProjectSelection", () => {
serverId: "host-a", serverId: "host-a",
projectId: "prj_b", projectId: "prj_b",
iconWorkingDir: "/work/selected", iconWorkingDir: "/work/selected",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
}; };
@@ -421,7 +421,7 @@ describe("reconcileProjectSelection", () => {
serverId: "host-a", serverId: "host-a",
projectId: "prj_b", projectId: "prj_b",
iconWorkingDir: "/work/selected", iconWorkingDir: "/work/selected",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
}; };
@@ -466,7 +466,7 @@ describe("reconcileProjectSelection", () => {
serverId: "host-b", serverId: "host-b",
projectId: "shared", projectId: "shared",
iconWorkingDir: "/work/shared", iconWorkingDir: "/work/shared",
canCreateWorktree: false, worktreeSupport: "unsupported" as const,
}, },
], ],
}; };
@@ -619,7 +619,7 @@ describe("reconcileProjectSelection", () => {
serverId: "host", serverId: "host",
projectId: "sibling", projectId: "sibling",
iconWorkingDir: "/work/sibling", iconWorkingDir: "/work/sibling",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
}; };
@@ -44,7 +44,11 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
const projectKind = overrides.projectKind ?? "git"; const projectKind = overrides.projectKind ?? "git";
const hosts = Array.from( const hosts = Array.from(
overrides.hosts ?? [ overrides.hosts ?? [
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" }, {
serverId: "srv",
iconWorkingDir: "/repo",
worktreeSupport: projectKind === "git" ? "supported" : "unsupported",
},
], ],
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }), (host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
); );
@@ -135,8 +139,12 @@ describe("buildSidebarProjectRowModel", () => {
const result = buildSidebarProjectRowModel({ const result = buildSidebarProjectRowModel({
project: project({ project: project({
hosts: [ hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }, {
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, serverId: "host-a",
iconWorkingDir: "/repo/a",
worktreeSupport: "unsupported" as const,
},
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const },
], ],
}), }),
collapsed: false, collapsed: false,
@@ -155,8 +163,16 @@ describe("buildSidebarProjectRowModel", () => {
project: project({ project: project({
projectKind: "directory", projectKind: "directory",
hosts: [ hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }, {
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false }, serverId: "host-a",
iconWorkingDir: "/repo/a",
worktreeSupport: "unsupported" as const,
},
{
serverId: "host-b",
iconWorkingDir: "/repo/b",
worktreeSupport: "unsupported" as const,
},
], ],
}), }),
collapsed: false, collapsed: false,
@@ -197,8 +213,8 @@ describe("buildSidebarProjectRowModel", () => {
const iconTarget = resolveSidebarProjectIconTarget( const iconTarget = resolveSidebarProjectIconTarget(
project({ project({
hosts: [ hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const },
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }, { serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" as const },
], ],
}), }),
); );
@@ -214,7 +230,9 @@ describe("buildSidebarProjectRowModel", () => {
const [iconTarget] = resolveSidebarProjectIconTargets([ const [iconTarget] = resolveSidebarProjectIconTargets([
project({ project({
viewKey: '["placement","host-b","project-b"]', viewKey: '["placement","host-b","project-b"]',
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }], hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const },
],
}), }),
]); ]);
@@ -230,8 +248,12 @@ describe("buildSidebarProjectRowModel", () => {
const groupedProject = project({ const groupedProject = project({
iconWorkingDir: "/remote/repo", iconWorkingDir: "/remote/repo",
hosts: [ hosts: [
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true }, {
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true }, serverId: "remote",
iconWorkingDir: "/remote/repo",
worktreeSupport: "supported" as const,
},
{ serverId: "local", iconWorkingDir: "/local/repo", worktreeSupport: "supported" as const },
], ],
}); });
@@ -75,7 +75,10 @@ function resolveNewWorkspaceTarget(
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>, supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
): SidebarProjectHostTarget | null { ): SidebarProjectHostTarget | null {
for (const host of project.hosts) { for (const host of project.hosts) {
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) { if (
host.worktreeSupport === "unsupported" &&
!supportsMultiplicityByServerId.get(host.serverId)
) {
continue; continue;
} }
const target = hostTarget(host); const target = hostTarget(host);
@@ -55,7 +55,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
serverId: workspaces[0]?.serverId ?? "s1", serverId: workspaces[0]?.serverId ?? "s1",
projectId: projectKey, projectId: projectKey,
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
canCreateWorktree: true, worktreeSupport: "supported" as const,
}, },
], ],
workspaces, workspaces,
@@ -150,7 +150,7 @@ describe("buildSidebarShortcutModel", () => {
directoryProject.projectKind = "directory"; directoryProject.projectKind = "directory";
directoryProject.hosts = directoryProject.hosts.map((host) => ({ directoryProject.hosts = directoryProject.hosts.map((host) => ({
...host, ...host,
canCreateWorktree: false, worktreeSupport: "unsupported" as const,
})); }));
const model = buildSidebarShortcutModel({ const model = buildSidebarShortcutModel({