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
+1 -34
View File
@@ -3,13 +3,7 @@ import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import {
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import {
createContext,
type ReactNode,
@@ -76,10 +70,6 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
@@ -863,28 +853,6 @@ function RootStack() {
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
useEffect(() => {
syncNavigationActiveWorkspace(navigationRef);
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
const unsubscribeState = navigationRef.addListener("state", () => {
syncNavigationActiveWorkspace(navigationRef);
});
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
syncNavigationActiveWorkspace(navigationRef);
});
return () => {
unsubscribeBrowserLocation();
unsubscribeState();
unsubscribeReady();
};
}, [navigationRef]);
return null;
}
function AppShell() {
return (
<SidebarAnimationProvider>
@@ -927,7 +895,6 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<NavigationActiveWorkspaceObserver />
<RootProviders>
<RuntimeProviders>
<AppShell />
@@ -55,7 +55,6 @@ function HostAgentReadyRouteContent() {
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
navigationMethod: "replace",
currentPathname: pathname,
});
}
@@ -107,7 +106,6 @@ function HostAgentReadyRouteContent() {
serverId,
workspaceId,
target: { kind: "agent", agentId },
navigationMethod: "replace",
currentPathname: pathname,
});
return;
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import {
@@ -9,9 +9,8 @@ import {
} from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import {
activateNavigationWorkspaceSelection,
type ActiveWorkspaceSelection,
useNavigationActiveWorkspaceSelection,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
@@ -105,24 +104,6 @@ function HostWorkspaceLayoutContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
const routeWorkspaceSelection = useMemo(
() =>
serverId && workspaceId
? {
serverId,
workspaceId,
}
: null,
[serverId, workspaceId],
);
useEffect(() => {
if (!routeWorkspaceSelection) {
return;
}
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
}, [routeWorkspaceSelection]);
useEffect(() => {
if (!openValue) {
return;
@@ -186,7 +167,7 @@ function HostWorkspaceLayoutContent() {
return null;
}
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
return <WorkspaceDeck />;
}
function areWorkspaceSelectionsEqual(
@@ -196,12 +177,8 @@ function areWorkspaceSelectionsEqual(
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
}
function WorkspaceDeck({
fallbackSelection,
}: {
fallbackSelection: ActiveWorkspaceSelection | null;
}) {
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
function WorkspaceDeck() {
const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
);
+1 -14
View File
@@ -18,7 +18,6 @@ const { redirectMock, state } = vi.hoisted(() => {
storeReady: false,
} as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null,
};
@@ -50,8 +49,7 @@ vi.mock("@/screens/startup-splash-screen", () => ({
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
useActiveWorkspaceSelection: () => state.workspaceSelection,
}));
describe("Index route startup navigation", () => {
@@ -68,7 +66,6 @@ describe("Index route startup navigation", () => {
storeReady: false,
};
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
redirectMock.mockReset();
@@ -98,16 +95,6 @@ describe("Index route startup navigation", () => {
expect(redirectMock).not.toHaveBeenCalled();
});
it("shows the startup splash while the workspace selection has not loaded", async () => {
state.anyOnlineHostServerId = "server-1";
state.isWorkspaceSelectionLoaded = false;
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the online host matches its server id", async () => {
state.anyOnlineHostServerId = "server-1";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
+4 -9
View File
@@ -3,10 +3,7 @@ import { Redirect, usePathname } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
import {
getLastNavigationWorkspaceRouteSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded,
} from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const isDesktop = shouldUseDesktopDaemon();
@@ -15,15 +12,13 @@ export default function Index() {
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded();
const workspaceSelection = useActiveWorkspaceSelection();
const redirectRoute = resolveStartupRedirectRoute({
pathname,
anyOnlineHostServerId,
workspaceSelection: isWorkspaceSelectionLoaded
? getLastNavigationWorkspaceRouteSelection()
: null,
isWorkspaceSelectionLoaded,
workspaceSelection,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
@@ -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,
@@ -2,7 +2,7 @@ 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 { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
@@ -10,7 +10,7 @@ import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
export function useActiveWorktreeNewAction() {
const selection = useNavigationActiveWorkspaceSelection();
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
@@ -24,8 +24,8 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
getLastNavigationWorkspaceRouteSelection,
getNavigationActiveWorkspaceSelection,
navigateToLastWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
export function useKeyboardShortcuts({
@@ -55,6 +55,7 @@ export function useKeyboardShortcuts({
});
const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
useEffect(() => {
if (!enabled) return;
@@ -96,6 +97,8 @@ export function useKeyboardShortcuts({
case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
case "navigate-last-workspace":
return navigateToLastWorkspace();
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
@@ -194,8 +197,7 @@ export function useKeyboardShortcuts({
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
navigationActiveWorkspace: activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
@@ -275,6 +277,7 @@ export function useKeyboardShortcuts({
bindings,
cycleTheme,
enabled,
activeWorkspaceSelection,
isMobile,
openProjectPickerAction,
pathname,
@@ -1,59 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* @vitest-environment jsdom
*/
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { dismissToMock } = vi.hoisted(() => ({
dismissToMock: vi.fn(),
const navigateToWorkspaceMock = vi.hoisted(() => vi.fn());
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
}));
vi.mock("expo-router", () => ({
router: {
dismissTo: dismissToMock,
},
}));
import { navigateToWorkspace, useWorkspaceNavigation } from "@/hooks/use-workspace-navigation";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import {
activateNavigationWorkspaceSelection,
getNavigationActiveWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
describe("navigateToWorkspace", () => {
beforeEach(() => {
dismissToMock.mockReset();
syncNavigationActiveWorkspace({ current: null });
describe("useWorkspaceNavigation", () => {
it("re-exports the workspace navigation action", () => {
expect(navigateToWorkspace).toBe(navigateToWorkspaceMock);
});
it("dismisses to the workspace route from a non-workspace route even when active selection is stale", () => {
activateNavigationWorkspaceSelection({
serverId: "server-1",
workspaceId: "workspace-a",
});
it("returns a stable callback wrapper", () => {
const { result } = renderHook(() => useWorkspaceNavigation());
navigateToWorkspace("server-1", "workspace-b", {
currentPathname: "/h/server-1/sessions",
});
result.current.navigateToWorkspace("server-1", "workspace-a");
expect(dismissToMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b");
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
it("keeps retained workspace switching on a workspace route", () => {
activateNavigationWorkspaceSelection({
serverId: "server-1",
workspaceId: "workspace-a",
});
navigateToWorkspace("server-1", "workspace-b", {
currentPathname: "/h/server-1/workspace/workspace-a",
});
expect(dismissToMock).not.toHaveBeenCalled();
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", undefined);
});
});
@@ -1,61 +1,16 @@
import { useCallback } from "react";
import { router } from "expo-router";
import {
activateNavigationWorkspaceSelection,
getNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
interface NavigateToWorkspaceOptions {
currentPathname?: string | null;
}
function shouldUseRetainedWorkspaceSwitch(input: {
hasActiveWorkspace: boolean;
currentPathname?: string | null;
}): boolean {
if (!input.hasActiveWorkspace) {
return false;
}
if (input.currentPathname == null) {
return false;
}
return parseHostWorkspaceRouteFromPathname(input.currentPathname) !== null;
}
/**
* Open a workspace. Once the workspace shell is mounted, switching workspaces
* is app-level state so native-stack does not rebuild every retained screen.
*/
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
options: NavigateToWorkspaceOptions = {},
) {
const activeWorkspace = getNavigationActiveWorkspaceSelection();
if (
shouldUseRetainedWorkspaceSwitch({
hasActiveWorkspace: activeWorkspace !== null,
currentPathname: options.currentPathname,
})
) {
activateNavigationWorkspaceSelection(
{ serverId, workspaceId },
{ updateBrowserHistory: true, historyMode: "push" },
);
return;
}
const href = buildHostWorkspaceRoute(serverId, workspaceId);
router.dismissTo(href);
}
export { navigateToWorkspace };
export function useWorkspaceNavigation() {
return {
navigateToWorkspace: useCallback(
(serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => {
(
serverId: string,
workspaceId: string,
options?: Parameters<typeof navigateToWorkspace>[2],
) => {
navigateToWorkspace(serverId, workspaceId, options);
},
[],
@@ -19,7 +19,6 @@ function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): ShortcutRouti
isMobile: false,
sidebarShortcutTargets: SIDEBAR_TARGETS,
navigationActiveWorkspace: null,
lastNavigationWorkspaceRoute: null,
commandCenterOpen: false,
shortcutsDialogOpen: false,
...overrides,
@@ -253,39 +252,25 @@ describe("routeKeyboardShortcut — settings.toggle", () => {
).toEqual<ShortcutAction>({ kind: "router-push", route: "/settings" });
});
it("replaces to the retained workspace route when leaving settings on desktop", () => {
it("navigates to the last workspace when leaving settings on desktop", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({
pathname: "/settings/general",
isMobile: false,
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
}),
),
).toEqual<ShortcutAction>({
kind: "router-replace",
route: "/h/srv/workspace/ws-2",
});
).toEqual<ShortcutAction>({ kind: "navigate-last-workspace" });
});
it("falls back to router.back() when no retained workspace exists", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({ pathname: "/settings/general", lastNavigationWorkspaceRoute: null }),
),
).toEqual<ShortcutAction>({ kind: "router-back" });
});
it("falls back to router.back() on mobile even if a retained workspace exists", () => {
it("falls back to router.back() on mobile", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({
pathname: "/settings/general",
isMobile: true,
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
}),
),
).toEqual<ShortcutAction>({ kind: "router-back" });
+4 -14
View File
@@ -1,10 +1,6 @@
import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import {
buildHostWorkspaceRoute,
buildSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { buildSettingsRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import {
getRelativeSidebarShortcutTarget,
type SidebarShortcutWorkspaceTarget,
@@ -15,7 +11,6 @@ export interface ShortcutRoutingContext {
isMobile: boolean;
sidebarShortcutTargets: ReadonlyArray<SidebarShortcutWorkspaceTarget>;
navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null;
lastNavigationWorkspaceRoute: SidebarShortcutWorkspaceTarget | null;
commandCenterOpen: boolean;
shortcutsDialogOpen: boolean;
}
@@ -35,6 +30,7 @@ export type ShortcutAction =
| { kind: "none" }
| { kind: "dispatch"; action: KeyboardActionDefinition }
| { kind: "navigate-workspace"; serverId: string; workspaceId: string }
| { kind: "navigate-last-workspace" }
| { kind: "router-replace"; route: string }
| { kind: "router-back" }
| { kind: "router-push"; route: string }
@@ -166,14 +162,8 @@ function routeSettingsToggle(ctx: ShortcutRoutingContext): ShortcutAction {
if (!ctx.pathname.startsWith("/settings")) {
return { kind: "router-push", route: buildSettingsRoute() };
}
if (!ctx.isMobile && ctx.lastNavigationWorkspaceRoute) {
return {
kind: "router-replace",
route: buildHostWorkspaceRoute(
ctx.lastNavigationWorkspaceRoute.serverId,
ctx.lastNavigationWorkspaceRoute.workspaceId,
),
};
if (!ctx.isMobile) {
return { kind: "navigate-last-workspace" };
}
return { kind: "router-back" };
}
@@ -377,7 +377,6 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
serverId,
workspaceId,
target: { kind: "draft", draftId },
navigationMethod: "replace",
});
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
}
+2 -5
View File
@@ -83,8 +83,7 @@ import {
buildSettingsSectionRoute,
type SettingsSectionSlug,
} from "@/utils/host-routes";
import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-active-workspace-store";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
// ---------------------------------------------------------------------------
// View model
@@ -961,9 +960,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}, [router]);
const handleBackToWorkspace = useCallback(() => {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
navigateToWorkspace(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId);
if (navigateToLastWorkspace()) {
return;
}
if (anyOnlineServerId) {
@@ -1,271 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* @vitest-environment jsdom
*/
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => {
const storage = new Map<string, string>();
return {
storage,
getItem: vi.fn(async (key: string): Promise<string | null> => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string): Promise<void> => {
storage.set(key, value);
}),
};
});
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
const routerMock = vi.hoisted(() => ({
dismissTo: vi.fn(),
}));
const pathnameState = vi.hoisted(() => ({
value: "/",
}));
const localParamsState = vi.hoisted(() => ({
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
vi.mock("expo-router", () => ({
router: routerMock,
useLocalSearchParams: () => localParamsState.value,
usePathname: () => pathnameState.value,
}));
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
import {
navigateToLastWorkspace,
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function installWindowStub(pathname: string) {
const windowStub = {
location: {
href: "",
origin: "http://localhost",
pathname: "",
search: "",
hash: "",
},
history: {
pushState: vi.fn((_state: unknown, _title: string, url: string) => {
updateLocation(url);
}),
replaceState: vi.fn((_state: unknown, _title: string, url: string) => {
updateLocation(url);
}),
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
function updateLocation(url: string) {
const next = new URL(url, windowStub.location.origin);
windowStub.location = {
href: next.href,
origin: next.origin,
pathname: next.pathname,
search: next.search,
hash: next.hash,
};
}
updateLocation(pathname);
vi.stubGlobal("window", windowStub);
return windowStub;
}
function createNavigationRef(serverId: string, workspaceId: string) {
return {
current: {
getCurrentRoute: () => ({
params: { serverId, workspaceId },
}),
},
};
}
function createNavigationPathRef(path: string) {
return {
current: {
getCurrentRoute: () => ({
path,
}),
},
};
}
function createNavigationPathWithParamsRef(path: string, serverId: string, workspaceId: string) {
return {
current: {
getCurrentRoute: () => ({
path,
params: { serverId, workspaceId },
}),
},
};
}
describe("navigation active workspace store", () => {
describe("workspace navigation", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.storage.clear();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
asyncStorageMock.getItem.mockImplementation(
async (key: string): Promise<string | null> => asyncStorageMock.storage.get(key) ?? null,
);
asyncStorageMock.setItem.mockImplementation(
async (key: string, value: string): Promise<void> => {
asyncStorageMock.storage.set(key, value);
},
);
routerMock.dismissTo.mockReset();
pathnameState.value = "/";
localParamsState.value = {};
});
afterEach(() => {
vi.unstubAllGlobals();
it("reports when no last workspace is known", () => {
expect(navigateToLastWorkspace()).toBe(false);
});
it("keeps explicit web workspace activation ahead of a stale navigation route sync", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
it("navigates to a workspace route", () => {
navigateToWorkspace("server-1", "workspace-a");
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
store.activateNavigationWorkspaceSelection(
{ serverId: "server-1", workspaceId: "workspace-b" },
{ updateBrowserHistory: true, historyMode: "push" },
);
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("clears a stale browser workspace when navigation sync reports a non-workspace route", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
it("reads the active workspace from the current route", () => {
pathnameState.value = "/h/server-1/workspace/workspace-a";
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
const { result } = renderHook(() => useActiveWorkspaceSelection());
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
expect(result.current).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
it("falls back to workspace route params during cold route mount", () => {
localParamsState.value = {
serverId: "server-1",
workspaceId: "workspace-a",
});
workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ",
};
store.syncNavigationActiveWorkspace(
createNavigationPathWithParamsRef("/h/server-1/sessions", "server-1", "workspace-a"),
);
const { result } = renderHook(() => useActiveWorkspaceSelection());
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
});
it("uses a one-shot workspace route override when returning to a retained shell", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
store.overrideNextNavigationWorkspaceRouteSelection({
expect(result.current).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
workspaceId: "/tmp/paseo-missing-workspace",
});
});
it("hydrates the last workspace route selection from storage on startup", async () => {
const storedSelection = createDeferred<string | null>();
asyncStorageMock.getItem.mockReturnValue(storedSelection.promise);
installWindowStub("/open-project");
it("navigates to the last workspace observed by the route reader", () => {
pathnameState.value = "/h/server-1/workspace/workspace-a";
renderHook(() => useActiveWorkspaceSelection());
const store = await import("@/stores/navigation-active-workspace-store");
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(false);
const hydration = store.hydrateLastNavigationWorkspaceRouteSelection();
storedSelection.resolve(JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }));
await hydration;
expect(asyncStorageMock.getItem).toHaveBeenCalledWith(
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
);
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
});
it("hydrates empty and corrupt workspace route storage as null", async () => {
installWindowStub("/open-project");
let store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
vi.resetModules();
asyncStorageMock.getItem.mockResolvedValueOnce("{not json");
store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
});
it("persists valid last workspace route selections", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
await Promise.resolve(); // flush the fire-and-forget setItem microtask
vi.resetModules();
const freshStore = await import("@/stores/navigation-active-workspace-store");
await freshStore.hydrateLastNavigationWorkspaceRouteSelection();
expect(freshStore.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(freshStore.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
expect(navigateToLastWorkspace()).toBe(true);
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
});
@@ -1,376 +1,72 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMemo, useSyncExternalStore } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
export interface ActiveWorkspaceSelection {
serverId: string;
workspaceId: string;
}
interface ActivateWorkspaceSelectionOptions {
updateBrowserHistory?: boolean;
historyMode?: "push" | "replace";
interface NavigateToWorkspaceOptions {
currentPathname?: string | null;
}
interface NavigationRouteParams {
let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null;
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return "";
}
function parseWorkspaceSelectionFromRouteParams(params: {
serverId?: string | string[];
workspaceId?: string | string[];
}
interface NavigationRouteLike {
params?: NavigationRouteParams | null;
path?: string | null;
}
type NavigationWorkspaceRouteState =
| { kind: "workspace"; selection: ActiveWorkspaceSelection }
| { kind: "nonWorkspace" }
| { kind: "unknown" };
interface NavigationObserverRef {
current: {
getCurrentRoute(): NavigationRouteLike | null | undefined;
} | null;
}
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
let snapshot: ActiveWorkspaceSelection | null = null;
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
let isLastWorkspaceRouteSelectionLoaded = false;
let lastWorkspaceRouteSelectionRevision = 0;
let lastWorkspaceRouteSelectionHydrationPromise: Promise<void> | null = null;
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): ActiveWorkspaceSelection | null {
return snapshot;
}
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
return;
}
snapshot = next;
notifyListeners();
}
function parseStoredLastWorkspaceRouteSelection(
stored: string | null,
): ActiveWorkspaceSelection | null {
if (!stored) {
return null;
}
try {
return normalizeActiveWorkspaceSelection(JSON.parse(stored));
} catch {
return null;
}
}
function normalizeActiveWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
const selection = input as Record<string, unknown>;
const serverId = typeof selection.serverId === "string" ? selection.serverId.trim() : "";
const workspaceId = typeof selection.workspaceId === "string" ? selection.workspaceId.trim() : "";
}): ActiveWorkspaceSelection | null {
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null;
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
}
function persistLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
void AsyncStorage.setItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY, JSON.stringify(next)).catch(
() => {},
);
}
function setLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
const normalized = normalizeActiveWorkspaceSelection(next);
if (!normalized) {
return;
}
if (
lastWorkspaceRouteSelection?.serverId === normalized.serverId &&
lastWorkspaceRouteSelection.workspaceId === normalized.workspaceId
) {
return;
}
lastWorkspaceRouteSelectionRevision += 1;
lastWorkspaceRouteSelection = normalized;
persistLastWorkspaceRouteSelection(normalized);
}
async function readLastWorkspaceRouteSelectionFromStorage(hydrationRevision: number) {
try {
const stored = await AsyncStorage.getItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY);
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = parseStoredLastWorkspaceRouteSelection(stored);
}
} catch {
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = null;
}
} finally {
isLastWorkspaceRouteSelectionLoaded = true;
notifyListeners();
}
}
function getBrowserLocationWorkspace(): ActiveWorkspaceSelection | null {
if (!isWeb || typeof window === "undefined") {
return null;
}
return parseHostWorkspaceRouteFromPathname(window.location.pathname);
}
function writeBrowserWorkspaceUrl(
next: ActiveWorkspaceSelection,
options: ActivateWorkspaceSelectionOptions,
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
_options: NavigateToWorkspaceOptions = {},
) {
if (!options.updateBrowserHistory || !isWeb || typeof window === "undefined") {
return;
}
const nextPath = buildHostWorkspaceRoute(next.serverId, next.workspaceId);
const currentUrl = new URL(window.location.href);
if (currentUrl.pathname === nextPath && !currentUrl.search && !currentUrl.hash) {
return;
}
const nextUrl = new URL(nextPath, window.location.origin);
const mode = options.historyMode ?? "push";
if (mode === "replace") {
window.history.replaceState(null, "", nextUrl.toString());
return;
}
window.history.pushState(null, "", nextUrl.toString());
lastWorkspaceSelection = { serverId, workspaceId };
router.dismissTo(buildHostWorkspaceRoute(serverId, workspaceId) as Href);
}
function extractActiveWorkspaceFromRoute(
route: NavigationRouteLike | null | undefined,
): ActiveWorkspaceSelection | null {
if (!route) {
return null;
export function navigateToLastWorkspace(): boolean {
if (!lastWorkspaceSelection) {
return false;
}
if (typeof route.path === "string") {
const parsed = parseHostWorkspaceRouteFromPathname(route.path);
if (parsed) {
return parsed;
}
}
const serverValue = Array.isArray(route.params?.serverId)
? route.params.serverId[0]
: route.params?.serverId;
const workspaceValue = Array.isArray(route.params?.workspaceId)
? route.params.workspaceId[0]
: route.params?.workspaceId;
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
const workspaceId =
typeof workspaceValue === "string"
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
return true;
}
function classifyNavigationWorkspaceRoute(
route: NavigationRouteLike | null | undefined,
): NavigationWorkspaceRouteState {
if (!route) {
return { kind: "unknown" };
}
if (typeof route.path === "string") {
const selection = parseHostWorkspaceRouteFromPathname(route.path);
if (selection) {
return { kind: "workspace", selection };
}
return { kind: "nonWorkspace" };
}
const selection = extractActiveWorkspaceFromRoute(route);
export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const selection =
parseHostWorkspaceRouteFromPathname(usePathname()) ??
parseWorkspaceSelectionFromRouteParams(params);
if (selection) {
return { kind: "workspace", selection };
lastWorkspaceSelection = selection;
}
return { kind: "unknown" };
}
function getActiveWorkspaceForNavigationSync(
route: NavigationRouteLike | null | undefined,
): ActiveWorkspaceSelection | null {
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
const browserWorkspace = getBrowserLocationWorkspace();
return browserWorkspace ?? routeState.selection;
}
if (routeState.kind === "nonWorkspace") {
return null;
}
return getBrowserLocationWorkspace();
}
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
const route = navigationRef.current?.getCurrentRoute();
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
setLastWorkspaceRouteSelection(routeState.selection);
if (nextWorkspaceRouteSelectionOverride) {
const overrideSelection = nextWorkspaceRouteSelectionOverride;
nextWorkspaceRouteSelectionOverride = null;
emitIfChanged(overrideSelection);
return;
}
}
emitIfChanged(getActiveWorkspaceForNavigationSync(route));
}
export function activateNavigationWorkspaceSelection(
next: ActiveWorkspaceSelection,
options: ActivateWorkspaceSelectionOptions = {},
) {
setLastWorkspaceRouteSelection(next);
writeBrowserWorkspaceUrl(next, options);
emitIfChanged(next);
}
export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
return getSnapshot();
}
export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelection | null {
return lastWorkspaceRouteSelection;
}
export function getIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return isLastWorkspaceRouteSelectionLoaded;
}
export function hydrateLastNavigationWorkspaceRouteSelection(): Promise<void> {
if (lastWorkspaceRouteSelectionHydrationPromise) {
return lastWorkspaceRouteSelectionHydrationPromise;
}
const hydrationRevision = lastWorkspaceRouteSelectionRevision;
lastWorkspaceRouteSelectionHydrationPromise =
readLastWorkspaceRouteSelectionFromStorage(hydrationRevision);
return lastWorkspaceRouteSelectionHydrationPromise;
}
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
nextWorkspaceRouteSelectionOverride = next;
}
export function syncBrowserActiveWorkspaceFromLocation() {
emitIfChanged(getBrowserLocationWorkspace());
}
export function addBrowserActiveWorkspaceLocationListener(): () => void {
if (!isWeb || typeof window === "undefined") {
return () => {};
}
const handlePopState = () => {
syncBrowserActiveWorkspaceFromLocation();
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}
export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return useSyncExternalStore(
subscribe,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
);
}
export function useIsNavigationWorkspaceSelected(input: {
serverId: string | null;
workspaceId: string | null;
enabled?: boolean;
}): boolean {
const enabled = input.enabled !== false;
return useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getSnapshot,
(selection) =>
enabled &&
Boolean(input.serverId) &&
Boolean(input.workspaceId) &&
selection?.serverId === input.serverId &&
selection.workspaceId === input.workspaceId,
Object.is,
);
}
void hydrateLastNavigationWorkspaceRouteSelection();
export function useIsNavigationProjectActive(input: {
serverId: string | null;
workspaceIds: readonly string[];
enabled?: boolean;
}): boolean {
const enabled = input.enabled !== false;
const workspaceIdsKey = input.workspaceIds.join("\0");
const workspaceIdSet = useMemo(() => {
void workspaceIdsKey;
return new Set(input.workspaceIds);
}, [input.workspaceIds, workspaceIdsKey]);
return useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getSnapshot,
(selection) =>
enabled &&
Boolean(input.serverId) &&
selection?.serverId === input.serverId &&
workspaceIdSet.has(selection.workspaceId),
Object.is,
);
return selection;
}
@@ -13,7 +13,6 @@ describe("workspace-setup-store", () => {
sourceWorkspaceId: "42",
displayName: "project",
creationMethod: "open_project",
navigationMethod: "replace",
});
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
@@ -22,7 +21,6 @@ describe("workspace-setup-store", () => {
sourceWorkspaceId: "42",
displayName: "project",
creationMethod: "open_project",
navigationMethod: "replace",
});
});
@@ -31,7 +29,6 @@ describe("workspace-setup-store", () => {
serverId: "server-1",
sourceDirectory: "/Users/test/project",
creationMethod: "create_worktree",
navigationMethod: "navigate",
});
useWorkspaceSetupStore.getState().clearWorkspaceSetup();
@@ -2,7 +2,6 @@ import type { SessionOutboundMessage } from "@server/shared/messages";
import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
export interface PendingWorkspaceSetup {
@@ -11,7 +10,6 @@ export interface PendingWorkspaceSetup {
sourceWorkspaceId?: string;
displayName?: string;
creationMethod: WorkspaceCreationMethod;
navigationMethod: WorkspaceSetupNavigationMethod;
}
export type WorkspaceSetupProgressPayload = Extract<
@@ -1,16 +1,16 @@
import { router } from "expo-router";
import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
export function redirectIfArchivingActiveWorkspace(input: {
serverId: string;
workspaceId: string;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
}): boolean {
const activeWorkspaceSelection = getNavigationActiveWorkspaceSelection();
if (
activeWorkspaceSelection?.serverId !== input.serverId ||
activeWorkspaceSelection.workspaceId !== input.workspaceId
input.activeWorkspaceSelection?.serverId !== input.serverId ||
input.activeWorkspaceSelection.workspaceId !== input.workspaceId
) {
return false;
}
@@ -3,10 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
const { replaceMock } = vi.hoisted(() => ({
@@ -96,7 +92,6 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
describe("redirectIfArchivingActiveWorkspace", () => {
afterEach(() => {
replaceMock.mockClear();
syncNavigationActiveWorkspace({ current: null });
useSessionStore.getState().clearSession("server-1");
});
@@ -109,12 +104,11 @@ describe("redirectIfArchivingActiveWorkspace", () => {
["feature", workspace({ id: "feature", name: "feature" })],
]),
);
activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "main" });
expect(
redirectIfArchivingActiveWorkspace({
serverId: "server-1",
workspaceId: "feature",
activeWorkspaceSelection: { serverId: "server-1", workspaceId: "main" },
}),
).toBe(false);
@@ -130,12 +124,11 @@ describe("redirectIfArchivingActiveWorkspace", () => {
["feature", workspace({ id: "feature", name: "feature" })],
]),
);
activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "feature" });
expect(
redirectIfArchivingActiveWorkspace({
serverId: "server-1",
workspaceId: "feature",
activeWorkspaceSelection: { serverId: "server-1", workspaceId: "feature" },
}),
).toBe(true);
@@ -2,12 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const { platformState, routerMock } = vi.hoisted(() => ({
platformState: {
isNative: false,
isWeb: true,
},
routerMock: {
back: vi.fn(),
canGoBack: vi.fn(() => false),
dismissTo: vi.fn(),
navigate: vi.fn(),
replace: vi.fn(),
@@ -16,12 +13,11 @@ const { platformState, routerMock } = vi.hoisted(() => ({
vi.mock("expo-router", () => ({
router: routerMock,
useLocalSearchParams: () => ({}),
usePathname: () => "/",
}));
vi.mock("@/constants/platform", () => ({
get isNative() {
return platformState.isNative;
},
get isWeb() {
return platformState.isWeb;
},
@@ -43,10 +39,6 @@ vi.mock("@react-native-async-storage/async-storage", () => {
});
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
getNavigationActiveWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation";
const SERVER_ID = "server-1";
@@ -56,15 +48,10 @@ const AGENT_ID = "agent-1";
describe("prepareWorkspaceTab", () => {
beforeEach(() => {
vi.useRealTimers();
platformState.isNative = false;
platformState.isWeb = true;
routerMock.back.mockReset();
routerMock.canGoBack.mockReset();
routerMock.canGoBack.mockReturnValue(false);
routerMock.dismissTo.mockReset();
routerMock.navigate.mockReset();
routerMock.replace.mockReset();
syncNavigationActiveWorkspace({ current: null });
useWorkspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
@@ -85,37 +72,17 @@ describe("prepareWorkspaceTab", () => {
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1);
});
it("pops back to the retained workspace shell for native replace navigation", () => {
vi.useFakeTimers();
platformState.isNative = true;
platformState.isWeb = false;
routerMock.canGoBack.mockReturnValue(true);
syncNavigationActiveWorkspace({
current: {
getCurrentRoute: () => ({
path: "/h/server-1/workspace/source-workspace",
}),
},
});
it("prepares a tab and navigates through the workspace navigation helper", () => {
const route = navigateToPreparedWorkspaceTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
navigationMethod: "replace",
});
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
expect(routerMock.back).toHaveBeenCalledOnce();
expect(routerMock.dismissTo).not.toHaveBeenCalled();
expect(routerMock.dismissTo).toHaveBeenCalledWith(
"/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU",
);
expect(routerMock.replace).not.toHaveBeenCalled();
vi.runAllTimers();
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
});
});
+3 -31
View File
@@ -1,11 +1,4 @@
import { router } from "expo-router";
import { isNative } from "@/constants/platform";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import {
activateNavigationWorkspaceSelection,
getLastNavigationWorkspaceRouteSelection,
overrideNextNavigationWorkspaceRouteSelection,
} from "@/stores/navigation-active-workspace-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
import {
@@ -22,7 +15,6 @@ interface PrepareWorkspaceTabInput {
}
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
navigationMethod?: "navigate" | "replace";
currentPathname?: string | null;
}
@@ -52,28 +44,8 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
const route = prepareWorkspaceTab(input);
if (input.navigationMethod === "replace") {
const canReturnToWorkspaceShell =
isNative && getLastNavigationWorkspaceRouteSelection() !== null && router.canGoBack();
if (canReturnToWorkspaceShell) {
const nextSelection = {
serverId: input.serverId,
workspaceId: input.workspaceId,
};
overrideNextNavigationWorkspaceRouteSelection(nextSelection);
router.back();
setTimeout(() => {
activateNavigationWorkspaceSelection(nextSelection);
}, 0);
return route;
}
navigateToWorkspace(input.serverId, input.workspaceId, {
currentPathname: input.currentPathname,
});
} else {
navigateToWorkspace(input.serverId, input.workspaceId, {
currentPathname: input.currentPathname,
});
}
navigateToWorkspace(input.serverId, input.workspaceId, {
currentPathname: input.currentPathname,
});
return route;
}