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
@@ -20,13 +20,15 @@ import {
CopyX,
ArrowLeftToLine,
ArrowRightToLine,
ChevronDown,
Columns2,
Copy,
Pencil,
RotateCw,
Rows2,
Globe,
FileDiff,
FolderTree,
GitPullRequest,
Plus,
SquarePen,
SquareTerminal,
@@ -59,7 +61,7 @@ import {
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import {
@@ -75,6 +77,7 @@ import {
type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
@@ -89,10 +92,25 @@ import { runPinnedTabTarget, type TabTargetHandlers } from "@/workspace-pins/run
import type { PinnedTabTarget } from "@/workspace-pins/target";
import { PinnedTargetsRow } from "@/workspace-pins/pinned-targets-row";
import { PinnableMenuItem } from "@/workspace-pins/pinnable-menu-item";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
// Chip geometry. `layoutMetrics` measures tabs from these same numbers, so a chip that changes
// shape without changing them mis-measures and drops the row into the overflow-scroll fallback at
// the wrong width. Keep them together.
const TAB_CHIP_HEIGHT = 26;
const TAB_CHIP_HORIZONTAL_PADDING = 8;
const TAB_CHIP_GAP = 4;
const TAB_ROW_PADDING_HORIZONTAL = 4;
const TAB_ICON_WIDTH = 14;
const TAB_DROP_INDICATOR_WIDTH = 4;
const TAB_MAX_WIDTH = 160;
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedX = withUnistyles(X);
@@ -104,21 +122,29 @@ const ThemedCopyX = withUnistyles(CopyX);
const ThemedPencil = withUnistyles(Pencil);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2);
const ThemedPlus = withUnistyles(Plus);
const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
const BROWSER_ICON = <ThemedGlobe size={14} uniProps={mutedColorMapping} />;
const CHANGES_ICON = <ThemedFileDiff size={14} uniProps={mutedColorMapping} />;
const FILES_ICON = <ThemedFolderTree size={14} uniProps={mutedColorMapping} />;
const PULL_REQUEST_ICON = <ThemedGitPullRequest size={14} uniProps={mutedColorMapping} />;
const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" };
const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" };
const BROWSER_TARGET: PinnedTabTarget = { kind: "browser" };
const CHANGES_TARGET = { kind: "working_diff" } as const;
const FILES_TARGET = { kind: "files" } as const;
const PULL_REQUEST_TARGET = { kind: "pull_request" } as const;
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
@@ -169,67 +195,32 @@ function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfil
);
}
interface WorkspaceInlineAddTabButtonProps {
shortcutKeys: ShortcutKey[][] | null;
onCreateAgentTab: () => void;
onLayout: (event: LayoutChangeEvent) => void;
}
function WorkspaceInlineAddTabButton({
shortcutKeys,
onCreateAgentTab,
onLayout,
}: WorkspaceInlineAddTabButtonProps) {
const { t } = useTranslation();
const tooltipText = t("workspace.tabs.actions.newAgent");
return (
<View style={styles.inlineAddButton} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab-inline"
onPress={onCreateAgentTab}
accessibilityRole="button"
accessibilityLabel={tooltipText}
style={inlineAddActionButtonStyle}
>
<ThemedPlus size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
);
}
interface WorkspaceTabRowExtrasProps {
interface TabTargetLauncherOptions {
normalizedServerId: string;
onCreateAgentTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onOpenChanges: () => void;
onOpenFiles: () => void;
onOpenPullRequest: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
onEditProfiles: () => void;
normalizedServerId: string;
showCreateBrowserTab: boolean;
terminalDisabled: boolean;
}
function WorkspaceTabRowExtras({
/**
* The `+` menu and the pinned-targets row launch the same targets but sit in different parts of
* the row, so each owns its own subscription rather than re-rendering the whole row from a shared
* one. `useDaemonConfig` is a cached query, so the second caller is free.
*/
function useTabTargetLauncher({
normalizedServerId,
onCreateAgentTab,
onCreateTerminal,
onCreateBrowser,
onOpenChanges,
onOpenFiles,
onOpenPullRequest,
onCreateTerminalWithProfile,
onEditProfiles,
normalizedServerId,
showCreateBrowserTab,
terminalDisabled,
}: WorkspaceTabRowExtrasProps) {
const { t } = useTranslation();
}: TabTargetLauncherOptions) {
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
@@ -241,9 +232,20 @@ function WorkspaceTabRowExtras({
createDraft: onCreateAgentTab,
createTerminal: onCreateTerminal,
createBrowser: onCreateBrowser,
openChanges: onOpenChanges,
openFiles: onOpenFiles,
openPullRequest: onOpenPullRequest,
createTerminalWithProfile: onCreateTerminalWithProfile,
}),
[onCreateAgentTab, onCreateBrowser, onCreateTerminal, onCreateTerminalWithProfile],
[
onCreateAgentTab,
onCreateBrowser,
onCreateTerminal,
onCreateTerminalWithProfile,
onOpenChanges,
onOpenFiles,
onOpenPullRequest,
],
);
const onLaunch = useCallback(
@@ -253,32 +255,74 @@ function WorkspaceTabRowExtras({
[handlers, profiles],
);
const launchers = usePinnedLaunchers({ serverId: normalizedServerId, onLaunch });
return { profiles, onLaunch };
}
interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions {
shortcutKeys: ShortcutKey[][] | null;
onEditProfiles: () => void;
showCreateBrowserTab: boolean;
terminalDisabled: boolean;
isGit: boolean;
showPullRequest: boolean;
onLayout: (event: LayoutChangeEvent) => void;
}
function WorkspaceNewTabButton({
shortcutKeys,
onCreateAgentTab,
onCreateTerminal,
onCreateBrowser,
onOpenChanges,
onOpenFiles,
onOpenPullRequest,
onCreateTerminalWithProfile,
onEditProfiles,
normalizedServerId,
showCreateBrowserTab,
terminalDisabled,
isGit,
showPullRequest,
onLayout,
}: WorkspaceNewTabButtonProps) {
const { t } = useTranslation();
const { profiles, onLaunch } = useTabTargetLauncher({
normalizedServerId,
onCreateAgentTab,
onCreateTerminal,
onCreateBrowser,
onCreateTerminalWithProfile,
onOpenChanges,
onOpenFiles,
onOpenPullRequest,
});
const tooltipText = t("workspace.tabs.actions.newTab");
return (
<>
<View style={styles.inlineAddButton} onLayout={onLayout}>
<DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<DropdownMenuTrigger
testID="workspace-new-tab-menu-trigger"
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.moreActions")}
style={newTabActionButtonStyle}
accessibilityLabel={tooltipText}
style={inlineAddActionButtonStyle}
>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
<ThemedPlus size={14} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{t("workspace.tabs.actions.moreActions")}</Text>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="bottom" align="end" offset={4} minWidth={200}>
<DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}>
<PinnableMenuItem
testID="workspace-new-tab-menu-agent"
target={DRAFT_TARGET}
label={t("workspace.tabs.actions.newAgent")}
leading={AGENT_ICON}
shortcut={shortcutKeys}
onSelect={onCreateAgentTab}
/>
<PinnableMenuItem
@@ -298,6 +342,31 @@ function WorkspaceTabRowExtras({
onSelect={onCreateBrowser}
/>
) : null}
{isGit ? (
<PinnableMenuItem
testID="workspace-new-tab-menu-changes"
target={CHANGES_TARGET}
label={t("workspace.tabs.actions.changes")}
leading={CHANGES_ICON}
onSelect={onOpenChanges}
/>
) : null}
<PinnableMenuItem
testID="workspace-new-tab-menu-files"
target={FILES_TARGET}
label={t("workspace.tabs.actions.files")}
leading={FILES_ICON}
onSelect={onOpenFiles}
/>
{showPullRequest ? (
<PinnableMenuItem
testID="workspace-new-tab-menu-pull-request"
target={PULL_REQUEST_TARGET}
label={t("workspace.tabs.actions.pullRequest")}
leading={PULL_REQUEST_ICON}
onSelect={onOpenPullRequest}
/>
) : null}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
{profiles.map((profile) => (
@@ -314,11 +383,24 @@ function WorkspaceTabRowExtras({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" />
</>
</View>
);
}
function WorkspacePinnedTargets(
options: TabTargetLauncherOptions & { isGit: boolean; showPullRequest: boolean },
) {
const { onLaunch } = useTabTargetLauncher(options);
const launchers = usePinnedLaunchers({
serverId: options.normalizedServerId,
onLaunch,
isGit: options.isGit,
hasPullRequest: options.showPullRequest,
});
return <PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" />;
}
function TabContextMenuItem({
entry,
}: {
@@ -445,7 +527,15 @@ interface WorkspaceDesktopTabsRowProps {
function getFallbackTabLabel(
tab: WorkspaceTabDescriptor,
labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string },
labels: {
newAgent: string;
setup: string;
terminal: string;
agent: string;
changes: string;
files: string;
pullRequest: string;
},
): string {
if (tab.target.kind === "draft") {
return labels.newAgent;
@@ -462,6 +552,12 @@ function getFallbackTabLabel(
if (tab.target.kind === "working_diff") {
return labels.changes;
}
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
return labels.agent;
}
@@ -487,16 +583,30 @@ function useMiddleClickClose(onClose: () => void) {
return ref;
}
/** The chip fill the running-status ring has to knock out of. Mirrors `styles.tab*` exactly. */
function resolveChipBackdrop({
isActiveFocused,
isFilled,
}: {
isActiveFocused: boolean;
isFilled: boolean;
}): SurfaceBackdrop {
if (isActiveFocused) return "surface2";
return isFilled ? "surface1" : "surface0";
}
function TabHandleContent({
presentation,
isHighlighted,
showLabel,
backdrop,
tabLabelSkeletonStyle,
tabLabelStyle,
}: {
presentation: WorkspaceTabPresentation;
isHighlighted: boolean;
showLabel: boolean;
backdrop: SurfaceBackdrop;
tabLabelSkeletonStyle: React.ComponentProps<typeof View>["style"];
tabLabelStyle: React.ComponentProps<typeof Text>["style"];
}) {
@@ -508,7 +618,7 @@ function TabHandleContent({
return (
<View style={styles.tabHandle} dataSet={tabHandleDataSet}>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop="surface0" />
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop={backdrop} />
</View>
{showLabel && presentation.titleState === "loading" ? (
<View style={tabLabelSkeletonStyle} />
@@ -562,9 +672,18 @@ function TabChip({
const middleClickRef = useMiddleClickClose(
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
);
const isCompact = useIsCompactFormFactor();
const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered;
const showTrailingAffordance = showCloseButton || presentation.modified;
// An active tab in a pane that does not have focus stays legible but quiet: it keeps the fill of
// a hovered chip and the muted label, so only one chip in the window reads as the live one.
const isActiveFocused = isActive && isFocused;
const isHovered = hovered || isCloseHovered;
const isHighlighted = isActiveFocused || isHovered;
const chipBackdrop: SurfaceBackdrop = resolveChipBackdrop({
isActiveFocused,
isFilled: isActive || isHovered,
});
const showCloseControl = showCloseButton && (isHovered || isNative || isCompact || isClosingTab);
const closeButtonDragBlockers = isWeb
? ({
onPointerDown: (event: { stopPropagation?: () => void }) => {
@@ -579,6 +698,9 @@ function TabChip({
const tabChipStyle = useCallback(
() => [
styles.tab,
isActiveFocused && styles.tabActive,
isActive && !isFocused && styles.tabActiveUnfocused,
!isActive && isHovered && styles.tabHovered,
isWeb && isDragging && ({ cursor: "grabbing" } as object),
{
minWidth: resolvedTabWidth,
@@ -586,14 +708,14 @@ function TabChip({
maxWidth: resolvedTabWidth,
},
],
[isDragging, resolvedTabWidth],
[isActive, isActiveFocused, isDragging, isFocused, isHovered, resolvedTabWidth],
);
const handleTabHoverIn = useCallback(() => {
const handleTabPointerEnter = useCallback(() => {
setHovered(true);
}, []);
const handleTabHoverOut = useCallback(() => {
const handleTabPointerLeave = useCallback(() => {
setHovered(false);
}, []);
@@ -621,38 +743,20 @@ function TabChip({
[onCloseTab, tab.tabId],
);
const closeButtonStyle = useCallback(
({ hovered: isButtonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(Boolean(isButtonHovered) || pressed) && styles.tabCloseButtonActive,
],
[],
);
const tabAccessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
const tabFocusIndicatorStyle = useMemo(
() => [styles.tabFocusIndicator, !isFocused && styles.tabFocusIndicatorUnfocused],
[isFocused],
);
const tabLabelSkeletonStyle = useMemo(
() => [
styles.tabLabelSkeleton,
showTrailingAffordance && styles.tabLabelSkeletonWithCloseButton,
],
[showTrailingAffordance],
);
const tabLabelSkeletonStyle = styles.tabLabelSkeleton;
const tabLabelStyle = useMemo(
() => [
styles.tabLabel,
isHighlighted && styles.tabLabelActive,
showTrailingAffordance && styles.tabLabelWithCloseButton,
],
[isHighlighted, showTrailingAffordance],
() => [styles.tabLabel, isHighlighted && styles.tabLabelActive],
[isHighlighted],
);
return (
<View ref={middleClickRef}>
<View
ref={middleClickRef}
style={styles.tabHoverFrame}
onPointerEnter={handleTabPointerEnter}
onPointerLeave={handleTabPointerLeave}
>
<ContextMenu key={tab.key}>
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
@@ -663,8 +767,6 @@ function TabChip({
triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined}
enabledOnMobile={false}
style={tabChipStyle}
onHoverIn={handleTabHoverIn}
onHoverOut={handleTabHoverOut}
onPressIn={handleNavigateTab}
onPress={handleNavigateTab}
accessibilityRole="button"
@@ -672,53 +774,22 @@ function TabChip({
accessibilityState={tabAccessibilityState}
aria-selected={isActive}
>
{isActive && <View style={tabFocusIndicatorStyle} />}
<TabHandleContent
presentation={presentation}
isHighlighted={isHighlighted}
showLabel={showLabel}
backdrop={chipBackdrop}
tabLabelSkeletonStyle={tabLabelSkeletonStyle}
tabLabelStyle={tabLabelStyle}
/>
{showTrailingAffordance ? (
<Pressable
{...(closeButtonDragBlockers as object | undefined)}
testID={closeButtonTestId}
disabled={isClosingTab}
onPressIn={handleCloseButtonPressIn}
onHoverIn={handleCloseButtonHoverIn}
onHoverOut={handleCloseButtonHoverOut}
onPress={handleCloseButtonPress}
style={closeButtonStyle}
{presentation.modified ? (
<View
style={styles.tabModifiedIndicator}
accessibilityLabel={t("workspace.tabs.modified")}
testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
>
{({ hovered: closeHovered, pressed }) => {
const highlighted = closeHovered || pressed;
if (isClosingTab) {
return (
<ThemedLoadingSpinner
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}
if (highlighted || !presentation.modified) {
return (
<ThemedX
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}
return (
<View
style={styles.tabModifiedDot}
accessibilityLabel={t("workspace.tabs.modified")}
testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
/>
);
}}
</Pressable>
<View style={styles.tabModifiedDot} />
</View>
) : null}
</ContextMenuTrigger>
</TooltipTrigger>
@@ -740,6 +811,46 @@ function TabChip({
</TooltipContent>
</Tooltip>
{showCloseButton ? (
<View
pointerEvents={showCloseControl ? "auto" : "none"}
style={[
styles.tabTrailingOverlay,
showCloseControl ? styles.tabTrailingOverlayShown : styles.tabTrailingOverlayHidden,
]}
>
<TrailingActionScrim backdrop={chipBackdrop} />
<Pressable
{...(closeButtonDragBlockers as object | undefined)}
testID={closeButtonTestId}
disabled={isClosingTab}
onPressIn={handleCloseButtonPressIn}
onHoverIn={handleCloseButtonHoverIn}
onHoverOut={handleCloseButtonHoverOut}
onPress={handleCloseButtonPress}
style={styles.tabCloseButton}
>
{({ hovered: closeHovered, pressed }) => {
const highlighted = closeHovered || pressed;
if (isClosingTab) {
return (
<ThemedLoadingSpinner
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}
return (
<ThemedX
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}}
</Pressable>
</View>
) : null}
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
@@ -798,6 +909,19 @@ export function WorkspaceDesktopTabsRow({
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? "";
const checkoutStatus = useCheckoutStatusQuery({
serverId: normalizedServerId,
cwd: workspaceRoot,
});
const isGit = checkoutStatus.status?.isGit === true;
const showPullRequest = isGit;
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const focusPane = useWorkspaceLayoutStore((state) => state.focusPane);
const openTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setTabsContainerWidth, event);
@@ -824,13 +948,13 @@ export function WorkspaceDesktopTabsRow({
(inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) +
(focusModeEnabled ? exitFocusModeWidth : 0),
),
rowPaddingHorizontal: 0,
tabGap: 0,
maxTabWidth: 200,
tabIconWidth: 14,
tabHorizontalPadding: 12,
rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
tabGap: TAB_CHIP_GAP,
maxTabWidth: TAB_MAX_WIDTH,
tabIconWidth: TAB_ICON_WIDTH,
tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING,
estimatedCharWidth: 7,
closeButtonWidth: 22,
closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH,
}),
[exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth],
);
@@ -842,6 +966,8 @@ export function WorkspaceDesktopTabsRow({
terminal: t("workspace.tabs.fallback.terminal"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}),
[t],
);
@@ -917,6 +1043,25 @@ export function WorkspaceDesktopTabsRow({
onCreateBrowserTab({ paneId });
}, [onCreateBrowserTab, paneId]);
const openPanelTarget = useCallback(
(target: { kind: "working_diff" } | { kind: "files" } | { kind: "pull_request" }) => {
if (!workspaceKey) {
return;
}
if (paneId) {
focusPane(workspaceKey, paneId);
}
openTabFocused(workspaceKey, target);
},
[focusPane, openTabFocused, paneId, workspaceKey],
);
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
const handleOpenPullRequest = useCallback(
() => openPanelTarget(PULL_REQUEST_TARGET),
[openPanelTarget],
);
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
const renderTab = useCallback(
@@ -1053,22 +1198,36 @@ export function WorkspaceDesktopTabsRow({
getItemData={getTabDragData}
renderItem={renderTab}
/>
<WorkspaceInlineAddTabButton
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onLayout={handleInlineAddButtonLayout}
/>
</ScrollView>
<WorkspaceNewTabButton
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
isGit={isGit}
showPullRequest={showPullRequest}
onLayout={handleInlineAddButtonLayout}
/>
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
<WorkspaceTabRowExtras
<WorkspacePinnedTargets
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onEditProfiles={handleEditProfiles}
onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
isGit={isGit}
showPullRequest={showPullRequest}
/>
{showPaneSplitActions ? (
<>
@@ -1251,7 +1410,8 @@ const styles = StyleSheet.create((theme) => ({
},
tabsContent: {
flexDirection: "row",
alignItems: "stretch",
alignItems: "center",
paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
},
tabsActions: {
flexDirection: "row",
@@ -1272,18 +1432,30 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1],
},
tab: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRightWidth: 1,
borderRightColor: theme.colors.border,
height: TAB_CHIP_HEIGHT,
paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING,
borderRadius: theme.borderRadius.md,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
userSelect: "none",
},
tabHovered: {
backgroundColor: theme.colors.surface1,
},
tabActive: {
backgroundColor: theme.colors.surface2,
},
tabActiveUnfocused: {
backgroundColor: theme.colors.surface1,
},
tabHoverFrame: {
position: "relative",
},
tabSlot: {
position: "relative",
overflow: "visible",
marginHorizontal: TAB_CHIP_GAP / 2,
},
tabHandle: {
flexDirection: "row",
@@ -1294,34 +1466,29 @@ const styles = StyleSheet.create((theme) => ({
userSelect: "none",
},
tabIcon: {
width: TAB_ICON_WIDTH,
height: TAB_ICON_WIDTH,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
tabFocusIndicator: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: theme.colors.accent,
},
tabFocusIndicatorUnfocused: {
backgroundColor: theme.colors.borderAccent,
},
// The chip box stops at the slot's padding box, so the gap between two chips runs from
// -TAB_CHIP_GAP to 0. Centre a TAB_DROP_INDICATOR_WIDTH pill in it.
tabDropIndicator: {
position: "absolute",
top: theme.spacing[2],
bottom: theme.spacing[2],
width: 5,
top: theme.spacing[0.5],
bottom: theme.spacing[0.5],
width: TAB_DROP_INDICATOR_WIDTH,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
zIndex: 10,
pointerEvents: "none",
},
tabDropIndicatorBefore: {
left: -3,
left: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
},
tabDropIndicatorAfter: {
right: -3,
right: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
},
tabLabel: {
flexShrink: 1,
@@ -1341,28 +1508,44 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3,
opacity: 0.9,
},
tabLabelSkeletonWithCloseButton: {
width: LOADING_TAB_LABEL_SKELETON_WIDTH,
},
tabLabelWithCloseButton: {
paddingRight: 0,
},
tabLabelActive: {
color: theme.colors.foreground,
},
tabTrailingOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 48,
borderTopRightRadius: theme.borderRadius.md,
borderBottomRightRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
zIndex: 2,
},
tabTrailingOverlayShown: {
opacity: 1,
},
tabTrailingOverlayHidden: {
opacity: 0,
},
tabCloseButton: {
position: "absolute",
right: 4,
width: 18,
height: 18,
marginLeft: 0,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
zIndex: 1,
},
tabCloseButtonShown: {
opacity: 1,
},
tabCloseButtonActive: {
backgroundColor: theme.colors.surface3,
tabModifiedIndicator: {
position: "absolute",
right: 8,
top: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
},
tabModifiedDot: {
width: 8,
@@ -74,6 +74,8 @@ import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
collectAllTabs,
createDefaultLayout,
findPaneById,
getFocusedBrowserId,
type WorkspaceLayout,
useWorkspaceLayoutStore,
@@ -152,10 +154,7 @@ import {
deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
} from "@/workspace-tabs/agent-visibility";
import {
deriveWorkspacePaneState,
resolveSideFileOpenPlacement,
} from "@/screens/workspace/workspace-pane-state";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
import {
buildWorkspacePaneContentModel,
WorkspacePaneContent,
@@ -204,6 +203,7 @@ import {
} from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { usePullRequestAutoAdd } from "@/panels/pull-request";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
@@ -344,6 +344,8 @@ function getFallbackTabOptionLabel(
browser: string;
agent: string;
changes: string;
files: string;
pullRequest: string;
},
): string {
if (tab.target.kind === "draft") {
@@ -364,6 +366,12 @@ function getFallbackTabOptionLabel(
if (tab.target.kind === "working_diff") {
return labels.changes;
}
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
@@ -379,6 +387,8 @@ function getFallbackTabOptionDescription(
terminal: string;
browser: string;
changes: string;
files: string;
pullRequest: string;
},
): string {
if (tab.target.kind === "draft") {
@@ -405,6 +415,12 @@ function getFallbackTabOptionDescription(
if (tab.target.kind === "working_diff") {
return labels.changes;
}
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
return tab.target.path;
}
@@ -603,6 +619,8 @@ function MobileWorkspaceTabOption({
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}),
[t],
);
@@ -1627,6 +1645,75 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`;
}
function canObservePullRequest(isRouteFocused: boolean, isGitCheckout: boolean): boolean {
return isRouteFocused && isGitCheckout;
}
interface ToggleWorkspaceExplorerPaneInput {
isCompact: boolean;
persistenceKey: string | null;
checkout: ExplorerCheckoutContext | null;
toggleCompact: (input: { isCompact: boolean; checkout: ExplorerCheckoutContext }) => void;
}
function toggleWorkspaceExplorerPane(input: ToggleWorkspaceExplorerPaneInput): void {
if (input.isCompact || !supportsDesktopPaneSplits()) {
if (input.checkout) {
input.toggleCompact({ isCompact: input.isCompact, checkout: input.checkout });
}
return;
}
if (!input.persistenceKey) {
return;
}
const store = useWorkspaceLayoutStore.getState();
const layout = store.layoutByWorkspace[input.persistenceKey] ?? createDefaultLayout();
const parentTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
const explorerPaneId = store.explorerPaneIdByWorkspace[input.persistenceKey] ?? null;
const explorerPane = findPaneById(layout.root, explorerPaneId);
if (explorerPane) {
if (explorerPane.hidden === true) {
store.showPane(input.persistenceKey, explorerPane.id);
} else {
store.hidePane(input.persistenceKey, explorerPane.id);
}
return;
}
const ensuredPane = store.ensureExplorerPane(input.persistenceKey);
if (!ensuredPane) {
return;
}
const tabId = parentTabId
? store.openChildTabFocused(input.persistenceKey, { kind: "working_diff" }, parentTabId)
: store.openTabFocused(input.persistenceKey, { kind: "working_diff" });
if (tabId) {
store.moveTabToPane(input.persistenceKey, tabId, ensuredPane.paneId);
}
}
function isWorkspaceExplorerPaneOpen(
state: ReturnType<typeof useWorkspaceLayoutStore.getState>,
persistenceKey: string | null,
): boolean {
if (!persistenceKey) {
return false;
}
const paneId = state.explorerPaneIdByWorkspace[persistenceKey];
const layout = state.layoutByWorkspace[persistenceKey];
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
return Boolean(pane && pane.hidden !== true);
}
function resolveExplorerOpen(
isCompact: boolean,
compactExplorerOpen: boolean,
desktopExplorerOpen: boolean,
): boolean {
return isCompact ? compactExplorerOpen : desktopExplorerOpen;
}
interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
@@ -1900,8 +1987,14 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor,
checkoutState: workspaceHeaderCheckoutState,
});
usePullRequestAutoAdd({
workspaceKey: persistenceKey,
serverId: normalizedServerId,
cwd: workspaceDirectory,
enabled: canObservePullRequest(isRouteFocused, isGitCheckout),
});
const isExplorerOpen = usePanelStore((state) =>
const isCompactExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const toggleFileExplorerForCheckout = usePanelStore(
@@ -1920,15 +2013,14 @@ function WorkspaceScreenContent({
};
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
const handleToggleExplorer = useCallback(() => {
if (!activeExplorerCheckout) {
return;
}
toggleFileExplorerForCheckout({
isCompact: isMobile,
checkout: activeExplorerCheckout,
});
}, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
const isDesktopExplorerOpen = useWorkspaceLayoutStore((state) =>
isWorkspaceExplorerPaneOpen(state, persistenceKey),
);
const isExplorerOpen = resolveExplorerOpen(
isMobile,
isCompactExplorerOpen,
isDesktopExplorerOpen,
);
const hasDiffStat = useMemo(() => Boolean(workspaceDescriptor?.diffStat), [workspaceDescriptor]);
const explorerToggleStyle = useCallback(
@@ -1996,6 +2088,14 @@ function WorkspaceScreenContent({
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const handleToggleExplorer = useCallback(() => {
toggleWorkspaceExplorerPane({
isCompact: isMobile,
persistenceKey,
checkout: activeExplorerCheckout,
toggleCompact: toggleFileExplorerForCheckout,
});
}, [activeExplorerCheckout, isMobile, persistenceKey, toggleFileExplorerForCheckout]);
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -2373,58 +2473,30 @@ function WorkspaceScreenContent({
],
);
const handleOpenFileFromChatInSidePane = useCallback(
(input: {
location: WorkspaceFileLocation;
sourcePaneId?: string;
parentTabId?: string | null;
}) => {
const handleOpenAssistantFileInExplorerPane = useCallback(
(input: { location: WorkspaceFileLocation; parentTabId?: string | null }) => {
const location = normalizeWorkspaceFileLocation(input.location);
if (!location) {
return;
}
if (!persistenceKey || isMobile || !input.sourcePaneId) {
if (!persistenceKey || isMobile || !supportsDesktopPaneSplits()) {
handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
return;
}
const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
const placement = resolveSideFileOpenPlacement({
layout: workspaceLayout,
sourcePaneId: input.sourcePaneId,
tabs: uiTabs,
target,
});
if (placement.kind === "focus-side-pane") {
focusWorkspacePane(persistenceKey, placement.paneId);
} else if (placement.kind === "split-side-pane") {
splitWorkspacePaneEmpty(persistenceKey, {
targetPaneId: placement.paneId,
position: "right",
const tabId = useWorkspaceLayoutStore
.getState()
.openTabInExplorerPaneFocused(persistenceKey, {
target,
parentTabId: input.parentTabId,
});
}
const tabId = input.parentTabId
? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId)
: openWorkspaceTabFocused(persistenceKey, target);
if (tabId) {
requestFileNavigation(tabId);
navigateToTabId(tabId);
}
},
[
handleOpenFileFromChat,
isMobile,
focusWorkspacePane,
navigateToTabId,
openWorkspaceChildTabFocused,
openWorkspaceTabFocused,
persistenceKey,
requestFileNavigation,
splitWorkspacePaneEmpty,
uiTabs,
workspaceLayout,
],
[handleOpenFileFromChat, isMobile, navigateToTabId, persistenceKey, requestFileNavigation],
);
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
@@ -2442,9 +2514,8 @@ function WorkspaceScreenContent({
focusWorkspacePane(persistenceKey, paneId);
}
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
handleOpenAssistantFileInExplorerPane({
location: request.location,
sourcePaneId: paneId ?? undefined,
parentTabId,
});
return;
@@ -2523,6 +2594,8 @@ function WorkspaceScreenContent({
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}),
[t],
);
@@ -6,11 +6,11 @@ const metrics = {
actionsReservedWidth: 120,
rowPaddingHorizontal: 8,
tabGap: 4,
maxTabWidth: 200,
maxTabWidth: 160,
tabIconWidth: 14,
tabHorizontalPadding: 12,
tabHorizontalPadding: 8,
estimatedCharWidth: 7,
closeButtonWidth: 22,
closeButtonWidth: 0,
};
describe("computeWorkspaceTabLayout", () => {
@@ -25,7 +25,7 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items).toHaveLength(3);
expect(result.items.every((item) => item.showLabel)).toBe(true);
expect(result.items.map((item) => item.width)).toEqual([200, 200, 200]);
expect(result.items.map((item) => item.width)).toEqual([160, 160, 160]);
});
it("shrinks equal-width tabs proportionally to fit the pane", () => {
@@ -41,7 +41,7 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.items.every((item) => item.showLabel)).toBe(true);
});
it("uses the split width for evenly sized tabs when space is available", () => {
it("keeps evenly sized tabs at the cap when the split width exceeds it", () => {
const result = computeWorkspaceTabLayout({
viewportWidth: 743,
tabLabelLengths: [8, 8, 8, 8],
@@ -55,32 +55,32 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items.map((item) => item.width)).toEqual([175, 175, 175, 175]);
expect(result.items.map((item) => item.width)).toEqual([160, 160, 160, 160]);
});
it("collapses to icon-only before allowing horizontal scroll fallback", () => {
const result = computeWorkspaceTabLayout({
viewportWidth: 388,
viewportWidth: 268,
tabLabelLengths: [14, 14, 14, 14],
metrics,
});
expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]);
expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]);
expect(result.items.every((item) => !item.showLabel)).toBe(true);
});
it("allows horizontal scroll only when icon-only tabs still cannot fit", () => {
const result = computeWorkspaceTabLayout({
viewportWidth: 300,
viewportWidth: 267,
tabLabelLengths: [14, 14, 14, 14],
metrics,
});
expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(true);
expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]);
expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]);
expect(result.items.every((item) => !item.showLabel)).toBe(true);
});
@@ -151,6 +151,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "working_diff") {
return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`;
}
if (tab.target.kind === "files" || tab.target.kind === "pull_request") {
return `workspace-${tab.target.kind}-close`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
}