mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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"], []]);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user