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.
This commit is contained in:
Mohamed Boudra
2026-10-23 15:57:12 +07:00
committed by GitHub
parent 6405edd8dc
commit 582beeb10b
35 changed files with 563 additions and 99 deletions
+8 -5
View File
@@ -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
+4 -4
View File
@@ -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
@@ -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();
});
});
@@ -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();
}
});
});
@@ -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();
}
});
});
@@ -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");
});
@@ -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");
@@ -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);
}
@@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise<void> {
export async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
export async function expectCompactSidebarOpen(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
}
+2 -1
View File
@@ -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 (
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
{surface}
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
</AppContainerLifetime>
);
}
@@ -6,6 +6,7 @@ export {
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
useAssistantFileLinkEnvironmentBridge,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
@@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue {
const AssistantFileLinkResolverContext =
createContext<AssistantFileLinkResolverContextValue | null>(null);
export function useAssistantFileLinkEnvironmentBridge() {
const value = useContext(AssistantFileLinkResolverContext);
return useCallback(
(children: ReactNode) => (
<AssistantFileLinkResolverContext.Provider value={value}>
{children}
</AssistantFileLinkResolverContext.Provider>
),
[value],
);
}
export function AssistantFileLinkResolverProvider({
client,
serverId,
@@ -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],
@@ -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()));
},
});
+2 -4
View File
@@ -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 => ({
@@ -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 (
<WorkspaceRow
@@ -145,14 +145,13 @@ function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) {
);
}
function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) {
function SidebarSearchRow() {
const { t } = useTranslation();
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search"));
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
setCommandCenterOpen(true);
}, [onBeforeNavigate, setCommandCenterOpen]);
}, [setCommandCenterOpen]);
return (
<SidebarHeaderRow
@@ -512,7 +512,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
@@ -543,9 +542,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
const handlePress = useCallback(() => {
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
}, [workspace.serverId, workspace.workspaceId]);
return (
<StatusWorkspaceRowWithMenu
@@ -32,9 +32,12 @@ import { useSharedValue } from "react-native-reanimated";
export function SheetHostScope({
children,
style,
enabled = true,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
/** 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 (
<BottomSheetModalInternalProvider value={value}>
<BottomSheetModalInternalProvider value={enabled ? value : parent}>
<View style={[styles.scope, style]}>
{children}
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
<PortalHost name={hostName} />
</View>
{enabled ? (
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
<PortalHost name={hostName} />
</View>
) : null}
</View>
</BottomSheetModalInternalProvider>
);
@@ -87,5 +92,6 @@ const styles = StyleSheet.create({
},
host: {
...StyleSheet.absoluteFill,
zIndex: 1,
},
});
@@ -119,6 +119,20 @@ const SessionsContext = createContext<ComposerEditingSessions | null>(null);
const WorkspaceContext = createContext<string | null>(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) => (
<SessionsContext.Provider value={sessions}>
<WorkspaceContext.Provider value={workspaceKey}>{children}</WorkspaceContext.Provider>
</SessionsContext.Provider>
),
[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());
@@ -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<DiffDocumentWorkspaceCache | null>(null);
export function useDiffDocumentWorkspaceCacheBridge() {
const cache = useDiffDocumentWorkspaceCache();
return useCallback(
(children: ReactNode) => (
<WorkspaceCacheContext.Provider value={cache}>{children}</WorkspaceCacheContext.Provider>
),
[cache],
);
}
export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) {
const [cache] = useState(createDiffDocumentWorkspaceCache);
return <WorkspaceCacheContext.Provider value={cache}>{children}</WorkspaceCacheContext.Provider>;
@@ -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<void> {
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],
);
}
+8 -1
View File
@@ -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;
}
@@ -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<string, ReturnType<typeof useEnvironment>>();
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 (
<ComposerEditingScope tabId="file" targetKey="file:a">
<Probe name="pane" />
</ComposerEditingScope>
);
}
function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) {
const onOpenFile = useCallback(
(target: InlinePathTarget) => {
opened.push({ root: workspaceRoot, path: target.path });
},
[workspaceRoot],
);
return (
<QueryClientProvider client={queryClient}>
<ComposerEditingSessionsProvider>
<WorkspaceComposerEditingSessionsProvider
workspaceKey="host:workspace"
tabIds={["file"]}
>
<DiffDocumentWorkspaceCacheProvider>
<AssistantFileLinkResolverProvider
workspaceRoot={workspaceRoot}
onOpenWorkspaceFile={onOpenFile}
>
<RetainedPanelActivity active={active}>
<Capture />
</RetainedPanelActivity>
</AssistantFileLinkResolverProvider>
</DiffDocumentWorkspaceCacheProvider>
</WorkspaceComposerEditingSessionsProvider>
</ComposerEditingSessionsProvider>
</QueryClientProvider>
);
}
const source = renderEnvironment(<Source workspaceRoot="/before" active />);
const sheetContent = (
<ComposerEditingScope tabId="file" targetKey="file:a">
<Probe name="sheet" />
</ComposerEditingScope>
);
const sheet = renderEnvironment(
<QueryClientProvider client={queryClient}>{bridge(sheetContent)}</QueryClientProvider>,
);
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(<Source workspaceRoot="/after" active={false} />);
// 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(
<QueryClientProvider client={queryClient}>{bridge(sheetContent)}</QueryClientProvider>,
);
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();
});
});
@@ -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<SheetTabsProps, "workspaceKey" | "onCl
}
/**
* The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. They present from inside
* the workspace's `SheetHostScope`, so each panel keeps every context an ordinary pane has, and a
* The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. Their environment
* crosses into the app overlay host so compact sheets cover both sidebars, and a
* sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of
* it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`,
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
@@ -160,6 +161,7 @@ function SheetTabSheet({
const { tab } = sheet;
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const isScreenFocused = useIsFocused();
const contextBridge = useWorkspaceSheetEnvironment();
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.target.kind);
if (!registration) {
@@ -236,7 +238,7 @@ function SheetTabSheet({
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
contextBridge={contextBridge}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
@@ -1,33 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { beforeEach, describe, expect, it } from "vitest";
import { openWorkspaceFileFromExplorer } from "./workspace-file-open-command";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { usePanelStore } from "@/stores/panel-store";
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
function createInput(closeExplorerAfterOpen: boolean) {
return {
filePath: "src/app.tsx",
persistenceKey: "server:workspace",
closeExplorerAfterOpen,
showMobileAgent: vi.fn(),
openWorkspaceTabInFocusedPane: vi.fn(() => "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");
});
});
@@ -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);
}
}
@@ -786,10 +786,11 @@ function WorkspacePanelSurface({
children: ReactNode;
tabIds: readonly string[];
}) {
const isCompact = useIsCompactFormFactor();
return (
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
<NewTabLauncherProvider value={launcher}>
<SheetHostScope>
<SheetHostScope enabled={!isCompact}>
<View style={styles.threePaneRow} onLayout={onLayout}>
{children}
</View>
@@ -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 ? (
<View style={styles.centerColumn}>
<FloatingHeaderLayout
@@ -0,0 +1,38 @@
import React, { useCallback, useContext } from "react";
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { useDiffDocumentWorkspaceCacheBridge } from "@/git/diff-document/workspace-cache";
import { HostRouteProvider, useHostRouteServerId } from "@/navigation/host-route-context";
/** The environment of the built-in compact panels; pane identity is supplied by their body. */
export function useWorkspaceSheetEnvironment(): ContextBridge {
const editing = useComposerEditingEnvironmentBridge();
const cache = useDiffDocumentWorkspaceCacheBridge();
const files = useAssistantFileLinkEnvironmentBridge();
const active = useRetainedPanelActive();
const serverId = useHostRouteServerId();
const navigation = useContext(NavigationContext);
const route = useContext(NavigationRouteContext);
return useCallback(
(children) => {
const content = editing(cache(files(children)));
return (
<NavigationContext.Provider value={navigation}>
<NavigationRouteContext.Provider value={route}>
<RetainedPanelActivity active={active}>
{serverId ? (
<HostRouteProvider serverId={serverId}>{content}</HostRouteProvider>
) : (
content
)}
</RetainedPanelActivity>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
);
},
[editing, cache, files, active, serverId, navigation, route],
);
}
@@ -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() ?? [],
@@ -22,7 +22,14 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
const remembered: ActiveWorkspaceSelection[] = [];
const openedTabs: RecordedTab[] = [];
const deferredTabs: unknown[] = [];
const revealed: Array<string | null> = [];
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<NavigateToWorkspaceDeps> = {}) {
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);
@@ -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();
}
}
@@ -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;
}
@@ -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");
},
);
+22
View File
@@ -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",
],