From 403fb03c9700598ddabe12053018fc73e26804b9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 20 Apr 2026 12:55:58 +0700 Subject: [PATCH] feat(app): retain workspace screens on sidebar switch --- packages/app/e2e/helpers/archive-tab.ts | 10 +- packages/app/e2e/helpers/launcher.ts | 30 +++--- packages/app/e2e/helpers/workspace-tabs.ts | 22 ++++- packages/app/e2e/helpers/workspace-ui.ts | 4 +- .../workspace-navigation-regression.spec.ts | 42 ++++++-- .../app/e2e/workspace-setup-streaming.spec.ts | 2 +- packages/app/src/app/_layout.tsx | 7 +- .../workspace/[workspaceId]/_layout.tsx | 97 +++++++++++++++++-- .../app/src/hooks/use-workspace-navigation.ts | 22 +++-- .../screens/workspace/workspace-screen.tsx | 8 +- .../navigation-active-workspace-store.ts | 69 ++++++++++++- 11 files changed, 265 insertions(+), 48 deletions(-) diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index aae05eb31..01d5b3e0f 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -210,13 +210,15 @@ export async function openWorkspaceWithAgents( } export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise { - await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({ - timeout: 30_000, - }); + await expect( + page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); } export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise { - await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, { + await expect( + page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }), + ).toHaveCount(0, { timeout: 30_000, }); } diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 887717e46..8de74e6f6 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -23,16 +23,18 @@ export async function gotoWorkspace(page: Page, cwd: string): Promise { /** Wait for the workspace tab bar to be visible. */ export async function waitForTabBar(page: Page): Promise { - await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({ + await expect( + page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000, }); } /** Return all tab test IDs currently in the tab bar. */ export async function getTabTestIds(page: Page): Promise { - const tabs = page.locator( - '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])', - ); + const tabs = page + .locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])') + .filter({ visible: true }); const count = await tabs.count(); const ids: string[] = []; for (let i = 0; i < count; i++) { @@ -55,6 +57,7 @@ export async function getActiveTabTestId(page: Page): Promise { .locator( '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]', ) + .filter({ visible: true }) .first(); if (await activeTab.isVisible().catch(() => false)) { return activeTab.getAttribute("data-testid"); @@ -67,14 +70,14 @@ export async function getActiveTabTestId(page: Page): Promise { /** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */ export async function clickNewTabButton(page: Page): Promise { - const button = page.getByTestId("workspace-new-agent-tab"); + const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(); await expect(button).toBeVisible({ timeout: 10_000 }); await button.click(); } /** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */ export async function clickNewTerminalButton(page: Page): Promise { - const button = page.getByTestId("workspace-new-terminal"); + const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(); await expect(button).toBeVisible({ timeout: 10_000 }); await button.click(); } @@ -94,26 +97,30 @@ export async function waitForLauncherPanel(page: Page): Promise { /** Assert the new agent tab button is visible in the tab bar. */ export async function assertNewChatTileVisible(page: Page): Promise { - await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible(); + await expect( + page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(), + ).toBeVisible(); } /** Assert the new terminal button is visible in the tab bar. */ export async function assertTerminalTileVisible(page: Page): Promise { - await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible(); + await expect( + page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(), + ).toBeVisible(); } // ─── Tab creation actions ───────────────────────────────────────────────── /** Click the new agent tab button to create a draft/chat tab. */ export async function clickNewChat(page: Page): Promise { - const button = page.getByTestId("workspace-new-agent-tab"); + const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(); await expect(button).toBeVisible({ timeout: 10_000 }); await button.click(); } /** Click the new terminal button to create a terminal tab. */ export async function clickTerminal(page: Page): Promise { - const button = page.getByTestId("workspace-new-terminal"); + const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(); await expect(button).toBeVisible({ timeout: 10_000 }); await button.click(); } @@ -131,13 +138,14 @@ export async function waitForTabWithTitle( page .locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])') .filter({ hasText: matcher }) + .filter({ visible: true }) .first(), ).toBeVisible({ timeout }); } /** Assert the new agent tab button is visible in the tab bar. */ export async function assertSingleNewTabButton(page: Page): Promise { - const buttons = page.getByTestId("workspace-new-agent-tab"); + const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }); const count = await buttons.count(); expect(count).toBeGreaterThanOrEqual(1); } diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/helpers/workspace-tabs.ts index 5ce7907dd..564818791 100644 --- a/packages/app/e2e/helpers/workspace-tabs.ts +++ b/packages/app/e2e/helpers/workspace-tabs.ts @@ -13,18 +13,30 @@ export async function getWorkspaceTabTestIds(page: Page): Promise { return ids; } +function visibleTestId(page: Page, testId: string) { + return page.getByTestId(testId).filter({ visible: true }); +} + export async function waitForWorkspaceTabsVisible(page: Page): Promise { - await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({ + await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({ timeout: 30_000, }); - await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({ + await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({ timeout: 30_000, }); } export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise { - const allTabIds = await getWorkspaceTabTestIds(page); - return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_")); + const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true }); + const count = await tabs.count(); + const ids: string[] = []; + for (let index = 0; index < count; index += 1) { + const testId = await tabs.nth(index).getAttribute("data-testid"); + if (testId && !ids.includes(testId)) { + ids.push(testId); + } + } + return ids; } export async function expectOnlyWorkspaceAgentTabsVisible( @@ -38,7 +50,7 @@ export async function expectOnlyWorkspaceAgentTabsVisible( expect(unexpected).toEqual([]); expect(visible.length).toBe(expected.size); for (const expectedId of expectedAgentIds) { - await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({ + await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({ timeout: 30_000, }); } diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index 5bb66a400..2d194f8b4 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -103,8 +103,8 @@ export async function expectWorkspaceHeader( page: Page, input: { title: string; subtitle: string }, ): Promise { - const titleLocator = page.getByTestId("workspace-header-title"); - const subtitleLocator = page.getByTestId("workspace-header-subtitle"); + const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true }); + const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true }); await expect(titleLocator.first()).toHaveText(input.title, { timeout: 30_000, diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 1d8da9030..eb0b63db1 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -96,6 +96,14 @@ test.describe("Workspace navigation regression", () => { `workspace-tab-agent_${firstAgent.id}`, ]); + const firstDeckEntry = page.getByTestId( + `workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`, + ); + const secondDeckEntry = page.getByTestId( + `workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`, + ); + await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 }); + await switchWorkspaceViaSidebar({ page, serverId, @@ -126,27 +134,45 @@ test.describe("Workspace navigation regression", () => { await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ `workspace-tab-agent_${secondAgent.id}`, ]); + await expect(firstDeckEntry).toBeAttached(); + await expect(firstDeckEntry).toBeHidden(); + await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2); + + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: firstWorkspace.workspaceId, + }); + await waitForWorkspaceTabsVisible(page); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { + timeout: 30_000, + }); + await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 }); + await expect(secondDeckEntry).toBeAttached(); + await expect(secondDeckEntry).toBeHidden(); + await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2); await page.reload(); await waitForSidebarHydration(page); await waitForWorkspaceTabsVisible(page); - await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { timeout: 30_000, }); await expectSidebarWorkspaceSelected({ page, serverId, - workspaceId: secondWorkspace.workspaceId, + workspaceId: firstWorkspace.workspaceId, }); await expectWorkspaceHeader(page, { - title: secondWorkspace.workspaceName, - subtitle: secondWorkspace.projectDisplayName, + title: firstWorkspace.workspaceName, + subtitle: firstWorkspace.projectDisplayName, }); - await expectWorkspaceTabVisible(page, secondAgent.id); - await expectWorkspaceTabHidden(page, firstAgent.id); - await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); + await expectWorkspaceTabVisible(page, firstAgent.id); + await expectWorkspaceTabHidden(page, secondAgent.id); + await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]); await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ - `workspace-tab-agent_${secondAgent.id}`, + `workspace-tab-agent_${firstAgent.id}`, ]); } finally { for (const agentId of agentIds) { diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index c2b143337..d6b40a8ac 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -94,7 +94,7 @@ test.describe("Workspace setup streaming", () => { await navigateToWorkspaceViaSidebar(page, workspace.id); await waitForWorkspaceTabsVisible(page); - await page.getByTestId("workspace-new-agent-tab").first().click(); + await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click(); await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 47159bcb8..d8a90e78b 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -83,7 +83,10 @@ import { parseWorkspaceOpenIntent, decodeWorkspaceIdFromPathSegment, } from "@/utils/host-routes"; -import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; +import { + addBrowserActiveWorkspaceLocationListener, + syncNavigationActiveWorkspace, +} from "@/stores/navigation-active-workspace-store"; import { isWeb, isNative } from "@/constants/platform"; polyfillCrypto(); @@ -843,6 +846,7 @@ function NavigationActiveWorkspaceObserver() { useEffect(() => { syncNavigationActiveWorkspace(navigationRef); + const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener(); const unsubscribeState = navigationRef.addListener("state", () => { syncNavigationActiveWorkspace(navigationRef); }); @@ -850,6 +854,7 @@ function NavigationActiveWorkspaceObserver() { syncNavigationActiveWorkspace(navigationRef); }); return () => { + unsubscribeBrowserLocation(); unsubscribeState(); unsubscribeReady(); }; 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 a5de8a39f..cab1aebae 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigation } from "@react-navigation/native"; +import { StyleSheet, View } from "react-native"; import { useGlobalSearchParams, useLocalSearchParams, @@ -7,6 +8,11 @@ import { useRootNavigationState, } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { + activateNavigationWorkspaceSelection, + type ActiveWorkspaceSelection, + useNavigationActiveWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { @@ -95,6 +101,23 @@ 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) { @@ -144,11 +167,73 @@ function HostWorkspaceLayoutContent() { return null; } + return ; +} + +function areWorkspaceSelectionsEqual( + left: ActiveWorkspaceSelection | null, + right: ActiveWorkspaceSelection | null, +): boolean { + return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId; +} + +function WorkspaceDeck({ + fallbackSelection, +}: { + fallbackSelection: ActiveWorkspaceSelection | null; +}) { + const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection; + const [mountedSelections, setMountedSelections] = useState(() => + activeSelection ? [activeSelection] : [], + ); + + useEffect(() => { + if (!activeSelection) { + return; + } + setMountedSelections((current) => { + if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) { + return current; + } + return [...current, activeSelection]; + }); + }, [activeSelection]); + + if (!activeSelection) { + return null; + } + return ( - + + {mountedSelections.map((selection) => { + const isActive = areWorkspaceSelectionsEqual(selection, activeSelection); + return ( + + + + ); + })} + ); } + +const styles = StyleSheet.create({ + deck: { + flex: 1, + }, + activeDeckEntry: { + flex: 1, + }, + inactiveDeckEntry: { + display: "none", + flex: 1, + }, +}); diff --git a/packages/app/src/hooks/use-workspace-navigation.ts b/packages/app/src/hooks/use-workspace-navigation.ts index 269252a5c..d69bc347c 100644 --- a/packages/app/src/hooks/use-workspace-navigation.ts +++ b/packages/app/src/hooks/use-workspace-navigation.ts @@ -1,17 +1,27 @@ import { useCallback } from "react"; import { router } from "expo-router"; +import { + activateNavigationWorkspaceSelection, + getNavigationActiveWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; /** - * Navigate to a workspace screen. Uses router.navigate() which, - * combined with getId on the workspace Stack.Screen, ensures: - * - Only one instance of each workspace exists in the stack - * - History is preserved (back button works) - * - No duplicate workspace screens + * 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) { + const activeWorkspace = getNavigationActiveWorkspaceSelection(); + if (activeWorkspace) { + activateNavigationWorkspaceSelection( + { serverId, workspaceId }, + { updateBrowserHistory: true, historyMode: "push" }, + ); + return; + } + const href = buildHostWorkspaceRoute(serverId, workspaceId); - router.navigate(href); + router.navigate(href as any); } export function useWorkspaceNavigation() { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 635540710..461709329 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -133,6 +133,7 @@ const EMPTY_SET = new Set(); type WorkspaceScreenProps = { serverId: string; workspaceId: string; + isRouteFocused?: boolean; }; type WorkspaceScreenContentProps = WorkspaceScreenProps & { @@ -571,15 +572,16 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) { return tabDescriptorMap; } -export function WorkspaceScreen({ serverId, workspaceId }: WorkspaceScreenProps) { - const isFocused = useIsFocused(); +export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: WorkspaceScreenProps) { + const navigationFocused = useIsFocused(); + const effectiveRouteFocused = isRouteFocused ?? navigationFocused; return ( ); diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index 59f111311..daf07282d 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -1,14 +1,21 @@ import { useSyncExternalStore } from "react"; import { + buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; +import { isWeb } from "@/constants/platform"; -interface ActiveWorkspaceSelection { +export interface ActiveWorkspaceSelection { serverId: string; workspaceId: string; } +interface ActivateWorkspaceSelectionOptions { + updateBrowserHistory?: boolean; + historyMode?: "push" | "replace"; +} + type NavigationRouteLike = { name?: unknown; params?: unknown; @@ -45,6 +52,36 @@ function emitIfChanged(next: ActiveWorkspaceSelection | null) { } } +function getBrowserLocationWorkspace(): ActiveWorkspaceSelection | null { + if (!isWeb || typeof window === "undefined") { + return null; + } + return parseHostWorkspaceRouteFromPathname(window.location.pathname); +} + +function writeBrowserWorkspaceUrl( + next: ActiveWorkspaceSelection, + options: ActivateWorkspaceSelectionOptions, +) { + 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()); +} + function extractActiveWorkspaceFromRoute( route: NavigationRouteLike | undefined, ): ActiveWorkspaceSelection | null { @@ -91,6 +128,36 @@ export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverR ); } +export function activateNavigationWorkspaceSelection( + next: ActiveWorkspaceSelection, + options: ActivateWorkspaceSelectionOptions = {}, +) { + emitIfChanged(next); + writeBrowserWorkspaceUrl(next, options); +} + +export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { + return getSnapshot(); +} + +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); }