Make the workspace right pane a first-class tab host (#3287)

* feat(app): add persistent workspace side panes

Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry.

* fix(app): localize pull request empty state

* test(app): update explorer pane coverage

* fix(app): preserve explorer pane workflows

* test(app): make explorer tab selection idempotent

* fix(app): persist workspace review pins

* fix(app): preserve sidebar scrim drag hook

* fix(app): restore companion pane interactions

* test(app): follow selected sidebar surface
This commit is contained in:
Mohamed Boudra
2026-08-16 18:18:32 +02:00
committed by GitHub
parent 6dcf6a4b08
commit ab274d635e
81 changed files with 2900 additions and 1051 deletions
+12 -103
View File
@@ -13,18 +13,8 @@ import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
formatPrTabLabel,
PullRequestPane,
PullRequestPaneError,
PullRequestPaneSkeleton,
PullRequestTabIcon,
usePrPaneData,
} from "@/git/pull-request-panel";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
import { useToast } from "@/contexts/toast-context";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
@@ -35,17 +25,14 @@ import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/util
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
import { PullRequestContent } from "@/panels/pull-request";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -318,31 +305,19 @@ function ExplorerSidebarContent({
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const toast = useToast();
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
serverId,
cwd: workspaceRoot,
enabled: canQueryPullRequest && isOpen,
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
isGit,
requested: activeTab === "pr",
enabled: isOpen,
timelineEnabled: activeTab === "pr",
});
const hasPullRequest = prPane.prNumber !== null;
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
const requestedTab: ExplorerTab =
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
const prTabLabel = formatPrTabLabel(prPane.prNumber);
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
const handlePrRetry = useCallback(() => {
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
[serverId, workspaceId, workspaceRoot],
);
return (
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -436,10 +411,9 @@ function ExplorerSidebarContent({
{resolvedTab === "pr" && (
<PrTabContent
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
onRetry={handlePrRetry}
/>
)}
</View>
@@ -447,42 +421,6 @@ function ExplorerSidebarContent({
);
}
/**
* Shared add-to-chat state for the changes/files panes: both expose an "add file
* to chat" action that attaches the file to the focused chat's composer.
* Available only when a workspace with a focused chat is available.
*/
function useAddFileToChat({
serverId,
workspaceId,
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
const focusedChat = useMemo(
() => resolveFocusedChatTarget({ serverId, layout }),
[serverId, layout],
);
const addFile = useCallback(
(filePath: string) => {
if (!focusedChat || !workspaceKey) {
return;
}
void useDraftStore.getState().attachWorkspaceFile({
draftKey: focusedChat.draftKey,
attachment: createWorkspaceFileAttachment({ path: filePath }),
});
focusTab(workspaceKey, focusedChat.tabId);
},
[focusTab, focusedChat, workspaceKey],
);
return { addFile, canAddToChat: focusedChat !== null };
}
function ChangedFilesPane({
serverId,
workspaceId,
@@ -496,6 +434,7 @@ function ChangedFilesPane({
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<GitDiffPane
host="explorer"
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
@@ -524,37 +463,7 @@ function FilesPane({
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
onRetry: () => void;
}
function PrTabContent({
serverId,
cwd,
prPane,
workspaceAttachmentScopeKey,
onRetry,
}: PrTabContentProps) {
if (prPane.data) {
return (
<PullRequestPane
serverId={serverId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
/>
);
}
if (prPane.error) {
return <PullRequestPaneError onRetry={onRetry} />;
}
return <PullRequestPaneSkeleton />;
}
const PrTabContent = PullRequestContent;
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
+1 -1
View File
@@ -1459,7 +1459,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => {
fileLinkActions.open({ href: url }, "main");
fileLinkActions.open({ href: url }, "side");
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { startResizeHandleDrag } from "@/components/resize-handle-drag";
describe("startResizeHandleDrag", () => {
it("previews pointer frames and commits only the final size", () => {
const preview = vi.fn();
const commit = vi.fn();
const drag = startResizeHandleDrag({
sizes: [0.5, 0.5],
index: 0,
preview,
commit,
});
drag.move(0.05);
drag.move(0.1);
expect(preview).toHaveBeenCalledTimes(2);
expect(preview.mock.calls[0]?.[0][0]).toBeCloseTo(0.55, 10);
expect(preview.mock.calls[0]?.[0][1]).toBeCloseTo(0.45, 10);
expect(preview.mock.calls[1]?.[0][0]).toBeCloseTo(0.6, 10);
expect(preview.mock.calls[1]?.[0][1]).toBeCloseTo(0.4, 10);
expect(commit).not.toHaveBeenCalled();
drag.finish();
expect(commit).toHaveBeenCalledOnce();
expect(commit.mock.calls[0]?.[0][0]).toBeCloseTo(0.6, 10);
expect(commit.mock.calls[0]?.[0][1]).toBeCloseTo(0.4, 10);
});
});
@@ -0,0 +1,35 @@
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
interface StartResizeHandleDragInput {
sizes: number[];
index: number;
preview: (sizes: number[]) => void;
commit: (sizes: number[]) => void;
}
export interface ResizeHandleDrag {
move: (deltaRatio: number) => void;
finish: () => void;
}
export function startResizeHandleDrag({
sizes,
index,
preview,
commit,
}: StartResizeHandleDragInput): ResizeHandleDrag {
let pendingSizes: number[] | null = null;
return {
move(deltaRatio) {
pendingSizes = computeResizeHandleSizes({ sizes, index, deltaRatio });
preview(pendingSizes);
},
finish() {
if (pendingSizes) {
commit(pendingSizes);
pendingSizes = null;
}
},
};
}
+23 -12
View File
@@ -1,19 +1,22 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
export interface ResizeHandleProps {
testID?: string;
direction: "horizontal" | "vertical";
groupId: string;
index: number;
sizes: number[];
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
}
interface PointerState {
containerSize: number;
pointerStart: number;
drag: ResizeHandleDrag;
}
function resetWindowHorizontalScroll() {
@@ -25,10 +28,12 @@ function resetWindowHorizontalScroll() {
}
export function ResizeHandle({
testID,
direction,
groupId,
index,
sizes,
onPreviewResizeSplit,
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
@@ -68,6 +73,12 @@ export function ResizeHandle({
containerSize,
pointerStart:
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
drag: startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
}),
});
if (pointerStatesRef.current.size === 1) {
@@ -113,14 +124,7 @@ export function ResizeHandle({
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
onResizeSplit(
groupId,
computeResizeHandleSizes({
sizes,
index,
deltaRatio,
}),
);
pointerState.drag.move(deltaRatio);
}
function handlePointerUp(upEvent: PointerEvent) {
@@ -128,6 +132,7 @@ export function ResizeHandle({
return;
}
pointerStatesRef.current.get(pointerId)?.drag.finish();
cleanup();
}
@@ -135,7 +140,7 @@ export function ResizeHandle({
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
},
[direction, groupId, index, onResizeSplit, sizes],
[direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
);
const handlePointerEnter = useCallback(() => {
@@ -181,8 +186,14 @@ export function ResizeHandle({
);
return (
<View style={handleStyle}>
{highlighted && <View pointerEvents="none" style={highlightStyle} />}
<View style={handleStyle} testID={testID}>
{highlighted && (
<View
pointerEvents="none"
style={highlightStyle}
testID={testID ? `${testID}-highlight` : undefined}
/>
)}
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
@@ -1,7 +1,6 @@
import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react";
import { memo, useMemo, useCallback, useState, type ReactNode } from "react";
import { Text, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
@@ -28,11 +27,7 @@ import {
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
// SCRIM_SOLID_OFFSET rightward, which keeps the kebab itself off the gradient entirely.
const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({
@@ -45,38 +40,6 @@ const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
/**
* react-native-svg's extractGradient reads stopColor off the child elements structurally,
* without rendering them, so wrapping Stop itself in withUnistyles hides the color from it and
* the native gradient silently falls back to black. Theme the whole SVG instead and keep real
* Stop elements as direct children of the gradient.
*/
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
{/* Same color at both ends, varying only stopOpacity. Interpolating a hex toward
`transparent` goes through black in some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
const scrimColorMappings: Record<SidebarSurfaceBackdrop, (theme: Theme) => { color: string }> = {
surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }),
surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }),
surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }),
surface2: (theme) => ({ color: theme.colors.surface2 }),
};
export function SidebarWorkspaceRowFrame({
workspace,
isDragging = false,
@@ -365,13 +328,6 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
top: 0,
right: 0,
},
trailingActionScrim: {
position: "absolute",
top: 0,
bottom: 0,
right: 0,
width: SCRIM_WIDTH,
},
}));
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
@@ -476,40 +432,14 @@ export function SidebarWorkspaceTrailingActionOverlay({
if (!visible || !children) return null;
return (
<>
{scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null}
{scrimBackdrop ? (
<TrailingActionScrim backdrop={scrimBackdrop} testID="sidebar-workspace-trailing-scrim" />
) : null}
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
</>
);
}
/**
* The row's own background, faded in from the right, sitting between the diff stat and the
* kebab. The kebab lands on fully opaque background while the diff dissolves underneath it
* rather than blinking out — hiding the diff outright was the old behavior and it cost a
* visible flicker on every hover.
*
* Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose:
* the fade has to start before the diff stat does or the diff's left edge cuts off hard.
*/
function TrailingActionScrim({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) {
// useId's output contains characters that are not legal inside url(#...) — React 19 wraps
// ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all.
// Keep the per-instance uniqueness, drop everything a fragment reference can't carry.
const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View
style={sidebarWorkspaceRowStyles.trailingActionScrim}
pointerEvents="none"
testID="sidebar-workspace-trailing-scrim"
>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
uniProps={scrimColorMappings[backdrop]}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
workspaceRowContent: {
position: "relative",
+135 -47
View File
@@ -28,6 +28,11 @@ import {
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
@@ -139,6 +144,9 @@ interface SplitPaneDropData {
paneId: string;
}
const EMPTY_SPLIT_NODES: SplitNode[] = [];
const EMPTY_SPLIT_SIZES: number[] = [];
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
}
@@ -721,9 +729,68 @@ function DragOverlayTabChipInner({
);
}
function SplitGroupChild({ flex, children }: { flex: number; children: ReactNode }) {
const childStyle = useMemo(() => [styles.groupChild, { flex }], [flex]);
return <View style={childStyle}>{children}</View>;
function SplitGroupChild({
resizeFlex,
index,
hidden,
children,
}: {
resizeFlex: SharedValue<number[]>;
index: number;
hidden: boolean;
children: ReactNode;
}) {
const resizeStyle = useAnimatedStyle(() => ({
flexGrow: resizeFlex.value[index] ?? 0,
}));
const childStyle = useMemo(
() => [
styles.groupChild,
{
flexShrink: hidden ? 0 : 1,
flexBasis: 0,
...(hidden ? { width: 0, height: 0 } : {}),
},
],
[hidden],
);
return (
<Animated.View
style={[childStyle, resizeStyle]}
testID={hidden ? "split-group-child-hidden" : "split-group-child"}
>
{children}
</Animated.View>
);
}
/**
* Flex grow per child, renormalized so the visible ones always sum to 1.
*
* `sizes` are fractions, so a two-pane group is `[0.5, 0.5]`. Hiding one child drops the group's
* total grow factor to 0.5, and CSS hands children that sum to less than 1 only that fraction of
* the free space — the other half of the row is simply left empty. Renormalizing is what makes a
* hidden pane give its space back instead of just going invisible. The stored `sizes` are never
* touched, so unhiding restores the width the user dragged to.
*/
function resolveVisibleGroupFlex(children: SplitNode[], sizes: number[]): number[] {
const visibleTotal = children.reduce(
(total, child, index) => (isSplitNodeHidden(child) ? total : total + (sizes[index] ?? 1)),
0,
);
if (visibleTotal <= 0) {
return children.map(() => 0);
}
return children.map((child, index) =>
isSplitNodeHidden(child) ? 0 : (sizes[index] ?? 1) / visibleTotal,
);
}
function isSplitNodeHidden(node: SplitNode): boolean {
if (node.kind === "pane") {
return node.pane.hidden === true;
}
return node.group.children.every(isSplitNodeHidden);
}
function SplitNodeView({
@@ -773,6 +840,23 @@ function SplitNodeView({
const storedGroupSizes = useWorkspaceLayoutStore((state) =>
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
);
const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES;
const groupSizes =
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
const visibleFlex = useMemo(
() => resolveVisibleGroupFlex(groupChildren, groupSizes),
[groupChildren, groupSizes],
);
const resizeFlex = useSharedValue(visibleFlex);
useEffect(() => {
resizeFlex.value = visibleFlex;
}, [resizeFlex, visibleFlex]);
const previewResizeSplit = useCallback(
(_groupId: string, sizes: number[]) => {
resizeFlex.value = resolveVisibleGroupFlex(groupChildren, sizes);
},
[groupChildren, resizeFlex],
);
const groupStyle = useMemo(
() => [
@@ -784,56 +868,56 @@ function SplitNodeView({
if (node.kind === "pane") {
return (
<WindowChromeRegion corners={windowChromeCorners}>
<SplitPaneView
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeRegion>
<RetainedPanel active={node.pane.hidden !== true}>
<WindowChromeRegion corners={windowChromeCorners}>
<SplitPaneView
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeRegion>
</RetainedPanel>
);
}
const groupSizes = storedGroupSizes ?? node.group.sizes;
return (
<View style={groupStyle}>
{node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}>
<SplitGroupChild flex={groupSizes[index] ?? 1}>
<SplitGroupChild resizeFlex={resizeFlex} index={index} hidden={isSplitNodeHidden(child)}>
<SplitNodeView
node={child}
workspaceKey={workspaceKey}
@@ -876,12 +960,16 @@ function SplitNodeView({
onExitFocusMode={onExitFocusMode}
/>
</SplitGroupChild>
{index < node.group.children.length - 1 ? (
{index < node.group.children.length - 1 &&
!isSplitNodeHidden(child) &&
!isSplitNodeHidden(node.group.children[index + 1]) ? (
<ResizeHandle
testID="workspace-split-resize-handle"
direction={node.group.direction}
groupId={node.group.id}
index={index}
sizes={groupSizes}
onPreviewResizeSplit={previewResizeSplit}
onResizeSplit={onResizeSplit}
/>
) : null}
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
acceptTreeRailContainerMeasurement,
resolveVisibleTreeRailWidth,
} from "@/components/tree-rail-layout";
describe("tree rail layout", () => {
it("keeps the last usable geometry while a retained panel is hidden", () => {
const visibleContainerWidth = 900;
const hiddenContainerWidth = acceptTreeRailContainerMeasurement(visibleContainerWidth, 0);
expect(hiddenContainerWidth).toBe(visibleContainerWidth);
expect(resolveVisibleTreeRailWidth(420, hiddenContainerWidth)).toBe(420);
});
it("clamps the rail after a usable narrow-container measurement", () => {
const containerWidth = acceptTreeRailContainerMeasurement(900, 500);
expect(resolveVisibleTreeRailWidth(420, containerWidth)).toBe(260);
});
});
@@ -0,0 +1,18 @@
import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store";
const MIN_CONTENT_WIDTH = 240;
export function resolveVisibleTreeRailWidth(
requestedWidth: number,
containerWidth: number,
): number {
const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH);
return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth);
}
export function acceptTreeRailContainerMeasurement(
currentWidth: number,
measuredWidth: number,
): number {
return measuredWidth > 0 ? measuredWidth : currentWidth;
}
+117
View File
@@ -0,0 +1,117 @@
import type { ReactNode } from "react";
import { Children, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, useWindowDimensions } from "react-native";
import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { StyleSheet } from "react-native-unistyles";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import {
acceptTreeRailContainerMeasurement,
resolveVisibleTreeRailWidth,
} from "@/components/tree-rail-layout";
import { usePanelStore } from "@/stores/panel-store";
export interface TreeRailProps {
/**
* Exactly two children, in order: content, then tree.
*
* Slots are children rather than `content` / `tree` props because `react-perf/jsx-no-jsx-as-prop`
* rejects JSX in a prop, including via a local variable. Pass both unconditionally — a `{cond &&
* …}` slot collapses out of the array and silently promotes the tree into the content position.
* Branch around the whole rail instead, the way `diff-panel.tsx` does.
*/
children: ReactNode;
testID?: string;
}
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
export function TreeRail({ children, testID }: TreeRailProps) {
const [content, tree] = Children.toArray(children);
const requestedWidth = usePanelStore((state) => state.treeRailWidth);
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
const { width: viewportWidth } = useWindowDimensions();
const [containerWidth, setContainerWidth] = useState(viewportWidth);
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth);
const startWidthRef = useRef(visibleWidth);
const resizeWidth = useSharedValue(visibleWidth);
const [resizePressed, setResizePressed] = useState(false);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
useEffect(() => {
resizeWidth.value = visibleWidth;
}, [resizeWidth, visibleWidth]);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => scheduleOnRN(showResizeGrip))
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidthRef.current = visibleWidth + event.translationX;
resizeWidth.value = visibleWidth;
})
.onUpdate((event) => {
resizeWidth.value = resolveVisibleTreeRailWidth(
startWidthRef.current - event.translationX,
containerWidth,
);
})
.onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
[containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth],
);
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number } } }) =>
setContainerWidth((currentWidth) =>
acceptTreeRailContainerMeasurement(currentWidth, event.nativeEvent.layout.width),
),
[],
);
return (
<View style={styles.container} testID={testID} onLayout={handleLayout}>
<View style={styles.content} testID={testID ? `${testID}-content` : undefined}>
{content}
</View>
<Animated.View
style={[styles.rail, railWidthStyle]}
testID={testID ? `${testID}-tree` : undefined}
>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID={testID ? `${testID}-resize-handle` : "tree-rail-resize-handle"}
/>
{tree}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
flexDirection: "row",
},
content: {
flex: 1,
minWidth: 0,
},
rail: {
position: "relative",
flexShrink: 0,
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
},
}));
@@ -0,0 +1,67 @@
import { useId } from "react";
import { View } from "react-native";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme";
export const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
const backdropColorMappings: Record<SurfaceBackdrop, (theme: Theme) => { color: string }> = {
surface0: (theme) => ({ color: theme.colors.surface0 }),
surface1: (theme) => ({ color: theme.colors.surface1 }),
surface2: (theme) => ({ color: theme.colors.surface2 }),
surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }),
surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }),
surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }),
};
/** Fades trailing content into the surface beneath an absolutely overlaid action. */
export function TrailingActionScrim({
backdrop,
testID,
}: {
backdrop: SurfaceBackdrop;
testID?: string;
}) {
// React-generated ids contain characters that are invalid inside SVG fragment references.
const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View style={styles.scrim} pointerEvents="none" testID={testID}>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
uniProps={backdropColorMappings[backdrop]}
/>
</View>
);
}
const styles = StyleSheet.create({
scrim: {
position: "absolute",
top: 0,
bottom: 0,
right: 0,
width: SCRIM_WIDTH,
},
});