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