diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index abd95fc35f..9415abd85a 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -7,7 +7,7 @@ contracts. | ---------------- | ---------------------------- | ------------------------------------------ | | Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock | | Side pane | Ordinary workspace content | Created and closed like any workspace pane | -| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab | +| Workspace sheet | Compact look-ups | Stacks tabs; each closes or becomes a tab | ## Panel host contract @@ -28,12 +28,17 @@ retention, while each shell owns its tabs, focus, dragging, resizing, and shortc 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 (`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. The sheet holds one ordinary -`WorkspaceTab`, which is not persisted. +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. -The sheet is modal and covers the whole compact column, header included. It renders in the -workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs the navigator and -every workspace provider an ordinary pane has, and a portal drops them +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)). A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it. diff --git a/docs/glossary.md b/docs/glossary.md index ac988e8d2b..dcf8178bc7 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -21,7 +21,7 @@ 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** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` 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). +- **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). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). diff --git a/packages/app/e2e/support/helpers/workspace-sheet.ts b/packages/app/e2e/support/helpers/workspace-sheet.ts index 8865073c81..b0544f7df6 100644 --- a/packages/app/e2e/support/helpers/workspace-sheet.ts +++ b/packages/app/e2e/support/helpers/workspace-sheet.ts @@ -7,8 +7,8 @@ export function workspaceSheet(page: Page, label: string): Locator { /** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */ const SHEET_SNAP_FRACTION = 0.92; -/** The dialog starts below the sheet's drag handle. */ -const SHEET_HANDLE_MAX_HEIGHT = 32; +/** The header starts below the sheet's drag indicator, inside the handle. */ +const SHEET_INDICATOR_MAX_HEIGHT = 32; /** * The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it @@ -18,14 +18,16 @@ export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Pro 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"); await expect .poll(async () => { - const box = await sheet.boundingBox(); - if (!box) return null; - const top = Math.round(box.y); + const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]); + if (!headerBox || !bodyBox) return null; + const top = Math.round(headerBox.y); return { - dockedToBottom: Math.round(box.y + box.height) === viewportHeight, - topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT, + dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight, + topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT, }; }) .toEqual({ dockedToBottom: true, topAtSnap: true }); @@ -73,9 +75,11 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise< await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); } +/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */ export async function tapWorkspaceSheetBackdrop(page: Page): Promise { await page .getByRole("button", { name: "Bottom sheet backdrop" }) + .last() .click({ position: { x: 10, y: 10 } }); } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 707c0e8622..f7b4d4056c 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Keyboard, Pressable, Text, View } from "react-native"; import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, @@ -12,7 +12,7 @@ import { useGlobalWebOverlayLayer, useWebOverlayRegistration, } from "../lib/overlay-root"; -import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated from "react-native-reanimated"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { @@ -223,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({ const WEB_EXIT_DURATION_MS = 160; -function SheetBackground({ style }: BottomSheetBackgroundProps) { +/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */ +export function SheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); const combinedStyle = useMemo( () => [ @@ -239,6 +240,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { return ; } +const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({ + indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, +})); + +const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 }; + +/** + * The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children + * join the handle, so a header passed here drags the sheet like the indicator does. That is how a + * sheet whose content scrolls on its own keeps its header draggable without content panning. + */ +export function SheetHandle({ + children, + // Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator. + indicatorStyle: _indicatorStyle, + ...props +}: ComponentProps) { + if (!children) return ; + // The children carry their own semantics; the handle's slider role would swallow them on web. + return ( + + {children} + + ); +} + /** * The sheet body, indented to the sheet's content inset. * @@ -449,7 +483,6 @@ export function AdaptiveModalSheet({ bodyStyle, contextBridge = null, }: AdaptiveModalSheetProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); @@ -481,10 +514,6 @@ export function AdaptiveModalSheet({ ) : null, [footer, footerClearanceStyle, footerContainerStyle], ); - const handleIndicatorStyle = useMemo( - () => ({ backgroundColor: theme.colors.palette.zinc[600] }), - [theme.colors.palette.zinc], - ); useEffect(() => { if (!isWeb && visible) { // A newly opened sheet owns input. A keyboard belonging to the sheet below @@ -600,7 +629,7 @@ export function AdaptiveModalSheet({ backdropOpacity={0.45} enablePanDownToClose backgroundComponent={SheetBackground} - handleIndicatorStyle={handleIndicatorStyle} + handleComponent={SheetHandle} keyboardBehavior="extend" keyboardBlurBehavior="restore" accessible={false} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 087a3ffca8..f054668347 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -35,6 +35,7 @@ import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { DensityIcon } from "@/components/ui/density-icon"; +import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; @@ -805,21 +806,18 @@ function DesktopSidebar({ function WorkspacesSectionHeader() { return ( - - Workspaces - - - - - - - - - - - - - + + + + + + + + + + + + ); } @@ -850,29 +848,6 @@ const styles = StyleSheet.create((theme) => ({ sidebarHeaderGroupBelowChrome: { paddingTop: 0, }, - workspacesSectionHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[2], - // Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title - // lands at spacing[2] left to align with project icons. Settings2's painted path stops - // inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail. - paddingLeft: theme.spacing[2], - paddingRight: 4, - paddingTop: theme.spacing[1], - paddingBottom: theme.spacing[1], - }, - workspacesSectionTitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - workspacesSectionActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, sidebarContent: { flex: 1, minHeight: 0, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index bbda666436..c750121368 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,7 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { density, densityFontSize, densityIconBox } from "@/styles/density"; +import { density, densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -93,7 +93,7 @@ import { SidebarWorkspaceMenu, } from "@/components/sidebar/sidebar-workspace-menu"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; -import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; +import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { @@ -2114,6 +2114,7 @@ function ProjectModeList({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; }) { + const { t } = useTranslation(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( @@ -2413,7 +2414,12 @@ function ProjectModeList({ <> {pinnedChats.length > 0 ? ( - + {pinnedCollapsed ? null : ( <> ({ }, newWorkspaceGhostText: { color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, minWidth: 0, flexShrink: 1, }, newWorkspaceGhostTextHovered: { - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, minWidth: 0, flexShrink: 1, color: theme.colors.foreground, @@ -2603,7 +2609,7 @@ const styles = StyleSheet.create((theme) => ({ }, projectTitle: { color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, fontWeight: "400", minWidth: 0, flexShrink: 1, diff --git a/packages/app/src/components/sidebar/pinned-section-header.tsx b/packages/app/src/components/sidebar/pinned-section-header.tsx deleted file mode 100644 index 2169492a08..0000000000 --- a/packages/app/src/components/sidebar/pinned-section-header.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { ChevronDown, ChevronRight } from "lucide-react-native"; -import { Pressable, Text } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { DensityIcon } from "@/components/ui/density-icon"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; -import { density, densityFontSize } from "@/styles/density"; - -export function PinnedSectionHeader({ - collapsed, - onToggle, -}: { - collapsed: boolean; - onToggle: () => void; -}) { - const { t } = useTranslation(); - const isCompact = useIsCompactFormFactor(); - const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); - - return ( - - {({ hovered }) => ( - <> - {t("sidebar.pinned.title")} - {hovered || isNative || isCompact ? ( - - ) : null} - - )} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - header: { - minHeight: density.rowHeight, - flexDirection: "row", - alignItems: "center", - alignSelf: "flex-start", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - userSelect: "none", - }, - title: { - color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "sm"), - fontWeight: theme.fontWeight.normal, - }, -})); diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx index 3f31181220..1830e3fa10 100644 --- a/packages/app/src/components/sidebar/project-leading-visual.tsx +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; -import { density, densityIconBox, densityIconProps } from "@/styles/density"; +import { densityIconBox, densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { @@ -24,6 +24,9 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; // Every surfaced status shares one badge shell, so the badge never changes size or position // between states. Only the thing inside it changes. +// Matches the workspace title's lineHeight (sidebar-workspace-row-content's +// workspaceBranchText) so the icon centers on the title rather than floating above it. +const LEADING_SLOT_HEIGHT = 20; const STATUS_BADGE_SIZE = 12; // How far the badge overhangs the icon's bottom-right corner. Well short of half the badge: // centered on the corner it reads as hanging off the icon rather than sitting on it, and the @@ -273,10 +276,10 @@ const styles = StyleSheet.create((theme) => { // The slot is as tall as the title's line box, not as tall as the icon, and centers the // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot // only as tall as the icon puts it above the title and the kebab — which is why the - // workspace status indicator shares the same `rowLineHeight`. + // workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`. projectLeadingVisualSlot: { width: densityIconBox("md"), - height: density.rowLineHeight, + height: LEADING_SLOT_HEIGHT, flexShrink: 0, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx index 3d437ae0ca..01e07eedd4 100644 --- a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx @@ -6,7 +6,7 @@ import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content"; import { DensityIcon } from "@/components/ui/density-icon"; -import { density, densityFontSize, densityIconBox } from "@/styles/density"; +import { density, densityIconBox } from "@/styles/density"; /** * The row that ends a truncated group. It is a workspace row that happens to say "Show more", so @@ -100,13 +100,13 @@ const styles = StyleSheet.create((theme) => ({ }, text: { color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, minWidth: 0, flexShrink: 1, }, textHovered: { color: theme.colors.foreground, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, minWidth: 0, flexShrink: 1, }, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 7001bf66a6..2eeded947d 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; import { DensityIcon } from "@/components/ui/density-icon"; import { Shortcut } from "@/components/ui/shortcut"; -import { density, densityFontSize, densityIconBox } from "@/styles/density"; +import { density, densityIconBox } from "@/styles/density"; import type { ShortcutKey } from "@/utils/format-shortcut"; type SidebarHeaderRowVariant = "header" | "compact" | "inline"; @@ -152,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({ iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") }, label: { flexShrink: 1, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, diff --git a/packages/app/src/components/sidebar/sidebar-section-header.tsx b/packages/app/src/components/sidebar/sidebar-section-header.tsx new file mode 100644 index 0000000000..7a959435bc --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-section-header.tsx @@ -0,0 +1,115 @@ +import { useMemo, type ReactNode } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { DensityIcon } from "@/components/ui/density-icon"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { density } from "@/styles/density"; + +/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle` + * collapses from its title; children sit at the trailing edge as its actions. */ +export function SidebarSectionHeader({ + title, + collapsed, + onToggle, + testID, + children, +}: { + title: string; + collapsed?: boolean; + onToggle?: () => void; + testID?: string; + children?: ReactNode; +}) { + return ( + + {onToggle ? ( + + ) : ( + + {title} + + )} + {children ? {children} : null} + + ); +} + +function CollapsibleTitle({ + title, + collapsed, + onToggle, + testID, +}: { + title: string; + collapsed: boolean; + onToggle: () => void; + testID?: string; +}) { + const isCompact = useIsCompactFormFactor(); + const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); + + return ( + + {({ hovered }) => ( + <> + {title} + {hovered || isNative || isCompact ? ( + + ) : null} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + minHeight: density.rowHeight, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + // The title lands at spacing[2] to align with the rows' leading icons. Trailing actions + // are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink + // rather than the box to the row rail. + paddingLeft: theme.spacing[2], + paddingRight: 4, + userSelect: "none", + }, + // The toggle reaches back over the header's leading padding so the whole label edge is pressable. + toggle: { + alignSelf: "stretch", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + marginLeft: -theme.spacing[2], + paddingHorizontal: theme.spacing[2], + }, + title: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 85c0d524f8..baffc3a010 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { StyleSheet } from "react-native-unistyles"; -import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { @@ -73,7 +73,7 @@ import { SidebarWorkspaceContextMenu, SidebarWorkspaceMenu, } from "@/components/sidebar/sidebar-workspace-menu"; -import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; +import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin"; @@ -146,6 +146,7 @@ export function SidebarStatusWorkspaceList({ parentGestureRef, dragGestureHostActive, }: StatusWorkspaceListProps) { + const { t } = useTranslation(); const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedWorkspaceGroupKeys, ); @@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({ <> {pinnedWorkspaces.length > 0 ? ( - + {pinnedCollapsed ? null : ( <> ({ }, statusGroupTitle: { color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, fontWeight: "400", minWidth: 0, flexShrink: 1, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 33f14afcb5..7fc904f3b3 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -16,7 +16,7 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; import { useAppSettings } from "@/hooks/use-settings"; -import { density, densityFontSize, densityIconBox } from "@/styles/density"; +import { densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; @@ -453,6 +453,9 @@ export function SidebarWorkspaceTrailingActionOverlay({ ); } +// The status dot centres on the title's first line, so both share its line height. +const TITLE_LINE_HEIGHT = 20; + const styles = StyleSheet.create((theme) => ({ workspaceRowContent: { position: "relative", @@ -481,7 +484,7 @@ const styles = StyleSheet.create((theme) => ({ workspaceStatusDot: { position: "relative", width: densityIconBox("md"), - height: density.rowLineHeight, + height: TITLE_LINE_HEIGHT, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", @@ -513,9 +516,9 @@ const styles = StyleSheet.create((theme) => ({ // to the meta row, so it takes the full width the trailing slot leaves behind. workspaceBranchText: { color: theme.colors.foreground, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, fontWeight: "400", - lineHeight: density.rowLineHeight, + lineHeight: TITLE_LINE_HEIGHT, opacity: 0.76, flex: 1, minWidth: 0, diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 8dd55e5678..55e475b424 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -154,3 +154,4 @@ export function useIsolatedBottomSheetVisibility({ } export { SheetVisibleFrame } from "./visible-frame"; +export { systemBackPress } from "./back-press"; diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 0ede3f803b..8a502749db 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -80,6 +80,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { await vi.importActual("react-native"); return { BottomSheetBackdrop: () => null, + BottomSheetHandle: () => null, BottomSheetModal: ReactModule.forwardRef(() => null), BottomSheetScrollView: ScrollView, BottomSheetTextInput: TextInput, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a8089b6112..ef5b6aa779 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -78,6 +78,7 @@ import { selectExplorerSidebarPaneId, selectPaneFocusSuspended, type WorkspaceLayout, + type WorkspaceSheet, type WorkspaceTabPlacement, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, @@ -207,6 +208,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 WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; @@ -1322,8 +1324,12 @@ function WorkspaceScreenContent({ const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const sheets = useWorkspaceLayoutStore((state) => + persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS, + ); + // Only the top sheet is on screen. const sheetTab = useWorkspaceLayoutStore((state) => - persistenceKey ? (state.sheetByWorkspace[persistenceKey]?.tab ?? null) : null, + persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null, ); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); @@ -2082,7 +2088,7 @@ function WorkspaceScreenContent({ const handleCloseTabById = useCallback( async (tabId: string) => { - if (persistenceKey && sheetTab?.tabId === tabId) { + if (persistenceKey && sheets.some((sheet) => sheet.tab.tabId === tabId)) { if (await confirmDiscardModifiedTab(tabId)) { closeWorkspaceTab(persistenceKey, tabId); } @@ -2113,7 +2119,7 @@ function WorkspaceScreenContent({ handleClosePassiveTab, handleCloseTerminalTab, persistenceKey, - sheetTab, + sheets, ], ); @@ -3536,8 +3542,8 @@ function WorkspaceScreenContent({ const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null; const workspacePanelTabIds = useMemo( - () => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])], - [uiTabs, sheetTab], + () => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)], + [uiTabs, sheets], ); const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`; const compactHeaderLayout = resolveCompactHeaderLayout({ diff --git a/packages/app/src/screens/workspace/workspace-sheet-host.tsx b/packages/app/src/screens/workspace/workspace-sheet-host.tsx index d44bdb6cc9..a46ea2c1c7 100644 --- a/packages/app/src/screens/workspace/workspace-sheet-host.tsx +++ b/packages/app/src/screens/workspace/workspace-sheet-host.tsx @@ -1,16 +1,31 @@ import BottomSheet, { BottomSheetBackdrop, type BottomSheetBackdropProps, + type BottomSheetHandleProps, } from "@gorhom/bottom-sheet"; import { useIsFocused } from "@react-navigation/native"; -import { type ComponentType, useCallback, useEffect, useMemo, useRef } from "react"; -import { BackHandler, View } from "react-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 { SheetHeaderView } from "@/components/adaptive-modal-sheet"; +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 { isNative } from "@/constants/platform"; import type { PanelIconProps } from "@/panels/panel-registry"; import { getPanelRegistration } from "@/panels/panel-registry"; import { ensurePanelsRegistered } from "@/panels/register-panels"; @@ -22,17 +37,7 @@ import { import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store"; const SNAP_POINTS = ["92%"]; - -const ThemedBottomSheet = withUnistyles(BottomSheet, (theme) => ({ - backgroundStyle: { - backgroundColor: theme.colors.surface0, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], - }, - handleIndicatorStyle: { - backgroundColor: theme.colors.palette.zinc[600], - }, -})); +const NO_SHEETS: readonly WorkspaceSheet[] = []; function PanelIcon({ icon: Icon, @@ -46,6 +51,8 @@ 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 ( + ); +} + +interface WorkspaceSheetHandleValue { + header: SheetHeader; + onClose: () => void; +} + +const WorkspaceSheetHandleContext = createContext(null); + +// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and +// reads the header from context; a per-render function would remount the header on every change. +function WorkspaceSheetHandle(props: BottomSheetHandleProps) { + const value = useContext(WorkspaceSheetHandleContext); + if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet"); + return ( + + + + ); +} + export interface WorkspaceSheetHostProps { workspaceKey: string | null; serverId: string; @@ -73,96 +119,54 @@ interface WorkspaceSheetPresentationProps extends Omit< "workspaceKey" | "onCloseTab" > { sheet: WorkspaceSheet; - onClose: () => Promise; - onOpenAsTab: () => void; + /** Only the bottom sheet dims the workspace; the ones above share its dim. */ + isBottom: boolean; + /** Only the top sheet answers Android Back; the ones below wait their turn. */ + isTop: boolean; + onClose: (tabId: string) => Promise; + onOpenAsTab: (tabId: string) => void; } /** - * Hosts the workspace's sheet tab over the compact tab strip. It renders in the workspace tree, - * not through a portal, so the panel keeps every context an ordinary pane has. + * 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 sheet = useWorkspaceLayoutStore((state) => - workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null, + const sheets = useWorkspaceLayoutStore((state) => + workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, ); const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); - const sheetTabId = sheet?.tab.tabId ?? null; - const handleClose = useCallback(async () => { - if (sheetTabId) await onCloseTab(sheetTabId); - }, [onCloseTab, sheetTabId]); - const handleOpenAsTab = useCallback(() => { - if (workspaceKey) openSheetAsTab(workspaceKey); - }, [openSheetAsTab, workspaceKey]); - if (!sheet) return null; + const handleOpenAsTab = useCallback( + (tabId: string) => { + if (workspaceKey) openSheetAsTab(workspaceKey, tabId); + }, + [openSheetAsTab, workspaceKey], + ); + if (sheets.length === 0) return null; return ( - + + {sheets.map((sheet, index) => ( + + ))} + ); } function WorkspaceSheetPresentation({ sheet, - serverId, - workspaceId, - isWorkspaceFocused, - buildContent, - onClose, - onOpenAsTab, -}: WorkspaceSheetPresentationProps) { - const sheetRef = useRef(null); - const isScreenFocused = useIsFocused(); - const ownsBackPress = isNative && isWorkspaceFocused && isScreenFocused; - 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 onClose().then(() => sheetRef.current?.snapToIndex(0)); - }, - [onClose], - ); - useEffect(() => { - // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back. - if (!ownsBackPress) return; - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - void onClose(); - return true; - }); - return () => subscription.remove(); - }, [onClose, ownsBackPress]); - - return ( - - - - - - ); -} - -function WorkspaceSheetBody({ - sheet, + isBottom, + isTop, serverId, workspaceId, isWorkspaceFocused, @@ -172,17 +176,110 @@ function WorkspaceSheetBody({ }: WorkspaceSheetPresentationProps) { const { t } = useTranslation(); const { tab } = sheet; + const sheetRef = useRef(null); + const isScreenFocused = useIsFocused(); ensurePanelsRegistered(); const registration = getPanelRegistration(tab.target.kind); if (!registration) { throw new Error(`No panel registration for kind: ${tab.target.kind}`); } - // The body is keyed by tab id and kind, so the registration and its descriptor hook stay fixed. const descriptor = registration.useDescriptor(tab.target as never, { serverId, workspaceId, tabId: tab.tabId, }); + + const handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]); + const handleChange = useCallback( + (index: number) => { + if (index !== -1) return; + // A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up. + void handleClose().then(() => sheetRef.current?.snapToIndex(0)); + }, + [handleClose], + ); + // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back. + const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; + useEffect(() => { + if (!ownsBackPress) return; + return systemBackPress.subscribe(() => { + void handleClose(); + return true; + }); + }, [handleClose, ownsBackPress]); + + const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], + ); + const handleHeaderClose = useCallback(() => { + void handleClose(); + }, [handleClose]); + const handleValue = useMemo( + () => ({ header, onClose: handleHeaderClose }), + [handleHeaderClose, header], + ); + + return ( + // Gorhom renders the handle beside the content, so the dialog wraps the whole sheet. + + {/* The header is the sheet's handle: the panel scrolls on its own, so dragging the content + would fight it, and the header is where people reach to pull the sheet down. */} + + + {/* A covered sheet keeps its state but stops its live work until it is on top again. */} + + + + + + + ); +} + +function WorkspaceSheetBody({ + tab, + isPaneFocused, + isWorkspaceFocused, + buildContent, +}: { + tab: WorkspaceSheet["tab"]; + /** Only the top sheet's pane is focused; the ones below are covered. */ + isPaneFocused: boolean; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; +}) { const tabDescriptor = useMemo( () => ({ key: tab.tabId, @@ -194,38 +291,20 @@ function WorkspaceSheetBody({ [tab], ); const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); - const handleClose = useCallback(() => { - void onClose(); - }, [onClose]); // 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], ); - const header = useMemo( - () => ({ - title: descriptor.label, - leading: , - actions: sheet.policy.openAsTab ? ( - - ) : undefined, - }), - [descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t], - ); return ( - - - - - + + ); } @@ -234,9 +313,11 @@ const styles = StyleSheet.create({ host: { ...StyleSheet.absoluteFillObject, }, - body: { - flex: 1, - minHeight: 0, + clearBackdrop: { + backgroundColor: "transparent", + }, + sheetLayer: { + ...StyleSheet.absoluteFillObject, }, content: { flex: 1, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 8c061d18b3..fc973f442d 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -4449,7 +4449,7 @@ describe("compact workspace sheet", () => { function createCompactStore() { const store = createWorkspaceLayoutStore(workspaceLayoutIds); - store.setState({ layoutByWorkspace: {}, sheetByWorkspace: {} }); + store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} }); store.getState().setLayoutMode("compact"); store.getState().openTab({ workspaceKey, @@ -4459,6 +4459,16 @@ describe("compact workspace sheet", () => { return store; } + function topSheet(store: ReturnType) { + return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1); + } + + function sheetTargets(store: ReturnType) { + return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map( + (sheet) => sheet.tab.target, + ); + } + function tabKinds(store: ReturnType): string[] { const layout = store.getState().layoutByWorkspace[workspaceKey]; const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab])); @@ -4523,7 +4533,7 @@ describe("compact workspace sheet", () => { const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab).toEqual({ + expect(topSheet(store)?.tab).toEqual({ tabId, target: fileTarget, createdAt: expect.any(Number), @@ -4547,22 +4557,71 @@ describe("compact workspace sheet", () => { const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); expect(tabId).toBe(fileTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId); }); - it("replaces the sheet content with the next sheet target", () => { + it("stacks the next sheet target over the open sheet", () => { const store = createCompactStore(); store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(tabKinds(store)).toEqual(["terminal"]); + + store.getState().closeTab(workspaceKey, nextTabId ?? ""); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("closes a lower sheet and keeps the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); const nextTarget = { kind: "file" as const, path: "src/other.ts" }; store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget); + store.getState().closeTab(workspaceKey, fileTabId ?? ""); + + expect(sheetTargets(store)).toEqual([nextTarget]); expect(tabKinds(store)).toEqual(["terminal"]); }); + it("brings a stacked target back to the top by dropping the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(tabId).toBe(fileTabId); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("opens the top sheet as a tab and dismisses the sheets that would cover it", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + store.getState().openSheetAsTab(workspaceKey, nextTabId ?? ""); + + expect(sheetTargets(store)).toEqual([]); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(nextTabId); + }); + it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => { const store = createCompactStore(); store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); @@ -4572,7 +4631,7 @@ describe("compact workspace sheet", () => { target: { kind: "file", path: "src/background.ts" }, intent: "background", }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget); + expect(topSheet(store)?.tab.target).toEqual(fileTarget); expect(tabKinds(store)).toEqual(["terminal", "file"]); store.getState().openTab({ @@ -4580,7 +4639,7 @@ describe("compact workspace sheet", () => { target: { kind: "terminal", terminalId: "term-2" }, intent: "reveal", }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); }); it("moves the sheet tab into the focused pane with the same tab id and state", () => { @@ -4591,10 +4650,10 @@ describe("compact workspace sheet", () => { if (!sheetTabId) throw new Error("Expected a sheet tab"); store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 }); - const tabId = store.getState().openSheetAsTab(workspaceKey); + const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId); expect(tabId).toBe(sheetTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); const layout = store.getState().layoutByWorkspace[workspaceKey]; const pane = findPaneContainingTab(layout.root, sheetTabId); expect(pane?.id).toBe(layout.focusedPaneId); @@ -4616,7 +4675,7 @@ describe("compact workspace sheet", () => { expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore); store.getState().closeTab(workspaceKey, sheetTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); expect(tabKinds(store)).toEqual(["terminal"]); }); @@ -4639,19 +4698,21 @@ describe("compact workspace sheet", () => { store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); - expect(store.getState().sheetByWorkspace).toEqual({}); + expect(store.getState().sheetsByWorkspace).toEqual({}); expect(tabKinds(store)).toEqual(["terminal", "file"]); }); - it("keeps the sheet content as a tab when the layout widens", () => { + it("keeps every stacked sheet as a tab when the layout widens, focusing the top one", () => { const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); const sheetTabId = store .getState() - .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); store.getState().setLayoutMode("split-panes"); - expect(store.getState().sheetByWorkspace).toEqual({}); + expect(store.getState().sheetsByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file", "file"]); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId); }); @@ -4669,7 +4730,7 @@ describe("compact workspace sheet", () => { target: { kind: "agent", agentId: "child" }, intent: "reveal", }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({ + expect(topSheet(store)).toMatchObject({ tab: { target: { kind: "agent", agentId: "child" } }, policy: { openAsTab: true }, }); @@ -4679,7 +4740,7 @@ describe("compact workspace sheet", () => { target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" }, intent: "reveal", }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({ + expect(topSheet(store)).toMatchObject({ tab: { target: { kind: "provider_subagent", subagentId: "task-1" } }, policy: { openAsTab: false }, }); @@ -4689,7 +4750,7 @@ describe("compact workspace sheet", () => { .getState() .openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" }); } - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); }); @@ -4715,9 +4776,9 @@ describe("compact workspace sheet", () => { intent: "reveal", parentTabId: childSheetTabId ?? undefined, }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId); + expect(topSheet(store)?.parentTabId).toBe(rootTabId); - store.getState().openSheetAsTab(workspaceKey); + store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? ""); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId); }); @@ -4746,7 +4807,7 @@ describe("compact workspace sheet", () => { intent: "reveal", parentTabId: openChildSheet(), }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); let layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId); @@ -4756,7 +4817,7 @@ describe("compact workspace sheet", () => { intent: "reveal", parentTabId: openChildSheet(), }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId); expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId); @@ -4798,22 +4859,45 @@ describe("compact workspace sheet", () => { }); function sheetTabId(store: ReturnType): string | null { - return store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null; + return topSheet(store)?.tab.tabId ?? null; } - it("opens the next sheet target as a tab under a modified sheet", () => { + it("stacks the next sheet target over a modified sheet", () => { const store = createCompactStore(); - const modifiedSheetTabId = openModifiedFileSheet(store); + openModifiedFileSheet(store); const nextTarget = { kind: "file" as const, path: "src/other.ts" }; const nextTabId = store .getState() .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); - expect(sheetTabId(store)).toBe(modifiedSheetTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.state).toEqual({ scrollTop: 40 }); - expect(stripTab(store, nextTabId ?? "")).toMatchObject({ target: nextTarget }); - expect(focusedTabId(store)).toBe(nextTabId); + expect(sheetTabId(store)).toBe(nextTabId); + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.state).toEqual({ + scrollTop: 40, + }); + }); + + it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => { + 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 focusedBefore = focusedTabId(store); + + const tabId = store + .getState() + .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, + ); + expect(stripTab(store, tabId ?? "")).toBeNull(); + expect(focusedTabId(store)).toBe(focusedBefore); }); it("opens a tab target under a modified sheet without closing it", () => { @@ -4853,23 +4937,6 @@ describe("compact workspace sheet", () => { expect(tabKinds(store)).toEqual(["terminal", "terminal"]); }); - it("replaces an unmodified sheet with the next sheet target", () => { - const store = createCompactStore(); - store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); - - store.getState().openTab({ - workspaceKey, - target: { kind: "file", path: "src/other.ts" }, - intent: "reveal", - }); - - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual({ - kind: "file", - path: "src/other.ts", - }); - expect(tabKinds(store)).toEqual(["terminal"]); - }); - it("closes an unmodified sheet when another tab is chosen", () => { const store = createCompactStore(); const secondTabId = store.getState().openTab({ @@ -4913,10 +4980,38 @@ describe("compact workspace sheet", () => { }; store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined(); + expect(topSheet(store)).toBeDefined(); store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); + }); + + it("drops a departed agent's sheet from the middle of the stack", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + store.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: false, + activeAgentIds: ["root"], + autoOpenAgentIds: [], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); }); it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => { @@ -4941,7 +5036,7 @@ describe("compact workspace sheet", () => { }; store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId).toBe(sheetTabId); + expect(topSheet(store)?.tab.tabId).toBe(sheetTabId); seedAgents([ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, @@ -4949,7 +5044,7 @@ describe("compact workspace sheet", () => { ]); store.getState().reconcileTabs(workspaceKey, snapshot); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect( collectAllTabs(layout.root).filter( @@ -4975,7 +5070,7 @@ describe("compact workspace sheet", () => { .replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" }); expect(replacedTabId).toBe(sheetTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({ + expect(topSheet(store)).toMatchObject({ tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } }, policy: { openAsTab: true }, }); @@ -4992,7 +5087,7 @@ describe("compact workspace sheet", () => { terminalId: "term-2", }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); expect(replacedTabId).toBe(sheetTabId); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId); @@ -5008,12 +5103,12 @@ describe("compact workspace sheet", () => { const received: string[][] = []; const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store); - store.getState().openTab({ + const sheetTabId = store.getState().openTab({ workspaceKey, target: { kind: "agent", agentId: "child" }, intent: "reveal", }); - store.getState().closeSheet(workspaceKey); + store.getState().closeTab(workspaceKey, sheetTabId ?? ""); stop(); expect(received).toEqual([[], ["child"], []]); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 226265860a..ba186b655a 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -126,8 +126,11 @@ export interface WorkspaceSheet { interface WorkspaceLayoutStore { /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; - /** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */ - sheetByWorkspace: Record; + /** + * 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. + */ + sheetsByWorkspace: Record; layoutByWorkspace: Record; splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; @@ -148,9 +151,8 @@ interface WorkspaceLayoutStore { input: Pick, ) => boolean; setLayoutMode: (mode: WorkspaceLayoutMode) => void; - closeSheet: (workspaceKey: string) => void; - /** Moves the sheet's tab, keeping its tab id, into the focused pane. */ - openSheetAsTab: (workspaceKey: string) => string | null; + /** Moves a sheet's tab, keeping its tab id, into the focused pane. */ + openSheetAsTab: (workspaceKey: string, tabId: string) => string | null; /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ autoOpenPullRequestTab: ( workspaceKey: string, @@ -508,12 +510,12 @@ export function selectExplorerSidebarPaneId( /** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */ export function selectPaneFocusSuspended( - state: Pick, + state: Pick, workspaceKey: string, ): boolean { return ( Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) || - workspaceKey in state.sheetByWorkspace + workspaceKey in state.sheetsByWorkspace ); } @@ -682,15 +684,63 @@ function attachParentTab(input: { }); } -function withoutSheet( - state: Pick, +const NO_SHEETS: readonly WorkspaceSheet[] = []; + +function getSheets( + state: Pick, workspaceKey: string, -): Pick { - if (!(workspaceKey in state.sheetByWorkspace)) { - return { sheetByWorkspace: state.sheetByWorkspace }; +): readonly WorkspaceSheet[] { + return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS; +} + +function findSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): WorkspaceSheet | undefined { + return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId); +} + +function withSheets( + state: Pick, + workspaceKey: string, + sheets: readonly WorkspaceSheet[], +): Pick { + if (sheets.length > 0) { + return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } }; } - const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace; - return { sheetByWorkspace }; + if (!(workspaceKey in state.sheetsByWorkspace)) { + return { sheetsByWorkspace: state.sheetsByWorkspace }; + } + const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace; + return { sheetsByWorkspace }; +} + +function withoutSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId), + ); +} + +function withSheetTab( + state: Pick, + workspaceKey: string, + tabId: string, + update: (sheet: WorkspaceSheet) => WorkspaceSheet, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).map((sheet) => + sheet.tab.tabId === tabId ? update(sheet) : sheet, + ), + ); } function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) { @@ -713,30 +763,16 @@ function resolveSheetPolicy( } /** - * The parent a newly opened tab links to. A target opened from inside the sheet names the sheet's - * tab, which the open replaces or closes, so it inherits the sheet's own parent instead. + * The parent a newly opened tab links to. A sheet tab is not in the layout, so a target opened + * from inside a sheet inherits that sheet's own parent instead. */ function resolveOpenParentTabId( - state: Pick, + state: Pick, workspaceKey: string, parentTabId: string | undefined, ): string | null { - const sheet = state.sheetByWorkspace[workspaceKey]; - if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId; - return parentTabId ?? null; -} - -/** A reveal of the resource the sheet already shows keeps the sheet's tab. */ -function sheetKeepsTabFor( - sheet: WorkspaceSheet | undefined, - target: WorkspaceTabTarget, - intent: WorkspaceTabOpenIntent, -): sheet is WorkspaceSheet { - return ( - intent === "reveal" && - sheet !== undefined && - panelResourceKey(sheet.tab.target) === panelResourceKey(target) - ); + if (!parentTabId) return null; + return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId; } function placeTargetInSheet(input: { @@ -766,12 +802,16 @@ function placeTargetInSheet(input: { }; } -/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */ +/** + * Moves one sheet's tab into the workspace's focused pane, keeping its tab id and state. The + * other sheets stay; callers that bring the tab to the front dismiss them. + */ function promoteSheetToTab( state: WorkspaceLayoutStore, workspaceKey: string, + tabId: string, ): { tabId: string; patch: Partial } | null { - const sheet = state.sheetByWorkspace[workspaceKey]; + const sheet = findSheet(state, workspaceKey, tabId); if (!sheet) return null; const { tab } = sheet; const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT); @@ -798,7 +838,7 @@ function promoteSheetToTab( return { tabId: result.tabId, patch: { - ...withoutSheet(state, workspaceKey), + ...withoutSheet(state, workspaceKey, tab.tabId), ...withoutFocusRestoration(state, workspaceKey), hiddenAgentIdsByWorkspace: tab.target.kind === "agent" @@ -817,37 +857,55 @@ 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( - state: Pick, - workspaceKey: string, -): boolean { - const sheet = state.sheetByWorkspace[workspaceKey]; - return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId); +function sheetHoldsUnsavedWork(workspaceKey: string, sheet: WorkspaceSheet): boolean { + return isSheetTabModified(workspaceKey, sheet.tab.tabId); } /** - * Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the - * sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab. + * 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. */ function resolveSheetPlacement( state: WorkspaceLayoutStore, workspaceKey: string, target: WorkspaceTabTarget, intent: WorkspaceTabOpenIntent, -): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null { +): { policy: CompactSheetPolicy; keptIndex: number | null } | null { const policy = resolveSheetPolicy(state, workspaceKey, target, intent); if (!policy) return null; - const sheet = state.sheetByWorkspace[workspaceKey]; - if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet }; - return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined }; + if (intent !== "reveal") return { policy, keptIndex: null }; + const sheets = getSheets(state, workspaceKey); + const resourceKey = panelResourceKey(target); + const index = sheets.findIndex((sheet) => panelResourceKey(sheet.tab.target) === resourceKey); + return { policy, keptIndex: index === -1 ? null : index }; } -/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */ -function dismissSheet( +/** Closes the sheets because navigation moved on underneath them, except ones with unsaved work. */ +function dismissSheets( + state: Pick, + workspaceKey: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheetHoldsUnsavedWork(workspaceKey, sheet)), + ); +} + +/** Moves a sheet's tab to the front of the tab strip, dismissing the sheets that would cover it. */ +function openSheetTabInFront( state: WorkspaceLayoutStore, workspaceKey: string, -): Partial { - return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey); + tabId: string, +): { tabId: string; patch: Partial } | null { + const promoted = promoteSheetToTab(state, workspaceKey, tabId); + if (!promoted) return null; + const next = { ...state, ...promoted.patch }; + return { + tabId: promoted.tabId, + patch: { ...promoted.patch, ...dismissSheets(next, workspaceKey) }, + }; } /** @@ -857,10 +915,10 @@ function dismissSheet( */ function sheetAgentNoLongerFits( workspaceKey: string, - sheet: WorkspaceSheet | undefined, + sheet: WorkspaceSheet, snapshot: WorkspaceTabSnapshot, ): boolean { - if (sheet?.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; + if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true; return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null; } @@ -905,7 +963,7 @@ export function createWorkspaceLayoutStore( return tabId; }, layoutMode: "split-panes", - sheetByWorkspace: {}, + sheetsByWorkspace: {}, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, @@ -933,17 +991,26 @@ export function createWorkspaceLayoutStore( input.intent, ); if (sheetPlacement) { + const sheets = getSheets(get(), normalizedWorkspaceKey); + const { keptIndex } = sheetPlacement; const sheet = placeTargetInSheet({ - kept: sheetPlacement.kept, + kept: keptIndex === null ? undefined : sheets[keptIndex], target: normalizedTarget, intent: input.intent, policy: sheetPlacement.policy, parentTabId, state: input.state, }); - set((state) => ({ - sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet }, - })); + const below = + keptIndex === null + ? sheets + : [ + ...sheets.slice(0, keptIndex), + ...sheets + .slice(keptIndex + 1) + .filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)), + ]; + set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet])); return sheet.tab.tabId; } const placement = getOpenTabPlacement( @@ -990,7 +1057,7 @@ export function createWorkspaceLayoutStore( set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), // A tab brought to the front dismisses the sheet over it. - ...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)), + ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -1032,24 +1099,25 @@ export function createWorkspaceLayoutStore( set((state) => { if (state.layoutMode === mode) return state; if (mode === "compact") return { layoutMode: mode }; - // Widening keeps whatever the user had open in the sheet as an ordinary tab. + // Widening keeps whatever the user had open in sheets as ordinary tabs. const next: WorkspaceLayoutStore = { ...state, layoutMode: mode }; - for (const workspaceKey of Object.keys(state.sheetByWorkspace)) { - const promoted = promoteSheetToTab(next, workspaceKey); - Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey)); + for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) { + for (const sheet of sheets) { + const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId); + Object.assign( + next, + promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId), + ); + } } return next; }); }, - closeSheet: (workspaceKey) => { + openSheetAsTab: (workspaceKey, tabId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) return; - set((state) => withoutSheet(state, normalizedWorkspaceKey)); - }, - openSheetAsTab: (workspaceKey) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) return null; - const promoted = promoteSheetToTab(get(), normalizedWorkspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) return null; + const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId); if (!promoted) return null; set(promoted.patch); return promoted.tabId; @@ -1173,8 +1241,8 @@ export function createWorkspaceLayoutStore( } set((state) => { - if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) { - return withoutSheet(state, normalizedWorkspaceKey); + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId); } const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); const explorerSidebarPaneId = resolveExplorerSidebarPaneId( @@ -1283,8 +1351,8 @@ export function createWorkspaceLayoutStore( return { ...withoutFocusRestoration(state, normalizedWorkspaceKey), - // Choosing a tab dismisses the sheet that covers it. - ...dismissSheet(state, normalizedWorkspaceKey), + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1308,8 +1376,8 @@ export function createWorkspaceLayoutStore( }); return nextLayout ? { - // Choosing a tab dismisses the sheet that covers it. - ...dismissSheet(state, normalizedWorkspaceKey), + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1323,29 +1391,27 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null; - const sheet = get().sheetByWorkspace[normalizedWorkspaceKey]; - if (sheet?.tab.tabId === normalizedTabId) { + if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) { const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget); - const replaced: WorkspaceSheet = { - ...sheet, - policy: policy ?? sheet.policy, - tab: { - ...sheet.tab, - target: normalizedTarget, - ...(tabState !== undefined ? { state: tabState } : {}), - }, - }; const next: WorkspaceLayoutStore = { ...get(), - sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced }, + ...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({ + ...sheet, + policy: policy ?? sheet.policy, + tab: { + ...sheet.tab, + target: normalizedTarget, + ...(tabState !== undefined ? { state: tabState } : {}), + }, + })), }; if (policy) { - set({ sheetByWorkspace: next.sheetByWorkspace }); + set({ sheetsByWorkspace: next.sheetsByWorkspace }); return normalizedTabId; } - // A target the sheet does not host becomes the tab it would have opened as. - const promoted = promoteSheetToTab(next, normalizedWorkspaceKey); - set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey)); + // A target sheets do not host becomes the tab it would have opened as. + const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId); + set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId)); return promoted?.tabId ?? null; } const result = replaceTabTargetInLayout({ @@ -1378,18 +1444,14 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); if (!normalizedWorkspaceKey || !normalizedTabId) return; set((state) => { - const sheet = state.sheetByWorkspace[normalizedWorkspaceKey]; - if (sheet?.tab.tabId === normalizedTabId) { - const { state: _previous, ...tab } = sheet.tab; - return { - sheetByWorkspace: { - ...state.sheetByWorkspace, - [normalizedWorkspaceKey]: { - ...sheet, - tab: tabState === undefined ? tab : { ...tab, state: tabState }, - }, - }, - }; + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withSheetTab(state, normalizedWorkspaceKey, normalizedTabId, (sheet) => { + const { state: _previous, ...tab } = sheet.tab; + return { + ...sheet, + tab: tabState === undefined ? tab : { ...tab, state: tabState }, + }; + }); } const layout = setTabStateInLayout({ layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), @@ -1485,11 +1547,11 @@ export function createWorkspaceLayoutStore( ); } } - const sheetAgentGone = sheetAgentNoLongerFits( - normalizedWorkspaceKey, - state.sheetByWorkspace[normalizedWorkspaceKey], - snapshot, + const sheets = getSheets(state, normalizedWorkspaceKey); + const fittingSheets = sheets.filter( + (sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot), ); + const sheetAgentGone = fittingSheets.length !== sheets.length; if ( state.layoutByWorkspace[normalizedWorkspaceKey] && nextLayout === rawLayout && @@ -1500,7 +1562,7 @@ export function createWorkspaceLayoutStore( } return { - ...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}), + ...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}), pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, @@ -2006,7 +2068,7 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || normalizedWorkspaceKey in state.sidePaneIdByWorkspace || normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace || - normalizedWorkspaceKey in state.sheetByWorkspace; + normalizedWorkspaceKey in state.sheetsByWorkspace; if (!hasAny) { return state; } @@ -2035,7 +2097,7 @@ export function createWorkspaceLayoutStore( const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } = state.sidePaneIdByWorkspace; return { - ...withoutSheet(state, normalizedWorkspaceKey), + ...withSheets(state, normalizedWorkspaceKey, NO_SHEETS), pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, @@ -2152,14 +2214,16 @@ export function observeOpenWorkspaceAgentIds( store = useWorkspaceLayoutStore, ): () => void { let previous: string[] | undefined; - const publish = (state: Pick) => { + const publish = ( + state: Pick, + ) => { const ids = new Set(); const tabsByWorkspace = [ ...Object.entries(state.layoutByWorkspace).map( ([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const, ), - ...Object.entries(state.sheetByWorkspace).map( - ([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const, + ...Object.entries(state.sheetsByWorkspace).map( + ([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const, ), ]; for (const [workspaceKey, tabs] of tabsByWorkspace) { @@ -2177,7 +2241,7 @@ export function observeOpenWorkspaceAgentIds( const unsubscribe = store.subscribe((state, before) => { if ( state.layoutByWorkspace !== before.layoutByWorkspace || - state.sheetByWorkspace !== before.sheetByWorkspace + state.sheetsByWorkspace !== before.sheetsByWorkspace ) { publish(state); } diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts index 5b452bcb93..2383685d24 100644 --- a/packages/app/src/styles/density.ts +++ b/packages/app/src/styles/density.ts @@ -2,7 +2,7 @@ import { isCompactBreakpoint } from "@/constants/layout"; import { ICON_SIZE, type Theme } from "@/styles/theme"; /** - * Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact + * Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact * layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay * desktop-sized everywhere, so a surface migrates by swapping its sizes for these. * @@ -11,10 +11,8 @@ import { ICON_SIZE, type Theme } from "@/styles/theme"; */ export type DensityIconSize = keyof typeof ICON_SIZE; -type TextSize = "sm" | "base"; const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; -const COMPACT_TEXT_SIZE: Record = { sm: "base", base: "lg" }; // Compact layouts end where `md` begins; see `isCompactBreakpoint`. function byLayout(compact: T, desktop: T) { @@ -25,9 +23,7 @@ export const density = { /** A list row such as a sidebar entry. */ rowHeight: byLayout(44, 36), /** A row in a tight group, such as the sidebar's navigation rows. */ - tightRowHeight: byLayout(40, 28), - /** The line height of a row's `base` text; slots that align to its first line share it. */ - rowLineHeight: byLayout(22, 20), + tightRowHeight: byLayout(36, 28), /** A square icon-only button that sits in a row or toolbar. */ iconButton: byLayout(44, 28), }; @@ -37,10 +33,6 @@ export function densityIconBox(size: DensityIconSize) { return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]); } -export function densityFontSize(theme: Theme, size: TextSize) { - return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]); -} - export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number { return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size]; } diff --git a/packages/app/src/workspace-tabs/open-beside.test.ts b/packages/app/src/workspace-tabs/open-beside.test.ts index ac181eeb75..9e80745db8 100644 --- a/packages/app/src/workspace-tabs/open-beside.test.ts +++ b/packages/app/src/workspace-tabs/open-beside.test.ts @@ -27,7 +27,7 @@ const WORKSPACE_KEY = "server-1:workspace-1"; beforeEach(() => { useWorkspaceLayoutStore.setState({ layoutMode: "split-panes", - sheetByWorkspace: {}, + sheetsByWorkspace: {}, layoutByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, sidePaneIdByWorkspace: {}, @@ -56,7 +56,7 @@ describe("openPreferredWorkspacePreview", () => { }); const state = useWorkspaceLayoutStore.getState(); - expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({ + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.at(-1)?.tab).toMatchObject({ tabId, target: { kind: "file", path: "src/preview.ts" }, }); @@ -96,7 +96,7 @@ describe("previews under a sheet with unsaved work", () => { }); } - it("reuses one preview tab instead of adding a tab per link", () => { + it("stacks link previews over the sheet instead of dropping its unsaved work", () => { const store = useWorkspaceLayoutStore.getState(); store.openTab({ workspaceKey: WORKSPACE_KEY, @@ -114,18 +114,17 @@ describe("previews under a sheet with unsaved work", () => { { modified: true }, ); - const firstPreviewTabId = previewFile("src/first.ts"); - const secondPreviewTabId = previewFile("src/second.ts"); + previewFile("src/first.ts"); + previewFile("src/second.ts"); const state = useWorkspaceLayoutStore.getState(); - expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget); - expect(secondPreviewTabId).toBe(firstPreviewTabId); - const root = state.layoutByWorkspace[WORKSPACE_KEY].root; - const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target])); - expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([ - { kind: "terminal", terminalId: "term-1" }, + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.map((sheet) => sheet.tab.target)).toEqual([ + sheetTarget, + { kind: "file", path: "src/first.ts" }, { kind: "file", path: "src/second.ts" }, ]); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + expect(findPaneById(root, "main")?.tabIds).toHaveLength(1); }); }); diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 784a7569ff..5f3e8c7e8f 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -8,6 +8,7 @@ export default Stub; export const BottomSheetModal = Stub; export const BottomSheetModalProvider = PassThrough; export const BottomSheetBackdrop = Stub; +export const BottomSheetHandle = Stub; export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub;