Restore the previous workspace on app start (#1101)

* Restore previous workspace on app start

* Guard workspace startup hydration

* Extract last workspace selection store
This commit is contained in:
Mohamed Boudra
2026-05-19 23:53:57 +08:00
committed by GitHub
parent 306601a0c3
commit b7a8567092
8 changed files with 280 additions and 25 deletions
@@ -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", () => {
@@ -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;
+20 -3
View File
@@ -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 () => {
+6 -4
View File
@@ -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,
});
@@ -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<string | null>((resolve) => {
this.finishRead = resolve;
});
private saved: string | null = null;
read(): Promise<string | null> {
return this.pendingRead;
}
async write(value: string): Promise<void> {
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",
});
});
});
@@ -0,0 +1,102 @@
export interface ActiveWorkspaceSelection {
serverId: string;
workspaceId: string;
}
export interface LastWorkspaceSelectionStorage {
read(): Promise<string | null>;
write(value: string): Promise<void>;
}
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
const record = input as Record<string, unknown>;
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<void> | 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<void> {
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);
};
},
};
}
@@ -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", () => {
@@ -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<void> {
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();