From 582beeb10bf7a0dc2297f35529f78df89dee9358 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 15:57:12 +0700 Subject: [PATCH] fix(app): preserve compact sidebar context under sheets (#7693) Keep compact sidebars open while users inspect Search, Import session, Add project, or file sheets. Reveal the resolved destination when navigation commits, and place compact workspace sheets above both sidebars while preserving shared editing and cache state. --- docs/explorer-sidebar.md | 13 +- docs/floating-panels.md | 8 +- .../app/e2e/browser/add-project-flow.spec.ts | 37 +++++ .../command-center-file-search.spec.ts | 32 +++++ .../browser/command-center-workspaces.spec.ts | 71 ++++++++++ .../e2e/browser/import-session-flow.spec.ts | 1 + .../e2e/browser/sidebar-nav-settings.spec.ts | 4 +- .../sidebar-workspace-mark-unread.spec.ts | 1 + .../e2e/support/helpers/usage-sidebar-item.ts | 7 + packages/app/src/app/_layout.tsx | 3 +- .../app/src/assistant-file-links/index.ts | 1 + .../app/src/assistant-file-links/provider.tsx | 12 ++ .../command-center/workspace-file-search.ts | 6 +- .../app/src/components/add-project-flow.tsx | 3 + packages/app/src/components/left-sidebar.tsx | 6 +- .../src/components/sidebar-workspace-list.tsx | 4 +- .../components/sidebar/sidebar-nav-rows.tsx | 5 +- .../sidebar/sidebar-status-list.tsx | 4 +- .../host-scope.tsx | 14 +- .../src/composer/editing-session/index.tsx | 14 ++ .../src/git/diff-document/workspace-cache.tsx | 12 +- packages/app/src/hooks/use-import-session.tsx | 8 +- packages/app/src/hosts/host-chooser.tsx | 9 +- .../retained-chat-content.browser.test.tsx | 127 +++++++++++++++++- .../app/src/screens/workspace/sheet-tabs.tsx | 8 +- .../workspace-file-open-command.test.ts | 88 ++++++++---- .../workspace/workspace-file-open-command.ts | 9 +- .../screens/workspace/workspace-screen.tsx | 18 +-- .../workspace/workspace-sheet-environment.tsx | 38 ++++++ .../index.ts | 8 ++ .../navigation.test.ts | 27 +++- .../navigation.ts | 13 +- .../app/src/stores/workspace-layout-store.ts | 6 +- .../app/src/workspace-tabs/navigation.test.ts | 23 ++++ packages/app/vitest.config.ts | 22 +++ 35 files changed, 563 insertions(+), 99 deletions(-) create mode 100644 packages/app/src/screens/workspace/workspace-sheet-environment.tsx diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 7207d14f87..a1778b47b8 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -43,13 +43,16 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab dismisses the sheets above it, since the tab would sit under them. The stack is not persisted. -Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag +Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside -Close, so the title keeps its width. The sheets present -from the shared workspace's `SheetHostScope`, so their panels read the navigator and every -workspace provider an ordinary pane has without a context bridge -([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)). +Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local +host sits behind the app-level left sidebar. The workspace environment bridge preserves existing +editing sessions, caches, file-link resolution and supported navigation context without moving +their providers. Keep this capability at the workspace boundary; new sheet callers must not +assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts +forward the same Gorhom modal queue; neither creates another stack. See +[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site). A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it. Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 8b81ca0ad9..f17c59c7c2 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -363,10 +363,10 @@ track phase. All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on close. This includes native centered dialogs: separate React Native Modals cannot present a -root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree -(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their -panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the -portal host and container measurement while the sheet still joins the same stack. That export is +root-owned sibling dialog over an existing iOS presenter. `SheetHostScope` moves the portal host +and container measurement while forwarding the same modal queue. Compact +[workspace sheets](explorer-sidebar.md#sheets) use the app overlay host above both sidebars; +wide layouts keep local geometry. That export is what `patches/@gorhom+bottom-sheet+*.patch` adds. A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts index f66b963d5f..5770c3be9d 100644 --- a/packages/app/e2e/browser/add-project-flow.spec.ts +++ b/packages/app/e2e/browser/add-project-flow.spec.ts @@ -420,3 +420,40 @@ test.describe("Add Project command-center flow", () => { } }); }); + +test.describe("Compact Add project context", () => { + test.use({ viewport: { width: 390, height: 844 } }); + test("canceling Add project preserves the sidebar", async ({ page }) => { + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await openAddProjectFlow(page); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + }); + test("successful Add project reveals the new workspace page", async ({ + page, + projectPickerFixture, + }) => { + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "directory-search"); + await addProjectFlowInput(page).fill(projectPickerFixture.fuzzyQuery); + await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName); + await page.keyboard.press("Enter"); + const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); + projectPickerFixture.rememberProjectId(projectId); + await expectNewWorkspaceForAddedProject(page, { + serverId: getServerId(), + projectId, + projectName: projectPickerFixture.projectName, + projectPath: projectPickerFixture.projectPath, + }); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + }); +}); diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts index 5949882492..2f16db6f09 100644 --- a/packages/app/e2e/browser/command-center-file-search.spec.ts +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -202,3 +202,35 @@ test("manual scrolling cancels a pending file-search reveal and query editing re await seeded.cleanup(); } }); + +test.describe("Compact file search context", () => { + test.use({ viewport: { width: 390, height: 844 } }); + test("a file result opens a sheet above the left sidebar", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "compact-search-file-", + title: "Compact file lookup", + repo: { files: [{ path: "lookup.ts", content: "export const lookup = true;\n" }] }, + }); + try { + await page.goto(`/h/${process.env.E2E_SERVER_ID}/workspace/${seeded.workspaceId}`); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + const search = await openCommandCenter(page); + await search.getByTestId("command-center-input").fill("lookup.ts"); + await search.getByRole("button", { name: "lookup.ts", exact: true }).click(); + await expect(page.getByTestId("sheet-tab-body")).toBeVisible(); + await expect(page.getByTestId("sheet-tab-header")).toContainText("lookup.ts"); + await page + .getByTestId("sheet-tab-header") + .getByRole("button", { name: "Close", exact: true }) + .click(); + await expect(page.getByTestId("sheet-tab-body")).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts index b47f8a9a4f..38b2e33cff 100644 --- a/packages/app/e2e/browser/command-center-workspaces.spec.ts +++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts @@ -185,3 +185,74 @@ test.describe("Command center workspaces", () => { } }); }); + +test.describe("Compact sidebar inspection", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + async function openSidebar(page: import("@playwright/test").Page) { + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } + + async function expectSidebarPreserved(page: import("@playwright/test").Page) { + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } + + test("canceling Search retains the compact sidebar", async ({ page }) => { + await gotoAppShell(page); + await openSidebar(page); + await openCommandCenter(page); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("command-center-panel")).not.toBeVisible(); + await expectSidebarPreserved(page); + }); + test("canceling Import session retains the compact sidebar", async ({ page }) => { + await gotoAppShell(page); + await openSidebar(page); + await page + .getByTestId("sidebar-project-empty-state") + .getByRole("button", { name: "Import session", exact: true }) + .click(); + await expect(page.getByTestId("import-session-sheet")).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByTestId("import-session-sheet")).not.toBeVisible(); + await expectSidebarPreserved(page); + }); + + test("Search reveals the same workspace and a selected root agent", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "compact-search-destination-", + title: "Compact destination", + }); + try { + const agent = await createIdleAgent(seeded.client, { + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title: "Compact root agent", + }); + await page.goto(buildHostWorkspaceRoute(getServerId(), seeded.workspaceId)); + await openSidebar(page); + const search = await openCommandCenter(page); + await search.getByTestId("command-center-input").fill("Compact destination"); + await search + .getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`) + .click(); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId)); + await openSidebar(page); + const agentSearch = await openCommandCenter(page); + await agentSearch.getByTestId("command-center-input").fill("Compact root agent"); + await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click(); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Message agent...", exact: true }), + ).toBeVisible(); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index 788a6f93ec..c6d34f38f2 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -182,6 +182,7 @@ test("captures the compact import-session journey", async ({ page }, testInfo) = await test.step("the selected row imports and opens the hydrated transcript", async () => { await flow.importSession(scenario.importSessionId); await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready."); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); await capture(page, testInfo, "08-mobile-agent-after-import.png"); }); diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index 177ddd563e..b557b73ccb 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -6,6 +6,7 @@ import { claudeAndCodexReports, expectPinnedUsage, openCompactSidebar, + expectCompactSidebarOpen, } from "../support/helpers/usage-sidebar-item"; import { gotoAppShell } from "../support/helpers/app"; import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items"; @@ -90,12 +91,13 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy await openAddProjectFlow(page); await page.keyboard.press("Escape"); await expect(addProjectFlow(page)).toBeHidden(); + await expectCompactSidebarOpen(page); await page.setViewportSize({ width: 1440, height: 900 }); await openSidebarNavSettings(page); await setFooterItemVisible(page, "usage", true); await leaveSettings(page); await page.setViewportSize({ width: 390, height: 844 }); - await openCompactSidebar(page); + await expectCompactSidebarOpen(page); await expectFooterSeparator(page, true); await expectFooterIconRow(page); await footerScreenshot(page, "footer-compact-with-rows"); diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts index 23127bbdd9..ccf29179b8 100644 --- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -180,6 +180,7 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) { async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) { await page.setViewportSize({ width: 390, height: 844 }); await openAgentRoute(page, workspace); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); await openMobileAgentSidebar(page); } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 95c6a4d689..2b4aa46e9b 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise { export async function openCompactSidebar(page: Page): Promise { await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); } + +export async function expectCompactSidebarOpen(page: Page): Promise { + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f24df99f26..99022863f5 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -2,6 +2,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar"; import { ComposerEditingSessionsProvider } from "@/composer/editing-session"; +import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal"; import "@/styles/unistyles"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; @@ -507,7 +508,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon return ( - {surface} + {surface} ); } diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 9b66e99c18..6d80abb1a6 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -6,6 +6,7 @@ export { export { AssistantFileLinkResolverProvider, useAssistantFileLinkEnvironment, + useAssistantFileLinkEnvironmentBridge, type AssistantFileLinkResolverProviderProps, } from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx index a8773a2398..935683bb0e 100644 --- a/packages/app/src/assistant-file-links/provider.tsx +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue { const AssistantFileLinkResolverContext = createContext(null); +export function useAssistantFileLinkEnvironmentBridge() { + const value = useContext(AssistantFileLinkResolverContext); + return useCallback( + (children: ReactNode) => ( + + {children} + + ), + [value], + ); +} + export function AssistantFileLinkResolverProvider({ client, serverId, diff --git a/packages/app/src/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts index 1008604bec..8864da5ea8 100644 --- a/packages/app/src/command-center/workspace-file-search.ts +++ b/packages/app/src/command-center/workspace-file-search.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { usePanelStore } from "@/stores/panel-store"; +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; @@ -131,8 +131,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string openWorkspaceFileFromExplorer({ filePath: path, persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), - closeExplorerAfterOpen: true, - showMobileAgent: usePanelStore.getState().showMobileAgent, openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) => useWorkspaceLayoutStore.getState().openTab({ workspaceKey, @@ -140,7 +138,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string intent: "reveal", placement, }), - focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab, + revealWorkspaceTab, }); }, [serverId, workspaceId], diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 06f7872995..a1cb5cf779 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,4 @@ +import { usePanelStore } from "@/stores/panel-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { router } from "expo-router"; import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; @@ -474,6 +475,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const openNewWorkspaceForProject = useCallback( (serverId: string, project: WorkspaceProjectDescriptorPayload) => { onClose(); + usePanelStore.getState().showMobileAgent(); router.push( buildNewWorkspaceRoute({ serverId, @@ -621,6 +623,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { testID: "add-project-flow-add-host", select: () => { onClose(); + usePanelStore.getState().showMobileAgent(); router.push(buildSettingsAddHostRoute(Date.now())); }, }); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 79f836d1d4..3f5d218150 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -166,9 +166,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const handleOpenProjectMobile = useCallback(() => { - showMobileAgent(); void openProjectPicker(); - }, [showMobileAgent, openProjectPicker]); + }, [openProjectPicker]); const handleOpenProjectDesktop = useCallback(() => { void openProjectPicker(); @@ -205,9 +204,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole }, []); const handleImportSessionMobile = useCallback(() => { - showMobileAgent(); openImportSession(); - }, [openImportSession, showMobileAgent]); + }, [openImportSession]); const labels = useMemo( (): SidebarLabels => ({ diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 1c7b3f28d0..cc248adeff 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1401,7 +1401,6 @@ function WorkspaceRowItem({ isCreating = false, selectionEnabled, activeWorkspaceSelection, - onWorkspacePress, drag, isDragging = false, dragHandleProps, @@ -1410,9 +1409,8 @@ function WorkspaceRowItem({ if (!workspace.serverId) { return; } - onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); - }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); + }, [workspace.serverId, workspace.workspaceId]); return ( state.setCommandCenterOpen); const handlePress = useCallback(() => { - onBeforeNavigate?.(); setCommandCenterOpen(true); - }, [onBeforeNavigate, setCommandCenterOpen]); + }, [setCommandCenterOpen]); return ( { if (!workspace.serverId) return; - onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); - }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); + }, [workspace.serverId, workspace.workspaceId]); return ( ; + /** Disabled scopes keep their mounted children and use the enclosing host. */ + enabled?: boolean; }) { const parent = useBottomSheetModalInternal(); const hostName = `sheet-host-${useId()}`; @@ -69,12 +72,14 @@ export function SheetHostScope({ ); return ( - + {children} - - - + {enabled ? ( + + + + ) : null} ); @@ -87,5 +92,6 @@ const styles = StyleSheet.create({ }, host: { ...StyleSheet.absoluteFill, + zIndex: 1, }, }); diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 6ad84d632d..4776b5af3d 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -119,6 +119,20 @@ const SessionsContext = createContext(null); const WorkspaceContext = createContext(null); const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null); +/** Keep the workspace's editing identity when a sheet renders in the app overlay host. */ +export function useComposerEditingEnvironmentBridge() { + const sessions = useContext(SessionsContext); + const workspaceKey = useContext(WorkspaceContext); + return useCallback( + (children: ReactNode) => ( + + {children} + + ), + [sessions, workspaceKey], + ); +} + /** App-container ownership survives replacement of both the gesture shell and tab layout. */ export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) { const [sessions] = useState(() => new ComposerEditingSessions()); diff --git a/packages/app/src/git/diff-document/workspace-cache.tsx b/packages/app/src/git/diff-document/workspace-cache.tsx index bd52000af3..5b9921e31d 100644 --- a/packages/app/src/git/diff-document/workspace-cache.tsx +++ b/packages/app/src/git/diff-document/workspace-cache.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; +import React, { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import invariant from "tiny-invariant"; import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model"; import type { @@ -162,6 +162,16 @@ function paletteKey(palette: DiffPalette): string { const WorkspaceCacheContext = createContext(null); +export function useDiffDocumentWorkspaceCacheBridge() { + const cache = useDiffDocumentWorkspaceCache(); + return useCallback( + (children: ReactNode) => ( + {children} + ), + [cache], + ); +} + export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) { const [cache] = useState(createDiffDocumentWorkspaceCache); return {children}; diff --git a/packages/app/src/hooks/use-import-session.tsx b/packages/app/src/hooks/use-import-session.tsx index bdcf5c4563..f08d152605 100644 --- a/packages/app/src/hooks/use-import-session.tsx +++ b/packages/app/src/hooks/use-import-session.tsx @@ -1,10 +1,9 @@ import { useCallback, useState, type ReactNode } from "react"; -import { type Href, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useHostChooser } from "@/hosts/host-chooser"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; import { useOpenProject } from "@/hooks/use-open-project"; interface UseImportSessionOptions { @@ -31,7 +30,6 @@ interface ImportedAgentTarget { export function useNavigateToImportedAgent( serverId: string | null | undefined, ): (agent: ImportedAgentTarget) => Promise { - const router = useRouter(); const normalizedServerId = serverId?.trim() || null; const openProject = useOpenProject(normalizedServerId); @@ -40,10 +38,10 @@ export function useNavigateToImportedAgent( if (!normalizedServerId) return; const project = await openProject(agent.cwd); if (project.ok) { - router.push(buildHostAgentDetailRoute(normalizedServerId, agent.id) as Href); + navigateToAgent({ serverId: normalizedServerId, agentId: agent.id }); } }, - [normalizedServerId, openProject, router], + [normalizedServerId, openProject], ); } diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx index c0b62697e0..5e4d7f053c 100644 --- a/packages/app/src/hosts/host-chooser.tsx +++ b/packages/app/src/hosts/host-chooser.tsx @@ -1,3 +1,4 @@ +import { usePanelStore } from "@/stores/panel-store"; import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Modal, @@ -81,7 +82,13 @@ export function useHostChooser() { ); if (availableHosts.length === 0) { - (input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))(); + ( + input.onNoHosts ?? + (() => { + usePanelStore.getState().showMobileAgent(); + router.push(buildSettingsAddHostRoute(Date.now())); + }) + )(); return false; } diff --git a/packages/app/src/panels/retained-chat-content.browser.test.tsx b/packages/app/src/panels/retained-chat-content.browser.test.tsx index cba12e3cbd..07b1ceca75 100644 --- a/packages/app/src/panels/retained-chat-content.browser.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -1,11 +1,39 @@ -import { act, useLayoutEffect, useState, useSyncExternalStore } from "react"; +import React, { + act, + useCallback, + useEffect, + useLayoutEffect, + useState, + useSyncExternalStore, +} from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it } from "vitest"; -import { RetainedPanelActivity } from "@/components/retained-panel"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent as NativeChat } from "./retained-chat-content.native"; import { RetainedChatContent as WebChat } from "./retained-chat-content.web"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { + ComposerEditingScope, + ComposerEditingSessionsProvider, + WorkspaceComposerEditingSessionsProvider, + useComposerEditingSession, +} from "@/composer/editing-session"; +import { + DiffDocumentWorkspaceCacheProvider, + useDiffDocumentWorkspaceCache, +} from "@/git/diff-document/workspace-cache"; +import { + AssistantFileLinkResolverProvider, + useAssistantFileLinkEnvironment, +} from "@/assistant-file-links"; +import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; + +import { render as renderEnvironment } from "@testing-library/react"; +import { useWorkspaceSheetEnvironment } from "@/screens/workspace/workspace-sheet-environment"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links"; + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let root: Root; let container: HTMLDivElement; @@ -99,3 +127,96 @@ it("retains modified panels beyond the LRU cap and evicts them normally after sa render("e"); expect(mounted).toEqual(["e", "a", "b"]); }); + +describe("workspace sheet environment", () => { + it("shares editing sessions and diff cache across the host, keeping resolver updates live", async () => { + let bridge: ContextBridge = (children) => children; + const observations = new Map>(); + let mounts = 0; + const queryClient = new QueryClient(); + const opened: Array<{ root: string; path: string }> = []; + function useEnvironment() { + return { + session: useComposerEditingSession("chat"), + cache: useDiffDocumentWorkspaceCache(), + files: useAssistantFileLinkEnvironment(), + active: useRetainedPanelActive(), + actions: useAssistantFileLinkActions(), + }; + } + function Probe({ name }: { name: string }) { + observations.set(name, useEnvironment()); + useEffect(() => { + if (name === "sheet") mounts++; + }, [name]); + return null; + } + function Capture() { + bridge = useWorkspaceSheetEnvironment(); + return ( + + + + ); + } + function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) { + const onOpenFile = useCallback( + (target: InlinePathTarget) => { + opened.push({ root: workspaceRoot, path: target.path }); + }, + [workspaceRoot], + ); + return ( + + + + + + + + + + + + + + ); + } + const source = renderEnvironment(); + const sheetContent = ( + + + + ); + const sheet = renderEnvironment( + {bridge(sheetContent)}, + ); + const pane = observations.get("pane")!; + expect(observations.get("sheet")?.session).toBe(pane.session); + expect(observations.get("sheet")?.cache).toBe(pane.cache); + expect(observations.get("sheet")?.files.workspaceRoot).toBe("/before"); + const actions = observations.get("sheet")!.actions; + act(() => pane.session.setFullscreen(true)); + source.rerender(); + // An action captured before the provider update must already use the live resolver value. + await act(async () => actions.open({ href: "/after/linked.ts" }, "preferred")); + expect(opened).toEqual([{ root: "/after", path: "/after/linked.ts" }]); + sheet.rerender( + {bridge(sheetContent)}, + ); + expect(observations.get("sheet")?.session).toBe(pane.session); + expect(observations.get("sheet")?.session.getFullscreen()).toBe(true); + expect(observations.get("sheet")?.cache).toBe(pane.cache); + expect(observations.get("sheet")?.files.workspaceRoot).toBe("/after"); + expect(observations.get("sheet")?.active).toBe(false); + expect(mounts).toBe(1); + sheet.unmount(); + source.unmount(); + }); +}); diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx index de13097503..e7c8005233 100644 --- a/packages/app/src/screens/workspace/sheet-tabs.tsx +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -37,6 +37,7 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; +import { useWorkspaceSheetEnvironment } from "./workspace-sheet-environment"; const SNAP_POINTS = ["92%"]; const NO_SHEETS: readonly SheetTab[] = []; @@ -101,8 +102,8 @@ interface SheetTabSheetProps extends Omit(null); const isScreenFocused = useIsFocused(); + const contextBridge = useWorkspaceSheetEnvironment(); ensurePanelsRegistered(); const registration = getPanelRegistration(tab.target.kind); if (!registration) { @@ -236,7 +238,7 @@ function SheetTabSheet({ return ( "file-tab"), - focusWorkspaceTab: vi.fn(), - }; -} - -describe("openWorkspaceFileFromExplorer", () => { - it("closes the phone overlay after opening a file", () => { - const input = createInput(true); - - openWorkspaceFileFromExplorer(input); - - expect(input.showMobileAgent).toHaveBeenCalledOnce(); +const workspaceKey = "server:workspace"; +function openFile(filePath: string, persistenceKey: string | null = workspaceKey) { + openWorkspaceFileFromExplorer({ + filePath, + persistenceKey, + openWorkspaceTabInFocusedPane: (key, target, placement) => + useWorkspaceLayoutStore + .getState() + .openTab({ workspaceKey: key, target, placement, intent: "reveal" }), + revealWorkspaceTab, }); - - it("keeps the tablet dock open after opening a file", () => { - const input = createInput(false); - - openWorkspaceFileFromExplorer(input); - - expect(input.showMobileAgent).not.toHaveBeenCalled(); - expect(input.openWorkspaceTabInFocusedPane).toHaveBeenCalledOnce(); - expect(input.focusWorkspaceTab).toHaveBeenCalledWith("server:workspace", "file-tab"); +} +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutMode: "compact", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + }); + usePanelStore.getState().showMobileFileExplorer(); +}); +describe("file search destination", () => { + it("preserves Explorer under a new file sheet", () => { + openFile("new.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]).toHaveLength(1); + }); + it.each([null, workspaceKey])("preserves context when no file opens (%s)", (key) => { + openFile("", key); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + }); + it("reveals an existing main tab", () => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("split-panes"); + store.openTab({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + openFile("existing.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + it("reveals the existing Explorer tab from the left sidebar", () => { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.showExplorerSidebar(workspaceKey)!; + store.openTab({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + usePanelStore.getState().showMobileAgentList(); + openFile("existing.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); }); }); diff --git a/packages/app/src/screens/workspace/workspace-file-open-command.ts b/packages/app/src/screens/workspace/workspace-file-open-command.ts index 0d990f3557..db96fd1204 100644 --- a/packages/app/src/screens/workspace/workspace-file-open-command.ts +++ b/packages/app/src/screens/workspace/workspace-file-open-command.ts @@ -11,20 +11,15 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; interface OpenWorkspaceFileFromExplorerInput { filePath: string; persistenceKey: string | null; - closeExplorerAfterOpen: boolean; - showMobileAgent: () => void; openWorkspaceTabInFocusedPane: ( workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement, ) => string | null; - focusWorkspaceTab: (workspaceKey: string, tabId: string) => void; + revealWorkspaceTab: (workspaceKey: string, tabId: string) => void; } export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void { - if (input.closeExplorerAfterOpen) { - input.showMobileAgent(); - } if (!input.persistenceKey) { return; } @@ -38,6 +33,6 @@ export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplor FOCUSED_PANE_PLACEMENT, ); if (tabId) { - input.focusWorkspaceTab(input.persistenceKey, tabId); + input.revealWorkspaceTab(input.persistenceKey, tabId); } } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ed1bbfdfcd..bcc2593611 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -786,10 +786,11 @@ function WorkspacePanelSurface({ children: ReactNode; tabIds: readonly string[]; }) { + const isCompact = useIsCompactFormFactor(); return ( - + {children} @@ -1138,6 +1139,7 @@ function WorkspaceScreenContent({ (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, ); const pendingOpenTarget = usePendingWorkspaceOpen({ + onOpened: revealWorkspaceTab, workspaceKey: persistenceKey, hasHydratedWorkspaces, workspaceExists: workspaceDescriptor !== null, @@ -1661,9 +1663,6 @@ function WorkspaceScreenContent({ if (!normalizedLocation) { return; } - if (isMobile) { - showMobileAgent(); - } if (!persistenceKey) { return; } @@ -1675,14 +1674,7 @@ function WorkspaceScreenContent({ navigateToTabId(tabId); } }, - [ - isMobile, - navigateToTabId, - openWorkspaceTabFocused, - revealWorkspaceChildTab, - persistenceKey, - showMobileAgent, - ], + [navigateToTabId, openWorkspaceTabFocused, revealWorkspaceChildTab, persistenceKey], ); const handleOpenPreferredAssistantFile = useCallback( @@ -3533,7 +3525,7 @@ function WorkspaceScreenContent({ composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), }); - // Compact sheets use the shared workspace scope, above both center and Explorer. + // Compact sheets escape to the app overlay host, above both sidebars. const workspaceCenterColumn = isMobile ? ( { + const content = editing(cache(files(children))); + return ( + + + + {serverId ? ( + {content} + ) : ( + content + )} + + + + ); + }, + [editing, cache, files, active, serverId, navigation, route], + ); +} diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 0404ea34ed..16f3a23f0b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -1,3 +1,5 @@ +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; +import { usePanelStore } from "@/stores/panel-store"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useLocalSearchParams, usePathname } from "expo-router"; import { useEffect, useSyncExternalStore } from "react"; @@ -34,6 +36,12 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( function navigateDeps(): NavigateToWorkspaceDeps { return { + revealTab: revealWorkspaceTab, + revealWorkspace: () => { + if (useWorkspaceLayoutStore.getState().layoutMode === "compact") { + usePanelStore.getState().showMobileAgent(); + } + }, getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, getSessionAgents: (serverId) => useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index 055049baee..a3bb69821a 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -22,7 +22,14 @@ function createFakeDeps(overrides: Partial = {}) { const remembered: ActiveWorkspaceSelection[] = []; const openedTabs: RecordedTab[] = []; const deferredTabs: unknown[] = []; + const revealed: Array = []; const deps: NavigateToWorkspaceDeps = { + revealTab: (_key, tabId) => { + revealed.push(tabId); + }, + revealWorkspace: () => { + revealed.push(null); + }, deferOpenTab: (input) => { deferredTabs.push(input); return null; @@ -38,7 +45,7 @@ function createFakeDeps(overrides: Partial = {}) { navigateToRoute: (route) => navigations.push(route), ...overrides, }; - return { deps, navigations, remembered, openedTabs, deferredTabs }; + return { deps, navigations, remembered, openedTabs, deferredTabs, revealed }; } function createLastSelectionDeps( @@ -65,6 +72,24 @@ function createLastSelectionDeps( } describe("workspace navigation", () => { + it("reveals even a current workspace without agents", () => { + const { deps, revealed } = createFakeDeps(); + navigateToWorkspace({ serverId: "server", workspaceId: "workspace" }, deps); + expect(revealed).toEqual([null]); + }); + + it("reveals an explicit target after opening instead of forcing center", () => { + const workspace = { id: "workspace" } as WorkspaceDescriptor; + const { deps, revealed } = createFakeDeps({ + getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), + }); + navigateToWorkspace( + { serverId: "server", workspaceId: "workspace", target: { kind: "agent", agentId: "root" } }, + deps, + ); + expect(revealed).toEqual(["root"]); + }); + it("reports when no last workspace is known", () => { const { deps } = createLastSelectionDeps(null); diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index f234b1c2a6..d856a9bb36 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -38,6 +38,8 @@ export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { deferOpenTab: (input: OpenWorkspaceTabInput) => null; rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; navigateToRoute: (route: string) => void; + revealTab: (workspaceKey: string, tabId: string | null) => void; + revealWorkspace: () => void; } export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps { @@ -93,8 +95,13 @@ export function navigateToWorkspace( workspaceId: input.workspaceId, }); const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated(); + const openTab: NavigateToWorkspaceDeps["openTab"] = (request) => { + if (shouldDeferOpen) return deps.deferOpenTab(request); + const tabId = deps.openTab(request); + deps.revealTab(request.workspaceKey, tabId); + return tabId; + }; if (input.target) { - const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab; prepareWorkspaceTab({ ...input, target: input.target }, { openTab }); } else { const workspaceAgents = resolvedWorkspaceId @@ -104,11 +111,13 @@ export function navigateToWorkspace( : []; const attentionAgentId = pickAttentionAgent(workspaceAgents); if (attentionAgentId && resolvedWorkspaceId) { - deps.openTab({ + openTab({ workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`, target: { kind: "agent", agentId: attentionAgentId }, intent: "reveal", }); + } else { + deps.revealWorkspace(); } } diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 60f96ae330..1c0b7a5037 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -2416,12 +2416,14 @@ interface PendingWorkspaceOpenReadiness { workspaceKey: string | null; hasHydratedWorkspaces: boolean; workspaceExists: boolean; + onOpened: (workspaceKey: string, tabId: string | null) => void; } export function usePendingWorkspaceOpen({ workspaceKey, hasHydratedWorkspaces, workspaceExists, + onOpened, }: PendingWorkspaceOpenReadiness): WorkspaceTabTarget | null { const workspaceReady = hasHydratedWorkspaces && workspaceExists; const pending = useWorkspaceLayoutStore((state) => @@ -2430,8 +2432,8 @@ export function usePendingWorkspaceOpen({ const layoutHydrated = useWorkspaceLayoutStoreHydrated(); useLayoutEffect(() => { if (workspaceKey && workspaceReady && layoutHydrated && pending) { - useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey); + onOpened(workspaceKey, useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey)); } - }, [workspaceKey, workspaceReady, layoutHydrated, pending]); + }, [workspaceKey, workspaceReady, layoutHydrated, pending, onOpened]); return pending ? pending.target : null; } diff --git a/packages/app/src/workspace-tabs/navigation.test.ts b/packages/app/src/workspace-tabs/navigation.test.ts index ab3d1539ab..c32fc91cf9 100644 --- a/packages/app/src/workspace-tabs/navigation.test.ts +++ b/packages/app/src/workspace-tabs/navigation.test.ts @@ -85,3 +85,26 @@ it("preserves dock visibility intent while compact targets are opened and reveal false, ); }); +it.each(["agent-list", "file-explorer"] as const)( + "preserves %s under a deferred file sheet and reveals deferred agents", + (panel) => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + if (panel === "agent-list") usePanelStore.getState().showMobileAgentList(); + const before = usePanelStore.getState().mobilePanel; + store.deferOpenTab({ + workspaceKey, + target: { kind: "file", path: "pending.ts" }, + intent: "reveal", + }); + revealWorkspaceTab(workspaceKey, store.consumePendingOpen(workspaceKey)); + expect(usePanelStore.getState().mobilePanel).toEqual(before); + store.deferOpenTab({ + workspaceKey, + target: { kind: "agent", agentId: "root" }, + intent: "reveal", + }); + revealWorkspaceTab(workspaceKey, store.consumePendingOpen(workspaceKey)); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }, +); diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index fd12cd6189..2d7149442e 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -121,10 +121,32 @@ export default defineConfig({ // so it scans the native files and dies on imports react-native-web has no answer for. // Unbundled, the same imports go through the resolver below and land on the web files. optimizeDeps: { + // Expo's public navigation barrel also loads screens. Match Metro's web-first resolution + // while optimizing its CJS dependencies, including DebugContainer.web.js. + esbuildOptions: { + resolveExtensions: [ + ".web.js", + ".web.jsx", + ".web.ts", + ".web.tsx", + ".js", + ".jsx", + ".ts", + ".tsx", + ".json", + ], + }, // Bundle the CJS dependencies of the excluded gesture-handler package for the browser. include: [ "react/jsx-runtime", "react/compiler-runtime", + "expo-router/react-navigation", + "@testing-library/react", + "@tanstack/react-query", + "react-i18next", + "i18next", + "@marijn/find-cluster-break", + "tiny-invariant", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ],