Stack workspace sheets and tighten sidebar density (#6885)

* Stack workspace sheets and tighten sidebar density

- Workspace sheets stack: opening a target from a sheet covers it, Back and
  drag close only the top sheet, and the header is the sheet's drag handle.
- The workspace sheet reuses the shared sheet handle, background and header.
- Fix the shared sheet handle indicator rendering black.
- Sidebar text returns to semantic sizes; Pinned and Workspaces share one
  section header.

* Pause covered workspace sheets and mock the shared sheet handle in tests

* Export BottomSheetHandle from the browser test Gorhom stub

* Let a stacked sheet's backdrop catch taps on iOS

* Bring a revealed sheet to the top instead of duplicating it as a tab
This commit is contained in:
Mohamed Boudra
2026-10-15 06:30:17 +07:00
committed by GitHub
parent f3ddf99fe3
commit 6ad124758c
22 changed files with 786 additions and 462 deletions
+11 -6
View File
@@ -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.
+1 -1
View File
@@ -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).
@@ -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<void> {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.last()
.click({ position: { x: 10, y: 10 } });
}
@@ -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 <Animated.View pointerEvents="none" style={combinedStyle} />;
}
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<typeof BottomSheetHandle>) {
if (!children) return <ThemedBottomSheetHandle {...props} />;
// The children carry their own semantics; the handle's slider role would swallow them on web.
return (
<ThemedBottomSheetHandle
{...props}
style={HANDLE_WITH_CONTENT_STYLE}
accessible={false}
accessibilityRole={null}
accessibilityLabel={null}
accessibilityHint={null}
>
{children}
</ThemedBottomSheetHandle>
);
}
/**
* 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}
+13 -38
View File
@@ -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 (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
<SidebarSectionHeader title="Workspaces">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</SidebarSectionHeader>
);
}
@@ -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,
@@ -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<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
@@ -2413,7 +2414,12 @@ function ProjectModeList({
<>
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -2550,12 +2556,12 @@ const styles = StyleSheet.create((theme) => ({
},
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,
@@ -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 (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.header}
testID="sidebar-pinned-section-header"
>
{({ hovered }) => (
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
</Pressable>
);
}
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,
},
}));
@@ -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",
@@ -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,
},
@@ -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,
},
@@ -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 (
<View style={styles.header}>
{onToggle ? (
<CollapsibleTitle
title={title}
collapsed={collapsed ?? false}
onToggle={onToggle}
testID={testID}
/>
) : (
<Text style={styles.title} testID={testID}>
{title}
</Text>
)}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
);
}
function CollapsibleTitle({
title,
collapsed,
onToggle,
testID,
}: {
title: string;
collapsed: boolean;
onToggle: () => void;
testID?: string;
}) {
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.toggle}
testID={testID}
>
{({ hovered }) => (
<>
<Text style={styles.title}>{title}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
</Pressable>
);
}
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],
},
}));
@@ -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 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -1099,7 +1105,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -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,
@@ -154,3 +154,4 @@ export function useIsolatedBottomSheetVisibility({
}
export { SheetVisibleFrame } from "./visible-frame";
export { systemBackPress } from "./back-press";
@@ -80,6 +80,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
await vi.importActual<typeof import("react-native")>("react-native");
return {
BottomSheetBackdrop: () => null,
BottomSheetHandle: () => null,
BottomSheetModal: ReactModule.forwardRef(() => null),
BottomSheetScrollView: ScrollView,
BottomSheetTextInput: TextInput,
@@ -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({
@@ -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 (
<BottomSheetBackdrop
@@ -58,6 +65,45 @@ function renderBackdrop(props: BottomSheetBackdropProps) {
);
}
// 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;
@@ -73,96 +119,54 @@ interface WorkspaceSheetPresentationProps extends Omit<
"workspaceKey" | "onCloseTab"
> {
sheet: WorkspaceSheet;
onClose: () => Promise<void>;
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<void>;
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 (
<WorkspaceSheetPresentation
{...rest}
sheet={sheet}
onClose={handleClose}
onOpenAsTab={handleOpenAsTab}
/>
<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,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: WorkspaceSheetPresentationProps) {
const sheetRef = useRef<BottomSheet>(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 (
<View style={styles.host} pointerEvents="box-none">
<ThemedBottomSheet
ref={sheetRef}
index={0}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
backdropComponent={renderBackdrop}
onChange={handleChange}
>
<WorkspaceSheetBody
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
sheet={sheet}
serverId={serverId}
workspaceId={workspaceId}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
onClose={onClose}
onOpenAsTab={onOpenAsTab}
/>
</ThemedBottomSheet>
</View>
);
}
function WorkspaceSheetBody({
sheet,
isBottom,
isTop,
serverId,
workspaceId,
isWorkspaceFocused,
@@ -172,17 +176,110 @@ function WorkspaceSheetBody({
}: 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}`);
}
// 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<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,
@@ -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: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
actions: sheet.policy.openAsTab ? (
<Button size="sm" variant="secondary" onPress={onOpenAsTab}>
{t("workspace.sheet.openAsTab")}
</Button>
) : undefined,
}),
[descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t],
);
return (
<View style={styles.body} role="dialog" aria-label={descriptor.label}>
<SheetHeaderView header={header} onClose={handleClose} />
<View style={contentStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isWorkspaceFocused}
/>
</View>
<View style={contentStyle} testID="workspace-sheet-body">
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
}
@@ -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,
@@ -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<typeof createCompactStore>) {
return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1);
}
function sheetTargets(store: ReturnType<typeof createCompactStore>) {
return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map(
(sheet) => sheet.tab.target,
);
}
function tabKinds(store: ReturnType<typeof createCompactStore>): 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<typeof createCompactStore>): 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"], []]);
+181 -117
View File
@@ -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<string, WorkspaceSheet>;
/**
* 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<string, readonly WorkspaceSheet[]>;
layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
explorerSidebarWidthByWorkspace: Record<string, number>;
@@ -148,9 +151,8 @@ interface WorkspaceLayoutStore {
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
) => 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<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetByWorkspace">,
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
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<WorkspaceLayoutStore, "sheetByWorkspace">,
const NO_SHEETS: readonly WorkspaceSheet[] = [];
function getSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
): Pick<WorkspaceLayoutStore, "sheetByWorkspace"> {
if (!(workspaceKey in state.sheetByWorkspace)) {
return { sheetByWorkspace: state.sheetByWorkspace };
): readonly WorkspaceSheet[] {
return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS;
}
function findSheet(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
): WorkspaceSheet | undefined {
return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId);
}
function withSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
sheets: readonly WorkspaceSheet[],
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
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<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
workspaceKey,
getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId),
);
}
function withSheetTab(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
update: (sheet: WorkspaceSheet) => WorkspaceSheet,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
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<WorkspaceLayoutStore, "sheetByWorkspace">,
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
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<WorkspaceLayoutStore> } | 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<WorkspaceLayoutStore, "sheetByWorkspace">,
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<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
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<WorkspaceLayoutStore> {
return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey);
tabId: string,
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | 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<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
const publish = (
state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetsByWorkspace">,
) => {
const ids = new Set<string>();
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);
}
+2 -10
View File
@@ -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<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
const COMPACT_TEXT_SIZE: Record<TextSize, keyof Theme["fontSize"]> = { sm: "base", base: "lg" };
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
function byLayout<T>(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];
}
@@ -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);
});
});
@@ -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;