Bound retained workspace screens (#1472)

* fix(app): prune retained workspace deck entries

* fix(app): narrow workspace deck retention selectors

* fix(app): preserve deck lru during route transitions

* test(app): update startup reconnect expectation
This commit is contained in:
Mohamed Boudra
2026-06-11 17:00:05 +00:00
committed by GitHub
parent c0614c3207
commit 62d9e656d7
7 changed files with 317 additions and 34 deletions
+9 -8
View File
@@ -139,17 +139,18 @@ class StartupAssertions {
this.page = page;
}
async expectsReconnectWelcome(): Promise<this> {
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
async expectsSavedHostShell(input: { label: string }): Promise<this> {
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
return this;
}
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
async expectsNoSavedHostErrorStatus(): Promise<this> {
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
return this;
+3 -3
View File
@@ -4,7 +4,7 @@ import { getServerId } from "./helpers/server-id";
import { startupScenario } from "./helpers/startup-dsl";
test.describe("Startup loading presentation", () => {
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
test("mobile reconnect preserves the saved host shell", async ({ page }) => {
const startup = await startupScenario(page)
.withMobileViewport()
.withSavedHost({
@@ -14,8 +14,8 @@ test.describe("Startup loading presentation", () => {
})
.openRoot();
await startup.expectsReconnectWelcome();
await startup.expectsNoSavedHostStatus({ label: "Dev" });
await startup.expectsSavedHostShell({ label: "Dev" });
await startup.expectsNoSavedHostErrorStatus();
await startup.expectsNoLocalServerStartupCopy();
});
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
@@ -7,9 +7,18 @@ import {
type ActiveWorkspaceSelection,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
areWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
getWorkspaceSelectionKey,
pruneMountedWorkspaceSelections,
shouldKeepWorkspaceDeckEntryMounted,
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
} from "@/screens/workspace/workspace-deck-retention";
import {
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
@@ -152,28 +161,33 @@ function HostWorkspaceRouteContent() {
return <WorkspaceDeck />;
}
function areWorkspaceSelectionsEqual(
left: ActiveWorkspaceSelection | null,
right: ActiveWorkspaceSelection | null,
): boolean {
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
}
function WorkspaceDeck() {
const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
);
const unmountWorkspaceSelection = useCallback((selection: ActiveWorkspaceSelection) => {
setMountedSelections((current) =>
current.filter(
(mountedSelection) => !areWorkspaceSelectionsEqual(mountedSelection, selection),
),
);
}, []);
useEffect(() => {
if (!activeSelection) {
return;
}
setMountedSelections((current) => {
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
const next = pruneMountedWorkspaceSelections({
currentSelections: current,
activeSelection,
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
});
if (areWorkspaceSelectionListsEqual(current, next)) {
return current;
}
return [...current, activeSelection];
return next;
});
}, [activeSelection]);
@@ -184,25 +198,61 @@ function WorkspaceDeck() {
return (
<View style={styles.deck}>
{mountedSelections.map((selection) => {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
return (
<View
key={`${selection.serverId}:${selection.workspaceId}`}
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
<WorkspaceDeckEntry
key={getWorkspaceSelectionKey(selection)}
selection={selection}
activeSelection={activeSelection}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
})}
</View>
);
}
function WorkspaceDeckEntry({
selection,
activeSelection,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
activeSelection: ActiveWorkspaceSelection;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
const workspaceExists = useWorkspaceExists(selection.serverId, selection.workspaceId);
const shouldKeepMounted = shouldKeepWorkspaceDeckEntryMounted({
isActive,
hasHydratedWorkspaces,
workspaceExists,
});
useEffect(() => {
if (!shouldKeepMounted) {
onUnmountInactive(selection);
}
}, [onUnmountInactive, selection, shouldKeepMounted]);
if (!shouldKeepMounted) {
return null;
}
return (
<View
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
);
}
const styles = StyleSheet.create({
deck: {
flex: 1,
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
pruneMountedWorkspaceSelections,
shouldKeepWorkspaceDeckEntryMounted,
} from "@/screens/workspace/workspace-deck-retention";
function workspace(workspaceId: string, serverId = "server"): ActiveWorkspaceSelection {
return { serverId, workspaceId };
}
function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
return selections.map((selection) => selection.workspaceId);
}
describe("pruneMountedWorkspaceSelections", () => {
it("keeps the active workspace and the two most recent inactive workspaces", () => {
const mountedAfterA = pruneMountedWorkspaceSelections({
currentSelections: [],
activeSelection: workspace("A"),
});
const mountedAfterB = pruneMountedWorkspaceSelections({
currentSelections: mountedAfterA,
activeSelection: workspace("B"),
});
const mountedAfterC = pruneMountedWorkspaceSelections({
currentSelections: mountedAfterB,
activeSelection: workspace("C"),
});
const mountedAfterD = pruneMountedWorkspaceSelections({
currentSelections: mountedAfterC,
activeSelection: workspace("D"),
});
expect(mountedWorkspaceIds(mountedAfterD)).toEqual(["D", "C", "B"]);
});
it("retains the active workspace", () => {
const mountedSelections = pruneMountedWorkspaceSelections({
currentSelections: [workspace("A")],
activeSelection: workspace("A"),
});
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A"]);
});
it("deduplicates retained workspace selections", () => {
const mountedSelections = pruneMountedWorkspaceSelections({
currentSelections: [workspace("B"), workspace("A"), workspace("B")],
activeSelection: workspace("A"),
});
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["A", "B"]);
});
it("always allows at least the active workspace", () => {
const mountedSelections = pruneMountedWorkspaceSelections({
currentSelections: [workspace("A"), workspace("B")],
activeSelection: workspace("C"),
maxMountedWorkspaces: 0,
});
expect(mountedWorkspaceIds(mountedSelections)).toEqual(["C"]);
});
});
describe("shouldKeepWorkspaceDeckEntryMounted", () => {
it("keeps the active workspace mounted even when it is missing from hydrated workspaces", () => {
expect(
shouldKeepWorkspaceDeckEntryMounted({
isActive: true,
hasHydratedWorkspaces: true,
workspaceExists: false,
}),
).toBe(true);
});
it("keeps inactive workspaces mounted until workspace hydration finishes", () => {
expect(
shouldKeepWorkspaceDeckEntryMounted({
isActive: false,
hasHydratedWorkspaces: false,
workspaceExists: false,
}),
).toBe(true);
});
it("unmounts inactive workspaces that are gone after hydration", () => {
expect(
shouldKeepWorkspaceDeckEntryMounted({
isActive: false,
hasHydratedWorkspaces: true,
workspaceExists: false,
}),
).toBe(false);
});
it("keeps inactive workspaces that still exist after hydration", () => {
expect(
shouldKeepWorkspaceDeckEntryMounted({
isActive: false,
hasHydratedWorkspaces: true,
workspaceExists: true,
}),
).toBe(true);
});
});
@@ -0,0 +1,89 @@
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 3;
interface PruneMountedWorkspaceSelectionsInput {
currentSelections: ActiveWorkspaceSelection[];
activeSelection: ActiveWorkspaceSelection | null;
maxMountedWorkspaces?: number;
}
interface WorkspaceDeckEntryMountInput {
isActive: boolean;
hasHydratedWorkspaces: boolean;
workspaceExists: boolean;
}
export function getWorkspaceSelectionKey(selection: ActiveWorkspaceSelection): string {
return `${selection.serverId}:${selection.workspaceId}`;
}
export function areWorkspaceSelectionsEqual(
left: ActiveWorkspaceSelection | null,
right: ActiveWorkspaceSelection | null,
): boolean {
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
}
export function areWorkspaceSelectionListsEqual(
left: ActiveWorkspaceSelection[],
right: ActiveWorkspaceSelection[],
): boolean {
if (left.length !== right.length) {
return false;
}
return left.every((selection, index) =>
areWorkspaceSelectionsEqual(selection, right[index] ?? null),
);
}
export function pruneMountedWorkspaceSelections({
currentSelections,
activeSelection,
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
if (!activeSelection) {
return [];
}
const maxSelections = Math.max(1, maxMountedWorkspaces);
const nextSelections: ActiveWorkspaceSelection[] = [];
const seenSelectionKeys = new Set<string>();
function appendSelection(selection: ActiveWorkspaceSelection): void {
if (nextSelections.length >= maxSelections) {
return;
}
const selectionKey = getWorkspaceSelectionKey(selection);
if (seenSelectionKeys.has(selectionKey)) {
return;
}
seenSelectionKeys.add(selectionKey);
nextSelections.push(selection);
}
appendSelection(activeSelection);
for (const selection of currentSelections) {
if (areWorkspaceSelectionsEqual(selection, activeSelection)) {
continue;
}
appendSelection(selection);
}
return nextSelections;
}
export function shouldKeepWorkspaceDeckEntryMounted({
isActive,
hasHydratedWorkspaces,
workspaceExists,
}: WorkspaceDeckEntryMountInput): boolean {
if (isActive) {
return true;
}
if (!hasHydratedWorkspaces) {
return true;
}
return workspaceExists;
}
@@ -3,11 +3,13 @@ import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import {
composeWorkspaceStructure,
selectHasHydratedWorkspaces,
selectHasWorkspaces,
selectProjectOrder,
selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd,
selectWorkspace,
selectWorkspaceExists,
selectWorkspaceExecutionAuthority,
selectWorkspaceFields,
selectWorkspaceKeys,
@@ -54,6 +56,22 @@ export function useWorkspaceFields<T>(
);
}
export function useWorkspaceExists(serverId: string | null, workspaceId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceExists(state, serverId, workspaceId),
workspaceEqualityFns.identity,
);
}
export function useHasHydratedWorkspaces(serverId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectHasHydratedWorkspaces(state, serverId),
workspaceEqualityFns.identity,
);
}
export function useWorkspaceExecutionAuthority(
serverId: string | null,
workspaceId: string | null,
@@ -17,7 +17,10 @@ export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
export interface SessionsSnapshot {
sessions: Record<string, { workspaces: Map<string, WorkspaceDescriptor> }>;
sessions: Record<
string,
{ hasHydratedWorkspaces?: boolean; workspaces: Map<string, WorkspaceDescriptor> }
>;
}
export interface SidebarOrderSnapshot {
@@ -110,6 +113,21 @@ export function selectWorkspaceFields<T>(
return workspace ? project(workspace) : null;
}
export function selectWorkspaceExists(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
): boolean {
return selectWorkspace(state, serverId, workspaceId) !== null;
}
export function selectHasHydratedWorkspaces(
state: SessionsSnapshot,
serverId: string | null,
): boolean {
return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false;
}
export function selectWorkspaceExecutionAuthority(
state: SessionsSnapshot,
serverId: string | null,