diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 3a9325b3b..a22880ed4 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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 ( @@ -927,7 +895,6 @@ export default function RootLayout() { return ( - diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 2e5d2cd87..45bdd8903 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -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; diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 00c707758..65b797c20 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -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 ; + return ; } 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(() => activeSelection ? [activeSelection] : [], ); diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx index c900692fd..a6ccf8635 100644 --- a/packages/app/src/app/index.test.tsx +++ b/packages/app/src/app/index.test.tsx @@ -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" }; diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 9e9b537dc..d9af23398 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -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, }); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 18ec795ca..5408c4f0f 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -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(); + renderSidebarFrame(root, counts); }); resetCounts(counts); return { root, container }; } +function renderSidebarFrame(root: Root, counts: RenderCounts) { + root.render(); +} + 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, }); }); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index aed70090c..294f86abb 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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( ( diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 70375a365..a65b3879e 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -195,7 +195,6 @@ export function WorkspaceSetupDialog() { serverId: pendingWorkspaceSetup.serverId, workspaceId, target, - navigationMethod: pendingWorkspaceSetup.navigationMethod, }); }, [clearWorkspaceSetup, pendingWorkspaceSetup], diff --git a/packages/app/src/components/worktree-setup-callout-source.test.tsx b/packages/app/src/components/worktree-setup-callout-source.test.tsx index c3ac049bc..b2f1431fe 100644 --- a/packages/app/src/components/worktree-setup-callout-source.test.tsx +++ b/packages/app/src/components/worktree-setup-callout-source.test.tsx @@ -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", () => ({ diff --git a/packages/app/src/components/worktree-setup-callout-source.tsx b/packages/app/src/components/worktree-setup-callout-source.tsx index 82305a1d1..45fc49e0e 100644 --- a/packages/app/src/components/worktree-setup-callout-source.tsx +++ b/packages/app/src/components/worktree-setup-callout-source.tsx @@ -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, diff --git a/packages/app/src/hooks/use-active-worktree-new-action.ts b/packages/app/src/hooks/use-active-worktree-new-action.ts index 434ffe20f..b8a27dd3e 100644 --- a/packages/app/src/hooks/use-active-worktree-new-action.ts +++ b/packages/app/src/hooks/use-active-worktree-new-action.ts @@ -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; diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 23eefc1fd..ee697206e 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -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[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, diff --git a/packages/app/src/hooks/use-workspace-navigation.test.ts b/packages/app/src/hooks/use-workspace-navigation.test.ts index 829555604..bb9e7c6e9 100644 --- a/packages/app/src/hooks/use-workspace-navigation.test.ts +++ b/packages/app/src/hooks/use-workspace-navigation.test.ts @@ -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); }); }); diff --git a/packages/app/src/hooks/use-workspace-navigation.ts b/packages/app/src/hooks/use-workspace-navigation.ts index bd6459712..829206139 100644 --- a/packages/app/src/hooks/use-workspace-navigation.ts +++ b/packages/app/src/hooks/use-workspace-navigation.ts @@ -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[2], + ) => { navigateToWorkspace(serverId, workspaceId, options); }, [], diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index 4030ca7d1..cf7c4d470 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -19,7 +19,6 @@ function makeCtx(overrides: Partial = {}): ShortcutRouti isMobile: false, sidebarShortcutTargets: SIDEBAR_TARGETS, navigationActiveWorkspace: null, - lastNavigationWorkspaceRoute: null, commandCenterOpen: false, shortcutsDialogOpen: false, ...overrides, @@ -253,39 +252,25 @@ describe("routeKeyboardShortcut — settings.toggle", () => { ).toEqual({ 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({ - kind: "router-replace", - route: "/h/srv/workspace/ws-2", - }); + ).toEqual({ 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({ 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({ kind: "router-back" }); diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index c655d3345..410009220 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -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; 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" }; } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index b0f647624..451dd1ca2 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -377,7 +377,6 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { serverId, workspaceId, target: { kind: "draft", draftId }, - navigationMethod: "replace", }); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); } diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 0c9ad7ab0..30ba14ba1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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) { diff --git a/packages/app/src/stores/navigation-active-workspace-store.test.ts b/packages/app/src/stores/navigation-active-workspace-store.test.ts index 235624aaf..c5f4b287f 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.test.ts @@ -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(); - return { - storage, - getItem: vi.fn(async (key: string): Promise => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string): Promise => { - 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() { - let resolve!: (value: T) => void; - const promise = new Promise((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 => asyncStorageMock.storage.get(key) ?? null, - ); - asyncStorageMock.setItem.mockImplementation( - async (key: string, value: string): Promise => { - 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(); - 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"); }); }); diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index aba0b5ba5..867a81ee3 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -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 | 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; - 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 { - 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; } diff --git a/packages/app/src/stores/workspace-setup-store.test.ts b/packages/app/src/stores/workspace-setup-store.test.ts index d0fdd13fe..9c9a4718d 100644 --- a/packages/app/src/stores/workspace-setup-store.test.ts +++ b/packages/app/src/stores/workspace-setup-store.test.ts @@ -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(); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index a190c39d6..be5053eb7 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -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< diff --git a/packages/app/src/utils/sidebar-workspace-archive-redirect.ts b/packages/app/src/utils/sidebar-workspace-archive-redirect.ts index 055067bce..1b0d8ed97 100644 --- a/packages/app/src/utils/sidebar-workspace-archive-redirect.ts +++ b/packages/app/src/utils/sidebar-workspace-archive-redirect.ts @@ -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; } diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 87c932487..8843c56d1 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -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); diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index 9a48b6c0b..72d4322aa 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -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, - }); }); }); diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index 9cdc41384..401e96e3b 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -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; }