Fix workspace navigation regression on web (#772)

Centralize workspace navigation behind a small route-derived API so the workspace shell reads active selection from the current Expo Router route instead of a separately synchronized store.

Remove the stale layout-driven active-workspace write that regressed web after 02e5b564.
This commit is contained in:
Mohamed Boudra
2026-05-06 13:22:31 +08:00
committed by GitHub
parent 0eacc98e17
commit 868479c493
26 changed files with 221 additions and 956 deletions
@@ -13,6 +13,18 @@ vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
const pathnameState = vi.hoisted(() => ({
value: "/",
}));
vi.mock("expo-router", () => ({
router: {
dismissTo: vi.fn(),
},
useLocalSearchParams: () => ({}),
usePathname: () => pathnameState.value,
}));
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
@@ -28,12 +40,7 @@ import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import {
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
@@ -222,10 +229,11 @@ function ProjectActiveProbe({
project: SidebarProjectEntry;
counts: RenderCounts;
}): null {
useIsNavigationProjectActive({
serverId,
workspaceIds: project.workspaces.map((entry) => entry.workspaceId),
});
const activeSelection = useActiveWorkspaceSelection();
const isActive =
activeSelection?.serverId === serverId &&
project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId);
void isActive;
incrementRecord(counts.projectSelection, project.projectKey);
return null;
}
@@ -239,7 +247,10 @@ function WorkspaceSelectionProbe({
workspaceId: string;
counts: RenderCounts;
}): null {
useIsNavigationWorkspaceSelected({ serverId, workspaceId });
const activeSelection = useActiveWorkspaceSelection();
const selected =
activeSelection?.serverId === serverId && activeSelection.workspaceId === workspaceId;
void selected;
incrementRecord(counts.rowSelection, workspaceId);
return null;
}
@@ -304,12 +315,16 @@ async function renderProbe(counts: RenderCounts): Promise<{ root: Root; containe
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<SidebarFrameProbe counts={counts} />);
renderSidebarFrame(root, counts);
});
resetCounts(counts);
return { root, container };
}
function renderSidebarFrame(root: Root, counts: RenderCounts) {
root.render(<SidebarFrameProbe counts={counts} />);
}
describe("sidebar workspace render isolation", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -328,7 +343,7 @@ describe("sidebar workspace render isolation", () => {
container?.remove();
container = null;
act(() => {
syncNavigationActiveWorkspace({ current: null });
pathnameState.value = "/";
getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
@@ -417,7 +432,7 @@ describe("sidebar workspace render isolation", () => {
});
});
it("isolates active selection updates to affected row and project boolean probes", async () => {
it("updates active selection probes from the active workspace route", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
@@ -427,29 +442,28 @@ describe("sidebar workspace render isolation", () => {
};
act(() => {
activateNavigationWorkspaceSelection({
serverId: SERVER_ID,
workspaceId: "a-one",
});
pathnameState.value = `/h/${SERVER_ID}/workspace/a-one`;
});
({ root, container } = await renderProbe(counts));
act(() => {
activateNavigationWorkspaceSelection({
serverId: SERVER_ID,
workspaceId: "b-two",
});
pathnameState.value = `/h/${SERVER_ID}/workspace/b-two`;
if (root) {
renderSidebarFrame(root, counts);
}
});
expect(counts.frame).toBe(0);
expect(counts.headers).toEqual({});
expect(counts.rows).toEqual({});
expect(counts.frame).toBe(1);
expect(counts.projectSelection).toEqual({
"project-a": 1,
"project-b": 1,
});
expect(counts.rowSelection).toEqual({
"a-main": 1,
"a-one": 1,
"a-two": 1,
"b-main": 1,
"b-one": 1,
"b-two": 1,
});
});
@@ -97,10 +97,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import {
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -1482,6 +1479,7 @@ function WorkspaceRowWithMenu({
isCreating?: boolean;
}) {
const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
@@ -1502,8 +1500,9 @@ function WorkspaceRowWithMenu({
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection,
});
}, [workspace.serverId, workspace.workspaceId]);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => {
if (isArchiving) {
@@ -1686,13 +1685,15 @@ function NonGitProjectRowWithMenuContent({
}) {
const toast = useToast();
const contextMenu = useContextMenu();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection,
});
}, [workspace.serverId, workspace.workspaceId]);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1839,11 +1840,11 @@ function FlattenedProjectRow({
selectionEnabled: boolean;
}) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId,
workspaceId: rowModel.workspace.workspaceId,
enabled: selectionEnabled,
});
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
selectionEnabled &&
activeWorkspaceSelection?.serverId === serverId &&
activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId;
if (!workspace) {
return null;
@@ -1970,11 +1971,11 @@ function WorkspaceRow({
selectionEnabled: boolean;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
enabled: selectionEnabled,
});
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
selectionEnabled &&
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection.workspaceId === workspace.workspaceId;
if (!hydratedWorkspace) {
return null;
@@ -2050,11 +2051,11 @@ function ProjectBlock({
() => project.workspaces.map((workspace) => workspace.workspaceId),
[project.workspaces],
);
const isProjectActive = useIsNavigationProjectActive({
serverId,
workspaceIds: projectWorkspaceIds,
enabled: selectionEnabled,
});
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const isProjectActive =
selectionEnabled &&
activeWorkspaceSelection?.serverId === serverId &&
projectWorkspaceIds.includes(activeWorkspaceSelection.workspaceId);
const renderWorkspaceRow = useCallback(
(
@@ -195,7 +195,6 @@ export function WorkspaceSetupDialog() {
serverId: pendingWorkspaceSetup.serverId,
workspaceId,
target,
navigationMethod: pendingWorkspaceSetup.navigationMethod,
});
},
[clearWorkspaceSetup, pendingWorkspaceSetup],
@@ -67,7 +67,7 @@ vi.mock("expo-router", () => ({
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useNavigationActiveWorkspaceSelection: () => activeSelection.value,
useActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({
@@ -5,7 +5,7 @@ import { useEffect } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
@@ -45,7 +45,7 @@ function hasSetupCommands(config: PaseoConfigRaw): boolean {
}
export function WorktreeSetupCalloutSource() {
const selection = useNavigationActiveWorkspaceSelection();
const selection = useActiveWorkspaceSelection();
const activeProject = useWorkspaceFields(
selection?.serverId ?? null,
selection?.workspaceId ?? null,