diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index e2cff2b36..e14fa7229 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -180,6 +180,18 @@ export async function goBackInSettings(page: Page): Promise { await page.getByRole("button", { name: "Back", exact: true }).click(); } +export async function closeCompactSettings(page: Page): Promise { + await goBackInSettings(page); + await expect(page).not.toHaveURL(/\/settings(\/|$)/); +} + +export async function removeCurrentHostFromSettings(page: Page): Promise { + await page.getByTestId("host-page-remove-host-button").click(); + await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible(); + await page.getByTestId("remove-host-confirm").click(); + await expect(page).toHaveURL(/\/settings$/); +} + export async function expectSettingsBackButton(page: Page): Promise { await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible(); } diff --git a/packages/app/e2e/settings-navigation.spec.ts b/packages/app/e2e/settings-navigation.spec.ts index e8b7ffac8..cd8c9d692 100644 --- a/packages/app/e2e/settings-navigation.spec.ts +++ b/packages/app/e2e/settings-navigation.spec.ts @@ -1,7 +1,9 @@ import { test, expect } from "./fixtures"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { gotoAppShell, openSettings } from "./helpers/app"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { + closeCompactSettings, openSettingsSection, expectSettingsHeader, openAddHostFlow, @@ -31,9 +33,18 @@ import { selectSettingsHost, expectSettingsHostPickerLabel, openSettingsHostSection, + removeCurrentHostFromSettings, } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; +async function openWorkspace( + page: import("@playwright/test").Page, + workspace: { workspaceId: string }, +) { + await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); + await expect(page.getByTestId("menu-button")).toBeVisible(); +} + test.describe("Settings sidebar navigation", () => { test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => { await gotoAppShell(page); @@ -187,4 +198,20 @@ test.describe("Settings — compact master-detail", () => { await openSettingsHostSection(page, secondaryServerId, "connections"); }); + + test("removing the last active host returns to welcome after settings closes", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "remove-host-compact-" }); + + await openWorkspace(page, workspace); + await openCompactSettings(page); + await openSettingsHostSection(page, getServerId(), "host"); + await removeCurrentHostFromSettings(page); + await closeCompactSettings(page); + + await expect(page).toHaveURL(/\/welcome$/); + await expect(page.getByTestId("welcome-direct-connection")).toBeVisible(); + }); }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 8adfa7286..a7d881be8 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -84,7 +84,6 @@ import { resolveActiveHost } from "@/utils/active-host"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; import { buildHostRootRoute, - mapPathnameToServer, parseHostAgentRouteFromPathname, parseServerIdFromPathname, parseWorkspaceOpenIntent, @@ -811,7 +810,6 @@ function OpenProjectListener() { } function AppWithSidebar({ children }: { children: ReactNode }) { - const router = useRouter(); const pathname = usePathname(); const params = useGlobalSearchParams<{ open?: string | string[] }>(); const hosts = useHosts(); @@ -820,16 +818,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) { const shouldShowAppChrome = storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId); - useEffect(() => { - if (!activeServerId || hosts.length === 0) { - return; - } - if (hosts.some((host) => host.serverId === activeServerId)) { - return; - } - router.replace(mapPathnameToServer(pathname, hosts[0].serverId)); - }, [activeServerId, hosts, pathname, router]); - // Parse selectedAgentKey directly from pathname // useLocalSearchParams doesn't update when navigating between same-pattern routes const selectedAgentKey = useMemo(() => { diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx new file mode 100644 index 000000000..2e6f67078 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -0,0 +1,25 @@ +import { Redirect, Slot, useLocalSearchParams } from "expo-router"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { useHosts } from "@/runtime/host-runtime"; +import { resolveKnownHostRoute } from "@/utils/host-routes"; + +export default function HostRouteLayout() { + return ( + + + + ); +} + +function KnownHostRoute() { + const params = useLocalSearchParams<{ serverId?: string | string[] }>(); + const hosts = useHosts(); + const routeServerId = typeof params.serverId === "string" ? params.serverId : null; + const resolution = resolveKnownHostRoute({ routeServerId, hosts }); + + if (resolution.kind === "redirect") { + return ; + } + + return ; +} diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 00fb6f7e5..1a2e7aca3 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -16,6 +16,7 @@ import { parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, parseWorkspaceOpenIntent, + resolveKnownHostRoute, } from "./host-routes"; describe("parseHostAgentRouteFromPathname", () => { @@ -190,3 +191,32 @@ describe("host settings section slugs", () => { expect(normalizeHostSectionSlug("daemon")).toBe("host"); }); }); + +describe("resolveKnownHostRoute", () => { + it("renders when the route host is still saved", () => { + expect( + resolveKnownHostRoute({ + routeServerId: "srv-current", + hosts: [{ serverId: "srv-current" }, { serverId: "srv-next" }], + }), + ).toEqual({ kind: "render" }); + }); + + it("sends removed host routes to the next saved host home", () => { + expect( + resolveKnownHostRoute({ + routeServerId: "srv-removed", + hosts: [{ serverId: "srv-next" }], + }), + ).toEqual({ kind: "redirect", href: "/h/srv-next/open-project" }); + }); + + it("sends host routes to welcome when no hosts are saved", () => { + expect( + resolveKnownHostRoute({ + routeServerId: "srv-removed", + hosts: [], + }), + ).toEqual({ kind: "redirect", href: "/welcome" }); + }); +}); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 256c98def..ec9825a3c 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -355,6 +355,27 @@ export function buildHostOpenProjectRoute(serverId: string) { return `${base}/open-project` as const; } +export type KnownHostRouteResolution = + | { kind: "render" } + | { kind: "redirect"; href: ReturnType | "/welcome" }; + +export function resolveKnownHostRoute(input: { + routeServerId: string | null | undefined; + hosts: readonly { serverId: string }[]; +}): KnownHostRouteResolution { + const routeServerId = trimNonEmpty(input.routeServerId); + if (routeServerId && input.hosts.some((host) => host.serverId === routeServerId)) { + return { kind: "render" }; + } + + const fallbackServerId = input.hosts[0]?.serverId; + if (fallbackServerId) { + return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) }; + } + + return { kind: "redirect", href: "/welcome" }; +} + export function buildHostNewWorkspaceRoute( serverId: string, sourceDirectory?: string,