diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 719b0b5468..96afdc54fa 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -288,7 +288,7 @@ parentAgentId === thisAgent.id AND !archivedAt - **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) instead, replacing the subagents list in place (Back returns to the list); the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index a1778b47b8..b63095e7a0 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -33,25 +33,36 @@ keeps only agents and terminals. A panel kind opts in with `compactSheet` in its (`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every -other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a -subagent chosen from the subagents list included, lands on top of it, and closing the top sheet -(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the -sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract. +other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on +top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below. + +A subagent chosen from the subagents list is the exception. It opens as a page of the list's own +sheet and replaces the list in place: Back returns to the list, Close ends both, and Open parent +closes the list, landing on the pane the list was opened from. The store's `selectSheetTab` describes +the `SheetTab` a reveal would show without opening it, so the page never enters `sheetsByWorkspace`. +See [menus.md](menus.md#a-page-that-fills-the-sheet) for the page contract. The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the 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 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. 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 +Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the +header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its +bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a +menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The +container owns Android Back; the pane never subscribes to it. + +Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the +sheet through `SheetDragArea`, 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. Compact sheets use the app overlay +host: a workspace-local host sits behind the app-level left sidebar. The sheet pane bridge +(`useSheetPaneBridge`) 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. diff --git a/docs/floating-panels.md b/docs/floating-panels.md index f17c59c7c2..4616a03815 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -410,6 +410,19 @@ bridges the menu's two contexts; the rest pass `null`. Wrapping providers _around_ the modal does nothing. They land on the wrong side. +## Gotcha 8 — Read safe-area insets where the overlay draws + +A compact sheet tab's body (`SheetBottomInset` in `adaptive-modal-sheet.tsx`) pads the bottom +inset and gives its content a bottom inset of 0, so the composer dock sits the same in a sheet as +in a tab. An overlay opened from that content and calling `useSafeAreaInsets()` at its call site +gets that 0 and draws over the navigation bar. + +- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See + `CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`. +- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider` + inside it and read the inset below that. The modal is its own window, and the provider measures + it. See `attachment-lightbox.tsx`. + ## Recipe for a new anchored panel Before you write a new one, ask: diff --git a/docs/glossary.md b/docs/glossary.md index d2c871f74b..4bb6391870 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -22,7 +22,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md). - **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md). -- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets). +- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. A subagent chosen from the subagents list shows as a page of the list's sheet instead of a sheet of its own. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`). See [explorer-sidebar.md](explorer-sidebar.md#sheets). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). diff --git a/docs/menus.md b/docs/menus.md index 9dc3e26cae..15ea6b5a5d 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -52,10 +52,12 @@ is another, with its own icons and hover-revealed actions — but both hand the and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press outright leaves the surface open behind whatever it opened, and skips the iOS wait. -A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu -sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab +A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second +sheet over it: the row names a [fill page](#a-page-that-fills-the-sheet) with `opensPage`, the page +replaces the list in place, Back returns to the list, and Close ends both. A row that opens a tab closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout -store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout. +store which sheet tab a row would show (`useSubagentSheetTab`), since the answer changes with the +layout; a row with none opens its tab. ## Pages @@ -101,6 +103,23 @@ The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its point, and with `enableDynamicSizing` that point is the content's own height — a short page does not grow, and the keyboard comes up over the field you are typing into. +## A page that fills the sheet + +A page can carry its own content instead of rows: `{ id, fill: true, content }`. Only the sheet +renders one. It takes the full-sheet snap point (`FULL_SHEET_SNAP_POINTS`) instead of sizing to +content, and the sheet stops panning on content drags, because the page scrolls on its own and a +drag would fight that scroll. The page's header drags the sheet instead, through `SheetDragArea`. + +The content renders on the far side of the sheet's portal, so it brings its own context bridge, +built where the page is defined. Sheet tabs are the one user: `useSheetPanePage` turns a `SheetTab` +into a fill page around the same `SheetPane` the workspace's own sheets render. See +[explorer-sidebar.md](explorer-sidebar.md#sheets). + +Back pops one page. The header's Back and Android Back both call `goBack`, which answers `false` at +the root so the sheet closes instead. A page whose definition disappears while it is open, such as +a subagent archived from elsewhere, is pruned from the path, and the sheet falls back to the page +that opened it. + ## Hover intent A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip @@ -189,7 +208,7 @@ its own. entering animation never ends at the off-screen measuring position; on a slow machine that write moved the open menu back off-screen. - **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is - rarely the height of the page before it. + rarely the height of the page before it. A fill page is the one exception. - **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts them on the wrong side of the portal and every item inside throws. Gotcha 7 in diff --git a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts index 77d995eda7..5b68663862 100644 --- a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts +++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts @@ -5,12 +5,16 @@ import { expectComposerVisible } from "../support/helpers/composer"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { + expectSubagentRowGone, expectSubagentRowVisible, openSubagentFromCompactTrack, seedSubagent, } from "../support/helpers/subagents"; import { + backToList, closeSheet, + expectNoSheetOpen, + expectOneSheetOpen, expectOpenTabCount, openSheetAsTab, expectDeclinedDiscardPrompt, @@ -52,6 +56,10 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents" { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId }, { parentId: workspace.agentId, title: "Sheet child" }, ); + const grandchild = await seedSubagent( + { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId }, + { parentId: child.id, title: "Sheet grandchild" }, + ); await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); await expectOpenTabCount(page, 1); @@ -79,12 +87,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents" await tapSheetBackdrop(page); }); - await test.step("a subagent opens in a sheet over the subagents list", async () => { + await test.step("a subagent opens in place of the subagents list", async () => { await openSubagentFromCompactTrack(page, child.id); const childSheet = sheet(page, child.title); await expect(childSheet).toBeVisible(); await expectSheetAtSnap(page, child.title); + await expectSubagentRowGone(page, child.id); + await expectOneSheetOpen(page); await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0); // The composer's model, thinking, and mode stand in for it, resolved the same way. An idle // seeded agent has no context usage yet. @@ -94,23 +104,51 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents" await expectOpenTabCount(page, 1); }); - await test.step("closing the subagent's sheet uncovers the list", async () => { - await closeSheet(page, child.title); + await test.step("Back returns the sheet to the list", async () => { + await backToList(page, child.title); await expectSubagentRowVisible(page, child.id); + await expectOneSheetOpen(page); + }); + + await test.step("Close closes the list with the subagent", async () => { + await page.getByTestId(`subagents-track-row-${child.id}`).click(); + await closeSheet(page, child.title); + await expectSubagentRowGone(page, child.id); + await expectNoSheetOpen(page); }); await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => { - await page.getByTestId(`subagents-track-row-${child.id}`).click(); + await openSubagentFromCompactTrack(page, child.id); const childSheet = sheet(page, child.title); await expectSheetAtSnap(page, child.title); await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0); await childSheet.getByTestId("parent-agent-callout-open").click(); await expect(childSheet).toHaveCount(0); + await expectNoSheetOpen(page); + await expectOpenTabCount(page, 1); + }); + + await test.step("a nested subagent's parent box lands on its parent's sheet", async () => { + await openSubagentFromCompactTrack(page, child.id); + const childSheet = sheet(page, child.title); + await openSubagentFromCompactTrack(page, grandchild.id, childSheet); + const grandchildSheet = sheet(page, grandchild.title); + await expectSheetAtSnap(page, grandchild.title); + await expect(grandchildSheet.getByTestId("parent-agent-callout")).toContainText(child.title); + + await grandchildSheet.getByTestId("parent-agent-callout-open").click(); + await expect(grandchildSheet).toHaveCount(0); + await expect(childSheet).toBeVisible(); + await expectOneSheetOpen(page); + + await childSheet.getByTestId("parent-agent-callout-open").click(); + await expect(childSheet).toHaveCount(0); + await expectNoSheetOpen(page); await expectOpenTabCount(page, 1); }); await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => { - await page.getByTestId(`subagents-track-row-${child.id}`).click(); + await openSubagentFromCompactTrack(page, child.id); await expectSheetAtSnap(page, child.title); await openSheetAsTab(page, child.title); await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0); @@ -123,7 +161,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents" await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0); }); - await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => { + await test.step("once its tab closes, the subagent reopens in place of the list", async () => { await page .getByRole("button", { name: /Switch tabs/ }) .filter({ visible: true }) @@ -141,7 +179,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents" await openSubagentFromCompactTrack(page, child.id); await expectSheetAtSnap(page, child.title); - await closeSheet(page, child.title); + await backToList(page, child.title); await expectSubagentRowVisible(page, child.id); await tapSheetBackdrop(page); await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); diff --git a/packages/app/e2e/support/helpers/sheet.ts b/packages/app/e2e/support/helpers/sheet.ts index 6288c6f9c1..94c33747d0 100644 --- a/packages/app/e2e/support/helpers/sheet.ts +++ b/packages/app/e2e/support/helpers/sheet.ts @@ -5,7 +5,7 @@ export function sheet(page: Page, label: string): Locator { return page.getByRole("dialog", { name: label }); } -/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */ +/** A sheet tab's header, which carries its title, Back, Close, and the Open as tab glyph. */ export function sheetHeader(page: Page, label: string): Locator { return page.getByTestId("sheet-tab-header").filter({ hasText: label }); } @@ -48,6 +48,12 @@ export async function closeSheet(page: Page, label: string): Promise { await expect(sheet(page, label)).toHaveCount(0); } +/** Steps a sheet opened from a list back to that list, in the same sheet. */ +export async function backToList(page: Page, label: string): Promise { + await sheetHeader(page, label).getByRole("button", { name: "Back" }).click(); + await expect(sheet(page, label)).toHaveCount(0); +} + export async function openSheetAsTab(page: Page, label: string): Promise { await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click(); await expect(sheet(page, label)).toHaveCount(0); @@ -76,6 +82,15 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise< await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); } +/** Each open sheet renders its own backdrop, so counting them counts the sheets. */ +export async function expectOneSheetOpen(page: Page): Promise { + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1); +} + +export async function expectNoSheetOpen(page: Page): Promise { + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); +} + /** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */ export async function tapSheetBackdrop(page: Page): Promise { await page diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts index a6fc5bcf21..ed10a69905 100644 --- a/packages/app/e2e/support/helpers/subagents.ts +++ b/packages/app/e2e/support/helpers/subagents.ts @@ -3,7 +3,7 @@ import { startIsolatedHostDaemon } from "./isolated-host-daemon"; import { seedWorkspace } from "./seed-client"; import { getE2EDaemonPort } from "./daemon-port"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; import { daemonWsRoutePattern } from "./daemon-port"; import type { SeededWorkspace } from "./seed-client"; @@ -151,10 +151,21 @@ export async function openSubagentsTrack(page: Page): Promise { await expect(panel).toBeVisible({ timeout: 30_000 }); } -/** On compact the track opens as a sheet, which closes once a row is chosen. */ -export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise { - await page.getByTestId("subagents-track-header").click(); - await page.getByTestId(`subagents-track-row-${childId}`).click(); +/** + * On compact the track opens as a sheet, and the chosen row opens in its place. `track` is the + * pane whose track to open, for when a subagent's sheet shows a track of its own. + */ +export async function openSubagentFromCompactTrack( + page: Page, + childId: string, + track: Page | Locator = page, +): Promise { + await track.getByTestId("subagents-track-header").click(); + const row = page.getByTestId(`subagents-track-row-${childId}`); + // Tap the row once the sheet has brought it on screen, as a finger would. Clicking it while it is + // still below the fold makes Playwright scroll the sheet host, and every sheet then docks short. + await expect(row).toBeInViewport(); + await row.click(); } export async function expectSubagentRowVisible(page: Page, childId: string): Promise { diff --git a/packages/app/src/components/adaptive-modal-sheet.test.tsx b/packages/app/src/components/adaptive-modal-sheet.test.tsx new file mode 100644 index 0000000000..96e8fba9d5 --- /dev/null +++ b/packages/app/src/components/adaptive-modal-sheet.test.tsx @@ -0,0 +1,33 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Text } from "react-native"; +import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SheetBottomInset } from "./adaptive-modal-sheet"; + +beforeEach(() => vi.stubGlobal("React", React)); + +const SCREEN_INSETS = { top: 24, right: 0, bottom: 34, left: 0 }; + +function BottomInsetProbe() { + return {String(useSafeAreaInsets().bottom)}; +} + +describe("SheetBottomInset", () => { + it("pads the bottom inset once and leaves none for its content", () => { + render( + + + + + , + ); + + expect(screen.getByTestId("sheet-body").style.paddingBottom).toBe("34px"); + // Content that pads the inset itself, like the composer dock, must not pad it again. + expect(screen.getByTestId("content-bottom-inset").textContent).toBe("0"); + }); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index f487f07f07..4ddf872daa 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -3,7 +3,7 @@ import type { ComponentProps, ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Keyboard, Pressable, Text, View } from "react-native"; -import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; +import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { @@ -12,7 +12,12 @@ import { useGlobalWebOverlayLayer, useWebOverlayRegistration, } from "../lib/overlay-root"; -import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import { + BottomSheetHandle, + useBottomSheetGestureHandlers, + type BottomSheetBackgroundProps, +} from "@gorhom/bottom-sheet"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated from "react-native-reanimated"; import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native"; import { @@ -25,7 +30,7 @@ import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-shee import { ScrollView } from "@/components/ui/scroll-view"; import { isWeb } from "@/constants/platform"; import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { AdaptiveTextInput } from "@/components/adaptive-text-input"; export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input"; @@ -40,6 +45,9 @@ export const SHEET_HORIZONTAL_PADDING_SCALE = 6; // glyph should line up with the X must reach the same rail. export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2; +/** The one height a sheet takes when it shows a whole pane rather than sizing to its content. */ +export const FULL_SHEET_SNAP_POINTS = ["92%"]; + export interface SheetHeaderSearch { onChange: (value: string) => void; onFocus?: () => void; @@ -244,32 +252,55 @@ const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({ indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, })); -const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 }; - -/** - * The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children - * join the handle, so a header passed here drags the sheet like the indicator does. That is how a - * sheet whose content scrolls on its own keeps its header draggable without content panning. - */ +/** The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. */ export function SheetHandle({ - children, // Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator. indicatorStyle: _indicatorStyle, ...props }: ComponentProps) { - if (!children) return ; - // The children carry their own semantics; the handle's slider role would swallow them on web. + return ; +} + +/** + * Content that drags the sheet the way its handle does. A sheet whose body scrolls on its own + * turns content panning off and wraps its header in this, so the header still pulls it down. + * Render it inside the sheet's content. + */ +export function SheetDragArea({ children }: { children: ReactNode }) { + const { handlePanGestureHandler } = useBottomSheetGestureHandlers(); + const gesture = useMemo( + () => + Gesture.Pan() + .shouldCancelWhenOutside(false) + .runOnJS(false) + .onStart(handlePanGestureHandler.handleOnStart) + .onChange(handlePanGestureHandler.handleOnChange) + .onEnd(handlePanGestureHandler.handleOnEnd) + .onFinalize(handlePanGestureHandler.handleOnFinalize), + [handlePanGestureHandler], + ); return ( - - {children} - + + {children} + + ); +} + +/** + * A compact sheet's content area that reaches the bottom of the screen. It owns the system + * navigation inset: it pads it once and tells its content none is left, so content that pads the + * inset itself, like the composer dock, sits the same in a sheet as in a tab. + */ +export function SheetBottomInset({ style, children, ...props }: ViewProps) { + const insets = useSafeAreaInsets(); + const contentInsets = useMemo(() => ({ ...insets, bottom: 0 }), [insets]); + const insetStyle = useMemo(() => [style, { paddingBottom: insets.bottom }], [style, insets]); + return ( + + + {children} + + ); } @@ -488,6 +519,76 @@ export interface AdaptiveModalSheetProps { contextBridge?: ContextBridge | null; } +/** + * The compact sheet's body. It renders on the far side of the sheet's portal so it reads the + * screen's bottom inset, not the one left where the sheet was opened: inside a sheet tab, none is. + */ +function CompactSheetBody({ + header, + onClose, + testID, + footer, + isKeyboardVisible, + scrollable, + bodyStyle, + contentStyle, + children, +}: { + header: SheetHeader; + onClose: () => void; + testID?: string; + footer: ReactNode; + isKeyboardVisible: boolean; + scrollable: boolean; + bodyStyle?: StyleProp; + contentStyle?: StyleProp; + children: ReactNode; +}) { + const insets = useSafeAreaInsets(); + const { contentPaddingBottom = 0, footerPaddingBottom = 0 } = getCompactSheetSafeAreaPadding({ + isCompact: true, + isKeyboardVisible, + hasFooter: Boolean(footer), + safeAreaBottom: insets.bottom, + }); + // Safe-area clearance is a separate layer: it must not replace the caller's + // padding (including an explicit zero), and the footer owns it when present. + const bodyClearanceStyle = { paddingBottom: contentPaddingBottom }; + const footerClearanceStyle = useMemo( + () => ({ paddingBottom: footerPaddingBottom }), + [footerPaddingBottom], + ); + const footerView = useMemo( + () => (footer ? {footer} : null), + [footer, footerClearanceStyle], + ); + return ( + + + + {scrollable ? ( + + + {children} + + + ) : ( + + + {children} + + + )} + + + ); +} + export function AdaptiveModalSheet({ header, visible, @@ -508,34 +609,11 @@ export function AdaptiveModalSheet({ }: AdaptiveModalSheetProps) { const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); - const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardVisibility(visible); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); - const compactSafeAreaPadding = useMemo( - () => - getCompactSheetSafeAreaPadding({ - isCompact: isMobile, - isKeyboardVisible, - hasFooter: Boolean(footer), - safeAreaBottom: insets.bottom, - }), - [footer, insets.bottom, isKeyboardVisible, isMobile], - ); - // Safe-area clearance is a separate layer: it must not replace the caller's - // padding (including an explicit zero), and the footer owns it when present. - const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 }; - const footerClearanceStyle = useMemo( - () => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }), - [compactSafeAreaPadding.footerPaddingBottom], - ); const footerView = useMemo( - () => - footer ? ( - - {footer} - - ) : null, - [footer, footerClearanceStyle, footerContainerStyle], + () => (footer ? {footer} : null), + [footer, footerContainerStyle], ); useEffect(() => { if (!isWeb && visible) { @@ -614,32 +692,6 @@ export function AdaptiveModalSheet({ }, [visible, isMobile, onDismiss, shouldRenderWeb]); if (isMobile) { - const sheetContent = ( - <> - - - {scrollable ? ( - - - {children} - - - ) : ( - - - {children} - - - )} - - - ); - return ( - {sheetContent} + + {children} + ); } diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx index ef5aa1833a..82fe6b139f 100644 --- a/packages/app/src/components/attachment-lightbox.test.tsx +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -72,6 +72,7 @@ vi.mock("react-i18next", () => ({ })); vi.mock("react-native-safe-area-context", () => ({ + SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children, useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), })); diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index 3cf2c5203f..0f14f2c515 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { Modal, Pressable, Text, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { AttachmentMetadata } from "@/attachments/types"; @@ -28,7 +28,6 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 }; export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) { const { t } = useTranslation(); - const insets = useSafeAreaInsets(); const metadata = source?.type === "attachment" ? source.metadata : null; const attachmentUrl = useAttachmentPreviewUrl(metadata); const url = source?.type === "uri" ? source.uri : attachmentUrl; @@ -56,18 +55,6 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) onKeyDown: handleWebOverlayKeyDown, }); - const contentLayerStyle = useMemo( - () => [ - styles.contentLayer, - { - paddingTop: insets.top, - paddingRight: insets.right, - paddingBottom: insets.bottom, - paddingLeft: insets.left, - }, - ], - [insets.bottom, insets.left, insets.right, insets.top], - ); const actions = useMemo( () => [ { @@ -91,17 +78,19 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) return ( - - - - - + {/* The modal is its own window: its insets are the window's, not the ones left where the + image was opened (inside a sheet tab, the bottom one is already spent). */} + + + + + {hasError ? ( {t("message.attachments.imageLoadFailed")} ) : ( @@ -117,15 +106,38 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) uri={url} /> )} - + - - + + ); } +function LightboxImageArea({ children }: { children: ReactNode }) { + const insets = useSafeAreaInsets(); + const style = useMemo( + () => [ + styles.contentLayer, + { + paddingTop: insets.top, + paddingRight: insets.right, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + }, + ], + [insets.bottom, insets.left, insets.right, insets.top], + ); + return ( + + + {children} + + + ); +} + const styles = StyleSheet.create((theme) => ({ root: { flex: 1, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 84d76c1083..d436758d45 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -938,7 +938,6 @@ interface MobileBodyProps { handleSelect: (id: string) => void; renderOption: RenderOptionFn | undefined; children: ReactNode; - safeAreaBottom: number; } function MobileComboboxBody(props: MobileBodyProps): ReactElement | null { @@ -961,21 +960,6 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement { () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], ); - const { footer, safeAreaBottom } = props; - const frameStyle = useMemo( - () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], - [footer, safeAreaBottom], - ); - const footerStyle = useMemo( - () => [styles.footer, { paddingBottom: safeAreaBottom }], - [safeAreaBottom], - ); - - const footerView = useMemo( - () => (footer ? {footer} : undefined), - [footer, footerStyle], - ); - const body = props.hasChildren ? ( props.children ) : ( @@ -1006,51 +990,74 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - - - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - - + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + ); } +/** + * Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the + * one left where the combobox was opened: inside a sheet tab, none is. + */ +function ComboboxSheetFrame({ footer, children }: { footer?: ReactNode; children: ReactNode }) { + const { bottom: safeAreaBottom } = useSafeAreaInsets(); + const frameStyle = useMemo( + () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], + [footer, safeAreaBottom], + ); + const footerStyle = useMemo( + () => [styles.footer, { paddingBottom: safeAreaBottom }], + [safeAreaBottom], + ); + const footerView = useMemo( + () => (footer ? {footer} : undefined), + [footer, footerStyle], + ); + return ( + + {children} + + ); +} + interface DesktopBodyProps { overlayLayer: number; isOpen: boolean; @@ -1327,7 +1334,6 @@ export function Combobox({ const isMobile = useIsCompactFormFactor(); const active = useRetainedPanelActive(); const floatingLayer = useOverlayLayer("floating"); - const safeAreaInsets = useSafeAreaInsets(); const titleColor = theme.colors.foreground; const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition); @@ -1608,7 +1614,6 @@ export function Combobox({ emptyText={resolvedEmptyText} handleSelect={handleSelect} renderOption={renderOption} - safeAreaBottom={safeAreaInsets.bottom} > {children} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 457181c53f..639804bd53 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -123,19 +123,25 @@ export function useIsolatedBottomSheetVisibility({ visible, isEnabled, onClose, + onBack, }: { visible: boolean; isEnabled?: boolean; onClose: () => void; + /** Offered Android Back before it closes the sheet; returns true when it handled the press. */ + onBack?: () => boolean; }) { const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + const onBackRef = useRef(onBack); + onBackRef.current = onBack; const tracker = useMemo( () => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current(), backPress: systemBackPress, + onBack: () => onBackRef.current?.() ?? false, }), [], ); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts index 60b386bd45..5205fb2486 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts @@ -204,6 +204,31 @@ describe("bottom sheet visibility tracker", () => { expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); }); + it("lets the sheet step back through its own pages before Back dismisses it", () => { + const sheet = new FakeBottomSheet(); + const backPress = new FakeBackPress(); + let openPages = 1; + const tracker = createBottomSheetVisibilityTracker({ + onClose: () => {}, + onBack: () => { + if (openPages === 0) return false; + openPages -= 1; + return true; + }, + backPress, + }); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + tracker.handleSheetIndexChange(0); + + expect(backPress.press()).toBe(true); + expect(openPages).toBe(0); + expect(sheet.events).toEqual([{ type: "present" }]); + + expect(backPress.press()).toBe(true); + expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); + }); + it("leaves Back to the navigator before the sheet is on screen and after it closes", () => { const context = setup(); context.tracker.attachController(context.sheet); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts index dec2dd535c..167ed2ebe6 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts @@ -36,6 +36,11 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing"; export function createBottomSheetVisibilityTracker(opts: { onClose: () => void; backPress?: BackPressSource; + /** + * Offered the Back press before the sheet takes it as a close. Returns true when it handled + * the press itself, such as stepping back a page inside the sheet. + */ + onBack?: () => boolean; }): BottomSheetVisibilityTracker { let controller: BottomSheetController | null = null; let visible = false; @@ -60,6 +65,7 @@ export function createBottomSheetVisibilityTracker(opts: { } releaseBackPress = opts.backPress?.subscribe(() => { + if (opts.onBack?.()) return true; dismiss(); return true; }) ?? null; diff --git a/packages/app/src/components/ui/menu/index.ts b/packages/app/src/components/ui/menu/index.ts index 5668556899..437090d146 100644 --- a/packages/app/src/components/ui/menu/index.ts +++ b/packages/app/src/components/ui/menu/index.ts @@ -12,6 +12,8 @@ export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } f export { MenuSurface, useMenuSurface, + type MenuFillPageDefinition, + type MenuListPageDefinition, type MenuPageDefinition, type MenuSurfaceProps, } from "./menu-surface"; diff --git a/packages/app/src/components/ui/menu/menu-context.test.tsx b/packages/app/src/components/ui/menu/menu-context.test.tsx index 40b45b9343..5640b7ef82 100644 --- a/packages/app/src/components/ui/menu/menu-context.test.tsx +++ b/packages/app/src/components/ui/menu/menu-context.test.tsx @@ -61,3 +61,38 @@ describe("useMenuState selectItem elsewhere", () => { expect(onSelect).toHaveBeenCalledTimes(1); }); }); + +describe("useMenuState pages", () => { + it("pops the open page on Back and leaves Back at the root to the surface", () => { + const { result } = renderHook(() => useMenuState({ defaultOpen: true })); + act(() => result.current.openSub({ id: "row", depth: 0 })); + + let handled = false; + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(true); + expect(result.current.path).toEqual([]); + + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(false); + expect(result.current.open).toBe(true); + }); + + it("falls back to the list when the open page's definition goes away", () => { + const { result } = renderHook(() => useMenuState({ defaultOpen: true })); + act(() => result.current.openSub({ id: "row", depth: 0 })); + + const keepNoPage = () => false; + act(() => result.current.prunePages(keepNoPage)); + + expect(result.current.path).toEqual([]); + let handled = true; + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(false); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-context.ts b/packages/app/src/components/ui/menu/menu-context.ts index 9635f09c9f..502580c308 100644 --- a/packages/app/src/components/ui/menu/menu-context.ts +++ b/packages/app/src/components/ui/menu/menu-context.ts @@ -7,6 +7,7 @@ import { goBack as goBackPath, MENU_ROOT_PATH, openSubPage, + pruneMissingPages, type MenuPath, } from "./menu-navigation"; import type { Rect } from "../anchor"; @@ -40,7 +41,10 @@ export interface MenuContextValue { path: MenuPath; openSub: (sub: { id: string; depth: number }) => void; closeSub: (depth: number) => void; - goBack: () => void; + /** Pops the open page. False at the root, where Back is the surface's to answer. */ + goBack: () => boolean; + /** Closes any open page that no longer has a definition, falling back to its parent. */ + prunePages: (hasPage: (id: string) => boolean) => void; } const MenuContext = createContext(null); @@ -160,7 +164,13 @@ export function useMenuState({ }, []); const goBack = useCallback(() => { + if (path.length === 0) return false; setPath((current) => goBackPath(current)); + return true; + }, [path.length]); + + const prunePages = useCallback((hasPage: (id: string) => boolean) => { + setPath((current) => pruneMissingPages(current, hasPage)); }, []); return useMemo( @@ -176,7 +186,19 @@ export function useMenuState({ openSub, closeSub, goBack, + prunePages, }), - [isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack], + [ + isOpen, + setOpen, + selectItem, + anchorRect, + presentation, + path, + openSub, + closeSub, + goBack, + prunePages, + ], ); } diff --git a/packages/app/src/components/ui/menu/menu-navigation.test.ts b/packages/app/src/components/ui/menu/menu-navigation.test.ts index 4b447ef3f2..9740f3df52 100644 --- a/packages/app/src/components/ui/menu/menu-navigation.test.ts +++ b/packages/app/src/components/ui/menu/menu-navigation.test.ts @@ -6,6 +6,7 @@ import { isSubPageOpen, MENU_ROOT_PATH, openSubPage, + pruneMissingPages, } from "./menu-navigation"; describe("openSubPage", () => { @@ -73,3 +74,15 @@ describe("isSubPageOpen", () => { expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false); }); }); + +describe("pruneMissingPages", () => { + it("falls back to the deepest page that still exists", () => { + expect(pruneMissingPages(["list", "row"], (id) => id === "list")).toEqual(["list"]); + expect(pruneMissingPages(["list", "row"], (id) => id === "row")).toEqual([]); + }); + + it("returns the same path when every page exists", () => { + const path = ["list", "row"]; + expect(pruneMissingPages(path, () => true)).toBe(path); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-navigation.ts b/packages/app/src/components/ui/menu/menu-navigation.ts index 2cccca9ed1..b38f9acf13 100644 --- a/packages/app/src/components/ui/menu/menu-navigation.ts +++ b/packages/app/src/components/ui/menu/menu-navigation.ts @@ -33,6 +33,16 @@ export function closeSubPage(path: MenuPath, depth: number): MenuPath { return path.slice(0, depth); } +/** + * Cuts the path back to the deepest page that still has a definition. A page can lose its + * definition while it is open (a subagent's page goes with its row when the subagent is + * archived), and the pages it opened go with it. + */ +export function pruneMissingPages(path: MenuPath, hasPage: (id: string) => boolean): MenuPath { + const missing = path.findIndex((id) => !hasPage(id)); + return missing === -1 ? path : path.slice(0, missing); +} + export function goBack(path: MenuPath): MenuPath { if (path.length === 0) return path; return path.slice(0, -1); diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 2f73149244..56ff2c0578 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -19,6 +19,11 @@ import { useIsolatedBottomSheetVisibility, type ContextBridge, } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + FULL_SHEET_SNAP_POINTS, + SheetBackground, + SheetHandle, +} from "@/components/adaptive-modal-sheet"; import { SPACING, type Theme } from "@/styles/theme"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useMenuContext, MenuContextProvider } from "./menu-context"; @@ -32,24 +37,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions"; const ThemedChevronLeft = withUnistyles(ChevronLeft); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not -// track, so the sheet is wrapped rather than reading the theme through a hook. -// See docs/unistyles.md. -const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({ - backgroundStyle: { - borderTopLeftRadius: theme.borderRadius.xl, - borderTopRightRadius: theme.borderRadius.xl, - borderWidth: 1, - backgroundColor: theme.colors.surface0, - borderColor: theme.colors.border, - }, - handleIndicatorStyle: { - width: 36, - height: 4, - backgroundColor: theme.colors.surface2, - }, -})); - /** How long the pointer must rest on a submenu row before its flyout opens. */ const HOVER_OPEN_DELAY_MS = 90; /** @@ -65,7 +52,7 @@ const HOVER_CLOSE_GRACE_MS = 260; */ const SUBMENU_OVERLAP = 5; -export interface MenuPageDefinition { +export interface MenuListPageDefinition { id: string; title: string; content: ReactNode; @@ -78,6 +65,24 @@ export interface MenuPageDefinition { * surface stops closing on hover, since its parent leads back to the same dismissal. */ hoverIntent?: boolean; + fill?: false; +} + +/** + * A sheet page that takes the sheet's full height and draws everything itself, header included: + * no back header, no scroller, no content panning. Only the sheet presentation opens one; a + * popover never has a trigger for it. + */ +export interface MenuFillPageDefinition { + id: string; + fill: true; + content: ReactNode; +} + +export type MenuPageDefinition = MenuListPageDefinition | MenuFillPageDefinition; + +function pageHoverIntent(page: MenuPageDefinition | undefined): boolean { + return !page || page.fill === true || page.hoverIntent !== false; } interface MenuSurfaceContextValue { @@ -126,8 +131,15 @@ export interface MenuSurfaceProps { * popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time. */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { - const { presentation, open, setOpen } = useMenuContext("MenuSurface"); + const { presentation, open, setOpen, path, prunePages } = useMenuContext("MenuSurface"); const active = useRetainedPanelActive(); + // An open page whose definition went away (its row was removed) falls back to its parent, so + // neither a render nor a later Back walks a path to a page that no longer exists. + const { pages } = props; + useEffect(() => { + if (path.length === 0) return; + prunePages((id) => pages?.some((page) => page.id === id) ?? false); + }, [pages, path, prunePages]); // A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes // for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet // and promoted it to a tab, say, would otherwise bring the list back on the next tab switch. @@ -234,11 +246,11 @@ function MenuPopoverSurface({ // `hoverIntent: false` takes a page off the pointer entirely — it is not opened by resting on // its trigger, and while it is open nothing on this surface closes on a pointer leaving it. const hoverValue = useMemo(() => { - const locked = openPages.some(({ page }) => page.hoverIntent === false); + const locked = openPages.some(({ page }) => !pageHoverIntent(page)); return { ...surfaceValue, hoverOpen: (sub) => { - if (pages.find((page) => page.id === sub.id)?.hoverIntent === false) return; + if (!pageHoverIntent(pages.find((page) => page.id === sub.id))) return; surfaceValue.hoverOpen(sub); }, hoverClose: (depth) => { @@ -353,17 +365,6 @@ function MenuSheetSurface({ const { height: windowHeight } = useWindowDimensions(); const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); - const safeAreaInsets = useSafeAreaInsets(); - - const sheetScrollContentStyle = useMemo( - () => ({ - paddingBottom: getMenuSheetBottomPadding({ - safeAreaBottom: safeAreaInsets.bottom, - breathingRoom: SPACING[1], - }), - }), - [safeAreaInsets.bottom], - ); const sheetDataSet = useMemo( () => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined), [keyboardFocusScope], @@ -374,11 +375,15 @@ function MenuSheetSurface({ visible: menu.open, isEnabled: true, onClose: handleClose, + // Android Back steps back through the pages before it closes the sheet. + onBack: menu.goBack, }); const openPageId = currentPageId(menu.path); const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; + const fillPage = openPage?.fill === true ? openPage : null; + const listPage = openPage?.fill === true ? null : openPage; // The sheet's content is teleported out of this subtree, so both menu contexts have to be // rebuilt on the other side. Providing them around the modal instead would put them on the @@ -393,12 +398,18 @@ function MenuSheetSurface({ ); return ( - - - {openPage ? ( - <> - - {openPage.content} - - ) : ( - <> - {sheetTitle ? ( - - ) : null} - {children} - - )} - - + {fillPage ? ( + + {fillPage.content} + + ) : ( + + {listPage ? ( + <> + + {listPage.content} + + ) : ( + <> + {sheetTitle ? ( + + ) : null} + {children} + + )} + + )} + + ); +} + +/** + * Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the + * one left where the menu was opened: inside a sheet tab, none is. + */ +function MenuSheetScrollView({ + dataSet, + testID, + children, +}: { + dataSet: { keyboardScope: KeyboardFocusScope } | undefined; + testID?: string; + children: ReactNode; +}) { + const safeAreaInsets = useSafeAreaInsets(); + const contentStyle = useMemo( + () => ({ + paddingBottom: getMenuSheetBottomPadding({ + safeAreaBottom: safeAreaInsets.bottom, + breathingRoom: SPACING[1], + }), + }), + [safeAreaInsets.bottom], + ); + return ( + + {children} + ); } @@ -470,6 +517,10 @@ function MenuSheetHeader({ } const styles = StyleSheet.create((theme) => ({ + fillPage: { + flex: 1, + minHeight: 0, + }, sheetHeader: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 4e06266058..341e7dc8e0 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -8,6 +8,8 @@ import { MenuSurface, MenuTrigger, useMenuContext, + useMenuDepth, + type MenuPageDefinition, type MenuTriggerState, } from "@/components/ui/menu"; import { StatusRing } from "@/components/status-ring"; @@ -61,6 +63,8 @@ export interface ComposerTrackPillProps { accessibilityLabel?: string; /** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */ children: ReactNode; + /** Pages a row can open in place of the list (`ComposerTrackRow` `opensPage`). */ + pages?: readonly MenuPageDefinition[]; } /** @@ -85,6 +89,7 @@ export function ComposerTrackPill({ testID, accessibilityLabel, children, + pages, }: ComposerTrackPillProps): ReactElement { return ( @@ -98,6 +103,7 @@ export function ComposerTrackPill({ align="start" offset={PANEL_OFFSET} sheetTitle={panelTitle} + pages={pages} minWidth={PANEL_MIN_WIDTH} maxWidth={PANEL_MAX_WIDTH} maxHeight={PANEL_MAX_HEIGHT} @@ -199,6 +205,11 @@ export interface ComposerTrackRowProps { * lands inside the panel is not. */ closeOnSelect?: boolean; + /** + * A page of the panel this row opens in place of the list, instead of running `onPress`. The + * panel stays open: Back returns to the list. + */ + opensPage?: string; disabled?: boolean; accessibilityLabel?: string; testID?: string; @@ -220,18 +231,23 @@ export function ComposerTrackRow({ children, onPress, closeOnSelect = true, + opensPage, disabled = false, accessibilityLabel, testID, }: ComposerTrackRowProps): ReactElement { - const { selectItem } = useMenuContext("ComposerTrackRow"); + const { selectItem, openSub } = useMenuContext("ComposerTrackRow"); + const depth = useMenuDepth(); const [hovered, setHovered] = useState(false); const handlePointerEnter = useCallback(() => setHovered(true), []); const handlePointerLeave = useCallback(() => setHovered(false), []); - const handleSelect = useCallback( - () => selectItem(onPress, closeOnSelect), - [closeOnSelect, onPress, selectItem], - ); + const handleSelect = useCallback(() => { + if (opensPage) { + selectItem(() => openSub({ id: opensPage, depth }), false); + return; + } + selectItem(onPress, closeOnSelect); + }, [closeOnSelect, depth, onPress, openSub, opensPage, selectItem]); const itemDataSet = useMemo( () => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }), @@ -251,7 +267,7 @@ export function ComposerTrackRow({ [hovered, renderRow], ); - if (!onPress) { + if (!onPress && !opensPage) { return {renderRow(false)}; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 3928da7e68..81050ae5ee 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1914,7 +1914,6 @@ export const ar: TranslationResources = { parent: { backToParent: "العودة إلى الوكيل الأصلي", backTo: "العودة إلى {{label}}", - parent: "الأصل", parentAgent: "الوكيل الأصلي", openParent: "فتح الوكيل الأصلي", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e7ed75e509..655f041ecd 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1939,7 +1939,6 @@ export const en = { parent: { backToParent: "Back to parent", backTo: "Back to {{label}}", - parent: "Parent", parentAgent: "Parent agent", openParent: "Open parent", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7cf68c69ba..f7e3472134 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1962,7 +1962,6 @@ export const es: TranslationResources = { parent: { backToParent: "Volver al padre", backTo: "Volver a {{label}}", - parent: "Padre", parentAgent: "Agente padre", openParent: "Abrir padre", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index da58636a70..86ff053750 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1960,7 +1960,6 @@ export const fr: TranslationResources = { parent: { backToParent: "Retour au parent", backTo: "Retour à {{label}}", - parent: "Parent", parentAgent: "Agent parent", openParent: "Ouvrir le parent", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index cd327a6bb5..5a4b594a24 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1933,7 +1933,6 @@ export const ja: TranslationResources = { parent: { backToParent: "親に戻る", backTo: "{{label}} に戻る", - parent: "親", parentAgent: "親エージェント", openParent: "親を開く", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index aad8d5e0cb..242324dd20 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1924,7 +1924,6 @@ export const ko: TranslationResources = { parent: { backToParent: "상위 에이전트로 돌아가기", backTo: "{{label}}(으)로 돌아가기", - parent: "상위", parentAgent: "상위 에이전트", openParent: "상위 에이전트 열기", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 696cb86b17..bbef1d73c5 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1947,7 +1947,6 @@ export const ptBR: TranslationResources = { parent: { backToParent: "Voltar ao pai", backTo: "Voltar para {{label}}", - parent: "Pai", parentAgent: "Agente pai", openParent: "Abrir pai", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ab1405852a..aa06a9b919 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1946,7 +1946,6 @@ export const ru: TranslationResources = { parent: { backToParent: "Назад к родителю", backTo: "Назад к {{label}}", - parent: "Родитель", parentAgent: "Родительский агент", openParent: "Открыть родителя", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index da97288cf4..f5d0c44eee 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1892,7 +1892,6 @@ export const zhCN: TranslationResources = { parent: { backToParent: "返回父代理", backTo: "返回 {{label}}", - parent: "父代理", parentAgent: "父代理", openParent: "打开父代理", }, diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index f7c724133a..6984633bcc 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -13,7 +13,7 @@ import { type ArchiveFinishedStatus, useArchiveSubagent, useDetachSubagent, - useSubagentOpensInSheet, + useSubagentSheetTab, type SubagentRow, } from "@/subagents"; import { ParentAgentPill } from "@/subagents/parent"; @@ -100,7 +100,7 @@ export const AgentTracks = memo(function AgentTracks({ }, [openSubagentTarget], ); - const opensInSheet = useSubagentOpensInSheet(workspaceKey); + const sheetTabFor = useSubagentSheetTab(workspaceKey, tabId); const handleOpenChanges = useCallback(() => { if (!workspaceKey) { return; @@ -134,7 +134,7 @@ export const AgentTracks = memo(function AgentTracks({ rows={subagentRows} onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} - opensInSheet={opensInSheet} + sheetTabFor={sheetTabFor} onArchiveSubagent={archiveSubagent} onArchiveFinished={onArchiveFinished} archiveFinishedStatus={archiveFinishedStatus} diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index e36f7880ca..99a38c2f8b 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -16,9 +16,10 @@ import { usePaneContext } from "@/panels/pane-context"; import { PaneSubtitle } from "@/panels/pane-subtitle"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; +import type { SheetTab } from "@/stores/workspace-layout-store"; import { ParentAgentCallout } from "@/subagents/parent"; import { type SubagentRow, useSubagentsForParent } from "@/subagents/select"; -import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet"; +import { useSubagentSheetTab } from "@/subagents/sheet-tab"; import { SubagentsTrack } from "@/subagents/track"; import { providerSubagentKey, @@ -51,12 +52,12 @@ function ProviderSubagentChildTrack({ serverId, rows, onOpenProviderSubagent, - opensInSheet, + sheetTabFor, }: { serverId: string; rows: ReturnType; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; - opensInSheet: (row: SubagentRow) => boolean; + sheetTabFor: (row: SubagentRow) => SheetTab | null; }) { if (rows.length === 0) return null; return ( @@ -66,7 +67,7 @@ function ProviderSubagentChildTrack({ rows={rows} onOpenSubagent={NOOP_SUBAGENT} onOpenProviderSubagent={onOpenProviderSubagent} - opensInSheet={opensInSheet} + sheetTabFor={sheetTabFor} onArchiveSubagent={NOOP_SUBAGENT} /> @@ -116,7 +117,7 @@ function useProviderSubagentDescriptor( function ProviderSubagentPanel() { const { t } = useTranslation(); - const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext(); + const { serverId, workspaceId, tabId, target, openFileInWorkspace, openTab } = usePaneContext(); invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; @@ -144,8 +145,9 @@ function ProviderSubagentPanel() { providerParentSubagentId: target.subagentId, }); const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); - const opensInSheet = useSubagentOpensInSheet( + const sheetTabFor = useSubagentSheetTab( buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + tabId, ); const openProviderChild = useCallback( (parentAgentId: string, subagentId: string) => { @@ -274,7 +276,7 @@ function ProviderSubagentPanel() { serverId={serverId} rows={childRows} onOpenProviderSubagent={openProviderChild} - opensInSheet={opensInSheet} + sheetTabFor={sheetTabFor} /> 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 57977dd462..3ffeb1bfef 100644 --- a/packages/app/src/panels/retained-chat-content.browser.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -34,11 +34,24 @@ import { 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 { + SheetPaneHostProvider, + useSheetPaneBridge, + type SheetPaneHost, +} from "@/screens/workspace/sheet-pane"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const SHEET_PANE_HOST: SheetPaneHost = { + workspaceKey: "host:workspace", + serverId: "host", + workspaceId: "workspace", + isWorkspaceFocused: true, + buildContent: () => { + throw new Error("The environment probe builds no pane content"); + }, +}; let root: Root; let container: HTMLDivElement; beforeEach(() => { @@ -155,14 +168,21 @@ describe("workspace sheet environment", () => { }, [name]); return null; } - function Capture() { - bridge = useWorkspaceSheetEnvironment(); + function CaptureBridge() { + bridge = useSheetPaneBridge(); return ( ); } + function Capture() { + return ( + + + + ); + } function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) { const onOpenFile = useCallback( (target: InlinePathTarget) => { diff --git a/packages/app/src/screens/workspace/sheet-pane/host.tsx b/packages/app/src/screens/workspace/sheet-pane/host.tsx new file mode 100644 index 0000000000..3f8e60783a --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-pane/host.tsx @@ -0,0 +1,84 @@ +import React, { createContext, useCallback, useContext, type ReactNode } from "react"; +import invariant from "tiny-invariant"; +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"; +import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +/** What a sheet pane needs from the workspace it shows a tab of. */ +export interface SheetPaneHost { + workspaceKey: string; + serverId: string; + workspaceId: string; + isWorkspaceFocused: boolean; + /** The pane content for a sheet's tab. Closing the tab from inside it runs `closeCurrentTab`. */ + buildContent: ( + tab: WorkspaceTabDescriptor, + closeCurrentTab: () => void, + ) => WorkspacePaneContentModel; +} + +const SheetPaneHostContext = createContext(null); + +/** `null` while there is no workspace to show sheets for. */ +export function SheetPaneHostProvider({ + value, + children, +}: { + value: SheetPaneHost | null; + children: ReactNode; +}) { + return {children}; +} + +/** The host of the workspace the caller is in, or null while there is none. */ +export function useSheetPaneHostIfAny(): SheetPaneHost | null { + return useContext(SheetPaneHostContext); +} + +export function useSheetPaneHost(): SheetPaneHost { + const host = useSheetPaneHostIfAny(); + invariant(host, "SheetPaneHostProvider is required"); + return host; +} + +/** + * Carries a sheet pane's environment across the portal into the app overlay host: the workspace + * host, and the environment of the built-in panels. Pane identity is supplied by the pane itself. + */ +export function useSheetPaneBridge(): ContextBridge { + const host = useSheetPaneHost(); + 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 ( + + + + + {serverId ? ( + {content} + ) : ( + content + )} + + + + + ); + }, + [host, editing, cache, files, active, serverId, navigation, route], + ); +} diff --git a/packages/app/src/screens/workspace/sheet-pane/index.ts b/packages/app/src/screens/workspace/sheet-pane/index.ts new file mode 100644 index 0000000000..1e6f6d1c40 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-pane/index.ts @@ -0,0 +1,16 @@ +/** + * A workspace tab shown in a sheet. One pane — header, body, bottom inset, focus — behind every + * sheet container: the workspace's sheet tabs, and pages of a menu that shows a sheet in place. + * + * See docs/explorer-sidebar.md. + */ + +export { + SheetPaneHostProvider, + useSheetPaneBridge, + useSheetPaneHost, + useSheetPaneHostIfAny, + type SheetPaneHost, +} from "./host"; +export { SheetPane, useLeaveSheet, type SheetPaneProps } from "./sheet-pane"; +export { useSheetPanePage } from "./page"; diff --git a/packages/app/src/screens/workspace/sheet-pane/page.tsx b/packages/app/src/screens/workspace/sheet-pane/page.tsx new file mode 100644 index 0000000000..1b076510b9 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-pane/page.tsx @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import { useMenuContext, type MenuFillPageDefinition } from "@/components/ui/menu"; +import { usePaneContext } from "@/panels/pane-context"; +import type { SheetTab } from "@/stores/workspace-layout-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import { useSheetPaneBridge } from "./host"; +import { SheetPane } from "./sheet-pane"; + +/** + * Shows sheet tabs as pages of a menu opened from a pane, so a row's sheet replaces the list it + * was chosen from instead of stacking a second sheet over it. Back returns to the list; Close + * closes the menu, which uncovers the pane the menu was opened from. + */ +export function useSheetPanePage(): (sheet: SheetTab) => MenuFillPageDefinition { + const bridge = useSheetPaneBridge(); + const { target: opener } = usePaneContext(); + return useCallback( + (sheet) => ({ + id: sheet.tab.tabId, + fill: true, + content: bridge( + // Keyed like the detached container, so the panel's descriptor hook stays fixed per page. + , + ), + }), + [bridge, opener], + ); +} + +function MenuSheetPane({ sheet, opener }: { sheet: SheetTab; opener: WorkspaceTabTarget }) { + const { setOpen, goBack } = useMenuContext("MenuSheetPane"); + const close = useCallback(() => setOpen(false), [setOpen]); + return ; +} diff --git a/packages/app/src/screens/workspace/sheet-pane/sheet-pane.tsx b/packages/app/src/screens/workspace/sheet-pane/sheet-pane.tsx new file mode 100644 index 0000000000..7b93f84746 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-pane/sheet-pane.tsx @@ -0,0 +1,207 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + type ComponentType, +} from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Maximize2 } from "lucide-react-native"; +import { + SheetBottomInset, + SheetDragArea, + SheetHeaderIconButton, + SheetHeaderView, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import type { PanelIconProps } from "@/panels/panel-registry"; +import { getPanelRegistration } from "@/panels/panel-registry"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content"; +import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; +import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import { useSheetPaneHost } from "./host"; + +function PanelIcon({ + icon: Icon, + size, + color, +}: { icon: ComponentType } & PanelIconProps) { + return ; +} + +const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +export interface SheetPaneProps { + sheet: SheetTab; + /** Closes the sheet, everything it was chosen from included. */ + onClose: () => void; + /** Steps back to the list the sheet was chosen from, when there is one. */ + onBack?: () => void; + /** The pane closing the sheet uncovers, when the container knows it. */ + opener?: WorkspaceTabTarget | null; + /** Only the top sheet's pane is focused; a covered one stays as live as the pane under it. */ + isTop: boolean; +} + +interface SheetPaneExit { + close: () => void; + opener: WorkspaceTabTarget | null; +} + +const SheetPaneExitContext = createContext(null); + +/** + * A tab shown in a sheet: its header (icon, title, Back, Open as tab, Close), which drags the + * sheet, over its pane. Every sheet container renders this; only the container differs. + */ +export function SheetPane({ sheet, onClose, onBack, opener = null, isTop }: SheetPaneProps) { + const { t } = useTranslation(); + const host = useSheetPaneHost(); + const { tab } = sheet; + const registration = getPanelRegistration(tab.target.kind); + if (!registration) { + throw new Error(`No panel registration for kind: ${tab.target.kind}`); + } + const descriptor = registration.useDescriptor(tab.target as never, { + serverId: host.serverId, + workspaceId: host.workspaceId, + tabId: tab.tabId, + }); + + const openTabInFront = useWorkspaceLayoutStore((state) => state.openTabInFront); + const { workspaceKey } = host; + const handleOpenAsTab = useCallback( + () => openTabInFront(workspaceKey, sheet), + [openTabInFront, sheet, workspaceKey], + ); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + back: onBack ? { onPress: onBack } : undefined, + // The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into + // the switcher, where it gets its composer back. + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, handleOpenAsTab, onBack, sheet.policy.openAsTab, t], + ); + + const tabDescriptor = useMemo( + () => ({ + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + state: tab.state, + }), + [tab], + ); + const { buildContent } = host; + const content = useMemo( + () => buildContent(tabDescriptor, onClose), + [buildContent, onClose, tabDescriptor], + ); + const exit = useMemo(() => ({ close: onClose, opener }), [onClose, opener]); + const isPaneFocused = isTop && host.isWorkspaceFocused; + useViewedSheetAgent({ + sourceId: `${workspaceKey}:sheet:${tab.tabId}`, + serverId: host.serverId, + agentId: isPaneFocused && tab.target.kind === "agent" ? tab.target.agentId : null, + }); + + return ( + + + {/* The body scrolls on its own, so the header is where the sheet drags from. */} + + + + + + + + + ); +} + +/** + * The agent of the sheet on top is viewed, alongside the pane under it: its timeline catches up + * and stays live. The workspace only publishes the agents of its panes. + */ +function useViewedSheetAgent({ + sourceId, + serverId, + agentId, +}: { + sourceId: string; + serverId: string; + agentId: string | null; +}) { + const viewedTimelineSync = useSessionStore( + (state) => state.sessions[serverId]?.viewedTimelineSync ?? null, + ); + useEffect(() => { + if (!agentId) return; + void getHostRuntimeStore() + .prepareAgentTimeline(serverId, agentId) + .catch(() => undefined); + }, [agentId, serverId]); + useLayoutEffect(() => { + if (!agentId || !viewedTimelineSync) return; + viewedTimelineSync.replaceVisibleAgentIds(sourceId, [agentId]); + return () => viewedTimelineSync.replaceVisibleAgentIds(sourceId, []); + }, [agentId, sourceId, viewedTimelineSync]); +} + +/** + * Leaves the sheet the calling pane is shown in, if any, on the way to `destination`. Answers + * whether closing the sheet already lands there, so the caller navigates only when it does not. + */ +export function useLeaveSheet(): (destination: WorkspaceTabTarget) => boolean { + const exit = useContext(SheetPaneExitContext); + return useCallback( + (destination) => { + if (!exit) return false; + exit.close(); + return exit.opener !== null && workspaceTabTargetsEqual(exit.opener, destination); + }, + [exit], + ); +} + +const styles = StyleSheet.create({ + pane: { + flex: 1, + minHeight: 0, + }, + body: { + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx index e7c8005233..30bebaac66 100644 --- a/packages/app/src/screens/workspace/sheet-tabs.tsx +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -1,131 +1,62 @@ -import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet"; import { useIsFocused } from "expo-router/react-navigation"; +import { useCallback, useEffect, useRef } from "react"; import { - type ComponentType, - type FC, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { View } from "react-native"; -import { useTranslation } from "react-i18next"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useStore } from "zustand"; -import { createStore } from "zustand/vanilla"; -import { Maximize2 } from "lucide-react-native"; -import { + FULL_SHEET_SNAP_POINTS, SheetBackground, SheetHandle, - SheetHeaderIconButton, - SheetHeaderView, - type SheetHeader, } from "@/components/adaptive-modal-sheet"; import { IsolatedBottomSheetModal, type IsolatedBottomSheetModalRef, systemBackPress, } from "@/components/ui/isolated-bottom-sheet-modal"; -import type { PanelIconProps } from "@/panels/panel-registry"; -import { getPanelRegistration } from "@/panels/panel-registry"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; -import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { - WorkspacePaneContent, - type WorkspacePaneContentModel, -} from "@/screens/workspace/workspace-pane-content"; + SheetPane, + useSheetPaneBridge, + useSheetPaneHost, + useSheetPaneHostIfAny, +} from "@/screens/workspace/sheet-pane"; import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; -import { useWorkspaceSheetEnvironment } from "./workspace-sheet-environment"; -const SNAP_POINTS = ["92%"]; const NO_SHEETS: readonly SheetTab[] = []; -function PanelIcon({ - icon: Icon, - size, - color, -}: { icon: ComponentType } & PanelIconProps) { - return ; -} - -const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ - color: theme.colors.foregroundMuted, -})); - -interface SheetTabHandleValue { - header: SheetHeader; - onClose: () => void; -} - -/** - * The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and - * renders it on the far side of the portal: a new function per render would remount the header on - * every change, and no context from here reaches it. So each sheet gets one handle component for - * its lifetime, which reads the header through a store the sheet keeps current. - */ -function useSheetTabHandle(value: SheetTabHandleValue): FC { - const [store] = useState(() => createStore(() => value)); - useEffect(() => store.setState(value, true), [store, value]); - const [Handle] = useState( - () => - function SheetTabHandle(props: BottomSheetHandleProps) { - const { header, onClose } = useStore(store); - return ( - - - - ); - }, - ); - return Handle; -} - export interface SheetTabsProps { - workspaceKey: string | null; - serverId: string; - workspaceId: string; - isWorkspaceFocused: boolean; - buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; /** The workspace's ordinary tab close, including its unsaved-changes guard. */ onCloseTab: (tabId: string) => Promise; } -interface SheetTabSheetProps extends Omit { +interface SheetTabSheetProps { sheet: SheetTab; - /** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */ + /** Only the top sheet answers Android Back; the ones below wait their turn. */ isTop: boolean; /** Runs the guarded close and resolves to whether the tab is still open. */ onClose: (tabId: string) => Promise; - onOpenAsTab: (tabId: string) => void; } /** * 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. + * crosses into the app overlay host so compact sheets cover both sidebars, and a sheet opened + * from another one lands on top of it and uncovers it on close. */ -export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) { - const sheets = useWorkspaceLayoutStore((state) => - workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, - ); - const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); - const handleOpenAsTab = useCallback( - (tabId: string) => { - if (workspaceKey) openSheetAsTab(workspaceKey, tabId); - }, - [openSheetAsTab, workspaceKey], +export function SheetTabs({ onCloseTab }: SheetTabsProps) { + const host = useSheetPaneHostIfAny(); + return host ? ( + + ) : null; +} + +function WorkspaceSheetTabs({ + workspaceKey, + onCloseTab, +}: SheetTabsProps & { workspaceKey: string }) { + const sheets = useWorkspaceLayoutStore( + (state) => state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS, ); const handleClose = useCallback( async (tabId: string) => { await onCloseTab(tabId); // A declined unsaved-changes prompt leaves the tab in its sheet. - const open = workspaceKey - ? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey] - : undefined; + const open = useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]; return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false; }, [onCloseTab, workspaceKey], @@ -136,42 +67,21 @@ export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) ))} ); } -function SheetTabSheet({ - sheet, - isTop, - serverId, - workspaceId, - isWorkspaceFocused, - buildContent, - onClose, - onOpenAsTab, -}: SheetTabSheetProps) { - const { t } = useTranslation(); +function SheetTabSheet({ sheet, isTop, onClose }: SheetTabSheetProps) { const { tab } = sheet; const sheetRef = useRef(null); const isScreenFocused = useIsFocused(); - const contextBridge = useWorkspaceSheetEnvironment(); - ensurePanelsRegistered(); - const registration = getPanelRegistration(tab.target.kind); - if (!registration) { - throw new Error(`No panel registration for kind: ${tab.target.kind}`); - } - const descriptor = registration.useDescriptor(tab.target as never, { - serverId, - workspaceId, - tabId: tab.tabId, - }); + const { isWorkspaceFocused } = useSheetPaneHost(); + const contextBridge = useSheetPaneBridge(); // The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off // the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still @@ -209,105 +119,23 @@ function SheetTabSheet({ }); }, [close, ownsBackPress]); - const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); - const header = useMemo( - () => ({ - title: descriptor.label, - leading: , - // The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into - // the switcher, where it gets its composer back. - actions: sheet.policy.openAsTab ? ( - - ) : undefined, - }), - [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], - ); - const handleValue = useMemo( - () => ({ header, onClose: close }), - [close, header], - ); - // The header is the sheet's handle: the panel scrolls on its own, so dragging the content would - // fight it, and the header is where people reach to pull the sheet down. - const SheetTabHandle = useSheetTabHandle(handleValue); - return ( - {/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */} - + ); } - -function SheetTabBody({ - tab, - label, - isPaneFocused, - isWorkspaceFocused, - buildContent, -}: { - tab: SheetTab["tab"]; - label: string; - /** Only the top sheet's pane is focused; the ones below are covered. */ - isPaneFocused: boolean; - isWorkspaceFocused: boolean; - buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; -}) { - const tabDescriptor = useMemo( - () => ({ - key: tab.tabId, - tabId: tab.tabId, - kind: tab.target.kind, - target: tab.target, - state: tab.state, - }), - [tab], - ); - const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); - // The sheet reaches the bottom of the screen, so it owns the system navigation inset. - const { bottom: bottomInset } = useSafeAreaInsets(); - const contentStyle = useMemo( - () => [styles.content, { paddingBottom: bottomInset }], - [bottomInset], - ); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - content: { - flex: 1, - minHeight: 0, - }, -}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts index 1378d6cc20..37f470bb3f 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.test.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -111,28 +111,6 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to }).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] }); }); -test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => { - const layout: WorkspaceLayout = { - focusedPaneId: "main", - root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } }, - }; - const tabs: WorkspaceTab[] = [ - { tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, - ]; - const sheetTab: WorkspaceTab = { - tabId: "sheet", - target: { kind: "agent", agentId: "agent-child" }, - createdAt: 2, - }; - - expect( - selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }), - ).toEqual(["agent-a", "agent-child"]); - expect( - selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }), - ).toEqual([]); -}); - test("single-pane tablet includes its visible Explorer agent without including other splits", () => { const layout: WorkspaceLayout = { focusedPaneId: "main", diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index 8643e84940..95a5ec84f8 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -6,8 +6,6 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state"; export function selectVisibleAgentIds(input: { layout: WorkspaceLayout | null; tabs: WorkspaceTab[]; - /** The workspace sheet's tab, shown over the panes. */ - sheetTab?: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; explorerPaneId?: string | null; @@ -29,7 +27,6 @@ export function selectVisibleAgentIds(input: { .target; return target ? [target] : []; }); - if (input.sheetTab) targets.push(input.sheetTab.target); return [ ...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))), ].sort(); @@ -38,7 +35,6 @@ export function selectVisibleAgentIds(input: { export function useVisibleAgentIds(input: { layout: WorkspaceLayout | null; tabs: WorkspaceTab[]; - sheetTab: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; explorerPaneId?: string | null; diff --git a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx index 84ec2634b9..6726c4d686 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx @@ -11,18 +11,7 @@ import { } from "@/screens/workspace/workspace-pane-content"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context"; - -vi.mock("@/panels/register-panels", () => ({ - ensurePanelsRegistered: vi.fn(), -})); - -vi.mock("@/panels/panel-registry", () => ({ - getPanelRegistration: () => ({ - kind: "agent", - component: ProbePanel, - useDescriptor: vi.fn(), - }), -})); +import { definePanel, registerPanel } from "@/panels/panel-registry"; interface ProbeSnapshot { paneContextValue: PaneContextValue; @@ -48,6 +37,18 @@ function ProbePanel() { return null; } +// The workspace screen registers the real panels; this probe stands in for each kind the tests open. +for (const kind of ["agent", "draft"] as const) { + registerPanel( + definePanel(kind, { + component: ProbePanel, + useDescriptor: () => { + throw new Error("The probe panel has no descriptor"); + }, + }), + ); +} + const agentTab: WorkspaceTabDescriptor = { key: "agent_agent-a", tabId: "agent_agent-a", diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 21476a85a4..95368240e9 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -9,7 +9,6 @@ import { } from "@/panels/pane-context"; import { useStableEvent } from "@/hooks/use-stable-event"; import { getPanelRegistration } from "@/panels/panel-registry"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { RenderProfile } from "@/utils/render-profiler"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; @@ -56,7 +55,6 @@ export function buildWorkspacePaneContentModel({ onOpenWorkspaceFile, onOpenImportSheet, }: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel { - ensurePanelsRegistered(); const registration = getPanelRegistration(tab.kind); invariant(registration, `No panel registration for kind: ${tab.kind}`); return { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d1b3fd3e9a..0b1cfe07ab 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -180,6 +180,8 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { ensurePanelsRegistered } from "@/panels/register-panels"; +import { SheetPaneHostProvider, type SheetPaneHost } from "@/screens/workspace/sheet-pane"; import { SheetTabs } from "@/screens/workspace/sheet-tabs"; import type { PaneHost } from "@/panels/panel-manifest"; import { WorkspaceFocusProvider } from "@/workspace/focus"; @@ -421,6 +423,10 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId, isRouteFocused, }: WorkspaceScreenProps) { + // Every reader of the panel registry renders inside the workspace screen, so it registers the + // panels before any of them renders. A reader importing the registration instead would import + // every panel, and the panels render readers. + ensurePanelsRegistered(); const navigationFocused = useIsFocused(); useEffect(() => { traceInstant("paseo.workspace.mount", { serverId, workspaceId }); @@ -764,13 +770,17 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { function WorkspaceContentProviders({ children, workspaceKey, + sheetPaneHost, }: { children: ReactNode; workspaceKey: string | null; + sheetPaneHost: SheetPaneHost | null; }) { return ( - {children} + + {children} + ); } @@ -1342,10 +1352,6 @@ function WorkspaceScreenContent({ const sheets = useWorkspaceLayoutStore((state) => persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS, ); - // Only the top sheet is on screen. - const sheetTab = useWorkspaceLayoutStore((state) => - persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null, - ); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState); @@ -1417,7 +1423,6 @@ function WorkspaceScreenContent({ const visibleAgentIds = useVisibleAgentIds({ layout: workspaceLayout, tabs: uiTabs, - sheetTab, routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, explorerPaneId: explorerSidebarPaneId, @@ -2916,6 +2921,8 @@ function WorkspaceScreenContent({ paneId?: string | null; focusPaneBeforeOpen?: boolean; host?: PaneHost; + /** Overrides the guarded tab close, for a host that closes the tab its own way. */ + onCloseCurrentTab?: () => void; }) => buildWorkspacePaneContentModel({ tab: input.tab, @@ -2965,9 +2972,11 @@ function WorkspaceScreenContent({ }); } : undefined, - onCloseCurrentTab: () => { - void handleCloseTabById(input.tab.tabId); - }, + onCloseCurrentTab: + input.onCloseCurrentTab ?? + (() => { + void handleCloseTabById(input.tab.tabId); + }), onRetargetCurrentTab: (target) => { if (!persistenceKey) { return; @@ -3042,16 +3051,36 @@ function WorkspaceScreenContent({ [buildPaneContentModel], ); const buildSheetPaneContentModel = useCallback( - function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) { + function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor, closeCurrentTab: () => void) { return buildPaneContentModel({ tab, paneId: null, focusPaneBeforeOpen: false, host: "sheet", + onCloseCurrentTab: closeCurrentTab, }); }, [buildPaneContentModel], ); + const sheetPaneHost = useMemo( + () => + persistenceKey + ? { + workspaceKey: persistenceKey, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + isWorkspaceFocused: isRouteFocused, + buildContent: buildSheetPaneContentModel, + } + : null, + [ + buildSheetPaneContentModel, + isRouteFocused, + normalizedServerId, + normalizedWorkspaceId, + persistenceKey, + ], + ); const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { if (!persistenceKey || paneFocusSuppressedRef.current) { return; @@ -3540,14 +3569,7 @@ function WorkspaceScreenContent({ )} - + ) : ( @@ -3767,7 +3789,11 @@ function WorkspaceScreenContent({ return gatedWorkspaceScreen; } return ( - + { - const content = editing(cache(files(children))); - return ( - - - - {serverId ? ( - {content} - ) : ( - content - )} - - - - ); - }, - [editing, cache, files, active, serverId, navigation, route], - ); -} diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index fb73376b8b..03f0254099 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -6,7 +6,6 @@ import { Check } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-registry"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; @@ -43,7 +42,6 @@ export function WorkspaceTabPresentationResolver({ workspaceId, children, }: WorkspaceTabPresentationResolverProps): ReactElement { - ensurePanelsRegistered(); const registration = getPanelRegistration(tab.kind); invariant(registration, `No panel registration for kind: ${tab.kind}`); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 431d550823..1946586e84 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -41,6 +41,7 @@ import { normalizeLayout, removePaneFromTree, removeTabFromTree, + selectSheetTab, stripEphemeralTabsFromLayout, type SplitNode, type SplitPane, @@ -4496,6 +4497,12 @@ describe("compact workspace sheet", () => { return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1); } + function requireTopSheet(store: ReturnType) { + const sheet = topSheet(store); + if (!sheet) throw new Error("Expected a sheet"); + return sheet; + } + function sheetTargets(store: ReturnType) { return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map( (sheet) => sheet.tab.target, @@ -4647,7 +4654,7 @@ describe("compact workspace sheet", () => { .getState() .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); - store.getState().openSheetAsTab(workspaceKey, nextTabId ?? ""); + store.getState().openTabInFront(workspaceKey, requireTopSheet(store)); expect(sheetTargets(store)).toEqual([]); expect(tabKinds(store)).toEqual(["terminal", "file"]); @@ -4683,7 +4690,7 @@ describe("compact workspace sheet", () => { if (!sheetTabId) throw new Error("Expected a sheet tab"); store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 }); - const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId); + const tabId = store.getState().openTabInFront(workspaceKey, requireTopSheet(store)); expect(tabId).toBe(sheetTabId); expect(topSheet(store)).toBeUndefined(); @@ -4787,6 +4794,40 @@ describe("compact workspace sheet", () => { expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); }); + it("describes the sheet a reveal would show without opening it, and opens it as a tab", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const rootTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" }); + + const sheet = selectSheetTab(store.getState(), { + workspaceKey, + target: { kind: "agent", agentId: "child" }, + parentTabId: rootTabId ?? undefined, + }); + expect(sheet?.parentTabId).toBe(rootTabId); + expect( + selectSheetTab(store.getState(), { + workspaceKey, + target: { kind: "agent", agentId: "root" }, + }), + ).toBeNull(); + expect(topSheet(store)).toBeUndefined(); + if (!sheet) throw new Error("Expected a sheet tab"); + + const tabId = store.getState().openTabInFront(workspaceKey, sheet); + + expect(tabId).toBe(sheet.tab.tabId); + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(tabId); + expect(layout.parentTabIdByTabId?.[tabId ?? ""]).toBe(rootTabId); + }); + it("gives a sheet target opened from inside the sheet the sheet's own parent", () => { seedAgents([ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, @@ -4811,7 +4852,7 @@ describe("compact workspace sheet", () => { }); expect(topSheet(store)?.parentTabId).toBe(rootTabId); - store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? ""); + store.getState().openTabInFront(workspaceKey, requireTopSheet(store)); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 1c0b7a5037..43387bc743 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -156,13 +156,12 @@ interface WorkspaceLayoutStore { * `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id. */ openTab: (input: OpenWorkspaceTabInput) => string | null; - /** Whether `openTab` with this input would show the target in a sheet. */ - opensInSheet: ( - input: Pick, - ) => boolean; setLayoutMode: (mode: WorkspaceLayoutMode) => void; - /** Moves a sheet's tab, keeping its tab id, into the focused pane. */ - openSheetAsTab: (workspaceKey: string, tabId: string) => string | null; + /** + * Moves a sheet's tab, keeping its tab id, into the focused pane and dismisses the sheets that + * would cover it. The sheet need not be one of the workspace's sheet tabs. + */ + openTabInFront: (workspaceKey: string, sheet: SheetTab) => string | null; /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ autoOpenPullRequestTab: ( workspaceKey: string, @@ -521,6 +520,29 @@ export function selectExplorerSidebarPaneId( return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]); } +/** + * The sheet tab a reveal of this target would show, or null when it opens as a tab. A caller + * that hosts the sheet itself (a list showing it in place) renders this without opening it. + */ +export function selectSheetTab( + state: Pick, + input: Pick, +): SheetTab | null { + const workspaceKey = trimNonEmpty(input.workspaceKey); + const target = normalizeWorkspaceTabTarget(input.target); + if (!workspaceKey || !target) return null; + const policy = resolveSheetPolicy(state, workspaceKey, target, "reveal"); + if (!policy) return null; + return placeTargetInSheet({ + kept: undefined, + target, + intent: "reveal", + policy, + parentTabId: resolveOpenParentTabId(state, workspaceKey, input.parentTabId), + state: undefined, + }); +} + /** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */ export function selectPaneFocusSuspended( state: Pick, @@ -822,10 +844,8 @@ function placeTargetInSheet(input: { function promoteSheetToTab( state: WorkspaceLayoutStore, workspaceKey: string, - tabId: string, + sheet: SheetTab, ): { tabId: string; patch: Partial } | null { - const sheet = findSheet(state, workspaceKey, tabId); - if (!sheet) return null; const { tab } = sheet; const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT); const input = { @@ -910,9 +930,9 @@ function dismissSheets( function openSheetTabInFront( state: WorkspaceLayoutStore, workspaceKey: string, - tabId: string, + sheet: SheetTab, ): { tabId: string; patch: Partial } | null { - const promoted = promoteSheetToTab(state, workspaceKey, tabId); + const promoted = promoteSheetToTab(state, workspaceKey, sheet); if (!promoted) return null; const next = { ...state, ...promoted.patch }; return { @@ -1208,15 +1228,6 @@ export function createWorkspaceLayoutStore( })); return result.tabId; }, - opensInSheet: (input) => { - const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey); - const normalizedTarget = normalizeWorkspaceTabTarget(input.target); - if (!normalizedWorkspaceKey || !normalizedTarget) return false; - return ( - resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !== - null - ); - }, setLayoutMode: (mode) => { set((state) => { if (state.layoutMode === mode) return state; @@ -1225,7 +1236,7 @@ export function createWorkspaceLayoutStore( const next: WorkspaceLayoutStore = { ...state, layoutMode: mode }; for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) { for (const sheet of sheets) { - const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId); + const promoted = promoteSheetToTab(next, workspaceKey, sheet); Object.assign( next, promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId), @@ -1235,11 +1246,10 @@ export function createWorkspaceLayoutStore( return next; }); }, - openSheetAsTab: (workspaceKey, tabId) => { + openTabInFront: (workspaceKey, sheet) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTabId = trimNonEmpty(tabId); - if (!normalizedWorkspaceKey || !normalizedTabId) return null; - const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId); + if (!normalizedWorkspaceKey) return null; + const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, sheet); if (!promoted) return null; set(promoted.patch); return promoted.tabId; @@ -1569,7 +1579,10 @@ export function createWorkspaceLayoutStore( return normalizedTabId; } // A target sheets do not host becomes the tab it would have opened as. - const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId); + const retargeted = findSheet(next, normalizedWorkspaceKey, normalizedTabId); + const promoted = retargeted + ? openSheetTabInFront(next, normalizedWorkspaceKey, retargeted) + : null; set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId)); return promoted?.tabId ?? null; } diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 6c539931e1..0b72ccfcf5 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -9,4 +9,4 @@ export { type ArchiveFinishedStatus, type UseArchiveFinishedSubagentsInput, } from "./use-archive-finished"; -export { useSubagentOpensInSheet } from "./opens-in-sheet"; +export { useSubagentSheetTab } from "./sheet-tab"; diff --git a/packages/app/src/subagents/opens-in-sheet.ts b/packages/app/src/subagents/opens-in-sheet.ts deleted file mode 100644 index 26432ea039..0000000000 --- a/packages/app/src/subagents/opens-in-sheet.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; -import type { SubagentRow } from "./select"; - -/** The workspace target a subagent row opens. */ -export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget { - return row.kind === "provider" - ? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id } - : { kind: "agent", agentId: row.id }; -} - -/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */ -export function useSubagentOpensInSheet( - workspaceKey: string | null, -): (row: SubagentRow) => boolean { - // The rows render the answer, so this subscribes to what the answer reads: the layout mode and - // the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet, - // and a row rendered while that tab existed must re-render once it closes, or choosing the row - // would still close the list it sits in. - useWorkspaceLayoutStore((state) => state.layoutMode); - useWorkspaceLayoutStore((state) => - workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, - ); - return (row) => - workspaceKey !== null && - useWorkspaceLayoutStore - .getState() - .opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" }); -} diff --git a/packages/app/src/subagents/parent/callout.tsx b/packages/app/src/subagents/parent/callout.tsx index f76e1267e3..7665c0d12a 100644 --- a/packages/app/src/subagents/parent/callout.tsx +++ b/packages/app/src/subagents/parent/callout.tsx @@ -3,8 +3,8 @@ import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { CornerUpLeft } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StatusIcon } from "@/components/status-icon"; import { Button } from "@/components/ui/button"; -import { ComposerTrackMark } from "@/composer/tracks"; import { FOOTER_HEIGHT } from "@/constants/layout"; import type { Theme } from "@/styles/theme"; import { useParentAgent, type ParentAgent } from "./use-parent-agent"; @@ -22,6 +22,7 @@ function ParentIcon({ } const ThemedParentIcon = withUnistyles(ParentIcon); +const ICON_SIZE = 16; const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); /** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */ @@ -38,18 +39,12 @@ export function ParentAgentCallout({ return ( - - - - - {t("subagents.parent.parent")} - - - {parent.label ?? t("subagents.parent.parentAgent")} - - - - + + + + + {parent.label ?? t("subagents.parent.parentAgent")} +