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
@@ -271,6 +271,48 @@ test.describe("Workspace navigation regression", () => {
await expect(page).toHaveURL(/\/settings\/general$/);
});
test("cold workspace URL keeps sidebar workspace navigation functional", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const workspaceClient = await connectNewWorkspaceDaemonClient();
const workspaceIds = new Set<string>();
const firstRepo = await createTempGitRepo("workspace-cold-url-a-");
const secondRepo = await createTempGitRepo("workspace-cold-url-b-");
try {
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
workspaceIds.add(firstWorkspace.workspaceId);
workspaceIds.add(secondWorkspace.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await waitForSidebarHydration(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000,
});
const secondRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${secondWorkspace.workspaceId}`,
);
await expect(secondRow).toBeVisible({ timeout: 30_000 });
await secondRow.click();
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
timeout: 30_000,
});
} finally {
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await workspaceClient.close().catch(() => undefined);
await secondRepo.cleanup();
await firstRepo.cleanup();
}
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {