mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Open a subagent in place of the subagents list on mobile (#8093)
* fix(app): seat the subagent sheet's parent callout like the composer The sheet tab body padded the bottom safe-area inset, and the composer dock padded it again, so the parent callout in a subagent sheet floated a full navigation-bar height above where the composer sits in a tab. The sheet body (SheetBottomInset) now owns the inset: it pads it once and tells its content none is left, so the dock stays host-agnostic. Overlay sheets (adaptive modal sheet, menu sheet, combobox sheet) read the inset on the far side of their portal, so ones opened from sheet content still clear the screen edge. The callout takes the composer's rail and shows the parent's provider icon through StatusIcon: no icon box, with the running ring, attention dot or needs-input alert on the icon. * fix(app): give the attachment lightbox its own safe area An RN Modal passes its call site's context straight through, so a lightbox opened from a sheet tab read the bottom inset the sheet body had already spent (0) and drew over the navigation bar. The modal is its own window: it now gets its own SafeAreaProvider, which measures that window, and the image area reads its insets below it. floating-panels.md records the rule for overlays: read insets where the overlay draws. * feat(app): open a subagent in place of the subagents list on compact On phones, choosing a subagent from the subagents list now replaces the list inside the same sheet instead of stacking a second sheet over it. Back returns to the list, Close closes both, Android Back steps subagent -> list -> closed, and Open parent lands on the pane the list was opened from (for a nested subagent, its parent's sheet). - Menu engine: fill pages (`fill: true`) take the full sheet height, Back pops a page via `goBack`, and a page whose definition disappears falls back to the list. - One sheet pane (`screens/workspace/sheet-pane`) renders the header, body, bottom inset and focus for both containers: the workspace's detached sheet tabs and the in-list menu page. Only the container differs. - The header drags the sheet through `SheetDragArea`; compact menu sheets use the shared `SheetBackground`/`SheetHandle` chrome. - `selectSheetTab` describes the sheet a reveal would show without opening it. * fix(app): put the parent callout on one row The Open parent button already says what the callout is, so the Parent label goes. The parent's title shrinks to one line before the button does, so Open parent never wraps or leaves the row. * refactor(app): register panels once in the workspace screen Every reader of the panel registry called ensurePanelsRegistered, which imports every panel module. The panels render those readers (the agent panel's subagent track and parent callout, the new-tab panel's launcher, the diff panel's review surface), so each reader closed an import cycle through register-panels. The workspace screen, the only route that renders a registry reader, now registers the panels before anything under it renders, and the readers import only the leaf panel-registry. The pane content test registers a probe panel for real instead of mocking the registry. * test(app): transform the markdown WASM factory when Vite versions its URL Importing the sheet pane barrel in retained-chat-content.browser.test.tsx made Vite request md4c.js with a ?v= query, which the CommonJS-to-ESM transform did not match, so the test file failed to import.
This commit is contained in:
@@ -288,7 +288,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) instead, replacing the subagents list in place (Back returns to the list); the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
+24
-13
@@ -33,25 +33,36 @@ keeps only agents and terminals. A panel kind opts in with `compactSheet` in its
|
||||
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
|
||||
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
|
||||
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
|
||||
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
|
||||
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
|
||||
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on
|
||||
top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below.
|
||||
|
||||
A subagent chosen from the subagents list is the exception. It opens as a page of the list's own
|
||||
sheet and replaces the list in place: Back returns to the list, Close ends both, and Open parent
|
||||
closes the list, landing on the pane the list was opened from. The store's `selectSheetTab` describes
|
||||
the `SheetTab` a reveal would show without opening it, so the page never enters `sheetsByWorkspace`.
|
||||
See [menus.md](menus.md#a-page-that-fills-the-sheet) for the page contract.
|
||||
|
||||
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
|
||||
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local
|
||||
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
|
||||
editing sessions, caches, file-link resolution and supported navigation context without moving
|
||||
their providers. Keep this capability at the workspace boundary; new sheet callers must not
|
||||
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
|
||||
forward the same Gorhom modal queue; neither creates another stack. See
|
||||
Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the
|
||||
header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its
|
||||
bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a
|
||||
menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The
|
||||
container owns Android Back; the pane never subscribes to it.
|
||||
|
||||
Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the
|
||||
sheet through `SheetDragArea`, because the panel scrolls on its own and content panning would fight
|
||||
it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the
|
||||
full-screen glyph beside Close, so the title keeps its width. Compact sheets use the app overlay
|
||||
host: a workspace-local host sits behind the app-level left sidebar. The sheet pane bridge
|
||||
(`useSheetPaneBridge`) preserves existing editing sessions, caches, file-link resolution and
|
||||
supported navigation context without moving their providers. Keep this capability at the workspace
|
||||
boundary; new sheet callers must not assemble provider internals. Wide layouts retain local hosting
|
||||
and pane geometry. Both hosts forward the same Gorhom modal queue; neither creates another stack.
|
||||
See
|
||||
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
|
||||
@@ -410,6 +410,19 @@ bridges the menu's two contexts; the rest pass `null`.
|
||||
|
||||
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
|
||||
|
||||
## Gotcha 8 — Read safe-area insets where the overlay draws
|
||||
|
||||
A compact sheet tab's body (`SheetBottomInset` in `adaptive-modal-sheet.tsx`) pads the bottom
|
||||
inset and gives its content a bottom inset of 0, so the composer dock sits the same in a sheet as
|
||||
in a tab. An overlay opened from that content and calling `useSafeAreaInsets()` at its call site
|
||||
gets that 0 and draws over the navigation bar.
|
||||
|
||||
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
|
||||
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
|
||||
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
|
||||
inside it and read the inset below that. The modal is its own window, and the provider measures
|
||||
it. See `attachment-lightbox.tsx`.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. A subagent chosen from the subagents list shows as a page of the list's sheet instead of a sheet of its own. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
+23
-4
@@ -52,10 +52,12 @@ is another, with its own icons and hover-revealed actions — but both hand the
|
||||
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
|
||||
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
|
||||
|
||||
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
|
||||
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
|
||||
A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second
|
||||
sheet over it: the row names a [fill page](#a-page-that-fills-the-sheet) with `opensPage`, the page
|
||||
replaces the list in place, Back returns to the list, and Close ends both. A row that opens a tab
|
||||
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
|
||||
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
|
||||
store which sheet tab a row would show (`useSubagentSheetTab`), since the answer changes with the
|
||||
layout; a row with none opens its tab.
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -101,6 +103,23 @@ The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its
|
||||
point, and with `enableDynamicSizing` that point is the content's own height — a short page does
|
||||
not grow, and the keyboard comes up over the field you are typing into.
|
||||
|
||||
## A page that fills the sheet
|
||||
|
||||
A page can carry its own content instead of rows: `{ id, fill: true, content }`. Only the sheet
|
||||
renders one. It takes the full-sheet snap point (`FULL_SHEET_SNAP_POINTS`) instead of sizing to
|
||||
content, and the sheet stops panning on content drags, because the page scrolls on its own and a
|
||||
drag would fight that scroll. The page's header drags the sheet instead, through `SheetDragArea`.
|
||||
|
||||
The content renders on the far side of the sheet's portal, so it brings its own context bridge,
|
||||
built where the page is defined. Sheet tabs are the one user: `useSheetPanePage` turns a `SheetTab`
|
||||
into a fill page around the same `SheetPane` the workspace's own sheets render. See
|
||||
[explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
|
||||
Back pops one page. The header's Back and Android Back both call `goBack`, which answers `false` at
|
||||
the root so the sheet closes instead. A page whose definition disappears while it is open, such as
|
||||
a subagent archived from elsewhere, is pruned from the path, and the sheet falls back to the page
|
||||
that opened it.
|
||||
|
||||
## Hover intent
|
||||
|
||||
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
|
||||
@@ -189,7 +208,7 @@ its own.
|
||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||
write moved the open menu back off-screen.
|
||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||
rarely the height of the page before it.
|
||||
rarely the height of the page before it. A fill page is the one exception.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
|
||||
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
|
||||
|
||||
@@ -5,12 +5,16 @@ import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentFromCompactTrack,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import {
|
||||
backToList,
|
||||
closeSheet,
|
||||
expectNoSheetOpen,
|
||||
expectOneSheetOpen,
|
||||
expectOpenTabCount,
|
||||
openSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
@@ -52,6 +56,10 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Sheet child" },
|
||||
);
|
||||
const grandchild = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: child.id, title: "Sheet grandchild" },
|
||||
);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
@@ -79,12 +87,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await tapSheetBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet over the subagents list", async () => {
|
||||
await test.step("a subagent opens in place of the subagents list", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expect(childSheet).toBeVisible();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expectSubagentRowGone(page, child.id);
|
||||
await expectOneSheetOpen(page);
|
||||
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
|
||||
// seeded agent has no context usage yet.
|
||||
@@ -94,23 +104,51 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("closing the subagent's sheet uncovers the list", async () => {
|
||||
await closeSheet(page, child.title);
|
||||
await test.step("Back returns the sheet to the list", async () => {
|
||||
await backToList(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
await expectOneSheetOpen(page);
|
||||
});
|
||||
|
||||
await test.step("Close closes the list with the subagent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await closeSheet(page, child.title);
|
||||
await expectSubagentRowGone(page, child.id);
|
||||
await expectNoSheetOpen(page);
|
||||
});
|
||||
|
||||
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectNoSheetOpen(page);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("a nested subagent's parent box lands on its parent's sheet", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
const childSheet = sheet(page, child.title);
|
||||
await openSubagentFromCompactTrack(page, grandchild.id, childSheet);
|
||||
const grandchildSheet = sheet(page, grandchild.title);
|
||||
await expectSheetAtSnap(page, grandchild.title);
|
||||
await expect(grandchildSheet.getByTestId("parent-agent-callout")).toContainText(child.title);
|
||||
|
||||
await grandchildSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(grandchildSheet).toHaveCount(0);
|
||||
await expect(childSheet).toBeVisible();
|
||||
await expectOneSheetOpen(page);
|
||||
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectNoSheetOpen(page);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await openSheetAsTab(page, child.title);
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
@@ -123,7 +161,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
|
||||
await test.step("once its tab closes, the subagent reopens in place of the list", async () => {
|
||||
await page
|
||||
.getByRole("button", { name: /Switch tabs/ })
|
||||
.filter({ visible: true })
|
||||
@@ -141,7 +179,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await closeSheet(page, child.title);
|
||||
await backToList(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
await tapSheetBackdrop(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
|
||||
@@ -5,7 +5,7 @@ export function sheet(page: Page, label: string): Locator {
|
||||
return page.getByRole("dialog", { name: label });
|
||||
}
|
||||
|
||||
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
|
||||
/** A sheet tab's header, which carries its title, Back, Close, and the Open as tab glyph. */
|
||||
export function sheetHeader(page: Page, label: string): Locator {
|
||||
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
|
||||
}
|
||||
@@ -48,6 +48,12 @@ export async function closeSheet(page: Page, label: string): Promise<void> {
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Steps a sheet opened from a list back to that list, in the same sheet. */
|
||||
export async function backToList(page: Page, label: string): Promise<void> {
|
||||
await sheetHeader(page, label).getByRole("button", { name: "Back" }).click();
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
|
||||
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
@@ -76,6 +82,15 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Each open sheet renders its own backdrop, so counting them counts the sheets. */
|
||||
export async function expectOneSheetOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectNoSheetOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
|
||||
export async function tapSheetBackdrop(page: Page): Promise<void> {
|
||||
await page
|
||||
|
||||
@@ -3,7 +3,7 @@ import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
@@ -151,10 +151,21 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** On compact the track opens as a sheet, which closes once a row is chosen. */
|
||||
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await page.getByTestId(`subagents-track-row-${childId}`).click();
|
||||
/**
|
||||
* On compact the track opens as a sheet, and the chosen row opens in its place. `track` is the
|
||||
* pane whose track to open, for when a subagent's sheet shows a track of its own.
|
||||
*/
|
||||
export async function openSubagentFromCompactTrack(
|
||||
page: Page,
|
||||
childId: string,
|
||||
track: Page | Locator = page,
|
||||
): Promise<void> {
|
||||
await track.getByTestId("subagents-track-header").click();
|
||||
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
||||
// Tap the row once the sheet has brought it on screen, as a finger would. Clicking it while it is
|
||||
// still below the fold makes Playwright scroll the sheet host, and every sheet then docks short.
|
||||
await expect(row).toBeInViewport();
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Text } from "react-native";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SheetBottomInset } from "./adaptive-modal-sheet";
|
||||
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const SCREEN_INSETS = { top: 24, right: 0, bottom: 34, left: 0 };
|
||||
|
||||
function BottomInsetProbe() {
|
||||
return <Text testID="content-bottom-inset">{String(useSafeAreaInsets().bottom)}</Text>;
|
||||
}
|
||||
|
||||
describe("SheetBottomInset", () => {
|
||||
it("pads the bottom inset once and leaves none for its content", () => {
|
||||
render(
|
||||
<SafeAreaInsetsContext.Provider value={SCREEN_INSETS}>
|
||||
<SheetBottomInset testID="sheet-body">
|
||||
<BottomInsetProbe />
|
||||
</SheetBottomInset>
|
||||
</SafeAreaInsetsContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sheet-body").style.paddingBottom).toBe("34px");
|
||||
// Content that pads the inset itself, like the composer dock, must not pad it again.
|
||||
expect(screen.getByTestId("content-bottom-inset").textContent).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
BottomSheetHandle,
|
||||
useBottomSheetGestureHandlers,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
|
||||
import {
|
||||
@@ -25,7 +30,7 @@ import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-shee
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
|
||||
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
|
||||
|
||||
@@ -40,6 +45,9 @@ export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
|
||||
// glyph should line up with the X must reach the same rail.
|
||||
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
|
||||
|
||||
/** The one height a sheet takes when it shows a whole pane rather than sizing to its content. */
|
||||
export const FULL_SHEET_SNAP_POINTS = ["92%"];
|
||||
|
||||
export interface SheetHeaderSearch {
|
||||
onChange: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
@@ -244,32 +252,55 @@ const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
|
||||
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
}));
|
||||
|
||||
const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 };
|
||||
|
||||
/**
|
||||
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
|
||||
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
|
||||
* sheet whose content scrolls on its own keeps its header draggable without content panning.
|
||||
*/
|
||||
/** The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. */
|
||||
export function SheetHandle({
|
||||
children,
|
||||
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
|
||||
indicatorStyle: _indicatorStyle,
|
||||
...props
|
||||
}: ComponentProps<typeof BottomSheetHandle>) {
|
||||
if (!children) return <ThemedBottomSheetHandle {...props} />;
|
||||
// The children carry their own semantics; the handle's slider role would swallow them on web.
|
||||
return <ThemedBottomSheetHandle {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content that drags the sheet the way its handle does. A sheet whose body scrolls on its own
|
||||
* turns content panning off and wraps its header in this, so the header still pulls it down.
|
||||
* Render it inside the sheet's content.
|
||||
*/
|
||||
export function SheetDragArea({ children }: { children: ReactNode }) {
|
||||
const { handlePanGestureHandler } = useBottomSheetGestureHandlers();
|
||||
const gesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.shouldCancelWhenOutside(false)
|
||||
.runOnJS(false)
|
||||
.onStart(handlePanGestureHandler.handleOnStart)
|
||||
.onChange(handlePanGestureHandler.handleOnChange)
|
||||
.onEnd(handlePanGestureHandler.handleOnEnd)
|
||||
.onFinalize(handlePanGestureHandler.handleOnFinalize),
|
||||
[handlePanGestureHandler],
|
||||
);
|
||||
return (
|
||||
<ThemedBottomSheetHandle
|
||||
{...props}
|
||||
style={HANDLE_WITH_CONTENT_STYLE}
|
||||
accessible={false}
|
||||
accessibilityRole={null}
|
||||
accessibilityLabel={null}
|
||||
accessibilityHint={null}
|
||||
>
|
||||
{children}
|
||||
</ThemedBottomSheetHandle>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<View>{children}</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact sheet's content area that reaches the bottom of the screen. It owns the system
|
||||
* navigation inset: it pads it once and tells its content none is left, so content that pads the
|
||||
* inset itself, like the composer dock, sits the same in a sheet as in a tab.
|
||||
*/
|
||||
export function SheetBottomInset({ style, children, ...props }: ViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentInsets = useMemo(() => ({ ...insets, bottom: 0 }), [insets]);
|
||||
const insetStyle = useMemo(() => [style, { paddingBottom: insets.bottom }], [style, insets]);
|
||||
return (
|
||||
<View {...props} style={insetStyle}>
|
||||
<SafeAreaInsetsContext.Provider value={contentInsets}>
|
||||
{children}
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,6 +519,76 @@ export interface AdaptiveModalSheetProps {
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact sheet's body. It renders on the far side of the sheet's portal so it reads the
|
||||
* screen's bottom inset, not the one left where the sheet was opened: inside a sheet tab, none is.
|
||||
*/
|
||||
function CompactSheetBody({
|
||||
header,
|
||||
onClose,
|
||||
testID,
|
||||
footer,
|
||||
isKeyboardVisible,
|
||||
scrollable,
|
||||
bodyStyle,
|
||||
contentStyle,
|
||||
children,
|
||||
}: {
|
||||
header: SheetHeader;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
footer: ReactNode;
|
||||
isKeyboardVisible: boolean;
|
||||
scrollable: boolean;
|
||||
bodyStyle?: StyleProp<ViewStyle>;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { contentPaddingBottom = 0, footerPaddingBottom = 0 } = getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
isKeyboardVisible,
|
||||
hasFooter: Boolean(footer),
|
||||
safeAreaBottom: insets.bottom,
|
||||
});
|
||||
// Safe-area clearance is a separate layer: it must not replace the caller's
|
||||
// padding (including an explicit zero), and the footer owns it when present.
|
||||
const bodyClearanceStyle = { paddingBottom: contentPaddingBottom };
|
||||
const footerClearanceStyle = useMemo(
|
||||
() => ({ paddingBottom: footerPaddingBottom }),
|
||||
[footerPaddingBottom],
|
||||
);
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerClearanceStyle}>{footer}</View> : null),
|
||||
[footer, footerClearanceStyle],
|
||||
);
|
||||
return (
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.bottomSheetVisibleScroll}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.contentGrow, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SheetVisibleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
header,
|
||||
visible,
|
||||
@@ -508,34 +609,11 @@ export function AdaptiveModalSheet({
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isKeyboardVisible = useKeyboardVisibility(visible);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: isMobile,
|
||||
isKeyboardVisible,
|
||||
hasFooter: Boolean(footer),
|
||||
safeAreaBottom: insets.bottom,
|
||||
}),
|
||||
[footer, insets.bottom, isKeyboardVisible, isMobile],
|
||||
);
|
||||
// Safe-area clearance is a separate layer: it must not replace the caller's
|
||||
// padding (including an explicit zero), and the footer owns it when present.
|
||||
const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 };
|
||||
const footerClearanceStyle = useMemo(
|
||||
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const footerView = useMemo(
|
||||
() =>
|
||||
footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null,
|
||||
[footer, footerClearanceStyle, footerContainerStyle],
|
||||
() => (footer ? <View style={[styles.footer, footerContainerStyle]}>{footer}</View> : null),
|
||||
[footer, footerContainerStyle],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
@@ -614,32 +692,6 @@ export function AdaptiveModalSheet({
|
||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||
|
||||
if (isMobile) {
|
||||
const sheetContent = (
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.bottomSheetVisibleScroll}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.contentGrow, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
@@ -658,7 +710,18 @@ export function AdaptiveModalSheet({
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
@@ -28,7 +28,6 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
|
||||
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
@@ -56,18 +55,6 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -91,17 +78,19 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View pointerEvents="box-none" style={contentLayerStyle}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{/* The modal is its own window: its insets are the window's, not the ones left where the
|
||||
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<LightboxImageArea>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
@@ -117,15 +106,38 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
uri={url}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</LightboxImageArea>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</WindowChromeRootRegion>
|
||||
</SafeAreaProvider>
|
||||
</ModalRoot>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function LightboxImageArea({ children }: { children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const style = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
return (
|
||||
<View pointerEvents="box-none" style={style}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
|
||||
@@ -938,7 +938,6 @@ interface MobileBodyProps {
|
||||
handleSelect: (id: string) => void;
|
||||
renderOption: RenderOptionFn | undefined;
|
||||
children: ReactNode;
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
|
||||
@@ -961,21 +960,6 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||
[props.titleColor],
|
||||
);
|
||||
const { footer, safeAreaBottom } = props;
|
||||
const frameStyle = useMemo(
|
||||
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
|
||||
[footer, safeAreaBottom],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [styles.footer, { paddingBottom: safeAreaBottom }],
|
||||
[safeAreaBottom],
|
||||
);
|
||||
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
|
||||
[footer, footerStyle],
|
||||
);
|
||||
|
||||
const body = props.hasChildren ? (
|
||||
props.children
|
||||
) : (
|
||||
@@ -1006,51 +990,74 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
keyboardBlurBehavior="none"
|
||||
presentation={props.presentation}
|
||||
>
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<View style={frameStyle}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</View>
|
||||
</SheetVisibleFrame>
|
||||
<ComboboxSheetFrame footer={props.footer}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</ComboboxSheetFrame>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the
|
||||
* one left where the combobox was opened: inside a sheet tab, none is.
|
||||
*/
|
||||
function ComboboxSheetFrame({ footer, children }: { footer?: ReactNode; children: ReactNode }) {
|
||||
const { bottom: safeAreaBottom } = useSafeAreaInsets();
|
||||
const frameStyle = useMemo(
|
||||
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
|
||||
[footer, safeAreaBottom],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [styles.footer, { paddingBottom: safeAreaBottom }],
|
||||
[safeAreaBottom],
|
||||
);
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
|
||||
[footer, footerStyle],
|
||||
);
|
||||
return (
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<View style={frameStyle}>{children}</View>
|
||||
</SheetVisibleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
interface DesktopBodyProps {
|
||||
overlayLayer: number;
|
||||
isOpen: boolean;
|
||||
@@ -1327,7 +1334,6 @@ export function Combobox({
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const active = useRetainedPanelActive();
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const titleColor = theme.colors.foreground;
|
||||
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
|
||||
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
|
||||
@@ -1608,7 +1614,6 @@ export function Combobox({
|
||||
emptyText={resolvedEmptyText}
|
||||
handleSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
safeAreaBottom={safeAreaInsets.bottom}
|
||||
>
|
||||
{children}
|
||||
</MobileComboboxBody>
|
||||
|
||||
@@ -123,19 +123,25 @@ export function useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled,
|
||||
onClose,
|
||||
onBack,
|
||||
}: {
|
||||
visible: boolean;
|
||||
isEnabled?: boolean;
|
||||
onClose: () => void;
|
||||
/** Offered Android Back before it closes the sheet; returns true when it handled the press. */
|
||||
onBack?: () => boolean;
|
||||
}) {
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const onBackRef = useRef(onBack);
|
||||
onBackRef.current = onBack;
|
||||
|
||||
const tracker = useMemo(
|
||||
() =>
|
||||
createBottomSheetVisibilityTracker({
|
||||
onClose: () => onCloseRef.current(),
|
||||
backPress: systemBackPress,
|
||||
onBack: () => onBackRef.current?.() ?? false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -204,6 +204,31 @@ describe("bottom sheet visibility tracker", () => {
|
||||
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("lets the sheet step back through its own pages before Back dismisses it", () => {
|
||||
const sheet = new FakeBottomSheet();
|
||||
const backPress = new FakeBackPress();
|
||||
let openPages = 1;
|
||||
const tracker = createBottomSheetVisibilityTracker({
|
||||
onClose: () => {},
|
||||
onBack: () => {
|
||||
if (openPages === 0) return false;
|
||||
openPages -= 1;
|
||||
return true;
|
||||
},
|
||||
backPress,
|
||||
});
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
tracker.handleSheetIndexChange(0);
|
||||
|
||||
expect(backPress.press()).toBe(true);
|
||||
expect(openPages).toBe(0);
|
||||
expect(sheet.events).toEqual([{ type: "present" }]);
|
||||
|
||||
expect(backPress.press()).toBe(true);
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
|
||||
const context = setup();
|
||||
context.tracker.attachController(context.sheet);
|
||||
|
||||
@@ -36,6 +36,11 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
|
||||
export function createBottomSheetVisibilityTracker(opts: {
|
||||
onClose: () => void;
|
||||
backPress?: BackPressSource;
|
||||
/**
|
||||
* Offered the Back press before the sheet takes it as a close. Returns true when it handled
|
||||
* the press itself, such as stepping back a page inside the sheet.
|
||||
*/
|
||||
onBack?: () => boolean;
|
||||
}): BottomSheetVisibilityTracker {
|
||||
let controller: BottomSheetController | null = null;
|
||||
let visible = false;
|
||||
@@ -60,6 +65,7 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
}
|
||||
releaseBackPress =
|
||||
opts.backPress?.subscribe(() => {
|
||||
if (opts.onBack?.()) return true;
|
||||
dismiss();
|
||||
return true;
|
||||
}) ?? null;
|
||||
|
||||
@@ -12,6 +12,8 @@ export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } f
|
||||
export {
|
||||
MenuSurface,
|
||||
useMenuSurface,
|
||||
type MenuFillPageDefinition,
|
||||
type MenuListPageDefinition,
|
||||
type MenuPageDefinition,
|
||||
type MenuSurfaceProps,
|
||||
} from "./menu-surface";
|
||||
|
||||
@@ -61,3 +61,38 @@ describe("useMenuState selectItem elsewhere", () => {
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMenuState pages", () => {
|
||||
it("pops the open page on Back and leaves Back at the root to the surface", () => {
|
||||
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
|
||||
act(() => result.current.openSub({ id: "row", depth: 0 }));
|
||||
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.goBack();
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(result.current.path).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
handled = result.current.goBack();
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
expect(result.current.open).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the list when the open page's definition goes away", () => {
|
||||
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
|
||||
act(() => result.current.openSub({ id: "row", depth: 0 }));
|
||||
|
||||
const keepNoPage = () => false;
|
||||
act(() => result.current.prunePages(keepNoPage));
|
||||
|
||||
expect(result.current.path).toEqual([]);
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.goBack();
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
goBack as goBackPath,
|
||||
MENU_ROOT_PATH,
|
||||
openSubPage,
|
||||
pruneMissingPages,
|
||||
type MenuPath,
|
||||
} from "./menu-navigation";
|
||||
import type { Rect } from "../anchor";
|
||||
@@ -40,7 +41,10 @@ export interface MenuContextValue {
|
||||
path: MenuPath;
|
||||
openSub: (sub: { id: string; depth: number }) => void;
|
||||
closeSub: (depth: number) => void;
|
||||
goBack: () => void;
|
||||
/** Pops the open page. False at the root, where Back is the surface's to answer. */
|
||||
goBack: () => boolean;
|
||||
/** Closes any open page that no longer has a definition, falling back to its parent. */
|
||||
prunePages: (hasPage: (id: string) => boolean) => void;
|
||||
}
|
||||
|
||||
const MenuContext = createContext<MenuContextValue | null>(null);
|
||||
@@ -160,7 +164,13 @@ export function useMenuState({
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (path.length === 0) return false;
|
||||
setPath((current) => goBackPath(current));
|
||||
return true;
|
||||
}, [path.length]);
|
||||
|
||||
const prunePages = useCallback((hasPage: (id: string) => boolean) => {
|
||||
setPath((current) => pruneMissingPages(current, hasPage));
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
@@ -176,7 +186,19 @@ export function useMenuState({
|
||||
openSub,
|
||||
closeSub,
|
||||
goBack,
|
||||
prunePages,
|
||||
}),
|
||||
[isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack],
|
||||
[
|
||||
isOpen,
|
||||
setOpen,
|
||||
selectItem,
|
||||
anchorRect,
|
||||
presentation,
|
||||
path,
|
||||
openSub,
|
||||
closeSub,
|
||||
goBack,
|
||||
prunePages,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isSubPageOpen,
|
||||
MENU_ROOT_PATH,
|
||||
openSubPage,
|
||||
pruneMissingPages,
|
||||
} from "./menu-navigation";
|
||||
|
||||
describe("openSubPage", () => {
|
||||
@@ -73,3 +74,15 @@ describe("isSubPageOpen", () => {
|
||||
expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneMissingPages", () => {
|
||||
it("falls back to the deepest page that still exists", () => {
|
||||
expect(pruneMissingPages(["list", "row"], (id) => id === "list")).toEqual(["list"]);
|
||||
expect(pruneMissingPages(["list", "row"], (id) => id === "row")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the same path when every page exists", () => {
|
||||
const path = ["list", "row"];
|
||||
expect(pruneMissingPages(path, () => true)).toBe(path);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,16 @@ export function closeSubPage(path: MenuPath, depth: number): MenuPath {
|
||||
return path.slice(0, depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts the path back to the deepest page that still has a definition. A page can lose its
|
||||
* definition while it is open (a subagent's page goes with its row when the subagent is
|
||||
* archived), and the pages it opened go with it.
|
||||
*/
|
||||
export function pruneMissingPages(path: MenuPath, hasPage: (id: string) => boolean): MenuPath {
|
||||
const missing = path.findIndex((id) => !hasPage(id));
|
||||
return missing === -1 ? path : path.slice(0, missing);
|
||||
}
|
||||
|
||||
export function goBack(path: MenuPath): MenuPath {
|
||||
if (path.length === 0) return path;
|
||||
return path.slice(0, -1);
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
useIsolatedBottomSheetVisibility,
|
||||
type ContextBridge,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
FULL_SHEET_SNAP_POINTS,
|
||||
SheetBackground,
|
||||
SheetHandle,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useMenuContext, MenuContextProvider } from "./menu-context";
|
||||
@@ -32,24 +37,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not
|
||||
// track, so the sheet is wrapped rather than reading the theme through a hook.
|
||||
// See docs/unistyles.md.
|
||||
const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
|
||||
backgroundStyle: {
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
/** How long the pointer must rest on a submenu row before its flyout opens. */
|
||||
const HOVER_OPEN_DELAY_MS = 90;
|
||||
/**
|
||||
@@ -65,7 +52,7 @@ const HOVER_CLOSE_GRACE_MS = 260;
|
||||
*/
|
||||
const SUBMENU_OVERLAP = 5;
|
||||
|
||||
export interface MenuPageDefinition {
|
||||
export interface MenuListPageDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
@@ -78,6 +65,24 @@ export interface MenuPageDefinition {
|
||||
* surface stops closing on hover, since its parent leads back to the same dismissal.
|
||||
*/
|
||||
hoverIntent?: boolean;
|
||||
fill?: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sheet page that takes the sheet's full height and draws everything itself, header included:
|
||||
* no back header, no scroller, no content panning. Only the sheet presentation opens one; a
|
||||
* popover never has a trigger for it.
|
||||
*/
|
||||
export interface MenuFillPageDefinition {
|
||||
id: string;
|
||||
fill: true;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export type MenuPageDefinition = MenuListPageDefinition | MenuFillPageDefinition;
|
||||
|
||||
function pageHoverIntent(page: MenuPageDefinition | undefined): boolean {
|
||||
return !page || page.fill === true || page.hoverIntent !== false;
|
||||
}
|
||||
|
||||
interface MenuSurfaceContextValue {
|
||||
@@ -126,8 +131,15 @@ export interface MenuSurfaceProps {
|
||||
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
|
||||
*/
|
||||
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
|
||||
const { presentation, open, setOpen } = useMenuContext("MenuSurface");
|
||||
const { presentation, open, setOpen, path, prunePages } = useMenuContext("MenuSurface");
|
||||
const active = useRetainedPanelActive();
|
||||
// An open page whose definition went away (its row was removed) falls back to its parent, so
|
||||
// neither a render nor a later Back walks a path to a page that no longer exists.
|
||||
const { pages } = props;
|
||||
useEffect(() => {
|
||||
if (path.length === 0) return;
|
||||
prunePages((id) => pages?.some((page) => page.id === id) ?? false);
|
||||
}, [pages, path, prunePages]);
|
||||
// A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes
|
||||
// for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet
|
||||
// and promoted it to a tab, say, would otherwise bring the list back on the next tab switch.
|
||||
@@ -234,11 +246,11 @@ function MenuPopoverSurface({
|
||||
// `hoverIntent: false` takes a page off the pointer entirely — it is not opened by resting on
|
||||
// its trigger, and while it is open nothing on this surface closes on a pointer leaving it.
|
||||
const hoverValue = useMemo<MenuSurfaceContextValue>(() => {
|
||||
const locked = openPages.some(({ page }) => page.hoverIntent === false);
|
||||
const locked = openPages.some(({ page }) => !pageHoverIntent(page));
|
||||
return {
|
||||
...surfaceValue,
|
||||
hoverOpen: (sub) => {
|
||||
if (pages.find((page) => page.id === sub.id)?.hoverIntent === false) return;
|
||||
if (!pageHoverIntent(pages.find((page) => page.id === sub.id))) return;
|
||||
surfaceValue.hoverOpen(sub);
|
||||
},
|
||||
hoverClose: (depth) => {
|
||||
@@ -353,17 +365,6 @@ function MenuSheetSurface({
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const menu = useMenuContext("MenuSurface");
|
||||
const { value: surfaceValue } = useSubAnchors();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
|
||||
const sheetScrollContentStyle = useMemo(
|
||||
() => ({
|
||||
paddingBottom: getMenuSheetBottomPadding({
|
||||
safeAreaBottom: safeAreaInsets.bottom,
|
||||
breathingRoom: SPACING[1],
|
||||
}),
|
||||
}),
|
||||
[safeAreaInsets.bottom],
|
||||
);
|
||||
const sheetDataSet = useMemo(
|
||||
() => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined),
|
||||
[keyboardFocusScope],
|
||||
@@ -374,11 +375,15 @@ function MenuSheetSurface({
|
||||
visible: menu.open,
|
||||
isEnabled: true,
|
||||
onClose: handleClose,
|
||||
// Android Back steps back through the pages before it closes the sheet.
|
||||
onBack: menu.goBack,
|
||||
});
|
||||
|
||||
const openPageId = currentPageId(menu.path);
|
||||
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
|
||||
const depth = menu.path.length;
|
||||
const fillPage = openPage?.fill === true ? openPage : null;
|
||||
const listPage = openPage?.fill === true ? null : openPage;
|
||||
|
||||
// The sheet's content is teleported out of this subtree, so both menu contexts have to be
|
||||
// rebuilt on the other side. Providing them around the modal instead would put them on the
|
||||
@@ -393,12 +398,18 @@ function MenuSheetSurface({
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedBottomSheetModal
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleComponent={SheetHandle}
|
||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
||||
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
|
||||
enableDynamicSizing
|
||||
// A fill page is the exception: it takes the height every full sheet has, and its own
|
||||
// content scrolls, so dragging it would fight that scroll.
|
||||
enableDynamicSizing={!fillPage}
|
||||
snapPoints={fillPage ? FULL_SHEET_SNAP_POINTS : undefined}
|
||||
enableContentPanningGesture={!fillPage}
|
||||
maxDynamicContentSize={windowHeight * 0.8}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
@@ -412,28 +423,64 @@ function MenuSheetSurface({
|
||||
keyboardBehavior="interactive"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
dataSet={sheetDataSet}
|
||||
contentContainerStyle={sheetScrollContentStyle}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
{openPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
||||
<MenuPage depth={depth}>{openPage.content}</MenuPage>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? (
|
||||
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
|
||||
) : null}
|
||||
<MenuPage depth={0}>{children}</MenuPage>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
{fillPage ? (
|
||||
<View style={styles.fillPage} testID={testID ? `${testID}-${fillPage.id}` : undefined}>
|
||||
{fillPage.content}
|
||||
</View>
|
||||
) : (
|
||||
<MenuSheetScrollView dataSet={sheetDataSet} testID={testID}>
|
||||
{listPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={listPage.title} onBack={menu.goBack} />
|
||||
<MenuPage depth={depth}>{listPage.content}</MenuPage>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? (
|
||||
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
|
||||
) : null}
|
||||
<MenuPage depth={0}>{children}</MenuPage>
|
||||
</>
|
||||
)}
|
||||
</MenuSheetScrollView>
|
||||
)}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the
|
||||
* one left where the menu was opened: inside a sheet tab, none is.
|
||||
*/
|
||||
function MenuSheetScrollView({
|
||||
dataSet,
|
||||
testID,
|
||||
children,
|
||||
}: {
|
||||
dataSet: { keyboardScope: KeyboardFocusScope } | undefined;
|
||||
testID?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const contentStyle = useMemo(
|
||||
() => ({
|
||||
paddingBottom: getMenuSheetBottomPadding({
|
||||
safeAreaBottom: safeAreaInsets.bottom,
|
||||
breathingRoom: SPACING[1],
|
||||
}),
|
||||
}),
|
||||
[safeAreaInsets.bottom],
|
||||
);
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
dataSet={dataSet}
|
||||
contentContainerStyle={contentStyle}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -470,6 +517,10 @@ function MenuSheetHeader({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
fillPage: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
MenuSurface,
|
||||
MenuTrigger,
|
||||
useMenuContext,
|
||||
useMenuDepth,
|
||||
type MenuPageDefinition,
|
||||
type MenuTriggerState,
|
||||
} from "@/components/ui/menu";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
@@ -61,6 +63,8 @@ export interface ComposerTrackPillProps {
|
||||
accessibilityLabel?: string;
|
||||
/** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */
|
||||
children: ReactNode;
|
||||
/** Pages a row can open in place of the list (`ComposerTrackRow` `opensPage`). */
|
||||
pages?: readonly MenuPageDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +89,7 @@ export function ComposerTrackPill({
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
children,
|
||||
pages,
|
||||
}: ComposerTrackPillProps): ReactElement {
|
||||
return (
|
||||
<MenuRoot compactMode="sheet">
|
||||
@@ -98,6 +103,7 @@ export function ComposerTrackPill({
|
||||
align="start"
|
||||
offset={PANEL_OFFSET}
|
||||
sheetTitle={panelTitle}
|
||||
pages={pages}
|
||||
minWidth={PANEL_MIN_WIDTH}
|
||||
maxWidth={PANEL_MAX_WIDTH}
|
||||
maxHeight={PANEL_MAX_HEIGHT}
|
||||
@@ -199,6 +205,11 @@ export interface ComposerTrackRowProps {
|
||||
* lands inside the panel is not.
|
||||
*/
|
||||
closeOnSelect?: boolean;
|
||||
/**
|
||||
* A page of the panel this row opens in place of the list, instead of running `onPress`. The
|
||||
* panel stays open: Back returns to the list.
|
||||
*/
|
||||
opensPage?: string;
|
||||
disabled?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
@@ -220,18 +231,23 @@ export function ComposerTrackRow({
|
||||
children,
|
||||
onPress,
|
||||
closeOnSelect = true,
|
||||
opensPage,
|
||||
disabled = false,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: ComposerTrackRowProps): ReactElement {
|
||||
const { selectItem } = useMenuContext("ComposerTrackRow");
|
||||
const { selectItem, openSub } = useMenuContext("ComposerTrackRow");
|
||||
const depth = useMenuDepth();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
||||
const handleSelect = useCallback(
|
||||
() => selectItem(onPress, closeOnSelect),
|
||||
[closeOnSelect, onPress, selectItem],
|
||||
);
|
||||
const handleSelect = useCallback(() => {
|
||||
if (opensPage) {
|
||||
selectItem(() => openSub({ id: opensPage, depth }), false);
|
||||
return;
|
||||
}
|
||||
selectItem(onPress, closeOnSelect);
|
||||
}, [closeOnSelect, depth, onPress, openSub, opensPage, selectItem]);
|
||||
|
||||
const itemDataSet = useMemo(
|
||||
() => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }),
|
||||
@@ -251,7 +267,7 @@ export function ComposerTrackRow({
|
||||
[hovered, renderRow],
|
||||
);
|
||||
|
||||
if (!onPress) {
|
||||
if (!onPress && !opensPage) {
|
||||
return <View accessibilityLabel={accessibilityLabel}>{renderRow(false)}</View>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1914,7 +1914,6 @@ export const ar: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "العودة إلى الوكيل الأصلي",
|
||||
backTo: "العودة إلى {{label}}",
|
||||
parent: "الأصل",
|
||||
parentAgent: "الوكيل الأصلي",
|
||||
openParent: "فتح الوكيل الأصلي",
|
||||
},
|
||||
|
||||
@@ -1939,7 +1939,6 @@ export const en = {
|
||||
parent: {
|
||||
backToParent: "Back to parent",
|
||||
backTo: "Back to {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Parent agent",
|
||||
openParent: "Open parent",
|
||||
},
|
||||
|
||||
@@ -1962,7 +1962,6 @@ export const es: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "Volver al padre",
|
||||
backTo: "Volver a {{label}}",
|
||||
parent: "Padre",
|
||||
parentAgent: "Agente padre",
|
||||
openParent: "Abrir padre",
|
||||
},
|
||||
|
||||
@@ -1960,7 +1960,6 @@ export const fr: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "Retour au parent",
|
||||
backTo: "Retour à {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Agent parent",
|
||||
openParent: "Ouvrir le parent",
|
||||
},
|
||||
|
||||
@@ -1933,7 +1933,6 @@ export const ja: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "親に戻る",
|
||||
backTo: "{{label}} に戻る",
|
||||
parent: "親",
|
||||
parentAgent: "親エージェント",
|
||||
openParent: "親を開く",
|
||||
},
|
||||
|
||||
@@ -1924,7 +1924,6 @@ export const ko: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "상위 에이전트로 돌아가기",
|
||||
backTo: "{{label}}(으)로 돌아가기",
|
||||
parent: "상위",
|
||||
parentAgent: "상위 에이전트",
|
||||
openParent: "상위 에이전트 열기",
|
||||
},
|
||||
|
||||
@@ -1947,7 +1947,6 @@ export const ptBR: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "Voltar ao pai",
|
||||
backTo: "Voltar para {{label}}",
|
||||
parent: "Pai",
|
||||
parentAgent: "Agente pai",
|
||||
openParent: "Abrir pai",
|
||||
},
|
||||
|
||||
@@ -1946,7 +1946,6 @@ export const ru: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "Назад к родителю",
|
||||
backTo: "Назад к {{label}}",
|
||||
parent: "Родитель",
|
||||
parentAgent: "Родительский агент",
|
||||
openParent: "Открыть родителя",
|
||||
},
|
||||
|
||||
@@ -1892,7 +1892,6 @@ export const zhCN: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "返回父代理",
|
||||
backTo: "返回 {{label}}",
|
||||
parent: "父代理",
|
||||
parentAgent: "父代理",
|
||||
openParent: "打开父代理",
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type ArchiveFinishedStatus,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentOpensInSheet,
|
||||
useSubagentSheetTab,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
@@ -100,7 +100,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
|
||||
const sheetTabFor = useSubagentSheetTab(workspaceKey, tabId);
|
||||
const handleOpenChanges = useCallback(() => {
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
@@ -134,7 +134,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet}
|
||||
sheetTabFor={sheetTabFor}
|
||||
onArchiveSubagent={archiveSubagent}
|
||||
onArchiveFinished={onArchiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedStatus}
|
||||
|
||||
@@ -16,9 +16,10 @@ import { usePaneContext } from "@/panels/pane-context";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { SheetTab } from "@/stores/workspace-layout-store";
|
||||
import { ParentAgentCallout } from "@/subagents/parent";
|
||||
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
|
||||
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
|
||||
import { useSubagentSheetTab } from "@/subagents/sheet-tab";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import {
|
||||
providerSubagentKey,
|
||||
@@ -51,12 +52,12 @@ function ProviderSubagentChildTrack({
|
||||
serverId,
|
||||
rows,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
sheetTabFor,
|
||||
}: {
|
||||
serverId: string;
|
||||
rows: ReturnType<typeof useSubagentsForParent>;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
opensInSheet: (row: SubagentRow) => boolean;
|
||||
sheetTabFor: (row: SubagentRow) => SheetTab | null;
|
||||
}) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
@@ -66,7 +67,7 @@ function ProviderSubagentChildTrack({
|
||||
rows={rows}
|
||||
onOpenSubagent={NOOP_SUBAGENT}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet}
|
||||
sheetTabFor={sheetTabFor}
|
||||
onArchiveSubagent={NOOP_SUBAGENT}
|
||||
/>
|
||||
</ComposerTrackBar>
|
||||
@@ -116,7 +117,7 @@ function useProviderSubagentDescriptor(
|
||||
|
||||
function ProviderSubagentPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
|
||||
const { serverId, workspaceId, tabId, target, openFileInWorkspace, openTab } = usePaneContext();
|
||||
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
|
||||
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
|
||||
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
|
||||
@@ -144,8 +145,9 @@ function ProviderSubagentPanel() {
|
||||
providerParentSubagentId: target.subagentId,
|
||||
});
|
||||
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
|
||||
const opensInSheet = useSubagentOpensInSheet(
|
||||
const sheetTabFor = useSubagentSheetTab(
|
||||
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
tabId,
|
||||
);
|
||||
const openProviderChild = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
@@ -274,7 +276,7 @@ function ProviderSubagentPanel() {
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
sheetTabFor={sheetTabFor}
|
||||
/>
|
||||
</View>
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
|
||||
|
||||
@@ -34,11 +34,24 @@ import {
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
|
||||
import { render as renderEnvironment } from "@testing-library/react";
|
||||
import { useWorkspaceSheetEnvironment } from "@/screens/workspace/workspace-sheet-environment";
|
||||
import {
|
||||
SheetPaneHostProvider,
|
||||
useSheetPaneBridge,
|
||||
type SheetPaneHost,
|
||||
} from "@/screens/workspace/sheet-pane";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const SHEET_PANE_HOST: SheetPaneHost = {
|
||||
workspaceKey: "host:workspace",
|
||||
serverId: "host",
|
||||
workspaceId: "workspace",
|
||||
isWorkspaceFocused: true,
|
||||
buildContent: () => {
|
||||
throw new Error("The environment probe builds no pane content");
|
||||
},
|
||||
};
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
@@ -155,14 +168,21 @@ describe("workspace sheet environment", () => {
|
||||
}, [name]);
|
||||
return null;
|
||||
}
|
||||
function Capture() {
|
||||
bridge = useWorkspaceSheetEnvironment();
|
||||
function CaptureBridge() {
|
||||
bridge = useSheetPaneBridge();
|
||||
return (
|
||||
<ComposerEditingScope tabId="file" targetKey="file:a">
|
||||
<Probe name="pane" />
|
||||
</ComposerEditingScope>
|
||||
);
|
||||
}
|
||||
function Capture() {
|
||||
return (
|
||||
<SheetPaneHostProvider value={SHEET_PANE_HOST}>
|
||||
<CaptureBridge />
|
||||
</SheetPaneHostProvider>
|
||||
);
|
||||
}
|
||||
function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) {
|
||||
const onOpenFile = useCallback(
|
||||
(target: InlinePathTarget) => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { createContext, useCallback, useContext, type ReactNode } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
|
||||
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
|
||||
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
|
||||
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { useDiffDocumentWorkspaceCacheBridge } from "@/git/diff-document/workspace-cache";
|
||||
import { HostRouteProvider, useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
/** What a sheet pane needs from the workspace it shows a tab of. */
|
||||
export interface SheetPaneHost {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
/** The pane content for a sheet's tab. Closing the tab from inside it runs `closeCurrentTab`. */
|
||||
buildContent: (
|
||||
tab: WorkspaceTabDescriptor,
|
||||
closeCurrentTab: () => void,
|
||||
) => WorkspacePaneContentModel;
|
||||
}
|
||||
|
||||
const SheetPaneHostContext = createContext<SheetPaneHost | null>(null);
|
||||
|
||||
/** `null` while there is no workspace to show sheets for. */
|
||||
export function SheetPaneHostProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: SheetPaneHost | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <SheetPaneHostContext.Provider value={value}>{children}</SheetPaneHostContext.Provider>;
|
||||
}
|
||||
|
||||
/** The host of the workspace the caller is in, or null while there is none. */
|
||||
export function useSheetPaneHostIfAny(): SheetPaneHost | null {
|
||||
return useContext(SheetPaneHostContext);
|
||||
}
|
||||
|
||||
export function useSheetPaneHost(): SheetPaneHost {
|
||||
const host = useSheetPaneHostIfAny();
|
||||
invariant(host, "SheetPaneHostProvider is required");
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries a sheet pane's environment across the portal into the app overlay host: the workspace
|
||||
* host, and the environment of the built-in panels. Pane identity is supplied by the pane itself.
|
||||
*/
|
||||
export function useSheetPaneBridge(): ContextBridge {
|
||||
const host = useSheetPaneHost();
|
||||
const editing = useComposerEditingEnvironmentBridge();
|
||||
const cache = useDiffDocumentWorkspaceCacheBridge();
|
||||
const files = useAssistantFileLinkEnvironmentBridge();
|
||||
const active = useRetainedPanelActive();
|
||||
const serverId = useHostRouteServerId();
|
||||
const navigation = useContext(NavigationContext);
|
||||
const route = useContext(NavigationRouteContext);
|
||||
return useCallback(
|
||||
(children) => {
|
||||
const content = editing(cache(files(children)));
|
||||
return (
|
||||
<SheetPaneHostContext.Provider value={host}>
|
||||
<NavigationContext.Provider value={navigation}>
|
||||
<NavigationRouteContext.Provider value={route}>
|
||||
<RetainedPanelActivity active={active}>
|
||||
{serverId ? (
|
||||
<HostRouteProvider serverId={serverId}>{content}</HostRouteProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</RetainedPanelActivity>
|
||||
</NavigationRouteContext.Provider>
|
||||
</NavigationContext.Provider>
|
||||
</SheetPaneHostContext.Provider>
|
||||
);
|
||||
},
|
||||
[host, editing, cache, files, active, serverId, navigation, route],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* A workspace tab shown in a sheet. One pane — header, body, bottom inset, focus — behind every
|
||||
* sheet container: the workspace's sheet tabs, and pages of a menu that shows a sheet in place.
|
||||
*
|
||||
* See docs/explorer-sidebar.md.
|
||||
*/
|
||||
|
||||
export {
|
||||
SheetPaneHostProvider,
|
||||
useSheetPaneBridge,
|
||||
useSheetPaneHost,
|
||||
useSheetPaneHostIfAny,
|
||||
type SheetPaneHost,
|
||||
} from "./host";
|
||||
export { SheetPane, useLeaveSheet, type SheetPaneProps } from "./sheet-pane";
|
||||
export { useSheetPanePage } from "./page";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMenuContext, type MenuFillPageDefinition } from "@/components/ui/menu";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { SheetTab } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useSheetPaneBridge } from "./host";
|
||||
import { SheetPane } from "./sheet-pane";
|
||||
|
||||
/**
|
||||
* Shows sheet tabs as pages of a menu opened from a pane, so a row's sheet replaces the list it
|
||||
* was chosen from instead of stacking a second sheet over it. Back returns to the list; Close
|
||||
* closes the menu, which uncovers the pane the menu was opened from.
|
||||
*/
|
||||
export function useSheetPanePage(): (sheet: SheetTab) => MenuFillPageDefinition {
|
||||
const bridge = useSheetPaneBridge();
|
||||
const { target: opener } = usePaneContext();
|
||||
return useCallback(
|
||||
(sheet) => ({
|
||||
id: sheet.tab.tabId,
|
||||
fill: true,
|
||||
content: bridge(
|
||||
// Keyed like the detached container, so the panel's descriptor hook stays fixed per page.
|
||||
<MenuSheetPane
|
||||
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||
sheet={sheet}
|
||||
opener={opener}
|
||||
/>,
|
||||
),
|
||||
}),
|
||||
[bridge, opener],
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSheetPane({ sheet, opener }: { sheet: SheetTab; opener: WorkspaceTabTarget }) {
|
||||
const { setOpen, goBack } = useMenuContext("MenuSheetPane");
|
||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||
return <SheetPane sheet={sheet} onClose={close} onBack={goBack} opener={opener} isTop />;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Maximize2 } from "lucide-react-native";
|
||||
import {
|
||||
SheetBottomInset,
|
||||
SheetDragArea,
|
||||
SheetHeaderIconButton,
|
||||
SheetHeaderView,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
|
||||
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useSheetPaneHost } from "./host";
|
||||
|
||||
function PanelIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color,
|
||||
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export interface SheetPaneProps {
|
||||
sheet: SheetTab;
|
||||
/** Closes the sheet, everything it was chosen from included. */
|
||||
onClose: () => void;
|
||||
/** Steps back to the list the sheet was chosen from, when there is one. */
|
||||
onBack?: () => void;
|
||||
/** The pane closing the sheet uncovers, when the container knows it. */
|
||||
opener?: WorkspaceTabTarget | null;
|
||||
/** Only the top sheet's pane is focused; a covered one stays as live as the pane under it. */
|
||||
isTop: boolean;
|
||||
}
|
||||
|
||||
interface SheetPaneExit {
|
||||
close: () => void;
|
||||
opener: WorkspaceTabTarget | null;
|
||||
}
|
||||
|
||||
const SheetPaneExitContext = createContext<SheetPaneExit | null>(null);
|
||||
|
||||
/**
|
||||
* A tab shown in a sheet: its header (icon, title, Back, Open as tab, Close), which drags the
|
||||
* sheet, over its pane. Every sheet container renders this; only the container differs.
|
||||
*/
|
||||
export function SheetPane({ sheet, onClose, onBack, opener = null, isTop }: SheetPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const host = useSheetPaneHost();
|
||||
const { tab } = sheet;
|
||||
const registration = getPanelRegistration(tab.target.kind);
|
||||
if (!registration) {
|
||||
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
|
||||
}
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId: host.serverId,
|
||||
workspaceId: host.workspaceId,
|
||||
tabId: tab.tabId,
|
||||
});
|
||||
|
||||
const openTabInFront = useWorkspaceLayoutStore((state) => state.openTabInFront);
|
||||
const { workspaceKey } = host;
|
||||
const handleOpenAsTab = useCallback(
|
||||
() => openTabInFront(workspaceKey, sheet),
|
||||
[openTabInFront, sheet, workspaceKey],
|
||||
);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: descriptor.label,
|
||||
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
|
||||
back: onBack ? { onPress: onBack } : undefined,
|
||||
// The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into
|
||||
// the switcher, where it gets its composer back.
|
||||
actions: sheet.policy.openAsTab ? (
|
||||
<SheetHeaderIconButton
|
||||
icon={Maximize2}
|
||||
accessibilityLabel={t("workspace.sheet.openAsTab")}
|
||||
onPress={handleOpenAsTab}
|
||||
testID="sheet-tab-open-as-tab"
|
||||
/>
|
||||
) : undefined,
|
||||
}),
|
||||
[descriptor.icon, descriptor.label, handleOpenAsTab, onBack, sheet.policy.openAsTab, t],
|
||||
);
|
||||
|
||||
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
state: tab.state,
|
||||
}),
|
||||
[tab],
|
||||
);
|
||||
const { buildContent } = host;
|
||||
const content = useMemo(
|
||||
() => buildContent(tabDescriptor, onClose),
|
||||
[buildContent, onClose, tabDescriptor],
|
||||
);
|
||||
const exit = useMemo<SheetPaneExit>(() => ({ close: onClose, opener }), [onClose, opener]);
|
||||
const isPaneFocused = isTop && host.isWorkspaceFocused;
|
||||
useViewedSheetAgent({
|
||||
sourceId: `${workspaceKey}:sheet:${tab.tabId}`,
|
||||
serverId: host.serverId,
|
||||
agentId: isPaneFocused && tab.target.kind === "agent" ? tab.target.agentId : null,
|
||||
});
|
||||
|
||||
return (
|
||||
<SheetPaneExitContext.Provider value={exit}>
|
||||
<View style={styles.pane}>
|
||||
{/* The body scrolls on its own, so the header is where the sheet drags from. */}
|
||||
<SheetDragArea>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
|
||||
</SheetDragArea>
|
||||
<SheetBottomInset
|
||||
style={styles.body}
|
||||
role="dialog"
|
||||
aria-label={descriptor.label}
|
||||
testID="sheet-tab-body"
|
||||
>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={host.isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</SheetBottomInset>
|
||||
</View>
|
||||
</SheetPaneExitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent of the sheet on top is viewed, alongside the pane under it: its timeline catches up
|
||||
* and stays live. The workspace only publishes the agents of its panes.
|
||||
*/
|
||||
function useViewedSheetAgent({
|
||||
sourceId,
|
||||
serverId,
|
||||
agentId,
|
||||
}: {
|
||||
sourceId: string;
|
||||
serverId: string;
|
||||
agentId: string | null;
|
||||
}) {
|
||||
const viewedTimelineSync = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.viewedTimelineSync ?? null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
void getHostRuntimeStore()
|
||||
.prepareAgentTimeline(serverId, agentId)
|
||||
.catch(() => undefined);
|
||||
}, [agentId, serverId]);
|
||||
useLayoutEffect(() => {
|
||||
if (!agentId || !viewedTimelineSync) return;
|
||||
viewedTimelineSync.replaceVisibleAgentIds(sourceId, [agentId]);
|
||||
return () => viewedTimelineSync.replaceVisibleAgentIds(sourceId, []);
|
||||
}, [agentId, sourceId, viewedTimelineSync]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves the sheet the calling pane is shown in, if any, on the way to `destination`. Answers
|
||||
* whether closing the sheet already lands there, so the caller navigates only when it does not.
|
||||
*/
|
||||
export function useLeaveSheet(): (destination: WorkspaceTabTarget) => boolean {
|
||||
const exit = useContext(SheetPaneExitContext);
|
||||
return useCallback(
|
||||
(destination) => {
|
||||
if (!exit) return false;
|
||||
exit.close();
|
||||
return exit.opener !== null && workspaceTabTargetsEqual(exit.opener, destination);
|
||||
},
|
||||
[exit],
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pane: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
@@ -1,131 +1,62 @@
|
||||
import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet";
|
||||
import { useIsFocused } from "expo-router/react-navigation";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
type ComponentType,
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useStore } from "zustand";
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import { Maximize2 } from "lucide-react-native";
|
||||
import {
|
||||
FULL_SHEET_SNAP_POINTS,
|
||||
SheetBackground,
|
||||
SheetHandle,
|
||||
SheetHeaderIconButton,
|
||||
SheetHeaderView,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
type IsolatedBottomSheetModalRef,
|
||||
systemBackPress,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
SheetPane,
|
||||
useSheetPaneBridge,
|
||||
useSheetPaneHost,
|
||||
useSheetPaneHostIfAny,
|
||||
} from "@/screens/workspace/sheet-pane";
|
||||
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceSheetEnvironment } from "./workspace-sheet-environment";
|
||||
|
||||
const SNAP_POINTS = ["92%"];
|
||||
const NO_SHEETS: readonly SheetTab[] = [];
|
||||
|
||||
function PanelIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color,
|
||||
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
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<BottomSheetHandleProps> {
|
||||
const [store] = useState(() => createStore<SheetTabHandleValue>(() => value));
|
||||
useEffect(() => store.setState(value, true), [store, value]);
|
||||
const [Handle] = useState(
|
||||
() =>
|
||||
function SheetTabHandle(props: BottomSheetHandleProps) {
|
||||
const { header, onClose } = useStore(store);
|
||||
return (
|
||||
<SheetHandle {...props}>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
|
||||
</SheetHandle>
|
||||
);
|
||||
},
|
||||
);
|
||||
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<void>;
|
||||
}
|
||||
|
||||
interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCloseTab"> {
|
||||
interface SheetTabSheetProps {
|
||||
sheet: SheetTab;
|
||||
/** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */
|
||||
/** Only the top sheet answers Android Back; the ones below wait their turn. */
|
||||
isTop: boolean;
|
||||
/** Runs the guarded close and resolves to whether the tab is still open. */
|
||||
onClose: (tabId: string) => Promise<boolean>;
|
||||
onOpenAsTab: (tabId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. Their environment
|
||||
* crosses into the app overlay host so compact sheets cover both sidebars, and a
|
||||
* sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of
|
||||
* it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`,
|
||||
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
|
||||
* crosses into the app overlay host so compact sheets cover both sidebars, and a sheet opened
|
||||
* from another one lands on top of it and uncovers it on close.
|
||||
*/
|
||||
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
|
||||
);
|
||||
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
|
||||
const handleOpenAsTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
|
||||
},
|
||||
[openSheetAsTab, workspaceKey],
|
||||
export function SheetTabs({ onCloseTab }: SheetTabsProps) {
|
||||
const host = useSheetPaneHostIfAny();
|
||||
return host ? (
|
||||
<WorkspaceSheetTabs workspaceKey={host.workspaceKey} onCloseTab={onCloseTab} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function WorkspaceSheetTabs({
|
||||
workspaceKey,
|
||||
onCloseTab,
|
||||
}: SheetTabsProps & { workspaceKey: string }) {
|
||||
const sheets = useWorkspaceLayoutStore(
|
||||
(state) => state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS,
|
||||
);
|
||||
const handleClose = useCallback(
|
||||
async (tabId: string) => {
|
||||
await onCloseTab(tabId);
|
||||
// A declined unsaved-changes prompt leaves the tab in its sheet.
|
||||
const open = workspaceKey
|
||||
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
|
||||
: undefined;
|
||||
const open = useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey];
|
||||
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
|
||||
},
|
||||
[onCloseTab, workspaceKey],
|
||||
@@ -136,42 +67,21 @@ export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps)
|
||||
<SheetTabSheet
|
||||
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
|
||||
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||
{...rest}
|
||||
sheet={sheet}
|
||||
isTop={index === sheets.length - 1}
|
||||
onClose={handleClose}
|
||||
onOpenAsTab={handleOpenAsTab}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTabSheet({
|
||||
sheet,
|
||||
isTop,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
onClose,
|
||||
onOpenAsTab,
|
||||
}: SheetTabSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
function SheetTabSheet({ sheet, isTop, onClose }: SheetTabSheetProps) {
|
||||
const { tab } = sheet;
|
||||
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||
const isScreenFocused = useIsFocused();
|
||||
const contextBridge = useWorkspaceSheetEnvironment();
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.target.kind);
|
||||
if (!registration) {
|
||||
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
|
||||
}
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId: tab.tabId,
|
||||
});
|
||||
const { isWorkspaceFocused } = useSheetPaneHost();
|
||||
const contextBridge = useSheetPaneBridge();
|
||||
|
||||
// The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off
|
||||
// the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still
|
||||
@@ -209,105 +119,23 @@ function SheetTabSheet({
|
||||
});
|
||||
}, [close, ownsBackPress]);
|
||||
|
||||
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: descriptor.label,
|
||||
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
|
||||
// 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 ? (
|
||||
<SheetHeaderIconButton
|
||||
icon={Maximize2}
|
||||
accessibilityLabel={t("workspace.sheet.openAsTab")}
|
||||
onPress={handleOpenAsTab}
|
||||
testID="sheet-tab-open-as-tab"
|
||||
/>
|
||||
) : undefined,
|
||||
}),
|
||||
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
|
||||
);
|
||||
const handleValue = useMemo<SheetTabHandleValue>(
|
||||
() => ({ 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 (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
snapPoints={SNAP_POINTS}
|
||||
snapPoints={FULL_SHEET_SNAP_POINTS}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose
|
||||
// The pane scrolls on its own; the sheet drags from its handle and header.
|
||||
enableContentPanningGesture={false}
|
||||
enableDismissOnClose={false}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleComponent={SheetTabHandle}
|
||||
handleComponent={SheetHandle}
|
||||
backdropOpacity={0.45}
|
||||
onChange={handleChange}
|
||||
accessible={false}
|
||||
>
|
||||
{/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */}
|
||||
<SheetTabBody
|
||||
tab={tab}
|
||||
label={descriptor.label}
|
||||
isPaneFocused={isTop && isWorkspaceFocused}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
buildContent={buildContent}
|
||||
/>
|
||||
<SheetPane sheet={sheet} onClose={close} isTop={isTop} />
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
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<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
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 (
|
||||
<View style={contentStyle} role="dialog" aria-label={label} testID="sheet-tab-body">
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,28 +111,6 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to
|
||||
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
|
||||
});
|
||||
|
||||
test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "main",
|
||||
root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
];
|
||||
const sheetTab: WorkspaceTab = {
|
||||
tabId: "sheet",
|
||||
target: { kind: "agent", agentId: "agent-child" },
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }),
|
||||
).toEqual(["agent-a", "agent-child"]);
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("single-pane tablet includes its visible Explorer agent without including other splits", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "main",
|
||||
|
||||
@@ -6,8 +6,6 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
||||
export function selectVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
/** The workspace sheet's tab, shown over the panes. */
|
||||
sheetTab?: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
explorerPaneId?: string | null;
|
||||
@@ -29,7 +27,6 @@ export function selectVisibleAgentIds(input: {
|
||||
.target;
|
||||
return target ? [target] : [];
|
||||
});
|
||||
if (input.sheetTab) targets.push(input.sheetTab.target);
|
||||
return [
|
||||
...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
|
||||
].sort();
|
||||
@@ -38,7 +35,6 @@ export function selectVisibleAgentIds(input: {
|
||||
export function useVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
sheetTab: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
explorerPaneId?: string | null;
|
||||
|
||||
@@ -11,18 +11,7 @@ import {
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context";
|
||||
|
||||
vi.mock("@/panels/register-panels", () => ({
|
||||
ensurePanelsRegistered: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/panels/panel-registry", () => ({
|
||||
getPanelRegistration: () => ({
|
||||
kind: "agent",
|
||||
component: ProbePanel,
|
||||
useDescriptor: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
import { definePanel, registerPanel } from "@/panels/panel-registry";
|
||||
|
||||
interface ProbeSnapshot {
|
||||
paneContextValue: PaneContextValue;
|
||||
@@ -48,6 +37,18 @@ function ProbePanel() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The workspace screen registers the real panels; this probe stands in for each kind the tests open.
|
||||
for (const kind of ["agent", "draft"] as const) {
|
||||
registerPanel(
|
||||
definePanel(kind, {
|
||||
component: ProbePanel,
|
||||
useDescriptor: () => {
|
||||
throw new Error("The probe panel has no descriptor");
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const agentTab: WorkspaceTabDescriptor = {
|
||||
key: "agent_agent-a",
|
||||
tabId: "agent_agent-a",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/panels/pane-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
@@ -56,7 +55,6 @@ export function buildWorkspacePaneContentModel({
|
||||
onOpenWorkspaceFile,
|
||||
onOpenImportSheet,
|
||||
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
return {
|
||||
|
||||
@@ -180,6 +180,8 @@ import {
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { SheetPaneHostProvider, type SheetPaneHost } from "@/screens/workspace/sheet-pane";
|
||||
import { SheetTabs } from "@/screens/workspace/sheet-tabs";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||
@@ -421,6 +423,10 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
}: WorkspaceScreenProps) {
|
||||
// Every reader of the panel registry renders inside the workspace screen, so it registers the
|
||||
// panels before any of them renders. A reader importing the registration instead would import
|
||||
// every panel, and the panels render readers.
|
||||
ensurePanelsRegistered();
|
||||
const navigationFocused = useIsFocused();
|
||||
useEffect(() => {
|
||||
traceInstant("paseo.workspace.mount", { serverId, workspaceId });
|
||||
@@ -764,13 +770,17 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
||||
function WorkspaceContentProviders({
|
||||
children,
|
||||
workspaceKey,
|
||||
sheetPaneHost,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
workspaceKey: string | null;
|
||||
sheetPaneHost: SheetPaneHost | null;
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceFocusProvider workspaceKey={workspaceKey}>
|
||||
<DiffDocumentWorkspaceCacheProvider>{children}</DiffDocumentWorkspaceCacheProvider>
|
||||
<DiffDocumentWorkspaceCacheProvider>
|
||||
<SheetPaneHostProvider value={sheetPaneHost}>{children}</SheetPaneHostProvider>
|
||||
</DiffDocumentWorkspaceCacheProvider>
|
||||
</WorkspaceFocusProvider>
|
||||
);
|
||||
}
|
||||
@@ -1342,10 +1352,6 @@ function WorkspaceScreenContent({
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS,
|
||||
);
|
||||
// Only the top sheet is on screen.
|
||||
const sheetTab = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null,
|
||||
);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
||||
@@ -1417,7 +1423,6 @@ function WorkspaceScreenContent({
|
||||
const visibleAgentIds = useVisibleAgentIds({
|
||||
layout: workspaceLayout,
|
||||
tabs: uiTabs,
|
||||
sheetTab,
|
||||
routeFocused: isRouteFocused,
|
||||
focusedPaneOnly: syncFocusedPaneOnly,
|
||||
explorerPaneId: explorerSidebarPaneId,
|
||||
@@ -2916,6 +2921,8 @@ function WorkspaceScreenContent({
|
||||
paneId?: string | null;
|
||||
focusPaneBeforeOpen?: boolean;
|
||||
host?: PaneHost;
|
||||
/** Overrides the guarded tab close, for a host that closes the tab its own way. */
|
||||
onCloseCurrentTab?: () => void;
|
||||
}) =>
|
||||
buildWorkspacePaneContentModel({
|
||||
tab: input.tab,
|
||||
@@ -2965,9 +2972,11 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onCloseCurrentTab: () => {
|
||||
void handleCloseTabById(input.tab.tabId);
|
||||
},
|
||||
onCloseCurrentTab:
|
||||
input.onCloseCurrentTab ??
|
||||
(() => {
|
||||
void handleCloseTabById(input.tab.tabId);
|
||||
}),
|
||||
onRetargetCurrentTab: (target) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
@@ -3042,16 +3051,36 @@ function WorkspaceScreenContent({
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const buildSheetPaneContentModel = useCallback(
|
||||
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) {
|
||||
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor, closeCurrentTab: () => void) {
|
||||
return buildPaneContentModel({
|
||||
tab,
|
||||
paneId: null,
|
||||
focusPaneBeforeOpen: false,
|
||||
host: "sheet",
|
||||
onCloseCurrentTab: closeCurrentTab,
|
||||
});
|
||||
},
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const sheetPaneHost = useMemo<SheetPaneHost | null>(
|
||||
() =>
|
||||
persistenceKey
|
||||
? {
|
||||
workspaceKey: persistenceKey,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
isWorkspaceFocused: isRouteFocused,
|
||||
buildContent: buildSheetPaneContentModel,
|
||||
}
|
||||
: null,
|
||||
[
|
||||
buildSheetPaneContentModel,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
persistenceKey,
|
||||
],
|
||||
);
|
||||
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
|
||||
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
||||
return;
|
||||
@@ -3540,14 +3569,7 @@ function WorkspaceScreenContent({
|
||||
</StreamTopOverlayProvider>
|
||||
)}
|
||||
</FloatingHeaderLayout>
|
||||
<SheetTabs
|
||||
workspaceKey={persistenceKey}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
buildContent={buildSheetPaneContentModel}
|
||||
onCloseTab={handleCloseTabById}
|
||||
/>
|
||||
<SheetTabs onCloseTab={handleCloseTabById} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.centerColumn}>
|
||||
@@ -3767,7 +3789,11 @@ function WorkspaceScreenContent({
|
||||
return gatedWorkspaceScreen;
|
||||
}
|
||||
return (
|
||||
<WorkspaceContentProviders key={persistenceKey} workspaceKey={persistenceKey}>
|
||||
<WorkspaceContentProviders
|
||||
key={persistenceKey}
|
||||
workspaceKey={persistenceKey}
|
||||
sheetPaneHost={sheetPaneHost}
|
||||
>
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
serverId={normalizedServerId}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import React, { useCallback, useContext } from "react";
|
||||
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
|
||||
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
|
||||
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
|
||||
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { useDiffDocumentWorkspaceCacheBridge } from "@/git/diff-document/workspace-cache";
|
||||
import { HostRouteProvider, useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
|
||||
/** The environment of the built-in compact panels; pane identity is supplied by their body. */
|
||||
export function useWorkspaceSheetEnvironment(): ContextBridge {
|
||||
const editing = useComposerEditingEnvironmentBridge();
|
||||
const cache = useDiffDocumentWorkspaceCacheBridge();
|
||||
const files = useAssistantFileLinkEnvironmentBridge();
|
||||
const active = useRetainedPanelActive();
|
||||
const serverId = useHostRouteServerId();
|
||||
const navigation = useContext(NavigationContext);
|
||||
const route = useContext(NavigationRouteContext);
|
||||
return useCallback(
|
||||
(children) => {
|
||||
const content = editing(cache(files(children)));
|
||||
return (
|
||||
<NavigationContext.Provider value={navigation}>
|
||||
<NavigationRouteContext.Provider value={route}>
|
||||
<RetainedPanelActivity active={active}>
|
||||
{serverId ? (
|
||||
<HostRouteProvider serverId={serverId}>{content}</HostRouteProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</RetainedPanelActivity>
|
||||
</NavigationRouteContext.Provider>
|
||||
</NavigationContext.Provider>
|
||||
);
|
||||
},
|
||||
[editing, cache, files, active, serverId, navigation, route],
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { Check } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-registry";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
@@ -43,7 +42,6 @@ export function WorkspaceTabPresentationResolver({
|
||||
workspaceId,
|
||||
children,
|
||||
}: WorkspaceTabPresentationResolverProps): ReactElement {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
normalizeLayout,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
selectSheetTab,
|
||||
stripEphemeralTabsFromLayout,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
@@ -4496,6 +4497,12 @@ describe("compact workspace sheet", () => {
|
||||
return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1);
|
||||
}
|
||||
|
||||
function requireTopSheet(store: ReturnType<typeof createCompactStore>) {
|
||||
const sheet = topSheet(store);
|
||||
if (!sheet) throw new Error("Expected a sheet");
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function sheetTargets(store: ReturnType<typeof createCompactStore>) {
|
||||
return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map(
|
||||
(sheet) => sheet.tab.target,
|
||||
@@ -4647,7 +4654,7 @@ describe("compact workspace sheet", () => {
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
|
||||
|
||||
store.getState().openSheetAsTab(workspaceKey, nextTabId ?? "");
|
||||
store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
|
||||
|
||||
expect(sheetTargets(store)).toEqual([]);
|
||||
expect(tabKinds(store)).toEqual(["terminal", "file"]);
|
||||
@@ -4683,7 +4690,7 @@ describe("compact workspace sheet", () => {
|
||||
if (!sheetTabId) throw new Error("Expected a sheet tab");
|
||||
store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
|
||||
|
||||
const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId);
|
||||
const tabId = store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
|
||||
|
||||
expect(tabId).toBe(sheetTabId);
|
||||
expect(topSheet(store)).toBeUndefined();
|
||||
@@ -4787,6 +4794,40 @@ describe("compact workspace sheet", () => {
|
||||
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
|
||||
});
|
||||
|
||||
it("describes the sheet a reveal would show without opening it, and opens it as a tab", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
]);
|
||||
const store = createCompactStore();
|
||||
const rootTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
|
||||
|
||||
const sheet = selectSheetTab(store.getState(), {
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
parentTabId: rootTabId ?? undefined,
|
||||
});
|
||||
expect(sheet?.parentTabId).toBe(rootTabId);
|
||||
expect(
|
||||
selectSheetTab(store.getState(), {
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "root" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(topSheet(store)).toBeUndefined();
|
||||
if (!sheet) throw new Error("Expected a sheet tab");
|
||||
|
||||
const tabId = store.getState().openTabInFront(workspaceKey, sheet);
|
||||
|
||||
expect(tabId).toBe(sheet.tab.tabId);
|
||||
expect(topSheet(store)).toBeUndefined();
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(tabId);
|
||||
expect(layout.parentTabIdByTabId?.[tabId ?? ""]).toBe(rootTabId);
|
||||
});
|
||||
|
||||
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
@@ -4811,7 +4852,7 @@ describe("compact workspace sheet", () => {
|
||||
});
|
||||
expect(topSheet(store)?.parentTabId).toBe(rootTabId);
|
||||
|
||||
store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? "");
|
||||
store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
|
||||
});
|
||||
|
||||
@@ -156,13 +156,12 @@ interface WorkspaceLayoutStore {
|
||||
* `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id.
|
||||
*/
|
||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||
/** Whether `openTab` with this input would show the target in a sheet. */
|
||||
opensInSheet: (
|
||||
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
|
||||
) => boolean;
|
||||
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
|
||||
/** Moves a sheet's tab, keeping its tab id, into the focused pane. */
|
||||
openSheetAsTab: (workspaceKey: string, tabId: string) => string | null;
|
||||
/**
|
||||
* Moves a sheet's tab, keeping its tab id, into the focused pane and dismisses the sheets that
|
||||
* would cover it. The sheet need not be one of the workspace's sheet tabs.
|
||||
*/
|
||||
openTabInFront: (workspaceKey: string, sheet: SheetTab) => string | null;
|
||||
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
|
||||
autoOpenPullRequestTab: (
|
||||
workspaceKey: string,
|
||||
@@ -521,6 +520,29 @@ export function selectExplorerSidebarPaneId(
|
||||
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet tab a reveal of this target would show, or null when it opens as a tab. A caller
|
||||
* that hosts the sheet itself (a list showing it in place) renders this without opening it.
|
||||
*/
|
||||
export function selectSheetTab(
|
||||
state: Pick<WorkspaceLayoutStore, "layoutMode" | "layoutByWorkspace" | "sheetsByWorkspace">,
|
||||
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "parentTabId">,
|
||||
): SheetTab | null {
|
||||
const workspaceKey = trimNonEmpty(input.workspaceKey);
|
||||
const target = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!workspaceKey || !target) return null;
|
||||
const policy = resolveSheetPolicy(state, workspaceKey, target, "reveal");
|
||||
if (!policy) return null;
|
||||
return placeTargetInSheet({
|
||||
kept: undefined,
|
||||
target,
|
||||
intent: "reveal",
|
||||
policy,
|
||||
parentTabId: resolveOpenParentTabId(state, workspaceKey, input.parentTabId),
|
||||
state: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
|
||||
export function selectPaneFocusSuspended(
|
||||
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
|
||||
@@ -822,10 +844,8 @@ function placeTargetInSheet(input: {
|
||||
function promoteSheetToTab(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
tabId: string,
|
||||
sheet: SheetTab,
|
||||
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
||||
const sheet = findSheet(state, workspaceKey, tabId);
|
||||
if (!sheet) return null;
|
||||
const { tab } = sheet;
|
||||
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
|
||||
const input = {
|
||||
@@ -910,9 +930,9 @@ function dismissSheets(
|
||||
function openSheetTabInFront(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
tabId: string,
|
||||
sheet: SheetTab,
|
||||
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
||||
const promoted = promoteSheetToTab(state, workspaceKey, tabId);
|
||||
const promoted = promoteSheetToTab(state, workspaceKey, sheet);
|
||||
if (!promoted) return null;
|
||||
const next = { ...state, ...promoted.patch };
|
||||
return {
|
||||
@@ -1208,15 +1228,6 @@ export function createWorkspaceLayoutStore(
|
||||
}));
|
||||
return result.tabId;
|
||||
},
|
||||
opensInSheet: (input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
|
||||
return (
|
||||
resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
|
||||
null
|
||||
);
|
||||
},
|
||||
setLayoutMode: (mode) => {
|
||||
set((state) => {
|
||||
if (state.layoutMode === mode) return state;
|
||||
@@ -1225,7 +1236,7 @@ export function createWorkspaceLayoutStore(
|
||||
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
|
||||
for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) {
|
||||
for (const sheet of sheets) {
|
||||
const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId);
|
||||
const promoted = promoteSheetToTab(next, workspaceKey, sheet);
|
||||
Object.assign(
|
||||
next,
|
||||
promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId),
|
||||
@@ -1235,11 +1246,10 @@ export function createWorkspaceLayoutStore(
|
||||
return next;
|
||||
});
|
||||
},
|
||||
openSheetAsTab: (workspaceKey, tabId) => {
|
||||
openTabInFront: (workspaceKey, sheet) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId) return null;
|
||||
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId);
|
||||
if (!normalizedWorkspaceKey) return null;
|
||||
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, sheet);
|
||||
if (!promoted) return null;
|
||||
set(promoted.patch);
|
||||
return promoted.tabId;
|
||||
@@ -1569,7 +1579,10 @@ export function createWorkspaceLayoutStore(
|
||||
return normalizedTabId;
|
||||
}
|
||||
// A target sheets do not host becomes the tab it would have opened as.
|
||||
const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId);
|
||||
const retargeted = findSheet(next, normalizedWorkspaceKey, normalizedTabId);
|
||||
const promoted = retargeted
|
||||
? openSheetTabInFront(next, normalizedWorkspaceKey, retargeted)
|
||||
: null;
|
||||
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId));
|
||||
return promoted?.tabId ?? null;
|
||||
}
|
||||
|
||||
@@ -9,4 +9,4 @@ export {
|
||||
type ArchiveFinishedStatus,
|
||||
type UseArchiveFinishedSubagentsInput,
|
||||
} from "./use-archive-finished";
|
||||
export { useSubagentOpensInSheet } from "./opens-in-sheet";
|
||||
export { useSubagentSheetTab } from "./sheet-tab";
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { SubagentRow } from "./select";
|
||||
|
||||
/** The workspace target a subagent row opens. */
|
||||
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
|
||||
return row.kind === "provider"
|
||||
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
|
||||
: { kind: "agent", agentId: row.id };
|
||||
}
|
||||
|
||||
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
|
||||
export function useSubagentOpensInSheet(
|
||||
workspaceKey: string | null,
|
||||
): (row: SubagentRow) => boolean {
|
||||
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
|
||||
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
|
||||
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
|
||||
// would still close the list it sits in.
|
||||
useWorkspaceLayoutStore((state) => state.layoutMode);
|
||||
useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
return (row) =>
|
||||
workspaceKey !== null &&
|
||||
useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CornerUpLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StatusIcon } from "@/components/status-icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
|
||||
@@ -22,6 +22,7 @@ function ParentIcon({
|
||||
}
|
||||
|
||||
const ThemedParentIcon = withUnistyles(ParentIcon);
|
||||
const ICON_SIZE = 16;
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */
|
||||
@@ -38,18 +39,12 @@ export function ParentAgentCallout({
|
||||
return (
|
||||
<View style={styles.rail}>
|
||||
<View style={styles.callout} testID="parent-agent-callout">
|
||||
<View style={styles.avatar}>
|
||||
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||
</Text>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
</View>
|
||||
</View>
|
||||
<StatusIcon statusBucket={parent.statusBucket} size={ICON_SIZE} backdrop="surface1">
|
||||
<ThemedParentIcon icon={parent.icon} size={ICON_SIZE} uniProps={foregroundColorMapping} />
|
||||
</StatusIcon>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -65,11 +60,13 @@ export function ParentAgentCallout({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The composer's rail, so the callout sits where the composer would.
|
||||
rail: {
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
callout: {
|
||||
width: "100%",
|
||||
@@ -84,31 +81,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[3],
|
||||
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
|
||||
},
|
||||
avatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
body: {
|
||||
// Shrinks before the button does, so Open parent never wraps or leaves the row.
|
||||
title: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useLeaveSheet } from "@/screens/workspace/sheet-pane";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -25,10 +26,14 @@ export function useParentAgentId(serverId: string, agentId: string): string | nu
|
||||
/** The parent as its own tab presents it, and the way back to it. */
|
||||
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
|
||||
const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId });
|
||||
// The parent may live in another workspace; navigateToAgent opens it wherever it is.
|
||||
const leaveSheet = useLeaveSheet();
|
||||
// Going back to the parent leaves the sheet the child is shown in. When the child was chosen
|
||||
// from the parent's list, closing the sheet already uncovers the parent; otherwise the parent
|
||||
// may live anywhere, even another workspace, and navigateToAgent opens it wherever it is.
|
||||
const open = useCallback(() => {
|
||||
if (leaveSheet({ kind: "agent", agentId: parentAgentId })) return;
|
||||
navigateToAgent({ serverId, agentId: parentAgentId });
|
||||
}, [parentAgentId, serverId]);
|
||||
}, [leaveSheet, parentAgentId, serverId]);
|
||||
return {
|
||||
label: descriptor.label || null,
|
||||
icon: descriptor.icon,
|
||||
|
||||
+22
-10
@@ -9,7 +9,7 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { useSubagentOpensInSheet } from "./opens-in-sheet";
|
||||
import { useSubagentSheetTab } from "./sheet-tab";
|
||||
import { selectSubagentsForParent, type SubagentRow } from "./select";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
@@ -116,28 +116,40 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSubagentOpensInSheet", () => {
|
||||
it("re-renders the row's answer as the child's tab opens and closes", () => {
|
||||
describe("useSubagentSheetTab", () => {
|
||||
it("re-renders the row's sheet 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);
|
||||
const { result } = renderHook(() => useSubagentSheetTab(workspaceKey, "parent-tab")(row));
|
||||
expect(result.current?.tab.target).toEqual({ kind: "agent", agentId: "child" });
|
||||
|
||||
let tabId: string | null = null;
|
||||
act(() => {
|
||||
const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" });
|
||||
tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null;
|
||||
tabId = result.current ? store.openTabInFront(workspaceKey, result.current) : null;
|
||||
});
|
||||
expect(tabId).not.toBeNull();
|
||||
expect(result.current).toBe(false);
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
act(() => {
|
||||
store.closeTab(workspaceKey, tabId ?? "");
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
expect(result.current?.tab.tabId).toBe(tabId);
|
||||
});
|
||||
|
||||
it("hands a re-rendered row the same sheet", () => {
|
||||
const row = seedParentWithChild();
|
||||
useWorkspaceLayoutStore.getState().setLayoutMode("compact");
|
||||
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useSubagentSheetTab(workspaceKey, "parent-tab")(row),
|
||||
);
|
||||
const first = result.current;
|
||||
rerender();
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
selectSheetTab,
|
||||
useWorkspaceLayoutStore,
|
||||
type SheetTab,
|
||||
} 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, the sheet that shows it in place of the list, or null when choosing
|
||||
* it reveals a tab instead. `parentTabId` is the tab of the pane the list belongs to.
|
||||
*/
|
||||
export function useSubagentSheetTab(
|
||||
workspaceKey: string | null,
|
||||
parentTabId: string,
|
||||
): (row: SubagentRow) => SheetTab | null {
|
||||
// The rows render the answer, so this subscribes to everything the answer reads. A subagent
|
||||
// already open as a tab reveals that tab instead, and a row rendered while that tab existed
|
||||
// must re-render once it closes.
|
||||
const layoutMode = useWorkspaceLayoutStore((state) => state.layoutMode);
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.sheetsByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
return useMemo(() => {
|
||||
// One answer per row until the state it reads changes, so a re-render of the list hands an
|
||||
// open sheet the same tab rather than a fresh one.
|
||||
const answers = new Map<string, SheetTab | null>();
|
||||
return (row) => {
|
||||
if (!workspaceKey) return null;
|
||||
const state = {
|
||||
layoutMode,
|
||||
layoutByWorkspace: layout ? { [workspaceKey]: layout } : {},
|
||||
sheetsByWorkspace: sheets ? { [workspaceKey]: sheets } : {},
|
||||
};
|
||||
const key = `${row.kind}:${row.id}`;
|
||||
const known = answers.get(key);
|
||||
if (known !== undefined) return known;
|
||||
const sheet = selectSheetTab(state, {
|
||||
workspaceKey,
|
||||
target: subagentRowTarget(row),
|
||||
parentTabId,
|
||||
});
|
||||
answers.set(key, sheet);
|
||||
return sheet;
|
||||
};
|
||||
}, [layout, layoutMode, parentTabId, sheets, workspaceKey]);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { getPanelManifest } from "@/panels/panel-manifest";
|
||||
import { useSheetPanePage } from "@/screens/workspace/sheet-pane";
|
||||
import type { SheetTab } from "@/stores/workspace-layout-store";
|
||||
import type { SubagentRow } from "./select";
|
||||
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||
import {
|
||||
@@ -40,10 +42,10 @@ export interface SubagentsTrackProps {
|
||||
onOpenSubagent: (address: AgentAddress) => 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.
|
||||
* The sheet a row opens, or null when it opens as a tab. A sheet replaces the list in place and
|
||||
* Back returns to it; a tab replaces the list's pane, so choosing it ends the list.
|
||||
*/
|
||||
opensInSheet: (row: SubagentRow) => boolean;
|
||||
sheetTabFor: (row: SubagentRow) => SheetTab | null;
|
||||
onArchiveSubagent: (address: AgentAddress) => void;
|
||||
onArchiveFinished?: () => void;
|
||||
archiveFinishedStatus?: ArchiveFinishedStatus;
|
||||
@@ -71,13 +73,14 @@ export function SubagentsTrack({
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
sheetTabFor,
|
||||
onArchiveSubagent,
|
||||
onArchiveFinished,
|
||||
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const sheetPanePage = useSheetPanePage();
|
||||
|
||||
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
|
||||
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
|
||||
@@ -88,9 +91,12 @@ export function SubagentsTrack({
|
||||
const pill = buildSubagentPillPresentation(t, rows);
|
||||
const finishedCount = countFinishedSubagents(rows);
|
||||
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
|
||||
const sheets = rows.map(sheetTabFor);
|
||||
const pages = sheets.flatMap((sheet) => (sheet ? [sheetPanePage(sheet)] : []));
|
||||
|
||||
return (
|
||||
<ComposerTrackPill
|
||||
pages={pages}
|
||||
testID="subagents-track-header"
|
||||
segments={pill.segments}
|
||||
accessibilityLabel={pill.accessibilityLabel}
|
||||
@@ -105,14 +111,14 @@ export function SubagentsTrack({
|
||||
/>
|
||||
</ComposerTrackActions>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
{rows.map((row, index) => (
|
||||
<SubagentsTrackRow
|
||||
key={row.kind === "paseo" ? `${row.address.serverId}:${row.id}` : row.id}
|
||||
row={row}
|
||||
serverId={serverId}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet(row)}
|
||||
sheetTabId={sheets[index]?.tab.tabId ?? null}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
@@ -184,7 +190,8 @@ interface SubagentsTrackRowProps {
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (address: AgentAddress) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
opensInSheet: boolean;
|
||||
/** The sheet page the row opens in place of the list, when it opens in a sheet. */
|
||||
sheetTabId: string | null;
|
||||
onArchiveSubagent: (address: AgentAddress) => void;
|
||||
onDetachSubagent?: (address: AgentAddress) => void;
|
||||
}
|
||||
@@ -194,7 +201,7 @@ function SubagentsTrackRow({
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
sheetTabId,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
@@ -269,7 +276,7 @@ function SubagentsTrackRow({
|
||||
<ComposerTrackRow
|
||||
accessibilityLabel={displayLabel}
|
||||
testID={`subagents-track-row-${row.id}`}
|
||||
closeOnSelect={!opensInSheet}
|
||||
opensPage={sheetTabId ?? undefined}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{renderRow}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type PanelIconProps,
|
||||
type PanelPresentation,
|
||||
} from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import {
|
||||
getTerminalProfileIcon,
|
||||
resolveTerminalProfiles,
|
||||
@@ -109,7 +108,6 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
const { config } = useDaemonConfig(serverId);
|
||||
const plugins = useInstalledPlugins();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
ensurePanelsRegistered();
|
||||
|
||||
const launchSelection = useCallback(
|
||||
(selection: NewTabSelection) => (destination: WorkspaceTabLaunchDestination) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_PANE_ID,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
selectSheetTab,
|
||||
useWorkspaceLayoutStore,
|
||||
type WorkspaceTabPlacement,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -144,7 +145,7 @@ export function openPreferredWorkspacePreview(
|
||||
// The sheet is already a single replaceable slot, so it needs no preview tab.
|
||||
if (
|
||||
existing ||
|
||||
store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" })
|
||||
selectSheetTab(store, { workspaceKey: input.workspaceKey, target: input.target }) !== null
|
||||
) {
|
||||
return openPreferredWorkspaceTarget(input);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export function useBottomSheetModal(): never {
|
||||
export function useBottomSheet(): never {
|
||||
throw new Error("useBottomSheet is unavailable in tests");
|
||||
}
|
||||
// A drag area only hands its gesture to a sheet; with no sheet drawn there is nothing to drag.
|
||||
export function useBottomSheetGestureHandlers(): never {
|
||||
throw new Error("useBottomSheetGestureHandlers is unavailable in tests");
|
||||
}
|
||||
export const BottomSheetModalInternalProvider = PassThrough;
|
||||
export const INITIAL_CONTAINER_LAYOUT = {
|
||||
height: 0,
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
// The package ships untranspiled Flow, which the unit project cannot parse. Insets are a device
|
||||
// fact no test asserts on, so they are zero.
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// The package ships untranspiled Flow, which the unit project cannot parse. Like the real package,
|
||||
// the hook reads the insets context; outside a provider the insets are zero.
|
||||
const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 } as const;
|
||||
|
||||
export const useSafeAreaInsets = () => ZERO_INSETS;
|
||||
export interface EdgeInsets {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export const SafeAreaInsetsContext = createContext<EdgeInsets | null>(ZERO_INSETS);
|
||||
export const useSafeAreaInsets = () => useContext(SafeAreaInsetsContext) ?? ZERO_INSETS;
|
||||
export const useSafeAreaFrame = () => ({ x: 0, y: 0, width: 0, height: 0 });
|
||||
export const SafeAreaProvider = ({ children }: { children?: unknown }) => children;
|
||||
export const SafeAreaView = ({ children }: { children?: unknown }) => children;
|
||||
|
||||
@@ -24,7 +24,9 @@ export default defineConfig({
|
||||
transform(source, id) {
|
||||
// The ESM package contains a CommonJS WASM factory. Metro handles the
|
||||
// interop; Vite needs an ESM export instead of the CommonJS trailer.
|
||||
if (!id.endsWith("/lib/module/web/wasm/md4c.js")) return;
|
||||
// Vite can request the file with a version query (`md4c.js?v=<hash>`), so match the
|
||||
// path without it.
|
||||
if (!id.split("?")[0].endsWith("/lib/module/web/wasm/md4c.js")) return;
|
||||
const trailer = source.lastIndexOf("\nif (typeof exports === 'object'");
|
||||
if (trailer < 0) throw new Error("Markdown WASM CommonJS trailer was not found");
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user