diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 7a021fb26..55caebd6b 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -190,6 +190,29 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise }); } +export async function openMissingProjectNewWorkspaceComposer( + page: Page, + input: { serverId: string; projectId: string; sourceDirectory: string }, +): Promise { + 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 { + 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 { await page.keyboard.press("Control+P"); @@ -286,9 +309,8 @@ export async function selectWorkspaceIsolation( await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); - // "New worktree" is only listed once the checkout status query confirms the - // selected project is a git repo, so wait for the option to appear before - // clicking it. + // Isolation options are derived from project capability. Wait for the option + // so this helper also covers route-to-project reconciliation. const option = page.getByTestId(`workspace-create-isolation-${isolation}`); await expect(option).toBeVisible({ timeout: 30_000 }); await option.click(); diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 920b03415..bc4c64229 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -2,8 +2,10 @@ import { expect, test } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { connectNewWorkspaceDaemonClient, + expectNewWorkspaceControlsEnabled, expectNewWorkspaceProjectSelected, openGlobalNewWorkspaceComposer, + openMissingProjectNewWorkspaceComposer, openNewWorkspaceComposer, openNewWorkspaceProjectPickerWithShortcut, } from "./helpers/new-workspace"; @@ -13,7 +15,7 @@ import { seedSavedSettingsHosts } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; import { projectEquivalenceViewKey } from "./helpers/project-view-key"; 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 // 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 }) => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" }); diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index d456efa3b..9338d55a4 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -45,7 +45,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt serverId: "srv", projectId: "project", iconWorkingDir: "/repo", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaces, diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 4bd435e4e..df8952749 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -14,6 +14,7 @@ import { import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store"; import { applyCheckoutStatusUpdateFromEvent, + ensureCheckoutStatus, type CheckoutPrStatusPayload, type CheckoutStatusPayload, 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", () => { it("writes the checkout status to the cache using the cwd from the payload", () => { const queryClient = createQueryClient(); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 79ba03a41..1f76dc67b 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -35,6 +35,24 @@ export async function fetchCheckoutStatus({ return payload; } +export async function ensureCheckoutStatus({ + queryClient, + client, + serverId, + cwd, +}: { + queryClient: QueryClient; + client: CheckoutStatusClient; + serverId: string; + cwd: string; +}): Promise { + return await queryClient.fetchQuery({ + queryKey: checkoutStatusQueryKey(serverId, cwd), + queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }), + staleTime: Infinity, + }); +} + export function applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 5f4e666d0..d108f9d86 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -93,7 +93,7 @@ function project(input: { { serverId: "srv", iconWorkingDir: input.iconWorkingDir ?? input.projectKey, - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], (host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }), @@ -251,7 +251,7 @@ describe("buildSidebarProjectsFromStructure", () => { { serverId: "relay:paseo-host", iconWorkingDir: "/repo/project-1", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaceKeys: ["relay:paseo-host:ws-main"], @@ -276,8 +276,16 @@ describe("shared sidebar workspace model", () => { projectName: "getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo", 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"], }), @@ -334,13 +342,13 @@ describe("shared sidebar workspace model", () => { serverId: "host-a", projectId: "getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, { serverId: "host-b", projectId: "getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaces: [ @@ -470,14 +478,22 @@ describe("shouldShowSidebarHostLabels", () => { project({ projectKey: "project-a", 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"], }), project({ projectKey: "project-b", 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"], }), @@ -493,8 +509,16 @@ describe("shouldShowSidebarHostLabels", () => { project({ projectKey: "getpaseo/paseo", 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"], }), diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 6feb14cb8..330822799 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -23,7 +23,7 @@ export interface SidebarWorkspacePlacement { projectName: string; projectRootPath?: string; workspaceDirectory?: string; - projectKind: WorkspaceDescriptor["projectKind"]; + projectKind: WorkspaceStructureProject["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; } @@ -52,7 +52,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement { export interface SidebarProjectEntry { viewKey: string; projectName: string; - projectKind: WorkspaceDescriptor["projectKind"]; + projectKind: WorkspaceStructureProject["projectKind"]; iconWorkingDir: string; hosts: WorkspaceStructureHostPlacement[]; workspaces: SidebarWorkspacePlacement[]; diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index c0a2d8c1a..525ea647b 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -35,14 +35,14 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }), projectKey: null, projectName: trimOptional(route.displayName) || projectId, - projectKind: "git", + projectKind: "unknown", iconWorkingDir, hosts: [ { serverId: route.serverId, projectId, iconWorkingDir, - canCreateWorktree: true, + worktreeSupport: "unknown", }, ], workspaceKeys: [], @@ -80,7 +80,7 @@ export function hostProjectFromWorkspace(input: { serverId: input.serverId, projectId: input.workspace.projectId, iconWorkingDir, - canCreateWorktree: canCreate, + worktreeSupport: canCreate ? "supported" : "unsupported", }, ], workspaceKeys: [`${input.serverId}:${input.workspace.id}`], @@ -88,7 +88,7 @@ export function hostProjectFromWorkspace(input: { } function projectCanCreateWorktree(project: HostProjectListItem): boolean { - return project.hosts.some((h) => h.canCreateWorktree); + return project.hosts.some((host) => host.worktreeSupport !== "unsupported"); } function getHostProjectPlacement( @@ -101,6 +101,13 @@ function getHostProjectPlacement( return null; } +export function getWorktreeSupportForHostProject(input: { + project: HostProjectListItem; + serverId: string; +}): WorkspaceStructureHostPlacement["worktreeSupport"] { + return getHostProjectPlacement(input.project, input.serverId)?.worktreeSupport ?? "unknown"; +} + export function getHostProjectSourceDirectory( project: HostProjectListItem, serverId: string, @@ -121,7 +128,7 @@ export function canCreateWorkspaceForHostProject(input: { if (!host) { return false; } - return input.allowAllProjects || host.canCreateWorktree; + return input.allowAllProjects || host.worktreeSupport !== "unsupported"; } export function filterWorkspaceProjectsForHost(input: { diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts index 6267ea267..dc07b1726 100644 --- a/packages/app/src/projects/host-projects.test.ts +++ b/packages/app/src/projects/host-projects.test.ts @@ -4,6 +4,7 @@ import { canCreateWorkspaceForHostProject, getHostProjectId, getHostProjectSourceDirectory, + getWorktreeSupportForHostProject, hostProjectFromRoute, } from "./host-project-model"; @@ -19,13 +20,13 @@ function project(): HostProjectListItem { serverId: "host-a", projectId: "prj_a", iconWorkingDir: "/repo/a", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, { serverId: "host-b", projectId: "prj_b", iconWorkingDir: "/repo/b", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaceKeys: [], @@ -49,6 +50,38 @@ describe("host project lookups", () => { ).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", () => { expect( hostProjectFromRoute({ diff --git a/packages/app/src/projects/host-projects.ts b/packages/app/src/projects/host-projects.ts index 094a459b0..3d874bedb 100644 --- a/packages/app/src/projects/host-projects.ts +++ b/packages/app/src/projects/host-projects.ts @@ -7,6 +7,7 @@ export { filterWorkspaceProjectsForHost, getHostProjectSourceDirectory, getHostProjectId, + getWorktreeSupportForHostProject, hostProjectFromRoute, hostProjectFromWorkspace, resolveHostProjectCandidate, diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index db25238c3..9cd08cc54 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -5,14 +5,14 @@ export interface WorkspaceStructureHostPlacement { serverId: string; projectId: string; iconWorkingDir: string; - canCreateWorktree: boolean; + worktreeSupport: "supported" | "unsupported" | "unknown"; } export interface WorkspaceStructureProject { viewKey: string; projectKey: string | null; projectName: string; - projectKind: WorkspaceDescriptor["projectKind"]; + projectKind: WorkspaceDescriptor["projectKind"] | "unknown"; iconWorkingDir: string; hosts: WorkspaceStructureHostPlacement[]; workspaceKeys: string[]; @@ -157,7 +157,7 @@ function addProjectToView(input: { serverId, projectId: project.projectId, iconWorkingDir: project.projectRootPath, - canCreateWorktree: project.projectKind === "git", + worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported", }; const draft = byProject.get(viewKey); if (!draft) { diff --git a/packages/app/src/screens/new-workspace-initial-context.test.ts b/packages/app/src/screens/new-workspace-initial-context.test.ts index 969aae6d9..8b18c5443 100644 --- a/packages/app/src/screens/new-workspace-initial-context.test.ts +++ b/packages/app/src/screens/new-workspace-initial-context.test.ts @@ -13,7 +13,14 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem { projectName: key, projectKind: "git", iconWorkingDir: `/work/${key}`, - hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }], + hosts: [ + { + serverId, + projectId: key, + iconWorkingDir: `/work/${key}`, + worktreeSupport: "supported" as const, + }, + ], workspaceKeys: [], }; } diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts index 23d3edae9..0d223dd32 100644 --- a/packages/app/src/screens/new-workspace-picker-item.test.ts +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; 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 = { 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(); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index f276510ea..32371e942 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -1,5 +1,6 @@ import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; +import type { CheckoutStatusPayload } from "@/git/checkout-status-cache"; export type PickerItem = | { 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, + }; +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 6948f8370..eb541c325 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -8,7 +8,7 @@ import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { Composer } from "@/composer"; 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 { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useForgeSearchQuery } from "@/git/use-forge-search-query"; +import { useCheckoutStatusQuery } from "@/git/use-status-query"; +import { ensureCheckoutStatus } from "@/git/checkout-status-cache"; import { useHostRuntimeClient, useHostRuntimeConnectionStatuses, @@ -67,6 +69,7 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp import { getHostProjectSourceDirectory, getHostProjectId, + getWorktreeSupportForHostProject, hostProjectFromRoute, hostProjectFromWorkspace, useHostProjects, @@ -87,7 +90,7 @@ import { remapDraftCwdToWorkspace, } from "./new-workspace-fork-context"; import { - pickerItemToCheckoutRequest, + resolveCheckoutRequest, type PickerCheckoutRequest, type PickerItem, } from "./new-workspace-picker-item"; @@ -109,19 +112,6 @@ const addProjectIcon = ( ); -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: { draftId: string | undefined; selectedServerId: string; @@ -564,7 +554,8 @@ function NewWorkspaceProjectPickerOption({ active={active} disabled={ isPending || - (!supportsWorkspaceMultiplicity && !project.hosts.some((host) => host.canCreateWorktree)) + (!supportsWorkspaceMultiplicity && + !project.hosts.some((host) => host.worktreeSupport !== "unsupported")) } onPress={onPress} /> @@ -697,21 +688,20 @@ interface WorkspaceIsolationState { showRefPicker: boolean; } -// Worktree isolation only makes sense for a git checkout. The effective isolation -// falls back to local whenever the selected directory isn't git so the flow -// never submits an impossible request. +// Preserve the user's worktree choice while route metadata is provisional. Once +// the authoritative placement arrives, unsupported projects fall back to local. function useWorkspaceIsolation(input: { supportsMultiplicity: boolean; - selectedIsGit: boolean; + worktreeSupport: "supported" | "unsupported" | "unknown"; }): WorkspaceIsolationState { - const { supportsMultiplicity, selectedIsGit } = input; + const { supportsMultiplicity, worktreeSupport } = input; // The last isolation choice is remembered alongside the other New Workspace // form preferences (provider, model, mode). A manual in-screen pick overrides // the remembered default until the screen remounts. const { preferences, updatePreferences } = useFormPreferences(); const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null); const isolation = manualIsolation ?? preferences.isolation ?? "local"; - const canCreateWorktree = supportsMultiplicity && selectedIsGit; + const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported"; const isWorktree = isolation === "worktree" && canCreateWorktree; const setIsolation = useCallback( @@ -812,8 +802,7 @@ async function createMultiplicityWorkspace(input: { isolation: "local" | "worktree"; project: HostProjectListItem; sourceDirectory: string; - selectedItem: PickerItem | null; - currentBranch: string | null; + checkoutRequest: PickerCheckoutRequest | undefined; withInitialAgent: boolean; prompt: string; attachments: AgentAttachment[]; @@ -827,9 +816,6 @@ async function createMultiplicityWorkspace(input: { const projectId = getHostProjectId(input.project, input.serverId); if (!projectId) throw new Error("Project is not available on the selected host"); const isWorktree = input.isolation === "worktree"; - const checkoutRequest = isWorktree - ? resolveCheckoutRequest(input.selectedItem, input.currentBranch) - : undefined; const firstAgentContext = buildFirstAgentContext({ prompt: input.prompt, attachments: input.attachments, @@ -841,7 +827,7 @@ async function createMultiplicityWorkspace(input: { cwd: input.sourceDirectory, projectId, worktreeSlug: createNameId(), - ...checkoutRequest, + ...input.checkoutRequest, } : { kind: "directory", @@ -1518,6 +1504,7 @@ export function NewWorkspaceScreen({ displayName: displayNameProp, draftId, }: NewWorkspaceScreenProps) { + const queryClient = useQueryClient(); const { theme } = useUnistyles(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -1567,7 +1554,6 @@ export function NewWorkspaceScreen({ }, [pickerSearchQuery]); const workspace = createdWorkspace; - const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1646,27 +1632,20 @@ export function NewWorkspaceScreen({ const hasSelectedSourceDirectory = selectedSourceDirectory !== null; const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory; - const checkoutStatusQuery = useQuery({ - queryKey: ["checkout-status", selectedServerId, selectedSourceDirectory], - queryFn: async () => { - 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 { status: checkoutStatus } = useCheckoutStatusQuery({ + serverId: selectedServerId, + cwd: selectedSourceDirectory ?? "", }); - 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 } = useWorkspaceIsolation({ supportsMultiplicity: supportsWorkspaceMultiplicity, - selectedIsGit: checkoutStatusQuery.data?.isGit === true, + worktreeSupport, }); const branchSuggestionsQuery = useQuery({ @@ -1885,6 +1864,7 @@ export function NewWorkspaceScreen({ cwd: string; prompt: string; attachments: AgentAttachment[]; + checkoutRequest: PickerCheckoutRequest | undefined; }): CreatePaseoWorktreeInput => { if (!selectedProject) { throw new Error("Choose a project"); @@ -1892,7 +1872,6 @@ export function NewWorkspaceScreen({ if (!selectedSourceDirectory) { throw new Error("Choose a host for this project"); } - const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch); const firstAgentContext = buildFirstAgentContext(input); const hostProjectId = getHostProjectId(selectedProject, selectedServerId); if (!hostProjectId) { @@ -1904,10 +1883,10 @@ export function NewWorkspaceScreen({ projectId: hostProjectId, worktreeSlug: createNameId(), ...(firstAgentContext ? { firstAgentContext } : {}), - ...checkoutRequest, + ...input.checkoutRequest, }; }, - [currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory], + [selectedProject, selectedServerId, selectedSourceDirectory], ); const ensureWorkspace = useCallback( @@ -1926,14 +1905,26 @@ export function NewWorkspaceScreen({ if (!selectedSourceDirectory) { 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 ? await createMultiplicityWorkspace({ - client: withConnectedClient(), + client: connectedClient, isolation: effectiveIsolation, project: selectedProject, sourceDirectory: selectedSourceDirectory, - selectedItem, - currentBranch, + checkoutRequest, withInitialAgent: input.withInitialAgent, prompt: input.prompt, attachments: input.attachments, @@ -1942,8 +1933,8 @@ export function NewWorkspaceScreen({ createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), }) : await createAndMergeWorkspace({ - client: withConnectedClient(), - createInput: buildCreateWorktreeInput(input), + client: connectedClient, + createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }), mergeWorkspaces, serverId: selectedServerId, createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), @@ -1954,9 +1945,9 @@ export function NewWorkspaceScreen({ [ buildCreateWorktreeInput, createdWorkspace, - currentBranch, effectiveIsolation, mergeWorkspaces, + queryClient, selectedItem, selectedProject, selectedServerId, diff --git a/packages/app/src/screens/new-workspace/project-picker.ts b/packages/app/src/screens/new-workspace/project-picker.ts index 54f4ab703..0d52b4dfa 100644 --- a/packages/app/src/screens/new-workspace/project-picker.ts +++ b/packages/app/src/screens/new-workspace/project-picker.ts @@ -166,7 +166,11 @@ export function useNewWorkspaceProjectPicker({ (id: string) => { const project = projectByOptionId.get(id); if (!project) return; - if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return; + if ( + !allowAllProjects && + !project.hosts.some((host) => host.worktreeSupport !== "unsupported") + ) + return; setProjectSelection({ contextKey: manualSelectionContextKey, project, diff --git a/packages/app/src/screens/new-workspace/project-selection.test.ts b/packages/app/src/screens/new-workspace/project-selection.test.ts index 0ec8caea8..2f4ea240b 100644 --- a/packages/app/src/screens/new-workspace/project-selection.test.ts +++ b/packages/app/src/screens/new-workspace/project-selection.test.ts @@ -23,7 +23,7 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem { serverId, projectId: projectKey, iconWorkingDir: `/work/${projectKey}`, - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaceKeys: [], @@ -127,7 +127,7 @@ describe("reconcileProjectSelection", () => { serverId: "host", projectId: "local-project-id", iconWorkingDir: "/work/project", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; @@ -138,7 +138,7 @@ describe("reconcileProjectSelection", () => { serverId: "host", projectId: "local-project-id", iconWorkingDir: "/work/project", - canCreateWorktree: false, + worktreeSupport: "unsupported" as const, }, ], }; @@ -301,7 +301,7 @@ describe("reconcileProjectSelection", () => { serverId: "host-b", projectId: "prj_a", iconWorkingDir: "/work/a", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; @@ -338,7 +338,7 @@ describe("reconcileProjectSelection", () => { serverId: "host-a", projectId: "prj_b", iconWorkingDir: "/work/selected", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; @@ -421,7 +421,7 @@ describe("reconcileProjectSelection", () => { serverId: "host-a", projectId: "prj_b", iconWorkingDir: "/work/selected", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; @@ -466,7 +466,7 @@ describe("reconcileProjectSelection", () => { serverId: "host-b", projectId: "shared", iconWorkingDir: "/work/shared", - canCreateWorktree: false, + worktreeSupport: "unsupported" as const, }, ], }; @@ -619,7 +619,7 @@ describe("reconcileProjectSelection", () => { serverId: "host", projectId: "sibling", iconWorkingDir: "/work/sibling", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; 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 429502014..6101963f7 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -44,7 +44,11 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry { const projectKind = overrides.projectKind ?? "git"; const hosts = Array.from( 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}` }), ); @@ -135,8 +139,12 @@ describe("buildSidebarProjectRowModel", () => { const result = buildSidebarProjectRowModel({ project: project({ 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, @@ -155,8 +163,16 @@ describe("buildSidebarProjectRowModel", () => { project: project({ projectKind: "directory", 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, @@ -197,8 +213,8 @@ describe("buildSidebarProjectRowModel", () => { const iconTarget = resolveSidebarProjectIconTarget( project({ hosts: [ - { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, - { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }, + { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const }, + { serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" as const }, ], }), ); @@ -214,7 +230,9 @@ describe("buildSidebarProjectRowModel", () => { const [iconTarget] = resolveSidebarProjectIconTargets([ project({ 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({ iconWorkingDir: "/remote/repo", 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 }, ], }); diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 0333ca3d1..10c0943c1 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -75,7 +75,10 @@ function resolveNewWorkspaceTarget( supportsMultiplicityByServerId: ReadonlyMap, ): SidebarProjectHostTarget | null { for (const host of project.hosts) { - if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) { + if ( + host.worktreeSupport === "unsupported" && + !supportsMultiplicityByServerId.get(host.serverId) + ) { continue; } const target = hostTarget(host); diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 7e91d5243..196705c2b 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -55,7 +55,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb serverId: workspaces[0]?.serverId ?? "s1", projectId: projectKey, iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaces, @@ -150,7 +150,7 @@ describe("buildSidebarShortcutModel", () => { directoryProject.projectKind = "directory"; directoryProject.hosts = directoryProject.hosts.map((host) => ({ ...host, - canCreateWorktree: false, + worktreeSupport: "unsupported" as const, })); const model = buildSidebarShortcutModel({