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", ],