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:
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user