diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index f501538270..378a0070ea 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -212,7 +212,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 the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it 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. 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) 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. 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 9415abd85a..c9a89f8a58 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -7,7 +7,7 @@ contracts. | ---------------- | ---------------------------- | ------------------------------------------ | | Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock | | Side pane | Ordinary workspace content | Created and closed like any workspace pane | -| Workspace sheet | Compact look-ups | Stacks tabs; each closes or becomes a tab | +| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack | ## Panel host contract @@ -23,23 +23,30 @@ can move between Explorer and main panes. Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts. -## Workspace sheet +## Sheets -On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab -switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest +On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher +keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest (`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 sheet holds one ordinary -`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet -(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has -a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved -work. Opening a sheet as a tab dismisses the sheets above it, since the -tab would sit under them. The stack is not persisted. +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. + +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 the whole compact column, header included. A sheet's header is its drag -handle, because the panel scrolls on its own and content panning would fight it. Sheets render in -the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need the navigator -and every workspace provider an ordinary pane has, and a portal drops them -([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)). +handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail +holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside +Close, so the title keeps its width. The sheets present +from inside the compact column's `SheetHostScope`, so their panels read the navigator and every +workspace provider an ordinary pane has without a context bridge +([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)). A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it. Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 9241cec56d..1af71ac4c9 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -343,11 +343,20 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked another pushed sheet. Close React state from `onDismiss`; use `onChange` only to track phase. -All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This -includes native centered dialogs: separate React Native Modals cannot present a root-owned -sibling dialog over an existing iOS presenter. The one exception is the -[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a -whole workspace panel. +All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no +exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on +close. This includes native centered dialogs: separate React Native Modals cannot present a +root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree +(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their +panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the +portal host and container measurement while the sheet still joins the same stack. That export is +what `patches/@gorhom+bottom-sheet+*.patch` adds. + +A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every +close path then parks the sheet at index -1 with its content mounted; the owner hears +`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the +sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and +unmounts it, so its backdrop and Back press call `close()`. The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary but stores it in the modal queue and later reads `.current`; provider dismissal, replacement, @@ -357,9 +366,10 @@ forwards commands to the current Gorhom handle. Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web, Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop -keeps its press target until portal teardown and asks the provider to dismiss the top modal. -Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native -retains Gorhom's gesture backdrop and the same push/replace presentation semantics. +keeps its press target until portal teardown and asks the provider to dismiss the top modal +(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the +authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the +same push/replace presentation semantics. ## Gotcha 7 — A sheet cannot read context from its call site diff --git a/docs/glossary.md b/docs/glossary.md index dcf8178bc7..644c109d44 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -21,7 +21,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. - **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). -- **Workspace sheet** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet). +- **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). - **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 50434b9e9d..9dc3e26cae 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -52,6 +52,11 @@ 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 +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. + ## Pages A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id` @@ -193,6 +198,9 @@ its own. backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes. - **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth). +- **A menu closes when its panel leaves the screen.** `MenuSurface` closes the menu when its + retained panel goes inactive. Hiding the surface alone leaves the open state behind, and the + menu would reappear with the panel on the next tab switch. - **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes diff --git a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts index 474a498353..77a2a0882b 100644 --- a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts +++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts @@ -4,18 +4,23 @@ import { expect, test } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents"; import { - closeWorkspaceSheet, + expectSubagentRowVisible, + openSubagentFromCompactTrack, + seedSubagent, +} from "../support/helpers/subagents"; +import { + closeSheet, expectOpenTabCount, - openWorkspaceSheetAsTab, + openSheetAsTab, expectDeclinedDiscardPrompt, - expectWorkspaceSheetAtSnap, - replaceWorkspaceSheetFileText, + expectSheetAtSnap, + replaceSheetFileText, selectTabFromSwitcher, - tapWorkspaceSheetBackdrop, - workspaceSheet, -} from "../support/helpers/workspace-sheet"; + sheet, + sheetHeader, + tapSheetBackdrop, +} from "../support/helpers/sheet"; const FILE_LINK = "linked.ts:3"; @@ -55,47 +60,90 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents" await expect(fileLink).toBeVisible({ timeout: 15_000 }); await fileLink.click(); - const sheet = workspaceSheet(page, "linked.ts"); - await expect(sheet).toBeVisible(); - await expect(sheet.getByText("three")).toBeVisible(); - await expectWorkspaceSheetAtSnap(page, "linked.ts"); - await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0); + const fileSheet = sheet(page, "linked.ts"); + await expect(fileSheet).toBeVisible(); + await expect(fileSheet.getByText("three")).toBeVisible(); + await expectSheetAtSnap(page, "linked.ts"); + await expect( + sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }), + ).toHaveCount(0); await expectOpenTabCount(page, 1); - await closeWorkspaceSheet(page, "linked.ts"); + await closeSheet(page, "linked.ts"); }); - await test.step("a subagent opens in a sheet without its composer", async () => { + await test.step("a subagent opens in a sheet over the subagents list", async () => { await openSubagentFromCompactTrack(page, child.id); - const sheet = workspaceSheet(page, child.title); - await expect(sheet).toBeVisible(); - await expectWorkspaceSheetAtSnap(page, child.title); - await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0); + const childSheet = sheet(page, child.title); + await expect(childSheet).toBeVisible(); + await expectSheetAtSnap(page, child.title); + 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. + await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText( + "Ten second stream · Low · Load test", + ); await expectOpenTabCount(page, 1); }); - await test.step("Open as tab promotes the subagent to a full tab", async () => { - await openWorkspaceSheetAsTab(page, child.title); + await test.step("closing the subagent's sheet uncovers the list", async () => { + await closeSheet(page, child.title); + await expectSubagentRowVisible(page, child.id); + }); + + 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 expectSheetAtSnap(page, child.title); + await openSheetAsTab(page, child.title); + await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0); await expectOpenTabCount(page, 2); await expectComposerVisible(page); }); - await test.step("a sheet with unsaved edits closes only through the guarded close", async () => { + await test.step("the list the subagent was opened from closed with its tab", async () => { await selectTabFromSwitcher(page, "Sheet parent"); + 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 page + .getByRole("button", { name: /Switch tabs/ }) + .filter({ visible: true }) + .click(); + await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible(); + const menuBase = `workspace-tab-menu-agent_${child.id}`; + await page.getByTestId(`${menuBase}-trigger`).click(); + await page.getByTestId(`${menuBase}-close`).click(); + await expectOpenTabCount(page, 1); + // Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is + // tapped once the row menu's own sheet has gone. + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1); + await tapSheetBackdrop(page); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + + await openSubagentFromCompactTrack(page, child.id); + await expectSheetAtSnap(page, child.title); + await closeSheet(page, child.title); + await expectSubagentRowVisible(page, child.id); + await tapSheetBackdrop(page); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + }); + + await test.step("a sheet with unsaved edits closes only through the guarded close", async () => { await page.getByText(FILE_LINK, { exact: true }).click(); - const sheet = workspaceSheet(page, "linked.ts"); - await expect(sheet).toBeVisible(); + const fileSheet = sheet(page, "linked.ts"); + await expect(fileSheet).toBeVisible(); // Hold the autosave so the edit stays unsaved. gate.holdNextClientRequest("fs.file.write.request"); - await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet"); + await replaceSheetFileText(page, "linked.ts", "edited in the sheet"); await gate.waitForHeldClientRequest(); - await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page)); - await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet"); + await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page)); + await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet"); await expectDeclinedDiscardPrompt(page, () => - sheet.getByRole("button", { name: "Close" }).click(), + sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(), ); - await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet"); + await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet"); gate.releaseHeldClientRequest(); await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet"); diff --git a/packages/app/e2e/support/helpers/workspace-sheet.ts b/packages/app/e2e/support/helpers/sheet.ts similarity index 66% rename from packages/app/e2e/support/helpers/workspace-sheet.ts rename to packages/app/e2e/support/helpers/sheet.ts index b0544f7df6..6288c6f9c1 100644 --- a/packages/app/e2e/support/helpers/workspace-sheet.ts +++ b/packages/app/e2e/support/helpers/sheet.ts @@ -1,11 +1,16 @@ import { expect, type Locator, type Page } from "@playwright/test"; -/** The compact workspace sheet, named after the tab it hosts. */ -export function workspaceSheet(page: Page, label: string): Locator { +/** A sheet tab's body, named after the tab it shows. */ +export function sheet(page: Page, label: string): Locator { return page.getByRole("dialog", { name: label }); } -/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */ +/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */ +export function sheetHeader(page: Page, label: string): Locator { + return page.getByTestId("sheet-tab-header").filter({ hasText: label }); +} + +/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */ const SHEET_SNAP_FRACTION = 0.92; /** The header starts below the sheet's drag indicator, inside the handle. */ const SHEET_INDICATOR_MAX_HEIGHT = 32; @@ -14,12 +19,11 @@ const SHEET_INDICATOR_MAX_HEIGHT = 32; * The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it * covers the workspace header; a sheet sunk below it hides content. */ -export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise { +export async function expectSheetAtSnap(page: Page, label: string): Promise { const viewportHeight = page.viewportSize()?.height ?? 0; const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION)); - const sheet = workspaceSheet(page, label); - const header = sheet.getByTestId("workspace-sheet-header"); - const body = sheet.getByTestId("workspace-sheet-body"); + const header = sheetHeader(page, label); + const body = sheet(page, label); await expect .poll(async () => { const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]); @@ -39,26 +43,22 @@ export async function expectOpenTabCount(page: Page, count: number): Promise { - const sheet = workspaceSheet(page, label); - await sheet.getByRole("button", { name: "Close" }).click(); - await expect(sheet).toHaveCount(0); +export async function closeSheet(page: Page, label: string): Promise { + await sheetHeader(page, label).getByRole("button", { name: "Close" }).click(); + await expect(sheet(page, label)).toHaveCount(0); } -export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise { - const sheet = workspaceSheet(page, label); - await sheet.getByRole("button", { name: "Open as tab" }).click(); - await expect(sheet).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); } -export async function replaceWorkspaceSheetFileText( +export async function replaceSheetFileText( page: Page, label: string, content: string, ): Promise { - const editor = workspaceSheet(page, label) - .getByTestId("file-source-editor") - .locator(".cm-content"); + const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content"); await editor.click(); await editor.press("ControlOrMeta+A"); await editor.pressSequentially(content); @@ -69,6 +69,7 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise< .getByRole("button", { name: /Switch tabs/ }) .filter({ visible: true }) .click(); + await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible(); const option = page.getByText(label, { exact: true }).filter({ visible: true }).last(); await expect(option).toBeVisible(); await option.click(); @@ -76,7 +77,7 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise< } /** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */ -export async function tapWorkspaceSheetBackdrop(page: Page): Promise { +export async function tapSheetBackdrop(page: Page): Promise { await page .getByRole("button", { name: "Bottom sheet backdrop" }) .last() diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index f7b4d4056c..d4d833e3f1 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -14,7 +14,7 @@ import { } from "../lib/overlay-root"; import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated from "react-native-reanimated"; -import { ArrowLeft, Search, X } from "lucide-react-native"; +import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native"; import { IsolatedBottomSheetModal, SheetVisibleFrame, @@ -287,6 +287,37 @@ function SheetContent({ style, children }: { style: StyleProp; childr return {children}; } +/** + * An icon action in a sheet header's trailing rail, drawn like the Close button so a header's + * actions line up with it at one size. Pass it through `SheetHeader.actions`. + */ +export function SheetHeaderIconButton({ + icon: Icon, + accessibilityLabel, + onPress, + testID, +}: { + icon: LucideIcon; + accessibilityLabel: string; + onPress: () => void; + testID?: string; +}) { + const { theme } = useUnistyles(); + return ( + + {({ pressed }) => ( + + )} + + ); +} + export function SheetHeaderView({ header, onClose, @@ -343,19 +374,11 @@ export function SheetHeaderView({ {header.actions ? {header.actions} : null} {showCloseButton ? ( - - {({ pressed }) => ( - - )} - + /> ) : null} {search ? ( diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx new file mode 100644 index 0000000000..d107547dcf --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx @@ -0,0 +1,91 @@ +import { + BottomSheetModalInternalProvider, + INITIAL_CONTAINER_LAYOUT, + useBottomSheetModalInternal, +} from "@gorhom/bottom-sheet"; +import { PortalHost } from "@gorhom/portal"; +import { useCallback, useId, useMemo, useRef, type ReactNode } from "react"; +import { + Dimensions, + StatusBar, + StyleSheet, + View, + type LayoutChangeEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { useSharedValue } from "react-native-reanimated"; + +/** + * Hosts the sheets presented from inside it. + * + * A sheet's content normally lands at the app root, on the far side of the portal, where every + * context between the root and the call site is gone (floating-panels.md Gotcha 7). Inside a + * scope, a sheet renders into the scope's own host instead, so its content reads the scope's + * contexts — the navigator, the workspace providers — without bridging each one by hand. The + * scope only moves where the sheet draws: it still joins the app-wide sheet stack, so stacking, + * Back and the backdrop treat it like every other sheet. + * + * The host fills the scope, and the sheets presented in it size and dock to the scope, not the + * window. Sheets presented outside the scope keep drawing at the root, above it. + */ +export function SheetHostScope({ + children, + style, +}: { + children: ReactNode; + style?: StyleProp; +}) { + const parent = useBottomSheetModalInternal(); + const hostName = `sheet-host-${useId()}`; + const containerLayoutState = useSharedValue({ ...INITIAL_CONTAINER_LAYOUT }); + const hostRef = useRef(null); + + // Mirrors Gorhom's own hosting container measurement, so a sheet in the scope lays out against + // the scope the way a root sheet lays out against the window. + const handleLayout = useCallback( + ({ nativeEvent }: LayoutChangeEvent) => { + const { height } = nativeEvent.layout; + hostRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => { + const windowHeight = Dimensions.get("window").height; + const top = pageY ?? 0; + containerLayoutState.set({ + height, + offset: { + top, + bottom: Math.max(0, windowHeight - (top + height + (StatusBar.currentHeight ?? 0))), + left: 0, + right: 0, + }, + }); + }); + }, + [containerLayoutState], + ); + + const value = useMemo( + () => ({ ...parent, hostName, containerLayoutState }), + [containerLayoutState, hostName, parent], + ); + + return ( + + + {children} + + + + + + ); +} + +const styles = StyleSheet.create({ + scope: { + flex: 1, + minHeight: 0, + }, + host: { + ...StyleSheet.absoluteFillObject, + }, +}); 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 55e475b424..457181c53f 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 @@ -40,6 +40,13 @@ type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, "enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent" > & { + /** + * Whether closing (drag, backdrop, Back) also dismisses, which unmounts the content. Default + * true: the owner hears `onDismiss` and drops its state. Pass `false` for a sheet whose close the + * owner may veto: it parks at index -1 with its content mounted, the owner hears `onChange(-1)`, + * and either snaps it back or unmounts it, which dismisses it from the stack. + */ + enableDismissOnClose?: boolean; /** * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge * would have to reach around it. @@ -84,19 +91,24 @@ export const IsolatedBottomSheetModal = forwardRef< presentation = "push", contextBridge, backdropOpacity, + enableDismissOnClose = true, ...bottomSheetProps } = props; const renderBackdrop = useCallback( (backdropProps: BottomSheetBackdropProps) => ( - + ), - [backdropOpacity], + [backdropOpacity, enableDismissOnClose], ); const modal = ( @@ -155,3 +167,4 @@ export function useIsolatedBottomSheetVisibility({ export { SheetVisibleFrame } from "./visible-frame"; export { systemBackPress } from "./back-press"; +export { SheetHostScope } from "./host-scope"; diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx index 1232566462..8c85ef77ca 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx @@ -1,5 +1,15 @@ import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; -export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { +/** + * What a backdrop tap does. `dismiss` takes the top modal off the stack and unmounts it; `close` + * only slides this sheet down, so a sheet that keeps its content through a close can veto it. + */ +export type SheetBackdropPress = "dismiss" | "close"; + +export function SheetBackdrop({ + pressBehavior: _pressBehavior, + ...props +}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) { + // Gorhom's native backdrop closes its own sheet, which here is always the top one. return ; } diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx index ba1e397a9e..749d89359c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx @@ -1,16 +1,30 @@ import { BottomSheetBackdrop, + useBottomSheet, useBottomSheetModal, type BottomSheetBackdropProps, } from "@gorhom/bottom-sheet"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, StyleSheet, View } from "react-native"; +import type { SheetBackdropPress } from "./sheet-backdrop"; -export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { +export type { SheetBackdropPress }; + +export function SheetBackdrop({ + pressBehavior, + ...props +}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) { const { dismiss } = useBottomSheetModal(); + const { close } = useBottomSheet(); const { t } = useTranslation(); - const dismissTopSheet = useCallback(() => dismiss(), [dismiss]); + const handlePress = useCallback(() => { + // A sheet that may veto its close slides itself down and lets its owner decide. The other + // sheets ask the provider for the top modal, so a lower backdrop caught mid-transition still + // dismisses the sheet the user sees. + if (pressBehavior === "close") close(); + else dismiss(); + }, [close, dismiss, pressBehavior]); return ( @@ -28,7 +42,7 @@ export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: numb accessibilityLabel={t("common.bottomSheetBackdrop")} // The provider owns presentation order, including when a lower backdrop remounts. // Keep this target until the portal unmounts: animation must not expose the sheet below. - onPress={dismissTopSheet} + onPress={handlePress} style={StyleSheet.absoluteFill} /> diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 3b76d4cf2b..2f73149244 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -126,8 +126,14 @@ 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 } = useMenuContext("MenuSurface"); + const { presentation, open, setOpen } = useMenuContext("MenuSurface"); const active = useRetainedPanelActive(); + // 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. + useEffect(() => { + if (!active && open) setOpen(false); + }, [active, open, setOpen]); // Portals escape the hidden panel. Remove the surface in its inactive commit, // before freezing can suspend an async menu action's closing update. if (!active) return null; diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 0b74c7e1da..9ebc92ed20 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -1,7 +1,8 @@ -import React, { act, type ReactNode } from "react"; +import React, { act, useCallback, useState, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { Pressable, Text } from "react-native"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RetainedPanelActivity } from "@/components/retained-panel"; import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks"; const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }]; @@ -109,6 +110,43 @@ describe("composer track panel", () => { expect(row("row")).not.toBeNull(); }); + it("closes for good when the panel it belongs to leaves the screen", () => { + const onPress = vi.fn(); + function Harness() { + const [active, setActive] = useState(true); + const hide = useCallback(() => setActive(false), []); + const show = useCallback(() => setActive(true), []); + return ( + <> + + + + + + Subagent one + + + + + ); + } + mount(); + + openPanel(); + expect(isPanelOpen()).toBe(true); + + click(row("hide") as Element); + click(row("show") as Element); + + expect(isPanelOpen()).toBe(false); + expect(row("row")).toBeNull(); + }); + it("leaves the panel alone when a row's own action button is pressed", () => { const onPress = vi.fn(); const onAction = vi.fn(); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5f00d8f70c..87cacb2f82 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -631,7 +631,7 @@ export const ar: TranslationResources = { }, switcher: { trigger: "تبديل علامات التبويب (فتح{{count}})", - title: "علامة التبويب التبديل", + title: "علامات تبويب الـ Workspace", searchPlaceholder: "علامات تبويب البحث", }, menu: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 91e7e2c92f..f9779a6d55 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -628,7 +628,7 @@ export const en = { }, switcher: { trigger: "Switch tabs ({{count}} open)", - title: "Switch tab", + title: "Workspace tabs", searchPlaceholder: "Search tabs", }, menu: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 6aff72fdd7..190b36d08d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -637,7 +637,7 @@ export const es: TranslationResources = { }, switcher: { trigger: "Cambiar pestañas ({{count}}abierto)", - title: "Cambiar pestaña", + title: "Pestañas del workspace", searchPlaceholder: "Pestañas de búsqueda", }, menu: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a4471f594c..6cfe15b1be 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -636,7 +636,7 @@ export const fr: TranslationResources = { }, switcher: { trigger: "Changer d’onglet ({{count}} ouverts)", - title: "Changer d’onglet", + title: "Onglets de l’espace de travail", searchPlaceholder: "Rechercher des onglets", }, menu: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9cbc630da2..cb37dde381 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -637,7 +637,7 @@ export const ja: TranslationResources = { }, switcher: { trigger: "タブを切り替え({{count}}件開いています)", - title: "タブを切り替え", + title: "ワークスペースのタブ", searchPlaceholder: "タブを検索", }, menu: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 98598d39dd..418490d395 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -632,7 +632,7 @@ export const ko: TranslationResources = { }, switcher: { trigger: "탭 전환 ({{count}}개 열림)", - title: "탭 전환", + title: "워크스페이스 탭", searchPlaceholder: "탭 검색", }, menu: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c3ca4994d3..b2fe479b74 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -636,7 +636,7 @@ export const ptBR: TranslationResources = { }, switcher: { trigger: "Alternar abas ({{count}} abertas)", - title: "Alternar aba", + title: "Abas do workspace", searchPlaceholder: "Buscar abas", }, menu: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 57616a22a7..086cd51c01 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -638,7 +638,7 @@ export const ru: TranslationResources = { }, switcher: { trigger: "Переключить вкладки (открыто: {{count}})", - title: "Переключить вкладку", + title: "Вкладки рабочего пространства", searchPlaceholder: "Поиск вкладок", }, menu: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 99088e068a..16dc24b3a5 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -631,7 +631,7 @@ export const zhCN: TranslationResources = { }, switcher: { trigger: "切换标签(已打开 {{count}} 个)", - title: "切换标签", + title: "Workspace 标签", searchPlaceholder: "搜索标签", }, menu: { diff --git a/packages/app/src/panels/agent-facts-line.test.ts b/packages/app/src/panels/agent-facts-line.test.ts new file mode 100644 index 0000000000..182a5ff683 --- /dev/null +++ b/packages/app/src/panels/agent-facts-line.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentFactsLine, type AgentFactsInput } from "./agent-facts-line"; + +const models = [ + { + provider: "claude", + id: "claude-fable-5-1", + label: "Fable 5.1", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, +]; + +const modes = [ + { id: "default", label: "Default" }, + { id: "bypassPermissions", label: "Bypass permissions" }, +]; + +const base: AgentFactsInput = { + models, + runtimeModelId: "claude-fable-5-1", + configuredModelId: null, + runtimeThinkingOptionId: "high", + explicitThinkingOptionId: null, + availableModes: modes, + currentModeId: "bypassPermissions", + contextWindowUsedTokens: 56_200, + contextWindowMaxTokens: 200_000, +}; + +describe("buildAgentFactsLine", () => { + it("joins model, thinking, mode, and context usage as the controls show them", () => { + expect(buildAgentFactsLine(base)).toBe( + "Fable 5.1 · High · Bypass permissions · 56k / 200k tokens (28%)", + ); + }); + + it("shows the raw model id before the catalog has loaded", () => { + expect( + buildAgentFactsLine({ + ...base, + models: null, + runtimeModelId: null, + configuredModelId: "gpt-6.1-sol", + runtimeThinkingOptionId: null, + explicitThinkingOptionId: "medium", + availableModes: [], + contextWindowUsedTokens: null, + contextWindowMaxTokens: null, + }), + ).toBe("gpt-6.1-sol · Medium"); + }); + + it("falls back to the provider's first mode until the agent reports one", () => { + expect( + buildAgentFactsLine({ + ...base, + currentModeId: null, + contextWindowUsedTokens: undefined, + contextWindowMaxTokens: undefined, + }), + ).toBe("Fable 5.1 · High · Default"); + }); + + it("leaves thinking and context out when the agent has none", () => { + expect( + buildAgentFactsLine({ + ...base, + models: [{ provider: "mock", id: "m", label: "Model M" }], + runtimeModelId: "m", + runtimeThinkingOptionId: null, + availableModes: [], + contextWindowUsedTokens: 10, + contextWindowMaxTokens: 0, + }), + ).toBe("Model M"); + }); + + it("is empty until the agent has a model", () => { + expect( + buildAgentFactsLine({ ...base, runtimeModelId: null, configuredModelId: " " }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/panels/agent-facts-line.ts b/packages/app/src/panels/agent-facts-line.ts new file mode 100644 index 0000000000..7d3cd80332 --- /dev/null +++ b/packages/app/src/panels/agent-facts-line.ts @@ -0,0 +1,59 @@ +import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import { formatAgentModeLabel } from "@/agent-controls/labels"; +import { formatTokenCount } from "@/components/context-window-meter.utils"; +import { resolveAgentModelSelection } from "@/composer/agent-controls/utils"; + +export interface AgentFactsInput { + models: AgentModelDefinition[] | null; + runtimeModelId: string | null | undefined; + configuredModelId: string | null | undefined; + runtimeThinkingOptionId: string | null | undefined; + explicitThinkingOptionId: string | null | undefined; + availableModes: AgentMode[]; + currentModeId: string | null | undefined; + contextWindowUsedTokens: number | null | undefined; + contextWindowMaxTokens: number | null | undefined; +} + +/** + * What the composer's controls and context meter show about a Paseo agent, as one line: model, + * thinking, mode, and context usage, each resolved exactly as its control resolves it. Null until + * the agent has a model at all. + */ +export function buildAgentFactsLine(input: AgentFactsInput): string | null { + const hasModel = Boolean(input.runtimeModelId?.trim() || input.configuredModelId?.trim()); + if (!hasModel) return null; + const selection = resolveAgentModelSelection(input); + const parts = [selection.displayModel]; + if (selection.selectedThinkingId) parts.push(selection.displayThinking); + const mode = resolveModeLabel(input.availableModes, input.currentModeId); + if (mode) parts.push(mode); + const context = formatContextUsage(input.contextWindowUsedTokens, input.contextWindowMaxTokens); + if (context) parts.push(context); + return parts.join(" · "); +} + +/** The mode control shows the current mode, or the provider's first one until the agent reports. */ +function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) { + const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0]; + return mode ? formatAgentModeLabel(mode) : null; +} + +/** The context meter's numbers: used of max, with the share it draws as the ring. */ +function formatContextUsage( + usedTokens: number | null | undefined, + maxTokens: number | null | undefined, +): string | null { + if ( + typeof usedTokens !== "number" || + typeof maxTokens !== "number" || + !Number.isFinite(usedTokens) || + !Number.isFinite(maxTokens) || + usedTokens < 0 || + maxTokens <= 0 + ) { + return null; + } + const percentage = Math.round(Math.min(100, (usedTokens / maxTokens) * 100)); + return `${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens (${percentage}%)`; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f857bbb349..a9e11df570 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -60,6 +60,9 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; +import { buildAgentFactsLine } from "@/panels/agent-facts-line"; +import { PaneSubtitle } from "@/panels/pane-subtitle"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; import { useHasPluginComposerPills } from "@/plugins"; @@ -1247,6 +1250,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null; const streamContent = ( + {showsComposer ? null : } { + const current = state.sessions[serverId]?.agents.get(agentId); + return current + ? { + provider: current.provider, + cwd: current.cwd, + runtimeModelId: current.runtimeInfo?.model ?? null, + configuredModelId: current.model ?? null, + runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null, + explicitThinkingOptionId: current.thinkingOptionId ?? null, + availableModes: current.availableModes, + currentModeId: current.currentModeId, + contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null, + contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null, + } + : null; + }), + ); + const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); + const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null; + const text = agent ? buildAgentFactsLine({ ...agent, models }) : null; + return text ? : null; +} + const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef, serverId, diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 3e1634c113..86faad329c 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -12,6 +12,7 @@ import { type ArchiveFinishedStatus, useArchiveSubagent, useDetachSubagent, + useSubagentOpensInSheet, type SubagentRow, } from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; @@ -90,6 +91,7 @@ export const AgentTracks = memo(function AgentTracks({ }, [openSubagentTarget], ); + const opensInSheet = useSubagentOpensInSheet(workspaceKey); const handleOpenChanges = useCallback(() => { if (!workspaceKey) { return; @@ -122,6 +124,7 @@ export const AgentTracks = memo(function AgentTracks({ rows={subagentRows} onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} + opensInSheet={opensInSheet} onArchiveSubagent={archiveSubagent} onArchiveFinished={onArchiveFinished} archiveFinishedStatus={archiveFinishedStatus} diff --git a/packages/app/src/panels/pane-subtitle.tsx b/packages/app/src/panels/pane-subtitle.tsx new file mode 100644 index 0000000000..eb5292d67b --- /dev/null +++ b/packages/app/src/panels/pane-subtitle.tsx @@ -0,0 +1,30 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * One muted line of facts about a pane's agent, under the pane's header and above its stream: + * model, thinking, tokens. Provider subagents get it from the daemon; a Paseo agent shown without + * its composer gets it from the same facts the composer's controls show. + */ +export function PaneSubtitle({ text, testID }: { text: string; testID?: string }) { + return ( + + + {text} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + text: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 6c2d482403..ecd4a082bf 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -13,9 +13,11 @@ import { ComposerTrackBar } from "@/composer/tracks"; import { useIsCompactFormFactor } from "@/constants/layout"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; 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 { useSubagentsForParent } from "@/subagents/select"; +import { type SubagentRow, useSubagentsForParent } from "@/subagents/select"; +import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet"; import { SubagentsTrack } from "@/subagents/track"; import { providerSubagentKey, @@ -30,6 +32,7 @@ import type { StreamItem } from "@/types/stream"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import type { TurnPresentation } from "@/timeline/turn-liveness"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; const EMPTY_PERMISSIONS = new Map(); const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -47,10 +50,12 @@ function ProviderSubagentChildTrack({ serverId, rows, onOpenProviderSubagent, + opensInSheet, }: { serverId: string; rows: ReturnType; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + opensInSheet: (row: SubagentRow) => boolean; }) { if (rows.length === 0) return null; return ( @@ -60,6 +65,7 @@ function ProviderSubagentChildTrack({ rows={rows} onOpenSubagent={NOOP_SUBAGENT} onOpenProviderSubagent={onOpenProviderSubagent} + opensInSheet={opensInSheet} onArchiveSubagent={NOOP_SUBAGENT} /> @@ -109,7 +115,7 @@ function useProviderSubagentDescriptor( function ProviderSubagentPanel() { const { t } = useTranslation(); - const { serverId, target, openFileInWorkspace, openTab } = usePaneContext(); + const { serverId, workspaceId, 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)}`; @@ -137,6 +143,9 @@ function ProviderSubagentPanel() { providerParentSubagentId: target.subagentId, }); const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); + const opensInSheet = useSubagentOpensInSheet( + buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + ); const openProviderChild = useCallback( (parentAgentId: string, subagentId: string) => { openTab({ kind: "provider_subagent", parentAgentId, subagentId }); @@ -243,17 +252,7 @@ function ProviderSubagentPanel() { return ( - {subtitle ? ( - - - {subtitle} - - - ) : null} + {subtitle ? : null} ); @@ -280,16 +280,6 @@ function ProviderSubagentPanel() { const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0 }, - subtitleHeader: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - subtitleText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, })); diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx new file mode 100644 index 0000000000..2b4e927a13 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -0,0 +1,311 @@ +import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet"; +import { useIsFocused } from "@react-navigation/native"; +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 { + 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"; +import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; + +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 { + sheet: SheetTab; + /** Only the top sheet's pane is focused and 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. They present from inside + * the workspace's `SheetHostScope`, so each panel keeps every context an ordinary pane has, 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. + */ +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], + ); + 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; + return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false; + }, + [onCloseTab, workspaceKey], + ); + return ( + <> + {sheets.map((sheet, index) => ( + + ))} + + ); +} + +function SheetTabSheet({ + sheet, + isTop, + serverId, + workspaceId, + isWorkspaceFocused, + buildContent, + onClose, + onOpenAsTab, +}: SheetTabSheetProps) { + const { t } = useTranslation(); + const { tab } = sheet; + const sheetRef = useRef(null); + const isScreenFocused = useIsFocused(); + 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, + }); + + // 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 + // reports that as `onChange(-1)`, which must not read as the user closing it: the store already + // moved on, and the same tab id may now be a pane tab (Open as tab). + const unmounted = useRef(false); + useEffect(() => { + sheetRef.current?.present(); + return () => { + unmounted.current = true; + }; + }, []); + + // Every close path (drag, backdrop, Close, Back) slides the sheet down first; `onChange(-1)` then + // runs the one guarded close. The sheet keeps its content through the close, so a declined + // unsaved-changes prompt only has to bring it back up. + const close = useCallback(() => sheetRef.current?.close(), []); + const handleChange = useCallback( + (index: number) => { + if (index !== -1 || unmounted.current) return; + void onClose(tab.tabId).then((stillOpen) => { + if (stillOpen) sheetRef.current?.snapToIndex(0); + return stillOpen; + }); + }, + [onClose, tab.tabId], + ); + // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns Back. + const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; + useEffect(() => { + if (!ownsBackPress) return; + return systemBackPress.subscribe(() => { + close(); + return true; + }); + }, [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/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ef5b6aa779..bfe131d008 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3,6 +3,7 @@ import { useComposerTabFullscreen, } from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; import { @@ -78,7 +79,7 @@ import { selectExplorerSidebarPaneId, selectPaneFocusSuspended, type WorkspaceLayout, - type WorkspaceSheet, + type SheetTab, type WorkspaceTabPlacement, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, @@ -165,7 +166,7 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; -import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host"; +import { SheetTabs } from "@/screens/workspace/sheet-tabs"; import type { PaneHost } from "@/panels/panel-manifest"; import { WorkspaceFocusProvider } from "@/workspace/focus"; import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache"; @@ -208,7 +209,7 @@ import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; -const NO_SHEETS: readonly WorkspaceSheet[] = []; +const NO_SHEETS: readonly SheetTab[] = []; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; @@ -3552,14 +3553,15 @@ function WorkspaceScreenContent({ composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), }); - // The sheet is the column's last child, so it covers the compact header as a modal. + // Sheets opened from the compact column present inside it, so their panels read the workspace + // providers, and they cover the compact header as a modal. const workspaceCenterColumn = isMobile ? ( - + )} - - - - + + ) : ( diff --git a/packages/app/src/screens/workspace/workspace-sheet-host.tsx b/packages/app/src/screens/workspace/workspace-sheet-host.tsx deleted file mode 100644 index a46ea2c1c7..0000000000 --- a/packages/app/src/screens/workspace/workspace-sheet-host.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import BottomSheet, { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - type BottomSheetHandleProps, -} from "@gorhom/bottom-sheet"; -import { useIsFocused } from "@react-navigation/native"; -import { - type ComponentType, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, -} 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 { - SheetBackground, - SheetHandle, - SheetHeaderView, - type SheetHeader, -} from "@/components/adaptive-modal-sheet"; -import { systemBackPress } from "@/components/ui/isolated-bottom-sheet-modal"; -import { RetainedPanelActivity } from "@/components/retained-panel"; -import { Button } from "@/components/ui/button"; -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"; -import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store"; - -const SNAP_POINTS = ["92%"]; -const NO_SHEETS: readonly WorkspaceSheet[] = []; - -function PanelIcon({ - icon: Icon, - size, - color, -}: { icon: ComponentType } & PanelIconProps) { - return ; -} - -const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ - color: theme.colors.foregroundMuted, -})); - -// The workspace sheet is a Gorhom sheet rendered in place rather than a portaled modal, so the -// backdrop closes the sheet itself instead of dismissing the top of the modal stack. -function renderBackdrop(props: BottomSheetBackdropProps) { - return ( - - ); -} - -// Stacked sheets share the bottom sheet's dim rather than darkening it again, but still catch the -// tap so it closes the top sheet. The backdrop is clear rather than transparent: iOS skips views -// under 0.01 opacity when hit testing, which would hand the tap to the sheet below. -function renderStackedBackdrop(props: BottomSheetBackdropProps) { - return ( - - ); -} - -interface WorkspaceSheetHandleValue { - header: SheetHeader; - onClose: () => void; -} - -const WorkspaceSheetHandleContext = createContext(null); - -// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and -// reads the header from context; a per-render function would remount the header on every change. -function WorkspaceSheetHandle(props: BottomSheetHandleProps) { - const value = useContext(WorkspaceSheetHandleContext); - if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet"); - return ( - - - - ); -} - -export interface WorkspaceSheetHostProps { - 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 WorkspaceSheetPresentationProps extends Omit< - WorkspaceSheetHostProps, - "workspaceKey" | "onCloseTab" -> { - sheet: WorkspaceSheet; - /** Only the bottom sheet dims the workspace; the ones above share its dim. */ - isBottom: boolean; - /** Only the top sheet answers Android Back; the ones below wait their turn. */ - isTop: boolean; - onClose: (tabId: string) => Promise; - onOpenAsTab: (tabId: string) => void; -} - -/** - * Hosts the workspace's sheets over the compact tab strip, one Gorhom sheet per entry, top last. - * They render in the workspace tree, not through a portal, so each panel keeps every context an - * ordinary pane has. The shared sheet parts (`SheetBackground`, `SheetHandle`, `SheetHeaderView`) - * keep them looking and dragging like every other sheet. - */ -export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) { - 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], - ); - if (sheets.length === 0) return null; - return ( - - {sheets.map((sheet, index) => ( - - ))} - - ); -} - -function WorkspaceSheetPresentation({ - sheet, - isBottom, - isTop, - serverId, - workspaceId, - isWorkspaceFocused, - buildContent, - onClose, - onOpenAsTab, -}: WorkspaceSheetPresentationProps) { - const { t } = useTranslation(); - const { tab } = sheet; - const sheetRef = useRef(null); - const isScreenFocused = useIsFocused(); - 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 handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]); - const handleChange = useCallback( - (index: number) => { - if (index !== -1) return; - // A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up. - void handleClose().then(() => sheetRef.current?.snapToIndex(0)); - }, - [handleClose], - ); - // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back. - const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; - useEffect(() => { - if (!ownsBackPress) return; - return systemBackPress.subscribe(() => { - void handleClose(); - return true; - }); - }, [handleClose, ownsBackPress]); - - const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); - const header = useMemo( - () => ({ - title: descriptor.label, - leading: , - actions: sheet.policy.openAsTab ? ( - - ) : undefined, - }), - [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], - ); - const handleHeaderClose = useCallback(() => { - void handleClose(); - }, [handleClose]); - const handleValue = useMemo( - () => ({ header, onClose: handleHeaderClose }), - [handleHeaderClose, header], - ); - - return ( - // Gorhom renders the handle beside the content, so the dialog wraps the whole sheet. - - {/* 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. */} - - - {/* A covered sheet keeps its state but stops its live work until it is on top again. */} - - - - - - - ); -} - -function WorkspaceSheetBody({ - tab, - isPaneFocused, - isWorkspaceFocused, - buildContent, -}: { - tab: WorkspaceSheet["tab"]; - /** 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({ - host: { - ...StyleSheet.absoluteFillObject, - }, - clearBackdrop: { - backgroundColor: "transparent", - }, - sheetLayer: { - ...StyleSheet.absoluteFillObject, - }, - content: { - flex: 1, - minHeight: 0, - }, -}); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index fc973f442d..5f170bed7e 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -4592,7 +4592,7 @@ describe("compact workspace sheet", () => { expect(tabKinds(store)).toEqual(["terminal"]); }); - it("brings a stacked target back to the top by dropping the sheets above it", () => { + it("uncovers a covered sheet by dropping the sheets above it", () => { const store = createCompactStore(); const fileTabId = store .getState() @@ -4878,13 +4878,15 @@ describe("compact workspace sheet", () => { }); }); - it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => { + it("keeps a covered sheet in place under a modified sheet instead of reordering", () => { const store = createCompactStore(); const otherTarget = { kind: "file" as const, path: "src/other.ts" }; const otherTabId = store .getState() .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); const modifiedSheetTabId = openModifiedFileSheet(store); + const unmodifiedTopTarget = { kind: "file" as const, path: "src/third.ts" }; + store.getState().openTab({ workspaceKey, target: unmodifiedTopTarget, intent: "reveal" }); const focusedBefore = focusedTabId(store); const tabId = store @@ -4892,10 +4894,10 @@ describe("compact workspace sheet", () => { .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); expect(tabId).toBe(otherTabId); - expect(sheetTargets(store)).toEqual([fileTarget, otherTarget]); - expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.tabId).toBe( - modifiedSheetTabId, - ); + // Moving a sheet would remount its panel, so the stack keeps its order: the modified sheet + // stays on top of the one revealed, and only the unmodified sheet above them closes. + expect(sheetTargets(store)).toEqual([otherTarget, fileTarget]); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); expect(stripTab(store, tabId ?? "")).toBeNull(); expect(focusedTabId(store)).toBe(focusedBefore); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index ba186b655a..2aea369df6 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -111,13 +111,13 @@ export interface OpenWorkspaceTabInput { } /** - * How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single + * How the app shell lays out a workspace: compact (one tab at a time, plus sheets), a single * wide pane (touch tablets, which cannot render splits), or split panes. */ export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes"; -/** A tab hosted by the compact workspace sheet instead of a pane. */ -export interface WorkspaceSheet { +/** A tab shown in a sheet over the compact workspace instead of in a pane. */ +export interface SheetTab { tab: WorkspaceTab; policy: CompactSheetPolicy; parentTabId: string | null; @@ -127,10 +127,12 @@ interface WorkspaceLayoutStore { /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; /** - * Each workspace's open sheets, bottom to top; only the top one is on screen. A workspace with - * no sheet has no entry. Not persisted: a reload returns to the tab strip. + * Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The + * list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount + * its panel. A workspace with no sheet has no entry. Not persisted: a reload returns to the tab + * strip. */ - sheetsByWorkspace: Record; + sheetsByWorkspace: Record; layoutByWorkspace: Record; splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; @@ -143,10 +145,10 @@ interface WorkspaceLayoutStore { pullRequestTabAutoOpenedByWorkspace: Record; /** * The one entry point for showing a target. On compact layouts a target whose panel declares - * `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id. + * `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 the sheet. */ + /** Whether `openTab` with this input would show the target in a sheet. */ opensInSheet: ( input: Pick, ) => boolean; @@ -684,12 +686,12 @@ function attachParentTab(input: { }); } -const NO_SHEETS: readonly WorkspaceSheet[] = []; +const NO_SHEETS: readonly SheetTab[] = []; function getSheets( state: Pick, workspaceKey: string, -): readonly WorkspaceSheet[] { +): readonly SheetTab[] { return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS; } @@ -697,14 +699,14 @@ function findSheet( state: Pick, workspaceKey: string, tabId: string, -): WorkspaceSheet | undefined { +): SheetTab | undefined { return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId); } function withSheets( state: Pick, workspaceKey: string, - sheets: readonly WorkspaceSheet[], + sheets: readonly SheetTab[], ): Pick { if (sheets.length > 0) { return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } }; @@ -732,7 +734,7 @@ function withSheetTab( state: Pick, workspaceKey: string, tabId: string, - update: (sheet: WorkspaceSheet) => WorkspaceSheet, + update: (sheet: SheetTab) => SheetTab, ): Pick { return withSheets( state, @@ -777,13 +779,13 @@ function resolveOpenParentTabId( function placeTargetInSheet(input: { /** The sheet whose tab the target keeps, if any. */ - kept: WorkspaceSheet | undefined; + kept: SheetTab | undefined; target: WorkspaceTabTarget; intent: WorkspaceTabOpenIntent; policy: CompactSheetPolicy; parentTabId: string | null; state: JsonValue | undefined; -}): WorkspaceSheet { +}): SheetTab { if (input.kept) { return { ...input.kept, tab: { ...input.kept.tab, target: input.target } }; } @@ -857,14 +859,14 @@ function promoteSheetToTab( * A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the * panel and drop the work. Only the explicit, guarded close removes it. */ -function sheetHoldsUnsavedWork(workspaceKey: string, sheet: WorkspaceSheet): boolean { +function sheetHoldsUnsavedWork(workspaceKey: string, sheet: SheetTab): boolean { return isSheetTabModified(workspaceKey, sheet.tab.tabId); } /** - * Where an open lands in the sheet stack, or null when it opens as a tab. A new target goes on - * top. A reveal of a resource a sheet already shows keeps that sheet's tab and brings it back to - * the top; the sheets above it close, except ones holding unsaved work, which stay beneath it. + * Where an open lands among the sheet tabs, or null when it opens as a tab. A new target goes on + * top. A reveal of a resource a sheet already shows keeps that sheet's tab and uncovers it by + * closing the sheets above it, except ones holding unsaved work, which stay where they are. */ function resolveSheetPlacement( state: WorkspaceLayoutStore, @@ -915,7 +917,7 @@ function openSheetTabInFront( */ function sheetAgentNoLongerFits( workspaceKey: string, - sheet: WorkspaceSheet, + sheet: SheetTab, snapshot: WorkspaceTabSnapshot, ): boolean { if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; @@ -1001,16 +1003,17 @@ export function createWorkspaceLayoutStore( parentTabId, state: input.state, }); - const below = + const next = keptIndex === null - ? sheets - : [ - ...sheets.slice(0, keptIndex), - ...sheets - .slice(keptIndex + 1) - .filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)), - ]; - set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet])); + ? [...sheets, sheet] + : sheets.flatMap((existing, index) => { + if (index === keptIndex) return [sheet]; + if (index < keptIndex) return [existing]; + return sheetHoldsUnsavedWork(normalizedWorkspaceKey, existing) + ? [existing] + : []; + }); + set((state) => withSheets(state, normalizedWorkspaceKey, next)); return sheet.tab.tabId; } const placement = getOpenTabPlacement( diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index e93d62eb2d..9c6227a34f 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -9,3 +9,4 @@ export { type ArchiveFinishedStatus, type UseArchiveFinishedSubagentsInput, } from "./use-archive-finished"; +export { useSubagentOpensInSheet } from "./opens-in-sheet"; diff --git a/packages/app/src/subagents/opens-in-sheet.test.tsx b/packages/app/src/subagents/opens-in-sheet.test.tsx new file mode 100644 index 0000000000..0792091135 --- /dev/null +++ b/packages/app/src/subagents/opens-in-sheet.test.tsx @@ -0,0 +1,133 @@ +/** + * @vitest-environment jsdom + */ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { useSubagentOpensInSheet } from "./opens-in-sheet"; +import { selectSubagentsForParent, type SubagentRow } from "./select"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "ws-main"; +const AGENT_TIMESTAMP = new Date("2026-04-21T10:00:00.000Z"); + +const AGENT_DEFAULTS: Agent = { + serverId: SERVER_ID, + id: "agent", + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, + lastUserMessageAt: null, + lastActivityAt: AGENT_TIMESTAMP, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: "Agent", + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, + model: null, + features: undefined, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + parentAgentId: null, + labels: {}, + projectPlacement: null, +}; + +const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, +}); +if (!workspaceKey) throw new Error("workspace key"); + +function seedParentWithChild(): SubagentRow { + const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" }; + const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" }; + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore.getState().setAgents( + SERVER_ID, + new Map([ + [parent.id, parent], + [child.id, child], + ]), + ); + const [row] = selectSubagentsForParent( + useSessionStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent" }, + new Set(), + ); + if (!row) throw new Error("child row"); + return row; +} + +afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + }); +}); + +describe("useSubagentOpensInSheet", () => { + it("re-renders the row's answer as the child's tab opens and closes", () => { + const row = seedParentWithChild(); + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + const target = { kind: "agent", agentId: "child" } as const; + + // The row renders its answer, as the track does, so a stale render is a stale answer. + const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row)); + expect(result.current).toBe(true); + + let tabId: string | null = null; + act(() => { + const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" }); + tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null; + }); + expect(tabId).not.toBeNull(); + expect(result.current).toBe(false); + + act(() => { + store.closeTab(workspaceKey, tabId ?? ""); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/packages/app/src/subagents/opens-in-sheet.ts b/packages/app/src/subagents/opens-in-sheet.ts new file mode 100644 index 0000000000..26432ea039 --- /dev/null +++ b/packages/app/src/subagents/opens-in-sheet.ts @@ -0,0 +1,29 @@ +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/track.tsx b/packages/app/src/subagents/track.tsx index 601030562f..c1834587d2 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -35,6 +35,11 @@ export interface SubagentsTrackProps { rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + /** + * Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on + * close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list. + */ + opensInSheet: (row: SubagentRow) => boolean; onArchiveSubagent: (id: string) => void; onArchiveFinished?: () => void; archiveFinishedStatus?: ArchiveFinishedStatus; @@ -63,6 +68,7 @@ export function SubagentsTrack({ rows, onOpenSubagent, onOpenProviderSubagent, + opensInSheet, onArchiveSubagent, onArchiveFinished, archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS, @@ -103,6 +109,7 @@ export function SubagentsTrack({ serverId={serverId} onOpenSubagent={onOpenSubagent} onOpenProviderSubagent={onOpenProviderSubagent} + opensInSheet={opensInSheet(row)} onArchiveSubagent={onArchiveSubagent} onDetachSubagent={onDetachSubagent} /> @@ -174,6 +181,7 @@ interface SubagentsTrackRowProps { row: SubagentRow; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + opensInSheet: boolean; onArchiveSubagent: (id: string) => void; onDetachSubagent?: (id: string) => void; } @@ -183,6 +191,7 @@ function SubagentsTrackRow({ row, onOpenSubagent, onOpenProviderSubagent, + opensInSheet, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { @@ -245,6 +254,7 @@ function SubagentsTrackRow({ {renderRow} diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 5f3e8c7e8f..ee27421efd 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -3,6 +3,7 @@ // to keep the module graph importable. const Stub = () => null; const PassThrough = ({ children }: { children?: unknown }) => children; +const sharedValue = (value: unknown) => ({ get: () => value }); export default Stub; export const BottomSheetModal = Stub; @@ -17,10 +18,27 @@ export function useBottomSheetModal(): never { throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests"); } +// A sheet host scope only redirects where sheets draw; with no sheet drawn there is nothing to host. +export function useBottomSheet(): never { + throw new Error("useBottomSheet is unavailable in tests"); +} +export const BottomSheetModalInternalProvider = PassThrough; +export const INITIAL_CONTAINER_LAYOUT = { + height: 0, + offset: { top: 0, bottom: 0, left: 0, right: 0 }, +}; +export function useBottomSheetModalInternal() { + return { + hostName: "stub", + containerLayoutState: sharedValue(INITIAL_CONTAINER_LAYOUT), + mountSheet: () => undefined, + unmountSheet: () => undefined, + willUnmountSheet: () => undefined, + }; +} + // The app imports the internal layout values to compute the visible sheet body. Browser unit tests // never render the native sheet, but Vite still validates this module's export surface. -const sharedValue = (value: unknown) => ({ get: () => value }); - export const KEYBOARD_STATUS = { SHOWN: "SHOWN" }; export function useBottomSheetInternal() { return { diff --git a/patches/@gorhom+bottom-sheet+5.2.14.patch b/patches/@gorhom+bottom-sheet+5.2.14.patch new file mode 100644 index 0000000000..f84d46f8fd --- /dev/null +++ b/patches/@gorhom+bottom-sheet+5.2.14.patch @@ -0,0 +1,116 @@ +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js +index ee51e76..dc1bc17 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js +@@ -315,7 +315,9 @@ function BottomSheetModalComponent(props, ref) { + } + currentIndexRef.current = _index; + nextIndexRef.current = null; +- statusRef.current = _index === -1 ? _constants.MODAL_STATUS.MINIMIZED : _constants.MODAL_STATUS.PRESENTED; ++ if (!(_index === -1 && statusRef.current === _constants.MODAL_STATUS.DISMISSING)) { ++ statusRef.current = _index === -1 ? _constants.MODAL_STATUS.MINIMIZED : _constants.MODAL_STATUS.PRESENTED; ++ } + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); + } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js +index e44ad86..96555ef 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js +@@ -15,6 +15,7 @@ var _exportNames = { + useBottomSheetTimingConfigs: true, + useBottomSheetInternal: true, + useBottomSheetModalInternal: true, ++ BottomSheetModalInternalProvider: true, + useScrollEventsHandlersDefault: true, + useGestureEventsHandlersDefault: true, + useBottomSheetGestureHandlers: true, +@@ -220,6 +221,13 @@ var _useBottomSheetSpringConfigs = require("./hooks/useBottomSheetSpringConfigs" + var _useBottomSheetTimingConfigs = require("./hooks/useBottomSheetTimingConfigs"); + var _useBottomSheetInternal = require("./hooks/useBottomSheetInternal"); + var _useBottomSheetModalInternal = require("./hooks/useBottomSheetModalInternal"); ++var _modalInternal = require("./contexts/modal/internal"); ++Object.defineProperty(exports, "BottomSheetModalInternalProvider", { ++ enumerable: true, ++ get: function () { ++ return _modalInternal.BottomSheetModalInternalProvider; ++ } ++}); + var _useScrollEventsHandlersDefault = require("./hooks/useScrollEventsHandlersDefault"); + var _useGestureEventsHandlersDefault = require("./hooks/useGestureEventsHandlersDefault"); + var _useBottomSheetGestureHandlers = require("./hooks/useBottomSheetGestureHandlers"); +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js +index 94fabdb..60ca66a 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js +@@ -308,7 +308,9 @@ function BottomSheetModalComponent(props, ref) { + } + currentIndexRef.current = _index; + nextIndexRef.current = null; +- statusRef.current = _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ if (!(_index === -1 && statusRef.current === MODAL_STATUS.DISMISSING)) { ++ statusRef.current = _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ } + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); + } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/index.js b/node_modules/@gorhom/bottom-sheet/lib/module/index.js +index 8259637..eb057fe 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/index.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/index.js +@@ -14,6 +14,7 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; +diff --git a/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts b/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts +index ffc6caa..550f1b3 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts ++++ b/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts +@@ -7,6 +7,8 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; ++export type { BottomSheetModalInternalContextType } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; +diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx +index 2954b3f..7260656 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx ++++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx +@@ -432,8 +432,15 @@ function BottomSheetModalComponent( + currentIndexRef.current = _index; + nextIndexRef.current = null; + +- statusRef.current = +- _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ // A sheet that is dismissing (dismiss(), or unmounting while presented) keeps that status, ++ // so `onClose` lands on DISMISSED and unmounts it; MINIMIZED would read as a user close, ++ // which with `enableDismissOnClose=false` leaves the portal mounted forever. ++ if (_index === -1 && statusRef.current === MODAL_STATUS.DISMISSING) { ++ // keep dismissing ++ } else { ++ statusRef.current = ++ _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ } + + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); +diff --git a/node_modules/@gorhom/bottom-sheet/src/index.ts b/node_modules/@gorhom/bottom-sheet/src/index.ts +index 7aaea57..6a5f5a4 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/index.ts ++++ b/node_modules/@gorhom/bottom-sheet/src/index.ts +@@ -12,6 +12,8 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; ++export type { BottomSheetModalInternalContextType } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 06d8892c60..3eeafc6371 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -26,6 +26,14 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-gesture-handler", patchPrefix: "react-native-gesture-handler+", }, + // Exports BottomSheetModalInternalProvider so a subtree can host its own sheets on the shared + // modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and + // keeps a dismissing modal's status through its final onChange(-1), so a modal with + // enableDismissOnClose=false that unmounts while presented still leaves the portal. + { + nodeModulesPath: "node_modules/@gorhom/bottom-sheet", + patchPrefix: "@gorhom+bottom-sheet+", + }, { nodeModulesPath: "node_modules/react-native-svg", patchPrefix: "react-native-svg+",