Keep workspace layouts usable in narrow columns across platforms (#8740)

* fix(app): retain Explorer resize width until acknowledged

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): choose workspace header by measured column width

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): separate iOS composer clipping from its shadow

Keep explicit coverage of the existing keyboard rotation guard.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): choose workspace chrome within each desktop pane

The outer width wrapper added a compact header while the desktop split container kept its full header and pane rails. Each pane now owns the complete choice, replacing its header and rail together.

Each split pane uses its own measured width: below 500 points it shows its own tab dropdown, even if the neighboring pane is wide. Pane width determines usable header space. Keep new-tab keyboard ownership outside the switched chrome and retain positive measurements while hidden.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): reserve readable width for the compact tab popover

Native scroll content does not contribute intrinsic width to the anchored frame. The count badge alone allowed the popover to compress its tab list and footer labels.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): preserve compact pane actions and window clearance

Carry the owning pane into compact launch actions, keep split and restore controls in its menu, and distribute window corners through visible split children. Keep tab rails below header-owned window controls.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): retain window dragging in compact panes

Keep the shared titlebar drag region when pane width replaces full desktop chrome. Cover two compact panes with both sidebars closed through the desktop host adapter.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): align pane journeys with measured workspace chrome

Give rail-specific split journeys enough column width, assert launched Diff content independently of header shape, and retain pane identity across compact transitions. Expect the compact selector in the half-screen Explorer workflow.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): share pane tab-switcher rendering after header rebase

Keep compact and vertical-bar header actions on the same pane-aware switcher contract while preserving the merged control geometry and script ownership.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): retain Explorer acknowledgements across consecutive drags

Keep earlier released widths as expected allocations while a newer gesture owns the frame, and restore the latest release if that gesture is canceled.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep focus mode exit in the compact workspace menu

Carry the workspace focus state into its compact action menu so a narrow header retains an exit without keyboard shortcuts.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): distinguish resize rollback from pending acknowledgements

Restore the last released width when repeated widths precede a canceled gesture, and retain ownership when an unchanged old allocation matches a newer release before its predecessors arrive.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): allow software WebGL time to finish atlas growth coverage

The 256-batch pixel regression crosses more than 500 render boundaries and exceeds its 30-second total budget under CPU contention. Keep the corpus, every pixel assertion, and the two-second per-frame deadline; allow 60 seconds for the complete workload. Reproduced the timeout with Chromium confined to one CPU and verified two successful constrained runs after the adjustment.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
This commit is contained in:
Mohamed Boudra
2026-11-04 12:23:41 +01:00
committed by GitHub
co-authored by Elsa Granger
parent 4407283a13
commit cab5eedfe3
27 changed files with 1063 additions and 209 deletions
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import {
beginExplorerSidebarResize,
finishExplorerSidebarResize,
reconcileExplorerSidebarResize,
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarFrameWidth,
resolveExplorerSidebarWidth,
type ExplorerSidebarResize,
} from "@/components/explorer-sidebar-layout";
describe("Explorer sidebar layout", () => {
@@ -19,4 +24,196 @@ describe("Explorer sidebar layout", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840);
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390);
});
describe("native resize ownership", () => {
const allocation = {
width: 320,
containerWidth: 1200,
overlay: false,
resizeEnabled: true,
};
const dragging: ExplorerSidebarResize = {
sourceWidths: [allocation.width],
restoreWidth: allocation.width,
containerWidth: allocation.containerWidth,
pendingWidth: null,
};
it.each([false, true])(
"keeps a second drag through the previous release acknowledgement (released=%s)",
(releaseSecond) => {
const first = finishExplorerSidebarResize(dragging, 480, true);
const second = beginExplorerSidebarResize(first, allocation);
const current = releaseSecond ? finishExplorerSidebarResize(second, 600, true) : second;
const acknowledged = reconcileExplorerSidebarResize(current, { ...allocation, width: 480 });
expect(
resolveExplorerSidebarFrameWidth({
...allocation,
width: 480,
resize: acknowledged,
resizeWidth: 600,
}),
).toBe(600);
const released = finishExplorerSidebarResize(acknowledged, 600, true);
expect(released?.pendingWidth).toBe(600);
expect(reconcileExplorerSidebarResize(released, { ...allocation, width: 600 })).toBeNull();
},
);
it("keeps a return to the minimum while an earlier wider release is acknowledged", () => {
const initial = { ...allocation, width: 240 };
const wider = finishExplorerSidebarResize(
beginExplorerSidebarResize(null, initial),
540,
true,
);
const returned = finishExplorerSidebarResize(
beginExplorerSidebarResize(wider, initial),
240,
true,
);
const awaiting = reconcileExplorerSidebarResize(returned, initial);
const intermediate = reconcileExplorerSidebarResize(awaiting, { ...initial, width: 540 });
expect(
resolveExplorerSidebarFrameWidth({
...initial,
width: 540,
resize: intermediate,
resizeWidth: 240,
}),
).toBe(240);
expect(reconcileExplorerSidebarResize(intermediate, initial)).toBeNull();
});
it("cancels back to the latest release when earlier widths repeat", () => {
const first = finishExplorerSidebarResize(dragging, 480, true);
const second = finishExplorerSidebarResize(
beginExplorerSidebarResize(first, allocation),
600,
true,
);
const third = finishExplorerSidebarResize(
beginExplorerSidebarResize(second, allocation),
480,
true,
);
const fourth = beginExplorerSidebarResize(third, allocation);
const canceled = finishExplorerSidebarResize(fourth, 700, false);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 700 }),
).toBe(480);
});
it("cancels a newer gesture back to its last released width", () => {
const first = finishExplorerSidebarResize(dragging, 480, true);
const second = beginExplorerSidebarResize(first, allocation);
const canceled = finishExplorerSidebarResize(second, 600, false);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 600 }),
).toBe(480);
expect(reconcileExplorerSidebarResize(canceled, { ...allocation, width: 480 })).toBeNull();
});
it("keeps the drag width after release while the JS commit still has the old prop", () => {
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: dragging, resizeWidth: 480 }),
).toBe(480);
const released = finishExplorerSidebarResize(dragging, 480, true);
expect(reconcileExplorerSidebarResize(released, allocation)).toBe(released);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 480 }),
).toBe(480);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 320 }),
).toBe(480);
});
it("acknowledges the committed width and immediately follows subsequent props", () => {
const released = finishExplorerSidebarResize(dragging, 480, true);
const acknowledgedAllocation = { ...allocation, width: 480 };
expect(
resolveExplorerSidebarFrameWidth({
...acknowledgedAllocation,
resize: released,
resizeWidth: 480,
}),
).toBe(480);
const acknowledged = reconcileExplorerSidebarResize(released, acknowledgedAllocation);
expect(acknowledged).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({
...allocation,
width: 360,
resize: acknowledged,
resizeWidth: 480,
}),
).toBe(360);
});
it.each([
{ name: "a new width prop", next: { ...allocation, width: 360 }, expected: 360 },
{
name: "a window allocation with an unchanged width prop",
next: { ...allocation, containerWidth: 900 },
expected: 320,
},
{
name: "a window allocation that clamps the width prop",
next: { ...allocation, width: 540, containerWidth: 900 },
expected: 540,
},
{ name: "overlay presentation", next: { ...allocation, overlay: true }, expected: 1200 },
{ name: "an inactive dock", next: { ...allocation, resizeEnabled: false }, expected: 320 },
])(
"yields synchronously to $name and does not resurrect the old handoff",
({ next, expected }) => {
const released = finishExplorerSidebarResize(dragging, 600, true);
expect(
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 600 }),
).toBe(expected);
const interrupted = reconcileExplorerSidebarResize(released, next);
expect(interrupted).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({
...allocation,
resize: interrupted,
resizeWidth: 600,
}),
).toBe(320);
},
);
it("restores canonical intent when a drag is canceled", () => {
const canceled = finishExplorerSidebarResize(dragging, 480, false);
expect(canceled).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 480 }),
).toBe(320);
});
it("cannot finish an interrupted drag over a newer allocation", () => {
const next = { ...allocation, width: 360, containerWidth: 1000 };
const interrupted = reconcileExplorerSidebarResize(dragging, next);
const released = finishExplorerSidebarResize(interrupted, 480, true);
expect(released).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 480 }),
).toBe(360);
});
it("settles a release at the already-committed width without retaining ownership", () => {
const released = finishExplorerSidebarResize(dragging, allocation.width, true);
expect(reconcileExplorerSidebarResize(released, allocation)).toBeNull();
});
it("keeps an earlier released handoff when a later touch fails before activation", () => {
const released = finishExplorerSidebarResize(dragging, 480, true);
const finalized = finishExplorerSidebarResize(released, 480, false);
expect(finalized).toBe(released);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: finalized, resizeWidth: 480 }),
).toBe(480);
});
});
});
@@ -16,6 +16,85 @@ export function resolveExplorerSidebarWidth(input: {
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
}
// A release keeps its UI width until React acknowledges it or replaces its allocation.
// Null pendingWidth means the gesture still owns the live resizeWidth instead.
export interface ExplorerSidebarResize {
sourceWidths: number[];
restoreWidth: number;
containerWidth: number;
pendingWidth: number | null;
}
interface ExplorerSidebarAllocation {
width: number;
containerWidth: number;
overlay: boolean;
resizeEnabled: boolean;
}
export function reconcileExplorerSidebarResize(
resize: ExplorerSidebarResize | null,
allocation: ExplorerSidebarAllocation,
): ExplorerSidebarResize | null {
"worklet";
if (
!resize ||
allocation.overlay ||
!allocation.resizeEnabled ||
allocation.containerWidth !== resize.containerWidth ||
!resize.sourceWidths.includes(allocation.width) ||
(allocation.width === resize.pendingWidth &&
resize.sourceWidths.indexOf(allocation.width) === resize.sourceWidths.length - 1)
) {
return null;
}
const acknowledgedIndex = resize.sourceWidths.indexOf(allocation.width);
return acknowledgedIndex > 0
? { ...resize, sourceWidths: resize.sourceWidths.slice(acknowledgedIndex) }
: resize;
}
export function beginExplorerSidebarResize(
previous: ExplorerSidebarResize | null,
allocation: ExplorerSidebarAllocation,
): ExplorerSidebarResize {
"worklet";
const current = reconcileExplorerSidebarResize(previous, allocation);
// Released widths remain expected allocations while a newer gesture owns the frame.
const sourceWidths = current ? [...current.sourceWidths] : [allocation.width];
if (current && current.pendingWidth !== null && !sourceWidths.includes(current.pendingWidth))
sourceWidths.push(current.pendingWidth);
return {
sourceWidths,
restoreWidth: current ? (current.pendingWidth ?? current.restoreWidth) : allocation.width,
containerWidth: allocation.containerWidth,
pendingWidth: null,
};
}
export function finishExplorerSidebarResize(
resize: ExplorerSidebarResize | null,
width: number,
success: boolean,
): ExplorerSidebarResize | null {
"worklet";
if (!resize || resize.pendingWidth !== null) return resize;
if (success) return { ...resize, pendingWidth: width };
return resize.sourceWidths.length > 1 ? { ...resize, pendingWidth: resize.restoreWidth } : null;
}
export function resolveExplorerSidebarFrameWidth(
allocation: ExplorerSidebarAllocation & {
resize: ExplorerSidebarResize | null;
resizeWidth: number;
},
): number {
"worklet";
if (allocation.overlay) return allocation.containerWidth;
const resize = reconcileExplorerSidebarResize(allocation.resize, allocation);
return resize ? (resize.pendingWidth ?? allocation.resizeWidth) : allocation.width;
}
export function resolveExplorerSidebarDockSizes(input: {
requestedWidth?: number;
containerWidth: number;
+124 -41
View File
@@ -1,3 +1,7 @@
import {
AdaptiveWorkspaceChrome,
type WorkspaceCompactHeaderActions,
} from "@/screens/workspace/adaptive-workspace-chrome";
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
import {
Fragment,
@@ -32,6 +36,7 @@ import {
WindowChromeSafeArea,
useWindowChromeCorners,
removeWindowChromeCorner,
resolveChildWindowChromeCorners,
type WindowChromeCorners,
} from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -42,6 +47,7 @@ import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pa
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
import {
WorkspaceDesktopTabsRow,
useWorkspaceNewTabKeyboardAction,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -61,6 +67,11 @@ import { isNative } from "@/constants/platform";
interface SplitContainerProps {
layout: WorkspaceLayout;
renderMainHeader?: () => ReactNode;
renderCompactHeader: (
tabs: WorkspaceTabDescriptor[],
activeTabKey: string,
actions: WorkspaceCompactHeaderActions,
) => ReactNode;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -145,7 +156,6 @@ const EMPTY_SPLIT_SIZES: number[] = [];
export function SplitContainer({
layout,
renderMainHeader,
workspaceKey,
normalizedServerId,
normalizedWorkspaceId,
@@ -174,9 +184,11 @@ export function SplitContainer({
onReorderTabsInPane,
focusModeEnabled,
onExitFocusMode,
renderMainHeader,
renderCompactHeader,
}: SplitContainerProps) {
const inheritedWindowChromeCorners = useWindowChromeCorners();
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
const windowChromeCorners = inheritedWindowChromeCorners;
const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag();
const [maximizedPane, setMaximizedPane] = useState<{
workspaceKey: string;
@@ -237,7 +249,6 @@ export function SplitContainer({
<View style={styles.workspaceShell}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{renderMainHeader?.()}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
@@ -279,6 +290,8 @@ export function SplitContainer({
onTogglePaneMaximized={handleTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
) : null}
</View>
@@ -410,6 +423,8 @@ function SplitNodeView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
renderMainHeader,
renderCompactHeader,
}: SplitNodeViewProps) {
const [groupContainerSize, setGroupContainerSize] = useState(0);
const groupId = node.kind === "group" ? node.group.id : null;
@@ -496,6 +511,8 @@ function SplitNodeView({
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
</WindowChromeRegion>
</RetainedPanel>
@@ -544,12 +561,25 @@ function SplitNodeView({
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners}
windowChromeCorners={resolveChildWindowChromeCorners({
corners: windowChromeCorners,
direction: node.group.direction,
isFirst:
node.group.children.findIndex(
(candidate) => !isSplitNodeHiddenForPresentation(candidate, maximizedPaneId),
) === index,
isLast:
node.group.children.findLastIndex(
(candidate) => !isSplitNodeHiddenForPresentation(candidate, maximizedPaneId),
) === index,
})}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
</SplitGroupChild>
{index < node.group.children.length - 1 &&
@@ -608,6 +638,8 @@ const SplitPaneView = memo(function SplitPaneView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
renderMainHeader,
renderCompactHeader,
}: SplitPaneViewProps) {
recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]);
const paneRef = useRef<View | null>(null);
@@ -701,6 +733,51 @@ const SplitPaneView = memo(function SplitPaneView({
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
const compactHeaderActions = useMemo(
() => ({
pane: {
id: paneId,
splitRight: focusModeEnabled ? undefined : handleSplitRight,
splitDown: focusModeEnabled ? undefined : handleSplitDown,
toggleMaximized:
workspaceHasMultiplePanes && !focusModeEnabled ? handleTogglePaneMaximized : undefined,
maximized: paneId === maximizedPaneId,
},
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
}),
[
paneId,
focusModeEnabled,
handleSplitRight,
handleSplitDown,
workspaceHasMultiplePanes,
handleTogglePaneMaximized,
maximizedPaneId,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleCloseOtherTabs,
],
);
const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]);
useWorkspaceNewTabKeyboardAction({
handlerName: "workspace-chrome-new-tab",
ownsKeyboardShortcuts: isFocused && isWorkspaceFocused,
normalizedServerId,
normalizedWorkspaceId,
paneId,
createNewTab,
});
const compactHeader = useMemo(
() => (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
</WindowChromeSafeArea>
),
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View
@@ -709,43 +786,49 @@ const SplitPaneView = memo(function SplitPaneView({
style={styles.pane}
testID={`workspace-pane-${pane.id}`}
>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused && isWorkspaceFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneSplitActions={!focusModeEnabled}
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
{renderMainHeader?.()}
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused && isWorkspaceFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
ownsKeyboardShortcuts={false}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneSplitActions={!focusModeEnabled}
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>
</WindowChromeRegion>
</AdaptiveWorkspaceChrome>
<View style={styles.paneContent}>
<WorkspacePanelHost
+8 -2
View File
@@ -8,6 +8,7 @@ import {
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Platform,
Text,
useWindowDimensions,
NativeSyntheticEvent,
@@ -1936,7 +1937,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperCombinedStyle = useMemo(
() => [
styles.inputWrapper,
isCompact && !isFullscreen && styles.inputWrapperCompact,
Platform.OS !== "ios" && isCompact && !isFullscreen && styles.inputWrapperCompact,
readOnly && styles.inputWrapperReadOnly,
inputWrapperStyle,
{ opacity: surfacePresentation.input.opacity },
@@ -2230,7 +2231,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return (
<View
ref={rootRef}
style={isFullscreen ? styles.fullscreenContainer : styles.container}
style={
isFullscreen
? styles.fullscreenContainer
: [styles.container, Platform.OS === "ios" && isCompact && styles.inputWrapperCompact]
}
testID="message-input-root"
onLayout={handleComposerLayout}
>
@@ -2276,6 +2281,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
position: "relative",
},
inputWrapper: {
...(Platform.OS === "ios" ? { overflow: "hidden" as const } : {}),
flexShrink: 1,
flexDirection: "column",
gap: theme.spacing[3],
@@ -29,6 +29,21 @@ describe("keyboard layout reservation", () => {
});
describe("resolveKeyboardShift", () => {
it.each([0, -1])(
"ignores a rotation report with height %s and progress one",
(rawKeyboardHeight) => {
expect(
resolveKeyboardShift({
rawKeyboardHeight,
keyboardProgress: 1,
bottomInset: 24,
isIos: true,
iosMinHeight: 120,
}),
).toBe(0);
},
);
it("keeps the existing open-keyboard offset behavior", () => {
expect(
resolveKeyboardShift({
@@ -0,0 +1,38 @@
import { useCallback, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { selectWorkspaceChrome } from "./workspace-tab-layout";
import { WorkspaceTabMeasurementScope } from "./workspace-desktop-tabs-row";
export function AdaptiveWorkspaceChrome({
compactHeader,
children,
}: {
compactHeader: ReactNode;
children: ReactNode;
}) {
const [width, setWidth] = useState<number | null>(null);
const measure = useCallback((event: LayoutChangeEvent) => {
const measured = event.nativeEvent.layout.width;
if (measured > 0) setWidth(measured);
}, []);
return (
<WorkspaceTabMeasurementScope>
<View onLayout={measure}>
{selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
</View>
</WorkspaceTabMeasurementScope>
);
}
export interface WorkspaceCompactHeaderActions {
pane?: {
id: string;
splitRight?: () => void;
splitDown?: () => void;
toggleMaximized?: () => void;
maximized: boolean;
};
closeAbove: (key: string) => Promise<void> | void;
closeBelow: (key: string) => Promise<void> | void;
closeOthers: (key: string) => Promise<void> | void;
}
@@ -1,3 +1,4 @@
import { workspaceTabSwitcherPopoverLayout } from "../workspace-tab-layout";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { Keyboard, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -123,14 +124,15 @@ function getFallbackTabOptionDescription(
}
interface MobileWorkspaceTabSwitcherProps {
paneId?: string;
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
onSelectSwitcherTab: (key: string) => void;
createTerminalDisabled: boolean;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateDraftTab: (input?: { paneId?: string }) => void;
onCreateTerminal: (input?: { paneId?: string }) => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
@@ -285,6 +287,7 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch
createTerminalDisabled,
onCreateDraftTab,
onCreateTerminal,
paneId,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
@@ -342,13 +345,13 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch
const handleNewAgent = useCallback(() => {
setIsOpen(false);
onCreateDraftTab();
}, [onCreateDraftTab]);
onCreateDraftTab({ paneId });
}, [onCreateDraftTab, paneId]);
const handleNewTerminal = useCallback(() => {
setIsOpen(false);
onCreateTerminal();
}, [onCreateTerminal]);
onCreateTerminal({ paneId });
}, [onCreateTerminal, paneId]);
const footer = useMemo(
() => (
@@ -456,6 +459,7 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch
</Pressable>
<Combobox
{...workspaceTabSwitcherPopoverLayout}
options={tabSwitcherOptions}
value={activeTabKey}
onSelect={onSelectSwitcherTab}
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Gesture } from "react-native-gesture-handler";
import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
@@ -9,7 +14,14 @@ import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
import {
beginExplorerSidebarResize,
finishExplorerSidebarResize,
reconcileExplorerSidebarResize,
resolveExplorerSidebarFrameWidth,
resolveExplorerSidebarWidth,
type ExplorerSidebarResize,
} from "@/components/explorer-sidebar-layout";
import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types";
export function useExplorerGeometry({
width,
@@ -21,10 +33,16 @@ export function useExplorerGeometry({
const insets = useSafeAreaInsets();
const resizeWidth = useSharedValue(width);
const startWidth = useSharedValue(width);
const resize = useSharedValue<ExplorerSidebarResize | null>(null);
const [resizePressed, setResizePressed] = useState(false);
useEffect(() => {
resizeWidth.value = width;
}, [resizeWidth, width]);
useAnimatedReaction(
() => ({ resize: resize.value, width, containerWidth, overlay, resizeEnabled }),
({ resize: currentResize, ...allocation }) => {
const nextResize = reconcileExplorerSidebarResize(currentResize, allocation);
if (nextResize !== currentResize) resize.value = nextResize;
},
[containerWidth, overlay, resize, resizeEnabled, width],
);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
@@ -37,22 +55,50 @@ export function useExplorerGeometry({
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidth.value = width + event.translationX;
resizeWidth.value = width;
const currentWidth = resolveExplorerSidebarFrameWidth({
width,
containerWidth,
overlay,
resizeEnabled,
resize: resize.value,
resizeWidth: resizeWidth.value,
});
resize.value = beginExplorerSidebarResize(resize.value, {
width,
containerWidth,
overlay,
resizeEnabled,
});
startWidth.value = currentWidth + event.translationX;
resizeWidth.value = currentWidth;
})
.onUpdate((event) => {
if (!resize.value) return;
resizeWidth.value = resolveExplorerSidebarWidth({
requestedWidth: startWidth.value - event.translationX,
containerWidth,
});
})
.onEnd(() => runOnJS(onResize)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
.onEnd((_event, success) => {
{
resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, success);
if (!success || !resize.value) return;
}
runOnJS(onResize)(resizeWidth.value);
})
.onFinalize((_event, success) => {
if (!success) {
resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, false);
}
scheduleOnRN(hideResizeGrip);
}),
[
resizeEnabled,
onResize,
containerWidth,
hideResizeGrip,
overlay,
resize,
resizeWidth,
showResizeGrip,
startWidth,
@@ -61,12 +107,21 @@ export function useExplorerGeometry({
);
// Reanimated retains the last animated value when its style is removed. Keep this
// style attached across folding and explicitly size overlays to the workspace viewport.
const animatedWidthStyle = useAnimatedStyle(
() => ({
width: overlay ? containerWidth : resizeWidth.value,
}),
[containerWidth, overlay],
);
const animatedWidthStyle = useAnimatedStyle(() => {
if (overlay) return { width: containerWidth };
{
return {
width: resolveExplorerSidebarFrameWidth({
width,
containerWidth,
overlay,
resizeEnabled,
resize: resize.value,
resizeWidth: resizeWidth.value,
}),
};
}
}, [containerWidth, overlay, resizeEnabled, width]);
return {
frameStyle: animatedWidthStyle,
panelStyle: {
@@ -1,6 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
Fragment,
createContext,
useContext,
useCallback,
useEffect,
useLayoutEffect,
@@ -488,6 +490,13 @@ interface WorkspaceTabLabelMeasurement {
width: number;
}
const TabMeasurementCache = createContext<Map<string, WorkspaceTabLabelMeasurement> | null>(null);
export function WorkspaceTabMeasurementScope({ children }: { children: React.ReactNode }) {
const cache = useMemo(() => new Map<string, WorkspaceTabLabelMeasurement>(), []);
return <TabMeasurementCache.Provider value={cache}>{children}</TabMeasurementCache.Provider>;
}
interface WorkspaceTabTrackSnapshot {
signature: string;
tabs: ResolvedWorkspaceDesktopTabRowItem[];
@@ -961,6 +970,47 @@ function TabChip({
);
}
export function useWorkspaceNewTabKeyboardAction({
handlerName = "workspace-new-tab",
ownsKeyboardShortcuts,
normalizedServerId,
normalizedWorkspaceId,
paneId,
createNewTab,
}: {
handlerName?: string;
ownsKeyboardShortcuts: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
paneId?: string;
createNewTab: () => void;
}) {
const handleNewTabKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!ownsKeyboardShortcuts) return false;
if (action.id === "workspace.tab.menu.open") {
createNewTab();
return true;
}
return false;
},
[createNewTab, ownsKeyboardShortcuts],
);
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: handlerName,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneId,
}),
actions: ["workspace.tab.menu.open"],
enabled: ownsKeyboardShortcuts,
priority: 200,
handle: handleNewTabKeyboardAction,
});
}
export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
const [presentations, setPresentations] = useState(
() => new Map<string, WorkspaceTabPresentation>(),
@@ -1062,9 +1112,13 @@ function ResolvedWorkspaceDesktopTabsRow({
const tabsScrollRef = useRef<ScrollView>(null);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const tabScrollBoundary = useHorizontalScrollBoundary();
const [labelMeasurements, setLabelMeasurements] = useState(
() => new Map<string, WorkspaceTabLabelMeasurement>(),
);
const measurementCache = useContext(TabMeasurementCache);
const [labelMeasurements, setLabelMeasurements] = useState(() => new Map(measurementCache ?? []));
useLayoutEffect(() => {
if (!measurementCache) return;
measurementCache.clear();
for (const [key, value] of labelMeasurements) measurementCache.set(key, value);
}, [measurementCache, labelMeasurements]);
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(null);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
@@ -1249,29 +1303,12 @@ function ResolvedWorkspaceDesktopTabsRow({
const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]);
const handleNewTabKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!ownsKeyboardShortcuts) return false;
if (action.id === "workspace.tab.menu.open") {
createNewTab();
return true;
}
return false;
},
[createNewTab, ownsKeyboardShortcuts],
);
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-new-tab",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneId,
}),
actions: ["workspace.tab.menu.open"],
enabled: ownsKeyboardShortcuts,
priority: 200,
handle: handleNewTabKeyboardAction,
useWorkspaceNewTabKeyboardAction({
ownsKeyboardShortcuts,
normalizedServerId,
normalizedWorkspaceId,
paneId,
createNewTab,
});
const rail: WorkspaceTabRailSurface = host === "explorer" ? "sidebar" : "surface";
@@ -1,3 +1,4 @@
import type { WorkspaceCompactHeaderActions } from "./adaptive-workspace-chrome";
import React, { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useRouter, type Href } from "expo-router";
@@ -206,13 +207,15 @@ function HeaderMenuProfileItem({
}
export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions {
pane?: WorkspaceCompactHeaderActions["pane"];
onExitFocusMode?: () => void;
normalizedServerId: string;
normalizedWorkspaceId: string;
scripts: UseWorkspaceScriptsMenuPagesInput;
showCreateBrowserTab: boolean;
createTerminalDisabled: boolean;
onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
onCreateBrowser: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfile, paneId?: string) => void;
onCreateBrowser: (input?: { paneId?: string }) => void;
}
/**
@@ -231,8 +234,19 @@ export function WorkspaceHeaderMenuMobile({
createTerminalDisabled,
onCreateTerminalWithProfile,
onCreateBrowser,
pane,
onExitFocusMode,
...workspaceActions
}: WorkspaceHeaderMenuMobileProps) {
const paneId = pane?.id;
const handleCreateBrowser = useCallback(
() => onCreateBrowser({ paneId }),
[onCreateBrowser, paneId],
);
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfile) => onCreateTerminalWithProfile(profile, paneId),
[onCreateTerminalWithProfile, paneId],
);
const { t } = useTranslation();
const router = useRouter();
const triggerStyle = useIconButtonChromeStyle({ size: "large" });
@@ -278,6 +292,30 @@ export function WorkspaceHeaderMenuMobile({
pages={pages}
sheetTitle={t("workspace.header.actions.workspaceActions")}
>
{onExitFocusMode ? (
<DropdownMenuItem onSelect={onExitFocusMode}>
{t("workspace.tabs.actions.exitFocusMode")}
</DropdownMenuItem>
) : null}
{pane?.splitRight ? (
<DropdownMenuItem onSelect={pane.splitRight}>
{t("workspace.tabs.actions.splitRight")}
</DropdownMenuItem>
) : null}
{pane?.splitDown ? (
<DropdownMenuItem onSelect={pane.splitDown}>
{t("workspace.tabs.actions.splitDown")}
</DropdownMenuItem>
) : null}
{pane?.toggleMaximized ? (
<DropdownMenuItem onSelect={pane.toggleMaximized}>
{t(
pane.maximized
? "workspace.tabs.actions.restorePane"
: "workspace.tabs.actions.maximizePane",
)}
</DropdownMenuItem>
) : null}
{scriptsMenu.trigger}
{pluginMenu.rows}
{pluginMenu.rows.length > 0 ? <DropdownMenuSeparator /> : null}
@@ -285,7 +323,7 @@ export function WorkspaceHeaderMenuMobile({
<DropdownMenuItem
testID="workspace-header-new-browser"
leading={MENU_NEW_BROWSER_ICON}
onSelect={onCreateBrowser}
onSelect={handleCreateBrowser}
>
{t("workspace.header.actions.newBrowser")}
</DropdownMenuItem>
@@ -298,7 +336,7 @@ export function WorkspaceHeaderMenuMobile({
key={profile.id}
profile={profile}
disabled={createTerminalDisabled}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
@@ -1,3 +1,7 @@
import {
AdaptiveWorkspaceChrome,
type WorkspaceCompactHeaderActions,
} from "./adaptive-workspace-chrome";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { useHostFeature } from "@/runtime/host-features";
import {
@@ -148,6 +152,7 @@ import {
} from "@/screens/workspace/use-workspace-tab-rename";
import {
WorkspaceDesktopTabsRow,
useWorkspaceNewTabKeyboardAction,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests";
@@ -1808,8 +1813,8 @@ function WorkspaceScreenContent({
});
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfile) => {
createTerminal({ profile, destination: { kind: "open" } });
(profile: TerminalProfile, paneId?: string) => {
createTerminal({ profile, destination: { kind: "open", paneId } });
},
[createTerminal],
);
@@ -3372,85 +3377,100 @@ function WorkspaceScreenContent({
handleOpenUrlInBrowserTab,
],
);
const workspaceTabSwitcher = useMemo(
() => (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
createTerminalDisabled={createTerminalDisabled}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
),
[
activeTabKey,
createTerminalDisabled,
handleCloseOtherTabs,
handleCloseTabById,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleCopyAgentId,
handleCopyFilePath,
handleCopyResumeCommand,
handleCopyTerminalId,
handleCreateDraftTab,
handleCreateTerminal,
handleReloadAgent,
handleRenameTab,
handleSelectSwitcherTab,
normalizedServerId,
normalizedWorkspaceId,
tabs,
],
);
const compactWorkspaceHeader = useMemo(
() => (
<CompactWorkspaceHeader
headerVariant={isMobile ? "compact" : "full"}
actionsActive={isRouteFocused && (!isMobile || workspacePanelActive)}
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
serverId={normalizedServerId}
onToggleExplorer={handleToggleExplorerSidebar}
explorerToggleLabel={explorerSidebarToggleLabel}
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
tabSwitcher={workspaceTabSwitcher}
>
<WorkspaceHeaderMenuMobile
const renderWorkspaceTabSwitcher = useCallback(
(
headerTabs: WorkspaceTabDescriptor[],
headerActiveTabKey: string,
actions: WorkspaceCompactHeaderActions,
) => {
return (
<MobileWorkspaceTabSwitcher
paneId={actions.pane?.id}
tabs={headerTabs}
activeTabKey={headerActiveTabKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
scripts={compactHeaderScripts}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
onSelectSwitcherTab={handleSelectSwitcherTab}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={actions.closeAbove}
onCloseTabsBelow={actions.closeBelow}
onCloseOtherTabs={actions.closeOthers}
/>
</CompactWorkspaceHeader>
),
);
},
[
normalizedServerId,
normalizedWorkspaceId,
handleSelectSwitcherTab,
createTerminalDisabled,
handleCreateDraftTab,
handleCreateTerminal,
handleCopyResumeCommand,
handleCopyAgentId,
handleCopyTerminalId,
handleCopyFilePath,
handleReloadAgent,
handleRenameTab,
handleCloseTabById,
],
);
const renderCompactWorkspaceHeader = useCallback(
(
headerTabs: WorkspaceTabDescriptor[],
headerActiveTabKey: string,
actions: WorkspaceCompactHeaderActions,
actionsOnly = false,
) => {
const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions);
return (
<CompactWorkspaceHeader
headerVariant={actionsOnly ? "full" : "compact"}
actionsActive={isRouteFocused && (!isMobile || workspacePanelActive)}
tabSwitcher={tabSwitcher}
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
serverId={normalizedServerId}
onToggleExplorer={handleToggleExplorerSidebar}
explorerToggleLabel={explorerSidebarToggleLabel}
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
>
<WorkspaceHeaderMenuMobile
pane={actions.pane}
onExitFocusMode={isFocusModeEnabled ? toggleFocusMode : undefined}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
scripts={compactHeaderScripts}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</CompactWorkspaceHeader>
);
},
[
renderWorkspaceTabSwitcher,
isFocusModeEnabled,
toggleFocusMode,
canOpenImportSheet,
compactHeaderScripts,
createTerminalDisabled,
@@ -3476,9 +3496,23 @@ function WorkspaceScreenContent({
workspaceHeaderSubtitle,
workspaceHeaderTitle,
workspacePanelActive,
workspaceTabSwitcher,
],
);
const compactWorkspaceHeader = renderCompactWorkspaceHeader(tabs, activeTabKey, {
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
});
const fullWorkspaceActions = renderCompactWorkspaceHeader(
tabs,
activeTabKey,
{
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
},
true,
);
const desktopSplitContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
return null;
@@ -3487,6 +3521,7 @@ function WorkspaceScreenContent({
<SplitContainer
layout={workspaceLayout}
renderMainHeader={renderWorkspaceScreenHeader}
renderCompactHeader={renderCompactWorkspaceHeader}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
workspaceKey={persistenceKey}
@@ -3523,6 +3558,7 @@ function WorkspaceScreenContent({
canRenderDesktopPaneSplits,
workspaceLayout,
renderWorkspaceScreenHeader,
renderCompactWorkspaceHeader,
persistenceKey,
desktopFocusModeEnabled,
toggleFocusMode,
@@ -3561,6 +3597,22 @@ function WorkspaceScreenContent({
[uiTabs, sheets],
);
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
const createFallbackTab = useCallback(
() => handleCreateNewTab({ paneId: focusedPaneIdOrUndefined }),
[handleCreateNewTab, focusedPaneIdOrUndefined],
);
const ownsFallbackKeyboardShortcuts = useMemo(
() => shouldRenderDesktopPaneFallback && isRouteFocused,
[shouldRenderDesktopPaneFallback, isRouteFocused],
);
useWorkspaceNewTabKeyboardAction({
handlerName: "workspace-chrome-new-tab",
ownsKeyboardShortcuts: ownsFallbackKeyboardShortcuts,
normalizedServerId,
normalizedWorkspaceId,
paneId: focusedPaneIdOrUndefined,
createNewTab: createFallbackTab,
});
const compactHeaderLayout = resolveCompactHeaderLayout({
header: compactWorkspaceHeader,
activeTab: activeTabDescriptor,
@@ -3584,37 +3636,41 @@ function WorkspaceScreenContent({
</View>
) : (
<View style={styles.centerColumn}>
{compactWorkspaceHeader}
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
{shouldRenderDesktopPaneFallback ? (
<NewTabLauncherProvider value={newTabLauncher}>
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={handleCreateNewTab}
onReorderTabs={handleReorderTabsInFocusedPane}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
/>
</NewTabLauncherProvider>
) : null}
{rendersDesktopSplitContent ? null : (
<AdaptiveWorkspaceChrome compactHeader={compactWorkspaceHeader}>
{fullWorkspaceActions}
{renderWorkspaceScreenHeader()}
{shouldRenderDesktopPaneFallback ? (
<NewTabLauncherProvider value={newTabLauncher}>
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
ownsKeyboardShortcuts={false}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={handleCreateNewTab}
onReorderTabs={handleReorderTabsInFocusedPane}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
/>
</NewTabLauncherProvider>
) : null}
</AdaptiveWorkspaceChrome>
)}
<View style={styles.centerContent}>
<View style={styles.content}>{desktopContent}</View>
</View>
@@ -1,3 +1,4 @@
import { buildDesktopFrameStyle } from "@/components/ui/combobox-frame-style";
import {
WorkspaceHeaderExplorerToggle,
WorkspaceExplorerSidebarToggle,
@@ -7,6 +8,8 @@ import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveWorkspaceTabChipBackdrop } from "@/screens/workspace/workspace-tab-chip-backdrop";
import { darkPureBlackTheme, darkTheme, lightTheme } from "@/styles/theme";
import {
workspaceTabSwitcherPopoverLayout,
selectWorkspaceChrome,
computeWorkspaceTabLayout,
retainWorkspaceTabMeasuredWidth,
} from "@/screens/workspace/workspace-tab-layout";
@@ -256,3 +259,37 @@ describe("resolveWorkspaceTabChipBackdrop", () => {
).toBe("surface0");
});
});
import { shouldUseCompactWorkspaceHeader } from "./workspace-tab-layout";
it.each([
[349, true],
[499, true],
[500, false],
[800, false],
])("workspace width %s selects compact=%s", (width, compact) => {
expect(shouldUseCompactWorkspaceHeader(width)).toBe(compact);
});
describe("desktop pane chrome", () => {
it("replaces the pane header and rail together at the pane width boundary", () => {
const full = ["workspace header", "pane tab rail"];
expect(selectWorkspaceChrome({ width: 440, compact: "pane dropdown", full })).toBe(
"pane dropdown",
);
expect(selectWorkspaceChrome({ width: 500, compact: "pane dropdown", full })).toBe(full);
expect(selectWorkspaceChrome({ width: null, compact: "pane dropdown", full })).toBe(full);
});
});
it("gives the tab switcher a readable popover width independent of its 16pt count badge", () => {
const frame = buildDesktopFrameStyle({
desktopMinWidth: workspaceTabSwitcherPopoverLayout.desktopMinWidth,
desktopLockWidth: false,
referenceWidth: 16,
desktopFixedHeight: undefined,
desktopPositionStyle: {},
shouldHideDesktopContent: false,
availableHeight: 600,
}) as Array<{ minWidth?: number }>;
expect(frame[0].minWidth).toBeGreaterThanOrEqual(320);
});
@@ -1,3 +1,9 @@
import { COMPACT_CONTENT_WIDTH } from "@/constants/layout";
export function shouldUseCompactWorkspaceHeader(width: number): boolean {
return width < COMPACT_CONTENT_WIDTH;
}
export type WorkspaceTabCloseButtonPolicy = "all" | "none";
export interface WorkspaceTabLayoutMetrics {
@@ -120,3 +126,16 @@ export function computeWorkspaceTabLayout(
requiresHorizontalScrollFallback,
};
}
export function selectWorkspaceChrome<Compact, Full>(input: {
width: number | null;
compact: Compact;
full: Full;
}): Compact | Full {
const compact = input.width !== null && shouldUseCompactWorkspaceHeader(input.width);
return compact ? input.compact : input.full;
}
// A count badge is only 16pt wide. Native popovers need a content width floor
// because their scrolling tab list does not contribute an intrinsic width.
export const workspaceTabSwitcherPopoverLayout = { desktopMinWidth: 320 } as const;
@@ -160,6 +160,8 @@ it("preserves another terminal's glyph pixels when a shared atlas is cleared", a
expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" });
});
// Software WebGL can take over 30 seconds for this corpus on a contended runner.
// Each capture still has its own two-second render deadline.
it("preserves both headers while ordinary output grows the shared glyph atlas", async () => {
const { active, sibling } = await mountPair();
const cells = active.terminal.cols * (active.terminal.rows - 2);
@@ -199,4 +201,4 @@ it("preserves both headers while ordinary output grows the shared glyph atlas",
label: `sibling frame ${batch}`,
});
}
}, 30000);
}, 60_000);
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
intersectWindowChromeCorners,
resolveChildWindowChromeCorners,
resolveHasOwnedWindowChromeObstruction,
resolveWindowChromeObstruction,
resolveWindowChromeSafeArea,
@@ -87,3 +88,54 @@ describe("window chrome", () => {
).toBe(false);
});
});
it("assigns window buttons only to exposed split edges", () => {
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "horizontal",
isFirst: true,
isLast: false,
}),
).toBe("top-left");
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "horizontal",
isFirst: false,
isLast: true,
}),
).toBe("top-right");
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "horizontal",
isFirst: false,
isLast: false,
}),
).toBe("none");
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "vertical",
isFirst: true,
isLast: false,
}),
).toBe("both");
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "vertical",
isFirst: false,
isLast: true,
}),
).toBe("none");
expect(
resolveChildWindowChromeCorners({
corners: "top-right",
direction: "horizontal",
isFirst: true,
isLast: true,
}),
).toBe("top-right");
});
+13
View File
@@ -83,6 +83,19 @@ export function intersectWindowChromeCorners(
);
}
export function resolveChildWindowChromeCorners(input: {
corners: WindowChromeCorners;
direction: "horizontal" | "vertical";
isFirst: boolean;
isLast: boolean;
}): WindowChromeCorners {
if (input.direction === "vertical") return input.isFirst ? input.corners : "none";
return intersectWindowChromeCorners(
input.corners,
windowChromeCornersFromFlags(input.isFirst, input.isLast),
);
}
export function removeWindowChromeCorner(
corners: WindowChromeCorners,
corner: WindowChromeCorner,