diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 4448e2382..e404682a2 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -159,7 +159,7 @@ export async function openNewWorkspaceComposer( await expect(button).toBeVisible({ timeout: 30_000 }); await button.click(); - await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, { + await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000, }); } @@ -167,7 +167,7 @@ export async function openNewWorkspaceComposer( export async function openGlobalNewWorkspaceComposer(page: Page): Promise { await page.getByTestId("sidebar-global-new-workspace").click(); - await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, { + await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000, }); } diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index eaf033050..a77a5a56a 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -6,7 +6,9 @@ import { openGlobalNewWorkspaceComposer, openNewWorkspaceComposer, } from "./helpers/new-workspace"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { seedSavedSettingsHosts } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; @@ -38,6 +40,19 @@ test.describe("New workspace entry points", () => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-global-button-" }); try { + await seedSavedSettingsHosts(page, [ + { + serverId: getServerId(), + label: "localhost", + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, + }, + { + serverId: "secondary-new-workspace-host", + label: "Secondary host", + endpoint: "127.0.0.1:9", + }, + ]); + await gotoAppShell(page); await waitForSidebarHydration(page); await expect( @@ -48,11 +63,12 @@ test.describe("New workspace entry points", () => { await expect(globalButton).toBeVisible({ timeout: 30_000 }); await openGlobalNewWorkspaceComposer(page); + await expect(page.getByTestId("host-chooser")).toHaveCount(0); - // The screen is up: its project picker trigger is the canonical landmark. await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({ timeout: 30_000, }); + await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 }); } finally { await seeded.cleanup(); } diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5f019d7cb..33f58c560 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -878,7 +878,11 @@ function AppWithSidebar({ children }: { children: ReactNode }) { const routeHasKnownHost = routeServerId !== null && hosts.some((host) => host.serverId === routeServerId); const shouldShowAppChrome = - storeReady && (pathname === "/open-project" || pathname === "/sessions" || routeHasKnownHost); + storeReady && + (pathname === "/open-project" || + pathname === "/new" || + pathname === "/sessions" || + routeHasKnownHost); // Parse selectedAgentKey directly from pathname // useLocalSearchParams doesn't update when navigating between same-pattern routes @@ -933,6 +937,7 @@ function RootStack() { + diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index 0fb551f30..70f7fcf55 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -39,7 +39,6 @@ function KnownHostRoute() { - ); diff --git a/packages/app/src/app/h/[serverId]/new.tsx b/packages/app/src/app/new.tsx similarity index 82% rename from packages/app/src/app/h/[serverId]/new.tsx rename to packages/app/src/app/new.tsx index c59d85be3..98fc99b99 100644 --- a/packages/app/src/app/h/[serverId]/new.tsx +++ b/packages/app/src/app/new.tsx @@ -2,7 +2,7 @@ import { useLocalSearchParams } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { NewWorkspaceScreen } from "@/screens/new-workspace-screen"; -export default function HostNewWorkspaceRoute() { +export default function NewWorkspaceRoute() { const params = useLocalSearchParams<{ serverId?: string; dir?: string; @@ -13,10 +13,17 @@ export default function HostNewWorkspaceRoute() { const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined; const displayName = typeof params.name === "string" ? params.name : undefined; const projectId = typeof params.projectId === "string" ? params.projectId : undefined; + const screenKey = JSON.stringify([ + serverId, + sourceDirectory ?? null, + displayName ?? null, + projectId ?? null, + ]); return ( { showMobileAgent(); @@ -178,13 +176,8 @@ export const LeftSidebar = memo(function LeftSidebar({ }, [openProjectPicker]); const handleNewWorkspaceNavigate = useCallback(() => { - chooseHost({ - title: "Choose host", - onChooseHost: (serverId) => { - router.push(buildHostNewWorkspaceRoute(serverId)); - }, - }); - }, [chooseHost]); + router.push(buildNewWorkspaceRoute()); + }, []); const handleSettingsMobile = useCallback(() => { showMobileAgent(); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 75e123535..7ae9d0571 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -59,7 +59,7 @@ import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { - buildHostNewWorkspaceRoute, + buildNewWorkspaceRoute, buildProjectSettingsRoute, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; @@ -992,7 +992,9 @@ function NewWorkspaceGhostRow({ const handlePress = useCallback(() => { onWorkspacePress?.(); router.navigate( - buildHostNewWorkspaceRoute(worktreeTarget.serverId, worktreeTarget.iconWorkingDir, { + buildNewWorkspaceRoute({ + serverId: worktreeTarget.serverId, + sourceDirectory: worktreeTarget.iconWorkingDir, displayName, projectId: project.projectKey, }) as Href, @@ -1287,7 +1289,9 @@ function ProjectHeaderRow({ } onWorkspacePress?.(); router.navigate( - buildHostNewWorkspaceRoute(worktreeTarget.serverId, worktreeTarget.iconWorkingDir, { + buildNewWorkspaceRoute({ + serverId: worktreeTarget.serverId, + sourceDirectory: worktreeTarget.iconWorkingDir, displayName, projectId: project.projectKey, }) as Href, diff --git a/packages/app/src/hooks/use-active-worktree-new-action.ts b/packages/app/src/hooks/use-active-worktree-new-action.ts index b8a27dd3e..7d79baf0e 100644 --- a/packages/app/src/hooks/use-active-worktree-new-action.ts +++ b/packages/app/src/hooks/use-active-worktree-new-action.ts @@ -4,7 +4,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useSessionStore } from "@/stores/session-store"; -import { buildHostNewWorkspaceRoute } from "@/utils/host-routes"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"]; @@ -14,7 +14,7 @@ export function useActiveWorktreeNewAction() { const serverId = selection?.serverId ?? null; const workspaceId = selection?.workspaceId ?? null; - const workingDir = useSessionStore((state) => { + const activeGitWorkspace = useSessionStore((state) => { if (!serverId || !workspaceId) { return null; } @@ -22,31 +22,30 @@ export function useActiveWorktreeNewAction() { if (!workspace || workspace.projectKind !== "git") { return null; } - return workspace.projectRootPath; + return workspace; }); - const displayName = useSessionStore((state) => { - if (!serverId || !workspaceId) { - return null; - } - const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId); - if (!workspace || workspace.projectKind !== "git") { - return null; - } - return workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId); - }); + const workingDir = activeGitWorkspace?.projectRootPath ?? null; + const projectId = activeGitWorkspace?.projectId ?? null; + const displayName = activeGitWorkspace + ? activeGitWorkspace.projectDisplayName || + projectDisplayNameFromProjectId(activeGitWorkspace.projectId) + : null; const handle = useCallback(() => { if (!serverId || !workingDir) { return false; } router.navigate( - buildHostNewWorkspaceRoute(serverId, workingDir, { + buildNewWorkspaceRoute({ + serverId, + sourceDirectory: workingDir, displayName: displayName ?? undefined, + projectId: projectId ?? undefined, }) as never, ); return true; - }, [serverId, workingDir, displayName]); + }, [serverId, workingDir, displayName, projectId]); useKeyboardActionHandler({ handlerId: "worktree-new-active", diff --git a/packages/app/src/hooks/use-global-new-workspace-action.ts b/packages/app/src/hooks/use-global-new-workspace-action.ts index c3500975c..abd526614 100644 --- a/packages/app/src/hooks/use-global-new-workspace-action.ts +++ b/packages/app/src/hooks/use-global-new-workspace-action.ts @@ -2,27 +2,29 @@ import { useCallback } from "react"; import { router } from "expo-router"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; +import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { buildHostNewWorkspaceRoute } from "@/utils/host-routes"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"]; export function useGlobalNewWorkspaceAction() { const selection = useActiveWorkspaceSelection(); const serverId = selection?.serverId ?? null; + const hosts = useHosts(); const handle = useCallback(() => { - if (!serverId) { + if (hosts.length === 0) { return false; } - router.navigate(buildHostNewWorkspaceRoute(serverId) as never); + router.navigate(buildNewWorkspaceRoute(serverId ? { serverId } : undefined) as never); return true; - }, [serverId]); + }, [hosts.length, serverId]); useKeyboardActionHandler({ handlerId: "workspace-new-global", actions: WORKSPACE_NEW_ACTIONS, - enabled: serverId !== null, + enabled: hosts.length > 0, priority: 0, handle, }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 0daa884fb..d1074f716 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -976,11 +976,24 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { function useNewWorkspaceHostSelector(initialServerId: string) { const allHosts = useHosts(); const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]); - const [selectedServerId, setSelectedServerId] = useState(initialServerId); + const lastWorkspaceSelection = useLastWorkspaceSelection(); + const normalizedInitialServerId = initialServerId.trim(); + const routeInitialServerId = allServerIds.includes(normalizedInitialServerId) + ? normalizedInitialServerId + : null; + const fallbackServerId = + lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId) + ? lastWorkspaceSelection.serverId + : (allServerIds[0] ?? ""); + const [manualServerId, setManualServerId] = useState(null); const [hostPickerOpen, setHostPickerOpen] = useState(false); + const selectedServerId = + manualServerId && allServerIds.includes(manualServerId) + ? manualServerId + : (routeInitialServerId ?? fallbackServerId); const handleSelectHost = useCallback((id: string) => { - setSelectedServerId(id); + setManualServerId(id); setHostPickerOpen(false); }, []); @@ -996,9 +1009,7 @@ function useNewWorkspaceHostSelector(initialServerId: string) { allHosts, allServerIds, selectedServerId, - setSelectedServerId, hostPickerOpen, - setHostPickerOpen, handleSelectHost, handleHostPickerOpenChange, openHostPicker, diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 9ccce6090..ca5ec3ae7 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { buildHostAgentDetailRoute, - buildHostNewWorkspaceRoute, buildHostRootRoute, buildHostWorkspaceOpenRoute, buildHostWorkspaceRoute, + buildNewWorkspaceRoute, buildOpenProjectRoute, resolveKnownHostRoute, buildSessionsRoute, @@ -149,19 +149,6 @@ describe("workspace route parsing", () => { ).toBe("/h/local/workspace/b64_L3RtcC9yZXBv"); }); - it("builds a global new workspace route without a source directory", () => { - expect(buildHostNewWorkspaceRoute("local")).toBe("/h/local/new"); - }); - - it("builds a project shortcut new workspace route with initial project context", () => { - expect( - buildHostNewWorkspaceRoute("local", "/repo/project", { - displayName: "Project", - projectId: "project-1", - }), - ).toBe("/h/local/new?dir=%2Frepo%2Fproject&name=Project&projectId=project-1"); - }); - it("round-trips URL-safe IDs through encode/decode", () => { const ids = ["1", "40", "164", "9999", "workspace-1", "opaque_id.v2~test"]; for (const id of ids) { @@ -216,6 +203,25 @@ describe("global routes", () => { it("buildSessionsRoute returns the all-host Sessions route", () => { expect(buildSessionsRoute()).toBe("/sessions"); }); + + it("buildNewWorkspaceRoute returns the all-host New Workspace route", () => { + expect(buildNewWorkspaceRoute()).toBe("/new"); + }); + + it("buildNewWorkspaceRoute accepts an initial host", () => { + expect(buildNewWorkspaceRoute({ serverId: "local" })).toBe("/new?serverId=local"); + }); + + it("buildNewWorkspaceRoute accepts initial project context", () => { + expect( + buildNewWorkspaceRoute({ + serverId: "local", + sourceDirectory: "/repo/project", + displayName: "Project", + projectId: "project-1", + }), + ).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&name=Project&projectId=project-1"); + }); }); describe("host settings section slugs", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 8b9141eea..999ef60d5 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -414,6 +414,39 @@ export function buildOpenProjectRoute() { return "/open-project" as const; } +interface NewWorkspaceRouteOptions { + serverId?: string; + sourceDirectory?: string; + displayName?: string; + projectId?: string; +} + +function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string { + const params = new URLSearchParams(); + const serverId = trimNonEmpty(options.serverId); + if (serverId) { + params.set("serverId", serverId); + } + if (options.sourceDirectory) { + params.set("dir", options.sourceDirectory); + } + if (options.displayName) { + params.set("name", options.displayName); + } + if (options.projectId) { + params.set("projectId", options.projectId); + } + return params.toString(); +} + +export function buildNewWorkspaceRoute(options: NewWorkspaceRouteOptions = {}) { + const query = buildNewWorkspaceSearch(options); + if (!query) { + return "/new" as const; + } + return `/new?${query}` as const; +} + export type KnownHostRouteResolution = | { kind: "render" } | { kind: "redirect"; href: ReturnType | "/welcome" }; @@ -434,32 +467,6 @@ export function resolveKnownHostRoute(input: { return { kind: "redirect", href: "/welcome" }; } -export function buildHostNewWorkspaceRoute( - serverId: string, - sourceDirectory?: string, - options?: { displayName?: string; projectId?: string }, -) { - const base = buildHostRootRoute(serverId); - if (base === "/") { - return "/" as const; - } - const params = new URLSearchParams(); - if (sourceDirectory) { - params.set("dir", sourceDirectory); - } - if (options?.displayName) { - params.set("name", options.displayName); - } - if (options?.projectId) { - params.set("projectId", options.projectId); - } - const query = params.toString(); - if (!query) { - return `${base}/new` as const; - } - return `${base}/new?${query}` as const; -} - export const SETTINGS_SECTION_SLUGS = [ "general", "daemon", diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index c52420228..0bed394be 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -40,7 +40,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => { archivedWorkspaceId: "/repo/.paseo/worktrees/feature", workspaces, }), - ).toBe("/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1"); + ).toBe("/new?serverId=server-1&dir=%2Frepo&name=Project&projectId=project-1"); }); it("redirects to the new workspace route when no sibling workspace target exists", () => { @@ -58,7 +58,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => { archivedWorkspaceId: "/repo/.paseo/worktrees/feature", workspaces, }), - ).toBe("/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1"); + ).toBe("/new?serverId=server-1&dir=%2Frepo&name=Project&projectId=project-1"); }); it("redirects to the new workspace route instead of another workspace", () => { @@ -78,7 +78,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => { archivedWorkspaceId: "/notes", workspaces, }), - ).toBe("/h/server-1/new?dir=%2Fnotes&name=Project&projectId=notes"); + ).toBe("/new?serverId=server-1&dir=%2Fnotes&name=Project&projectId=notes"); }); }); @@ -136,6 +136,6 @@ describe("redirectIfArchivingActiveWorkspace", () => { ), ).toBe(true); - expect(routes).toEqual(["/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1"]); + expect(routes).toEqual(["/new?serverId=server-1&dir=%2Frepo&name=Project&projectId=project-1"]); }); }); diff --git a/packages/app/src/utils/workspace-archive-navigation.ts b/packages/app/src/utils/workspace-archive-navigation.ts index 6117d3b74..6c799880d 100644 --- a/packages/app/src/utils/workspace-archive-navigation.ts +++ b/packages/app/src/utils/workspace-archive-navigation.ts @@ -1,6 +1,6 @@ import type { Href } from "expo-router"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes"; +import { buildHostRootRoute, buildNewWorkspaceRoute } from "@/utils/host-routes"; import { resolveWorkspaceRouteId } from "@/utils/workspace-identity"; export function buildWorkspaceArchiveRedirectRoute(input: { @@ -23,7 +23,9 @@ export function buildWorkspaceArchiveRedirectRoute(input: { return buildHostRootRoute(input.serverId); } - return buildHostNewWorkspaceRoute(input.serverId, sourceDirectory, { + return buildNewWorkspaceRoute({ + serverId: input.serverId, + sourceDirectory, displayName: archivedWorkspace.projectDisplayName, projectId: archivedWorkspace.projectId, });