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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user