Make New Workspace an app-wide screen (#1746)

* fix(app): make new workspace global

New Workspace now opens as an app-wide screen. Host and project context can still preselect the form through query params, but the route itself no longer lives under a host.

* fix(app): simplify new workspace selection
This commit is contained in:
Mohamed Boudra
2026-06-26 15:50:02 +08:00
committed by GitHub
parent 14a91d889c
commit 3fbd82664d
14 changed files with 140 additions and 89 deletions
+2 -2
View File
@@ -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<void> {
await page.getByTestId("sidebar-global-new-workspace").click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
+17 -1
View File
@@ -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();
}
+6 -1
View File
@@ -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() {
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="pair-scan" />
@@ -39,7 +39,6 @@ function KnownHostRoute() {
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="new" />
<Stack.Screen name="settings" />
</Stack>
);
@@ -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 (
<HostRouteBootstrapBoundary>
<NewWorkspaceScreen
key={screenKey}
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
+3 -10
View File
@@ -30,7 +30,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useHostChooser } from "@/hosts/host-chooser";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
@@ -52,7 +51,7 @@ import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
buildHostNewWorkspaceRoute,
buildNewWorkspaceRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsHostSectionRoute,
@@ -166,7 +165,6 @@ export const LeftSidebar = memo(function LeftSidebar({
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker();
const chooseHost = useHostChooser();
const handleOpenProjectMobile = useCallback(() => {
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();
@@ -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,
@@ -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",
@@ -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,
});
@@ -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<string | null>(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,
+20 -14
View File
@@ -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", () => {
+33 -26
View File
@@ -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<typeof buildOpenProjectRoute> | "/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",
@@ -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"]);
});
});
@@ -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,
});