Fix cold workspace URL sidebar navigation

Directly opening a workspace URL created a different React Navigation state shape than entering from /. Expo Router parsed the URL as a workspace layout route with a nested index child whose route.path and params pointed at the original workspace. Later sidebar navigation used router.dismissTo for another workspace. React Navigation's POP_TO reused the existing workspace route by name and updated the parent params, but preserved the nested child state. The result was a hybrid state: parent workspace params for B, child index path for A. Expo Router serialized the focused child path, so the address bar stayed on A and sidebar clicks looked like no-ops.

The fix is to stop using workspace/[workspaceId]/_layout.tsx as the actual screen. That layout did not render a Slot, so it only added an extra navigation level for Expo Router to preserve. Move the workspace UI, bootstrap boundary, open-intent consumption, and retained WorkspaceDeck into the leaf index route, making /h/:serverId/workspace/:workspaceId one real rendered route.

Keep workspace switches on navigateToWorkspace/router.dismissTo. This is not another replace workaround: replace previously created duplicate mounted workspace shells. The route-shape fix removes the stale nested child state that made dismissTo fail after cold deep links.

Also route archive/new-agent and notification flows through the centralized workspace navigation path so they do not bypass the same route contract.

Regression coverage: cold-load workspace A, click workspace B in the sidebar, and assert the URL changes to B. Verified with workspace-navigation-regression.spec.ts, settings-navigation.spec.ts, npm run typecheck, and npm run lint.
This commit is contained in:
Mohamed Boudra
2026-05-06 15:13:53 +07:00
parent 868479c493
commit 5e5fc97798
13 changed files with 342 additions and 296 deletions
@@ -2,25 +2,11 @@ import { describe, expect, it } from "vitest";
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import {
buildNewAgentRoute,
parseAgentKey,
resolveNewAgentWorkingDir,
resolveSelectedAgentForNewAgent,
} from "./new-agent-routing";
describe("buildNewAgentRoute", () => {
it("falls back to server workspace route with dot workspace when no working directory is provided", () => {
expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/workspace/.");
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/workspace/.");
});
it("encodes the working directory as a workspace path segment", () => {
expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe(
"/h/srv-1/workspace/b64_L1VzZXJzL21lL2Rldi9wYXNlbw",
);
});
});
describe("resolveNewAgentWorkingDir", () => {
it("returns the current cwd for regular checkouts", () => {
expect(resolveNewAgentWorkingDir("/repo/path", null)).toBe("/repo/path");
@@ -1,7 +1,6 @@
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import {
parseHostWorkspaceOpenIntentFromPathname,
buildHostWorkspaceRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
@@ -68,8 +67,3 @@ export function resolveNewAgentWorkingDir(
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
}
export function buildNewAgentRoute(serverId: string, workingDir?: string | null) {
const trimmedWorkingDir = workingDir?.trim();
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
}
@@ -45,14 +45,14 @@ describe("resolveNotificationTarget", () => {
});
describe("buildNotificationRoute", () => {
it("routes to workspace with one-shot open intent when workspace id is present", () => {
it("routes to the agent path when workspace id is present", () => {
expect(
buildNotificationRoute({
serverId: "srv-1",
agentId: "agent-1",
workspaceId: "ws-main",
}),
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1");
).toBe("/h/srv-1/agent/agent-1");
});
it("routes directly to server-scoped agent path when both ids are present", () => {
+2 -10
View File
@@ -1,9 +1,5 @@
import type { Href } from "expo-router";
import {
buildHostAgentDetailRoute,
buildHostRootRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildHostRootRoute } from "@/utils/host-routes";
type NotificationData = Record<string, unknown> | null | undefined;
type NotificationRoute = Extract<Href, string>;
@@ -30,12 +26,8 @@ export function resolveNotificationTarget(data: NotificationData): {
}
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
const { serverId, agentId, workspaceId } = resolveNotificationTarget(data);
const { serverId, agentId } = resolveNotificationTarget(data);
if (serverId && agentId) {
if (workspaceId) {
const base = buildHostWorkspaceRoute(serverId, workspaceId);
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}` as const;
}
return buildHostAgentDetailRoute(serverId, agentId);
}
if (serverId) {