diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index fba5b504e..cd0055412 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -171,14 +171,24 @@ describe("resolveStartupRedirectRoute", () => { expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); }); - it("redirects to the host root when the persisted workspace targets a different server", () => { + it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => { const route = resolveStartupRedirectRoute({ ...baseInput, anyOnlineHostServerId: "server-2", workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, }); - expect(route).toBe("/h/server-2"); + expect(route).toBeNull(); + }); + + it("resolves the persisted workspace when another host is first online", () => { + const selection = resolveStartupWorkspaceSelection({ + ...baseInput, + anyOnlineHostServerId: "server-2", + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + }); + + expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); }); it("redirects to the host root when no persisted workspace exists", () => { diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index 45c821f6e..0af3f1d91 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -79,11 +79,7 @@ export function resolveStartupWorkspaceSelection( if (!input.isWorkspaceSelectionLoaded) { return null; } - if ( - !input.anyOnlineHostServerId || - !input.workspaceSelection || - input.workspaceSelection.serverId !== input.anyOnlineHostServerId - ) { + if (!input.workspaceSelection) { return null; } return input.workspaceSelection; diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx index 1b200928d..cd6540363 100644 --- a/packages/app/src/app/index.test.tsx +++ b/packages/app/src/app/index.test.tsx @@ -18,6 +18,7 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => { storeReady: false, } as HostRuntimeBootstrapState, anyOnlineHostServerId: null as string | null, + isWorkspaceSelectionLoaded: true, workspaceSelection: null as ActiveWorkspaceSelection | null, }; @@ -51,7 +52,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({ vi.mock("@/stores/navigation-active-workspace-store", () => ({ navigateToWorkspace: navigateToWorkspaceMock, - useActiveWorkspaceSelection: () => state.workspaceSelection, + useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded, + useLastWorkspaceSelection: () => state.workspaceSelection, })); describe("Index route startup navigation", () => { @@ -68,6 +70,7 @@ describe("Index route startup navigation", () => { storeReady: false, }; state.anyOnlineHostServerId = null; + state.isWorkspaceSelectionLoaded = true; state.workspaceSelection = null; navigateToWorkspaceMock.mockReset(); redirectMock.mockReset(); @@ -98,6 +101,17 @@ describe("Index route startup navigation", () => { expect(redirectMock).not.toHaveBeenCalled(); }); + it("shows the startup splash while the persisted 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(navigateToWorkspaceMock).not.toHaveBeenCalled(); + 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" }; @@ -111,13 +125,16 @@ describe("Index route startup navigation", () => { expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); }); - it("navigates to the host root when the persisted workspace targets a different server", async () => { + it("restores the persisted workspace even when the first online host is different", async () => { state.anyOnlineHostServerId = "server-2"; state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; await renderIndex(); - expect(redirectMock).toHaveBeenCalledWith("/h/server-2"); + expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", { + currentPathname: "/", + }); + expect(redirectMock).not.toHaveBeenCalled(); }); it("navigates to the host root when no persisted workspace exists", async () => { diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 08ecb638c..d2fbbd53d 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -8,7 +8,8 @@ import { } from "@/app/host-runtime-bootstrap"; import { navigateToWorkspace, - useActiveWorkspaceSelection, + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; @@ -18,20 +19,21 @@ export default function Index() { const pathname = usePathname(); const bootstrapState = useHostRuntimeBootstrapState(); const anyOnlineHostServerId = useEarliestOnlineHostServerId(); - const workspaceSelection = useActiveWorkspaceSelection(); + const workspaceSelection = useLastWorkspaceSelection(); + const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated(); const redirectRoute = resolveStartupRedirectRoute({ pathname, anyOnlineHostServerId, workspaceSelection, - isWorkspaceSelectionLoaded: true, + isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); const startupWorkspaceSelection = resolveStartupWorkspaceSelection({ pathname, anyOnlineHostServerId, workspaceSelection, - isWorkspaceSelectionLoaded: true, + isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); diff --git a/packages/app/src/stores/last-workspace-selection.test.ts b/packages/app/src/stores/last-workspace-selection.test.ts new file mode 100644 index 000000000..06f4dceec --- /dev/null +++ b/packages/app/src/stores/last-workspace-selection.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + createLastWorkspaceSelectionStore, + type ActiveWorkspaceSelection, + type LastWorkspaceSelectionStorage, +} from "./last-workspace-selection"; + +class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage { + private finishRead: (value: string | null) => void = () => {}; + private readonly pendingRead = new Promise((resolve) => { + this.finishRead = resolve; + }); + private saved: string | null = null; + + read(): Promise { + return this.pendingRead; + } + + async write(value: string): Promise { + this.saved = value; + } + + finishHydrationWith(selection: ActiveWorkspaceSelection | null) { + this.finishRead(selection ? JSON.stringify(selection) : null); + } + + getSavedSelection(): ActiveWorkspaceSelection | null { + return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null; + } +} + +describe("last workspace selection", () => { + it("hydrates the saved workspace selection", async () => { + const storage = new DelayedWorkspaceSelectionStorage(); + const store = createLastWorkspaceSelectionStore(storage); + const hydration = store.hydrate(); + + storage.finishHydrationWith({ serverId: "server-saved", workspaceId: "workspace-saved" }); + await hydration; + + expect(store.getSelection()).toEqual({ + serverId: "server-saved", + workspaceId: "workspace-saved", + }); + expect(store.isHydrated()).toBe(true); + }); + + it("keeps a newer workspace selection when storage hydration finishes late", async () => { + const storage = new DelayedWorkspaceSelectionStorage(); + const store = createLastWorkspaceSelectionStore(storage); + const hydration = store.hydrate(); + + store.remember({ serverId: "server-new", workspaceId: "workspace-new" }); + storage.finishHydrationWith({ serverId: "server-old", workspaceId: "workspace-old" }); + await hydration; + + expect(store.getSelection()).toEqual({ + serverId: "server-new", + workspaceId: "workspace-new", + }); + expect(storage.getSavedSelection()).toEqual({ + serverId: "server-new", + workspaceId: "workspace-new", + }); + }); +}); diff --git a/packages/app/src/stores/last-workspace-selection.ts b/packages/app/src/stores/last-workspace-selection.ts new file mode 100644 index 000000000..a9fe19b9e --- /dev/null +++ b/packages/app/src/stores/last-workspace-selection.ts @@ -0,0 +1,102 @@ +export interface ActiveWorkspaceSelection { + serverId: string; + workspaceId: string; +} + +export interface LastWorkspaceSelectionStorage { + read(): Promise; + write(value: string): Promise; +} + +function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return null; + } + const record = input as Record; + const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; + const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : ""; + if (!serverId || !workspaceId) { + return null; + } + return { serverId, workspaceId }; +} + +function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null { + if (!stored) { + return null; + } + try { + return normalizeWorkspaceSelection(JSON.parse(stored)); + } catch { + return null; + } +} + +export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectionStorage) { + let selection: ActiveWorkspaceSelection | null = null; + let hydrated = false; + let hydrationPromise: Promise | null = null; + let revision = 0; + const listeners = new Set<() => void>(); + + function notifyListeners() { + for (const listener of listeners) { + listener(); + } + } + + function remember(next: ActiveWorkspaceSelection) { + const normalized = normalizeWorkspaceSelection(next); + if (!normalized) { + return; + } + if ( + selection?.serverId === normalized.serverId && + selection.workspaceId === normalized.workspaceId + ) { + return; + } + selection = normalized; + revision += 1; + notifyListeners(); + void storage.write(JSON.stringify(normalized)).catch(() => {}); + } + + function hydrate(): Promise { + if (hydrationPromise) { + return hydrationPromise; + } + const hydrationRevision = revision; + hydrationPromise = storage + .read() + .then((stored) => { + if (revision === hydrationRevision) { + selection = parseStoredWorkspaceSelection(stored); + } + return undefined; + }) + .catch(() => { + if (revision === hydrationRevision) { + selection = null; + } + }) + .finally(() => { + hydrated = true; + notifyListeners(); + }); + return hydrationPromise; + } + + return { + getSelection: () => selection, + hydrate, + isHydrated: () => hydrated, + remember, + subscribe: (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} 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 c5f4b287f..3fbb93f7b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.test.ts @@ -13,6 +13,10 @@ const pathnameState = vi.hoisted(() => ({ const localParamsState = vi.hoisted(() => ({ value: {} as { serverId?: string | string[]; workspaceId?: string | string[] }, })); +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(async () => null as string | null), + setItem: vi.fn(async () => undefined), +})); vi.mock("expo-router", () => ({ router: routerMock, @@ -20,6 +24,10 @@ vi.mock("expo-router", () => ({ usePathname: () => pathnameState.value, })); +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: asyncStorageMock, +})); + import { navigateToLastWorkspace, navigateToWorkspace, @@ -29,6 +37,7 @@ import { describe("workspace navigation", () => { beforeEach(() => { routerMock.dismissTo.mockReset(); + asyncStorageMock.setItem.mockClear(); pathnameState.value = "/"; localParamsState.value = {}; }); @@ -41,6 +50,10 @@ describe("workspace navigation", () => { navigateToWorkspace("server-1", "workspace-a"); expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + "paseo:last-workspace-route-selection", + JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }), + ); }); it("reads the active workspace from the current route", () => { diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index 930f13e14..698e1a148 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -1,4 +1,11 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; import { router, useLocalSearchParams, usePathname, type Href } from "expo-router"; +import { useEffect, useSyncExternalStore } from "react"; +import { + createLastWorkspaceSelectionStore, + type ActiveWorkspaceSelection, + type LastWorkspaceSelectionStorage, +} from "@/stores/last-workspace-selection"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { pickAttentionAgent } from "@/utils/agent-attention"; @@ -12,16 +19,34 @@ import { resolveWorkspaceMapKeyByIdentity, } from "@/utils/workspace-execution"; -export interface ActiveWorkspaceSelection { - serverId: string; - workspaceId: string; -} +export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; interface NavigateToWorkspaceOptions { currentPathname?: string | null; } -let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null; +const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection"; + +const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { + read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), + write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value), +}; + +const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( + lastWorkspaceSelectionStorage, +); + +export function hydrateLastWorkspaceSelection(): Promise { + return lastWorkspaceSelectionStore.hydrate(); +} + +export function getLastWorkspaceSelection(): ActiveWorkspaceSelection | null { + return lastWorkspaceSelectionStore.getSelection(); +} + +export function getIsLastWorkspaceSelectionHydrated(): boolean { + return lastWorkspaceSelectionStore.isHydrated(); +} function getParamValue(value: string | string[] | undefined): string { if (typeof value === "string") { @@ -74,16 +99,17 @@ export function navigateToWorkspace( }); } - lastWorkspaceSelection = { serverId, workspaceId }; + lastWorkspaceSelectionStore.remember({ serverId, workspaceId }); const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href; router.dismissTo(route); } export function navigateToLastWorkspace(): boolean { - if (!lastWorkspaceSelection) { + const selection = lastWorkspaceSelectionStore.getSelection(); + if (!selection) { return false; } - navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId); + navigateToWorkspace(selection.serverId, selection.workspaceId); return true; } @@ -95,8 +121,31 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { const selection = parseHostWorkspaceRouteFromPathname(usePathname()) ?? parseWorkspaceSelectionFromRouteParams(params); - if (selection) { - lastWorkspaceSelection = selection; - } + const serverId = selection?.serverId ?? null; + const workspaceId = selection?.workspaceId ?? null; + useEffect(() => { + if (!serverId || !workspaceId) { + return; + } + lastWorkspaceSelectionStore.remember({ serverId, workspaceId }); + }, [serverId, workspaceId]); return selection; } + +export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null { + return useSyncExternalStore( + lastWorkspaceSelectionStore.subscribe, + getLastWorkspaceSelection, + getLastWorkspaceSelection, + ); +} + +export function useIsLastWorkspaceSelectionHydrated(): boolean { + return useSyncExternalStore( + lastWorkspaceSelectionStore.subscribe, + getIsLastWorkspaceSelectionHydrated, + getIsLastWorkspaceSelectionHydrated, + ); +} + +void hydrateLastWorkspaceSelection();