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
@@ -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",