feat(app): follow iPhone Duo vertical bars and fold divisions (#8741)

* feat(app): follow system vertical bars and fold divisions

Keep custom navigation controls outside system content areas and use active division regions for temporary panel allocation. Normal sidebar preferences resume when the division disappears.

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

* fix(app): show window actions in the active portal scope

The bottom-sheet provider creates its own portal context. Register the window action host within that context so symbols reach the root host.

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

* fix(app): hide workspace bar actions behind compact panels

Compact panels keep the workspace route focused while covering its content. Hide its portal actions until the workspace panel is active again.

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

* fix(app): continue the sidebar surface across divisions

Keep the fold free of controls while painting it as part of the sidebar, with the shared divider at the workspace edge.

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

* fix(app): omit tab count beside the full tab rail

The full tab rail already exposes navigation. Use the same compact/full header selection for the vertical action list.

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

* fix(app): preserve folded navigation and bar action lifetime

Use resolved sidebar visibility for desktop activity, and keep bar controls mounted while compact panels cover them so pending menu work survives. Carry circular button chrome inside the portal and exercise the rendered boundary in Chromium.

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

* fix(app): own script actions across header placement changes

Keep one script action instance on the workspace screen so pending restarts survive removal of either menu. Pass activity through the bar portal so covered menus close their overlays without taking workflow state with them.

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

* test(app): exercise script ownership through the workspace

Replace synthetic ownership fixtures with a real workspace, script menu and isolated terminal worker. Hold terminal exit until the compact header is removed so moving pending state back into the menu fails the regression. Exercise portal activity with production menu and header controls.

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 11:05:45 +01:00
committed by GitHub
co-authored by Elsa Granger
parent fcbfb4f0d3
commit 4991efddf8
25 changed files with 948 additions and 219 deletions
@@ -1,5 +1,6 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
@@ -190,3 +191,49 @@ test("compact header menu runs, shows, and stops workspace scripts", async ({ pa
await workspace.cleanup();
}
});
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
await openRunningScriptPage(page, "lint");
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
try {
await menuRow(page, "Restart").click();
await page.setViewportSize({ width: 1280, height: 844 });
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
} finally {
resumeWorker();
}
}
async function expectScriptTerminalReplaced(
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
previousId: string,
) {
await expect
.poll(
async () => {
const after = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
},
{ timeout: 15_000 },
)
.toBe(1);
}
test("pending script restart survives removal of the compact header", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const workspace = await openWorkspaceWithScripts(page);
try {
await runScriptFromScriptsPage(page, "lint");
const before = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
expect(before.terminals).toHaveLength(1);
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
} finally {
await workspace.cleanup();
}
});
@@ -6,6 +6,11 @@ env APP_ID=sh.paseo.debug
# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"sidebar-close\"" 10000
press "id=\"sidebar-close\""
# The close target must clear system controls, including a trailing status column.
wait "label=\"Open menu\"" 5000
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
@@ -1,3 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { realpathSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -380,3 +382,21 @@ export function observeSetupPanelTraffic(page: Page) {
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
/** Hold this isolated daemon's terminal worker so script exit cannot race a layout change. */
export async function pauseWorkspaceTerminalWorker(terminalId: string): Promise<() => void> {
const home = process.env.E2E_PASEO_HOME;
if (!home) throw new Error("Missing isolated E2E home");
const directory = path.join(home, "terminal-worker");
for (const name of await readdir(directory)) {
if (!name.endsWith(".json")) continue;
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ id: string }>;
};
if (!record.terminals.some((terminal) => terminal.id === terminalId)) continue;
process.kill(record.pid, "SIGSTOP");
return () => process.kill(record.pid, "SIGCONT");
}
throw new Error(`No isolated terminal worker owns ${terminalId}`);
}
@@ -0,0 +1,4 @@
{
"platforms": ["ios"],
"ios": { "modules": ["PaseoWindowLayoutModule"] }
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'PaseoWindowLayout'
s.version = '0.1.0'
s.summary = 'System window layout geometry for Paseo'
s.description = 'Publishes system bar and division geometry'
s.license = 'Apache-2.0'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '16.4' }
s.swift_version = '5.4'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,110 @@
import ExpoModulesCore
import UIKit
public final class PaseoWindowLayoutModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoWindowLayout")
View(WindowLayoutView.self) {
Events("onGeometryChange")
}
}
}
final class WindowLayoutView: ExpoView {
let onGeometryChange = EventDispatcher()
private var updateScheduled = false
private var previousPayload: [String: Any]?
private var divisions: [CGRect] = []
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
isUserInteractionEnabled = false
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) {
(view: WindowLayoutView, _) in
view.setNeedsUpdateConstraints()
view.scheduleUpdate()
}
}
#endif
}
override func didMoveToWindow() {
super.didMoveToWindow()
previousPayload = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func layoutSubviews() {
super.layoutSubviews()
if bounds != measuredBounds {
measuredBounds = bounds
setNeedsUpdateConstraints()
}
scheduleUpdate()
}
override func updateConstraints() {
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
case .trailing: edge = "trailing"
default: edge = "none"
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
for: .bar(onEdge: edge == "trailing" ? .right : .left, extent: 44)
)
}
}
#endif
super.updateConstraints()
scheduleUpdate()
}
private func scheduleUpdate() {
guard !updateScheduled else { return }
updateScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.updateScheduled = false
self.publish()
}
}
private func publish() {
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
let bar = barGuide?.layoutFrame
var barPayload: Any = NSNull()
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
previousPayload = payload
onGeometryChange(payload)
}
private func rect(_ frame: CGRect) -> [String: CGFloat] {
["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
}
}
+48 -20
View File
@@ -1,4 +1,12 @@
import { useHardwareKeyboardPresent } from "@/keyboard";
import {
WindowLayoutProvider,
WindowLayoutActionsHost,
WindowLayoutSurface,
WindowLayoutDivisionGap,
useWindowLayout,
resolveDesktopDivisionLayout,
} from "@/window-layout";
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -59,6 +67,7 @@ import {
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
} from "@/components/desktop-sidebar-layout";
import { isNative, isWeb } from "@/constants/platform";
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
@@ -355,9 +364,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
});
const cycleTheme = useCallback(() => {
void updateSettings({ theme: getNextThemePreference(settings.theme) });
@@ -418,6 +434,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
isCompactLayout,
isMounted: desktopSidebarMounted,
isOpen: isDesktopAgentListOpen,
divisionLayout,
canShare: canDesktopAppSidebarShare({
contentMinimumWidth: appContentMinimumWidth,
requestedSidebarWidth: sidebarWidth,
@@ -441,18 +458,19 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
if (isWeb) {
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
}
const workspaceChromeStyle = useMemo(
() => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }],
[insets.left, insets.right, isCompactLayout],
);
const workspaceChrome = (
<View style={workspaceChromeStyle}>
<WindowLayoutSurface compact={isCompactLayout} style={rowStyle}>
<WorkspaceLayoutModePublisher />
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{themedSidebarChrome}
</WindowChromeRegion>
) : null}
<WindowLayoutDivisionGap
layout={divisionLayout}
sidebarVisible={desktopSidebarVisible}
compact={isCompactLayout}
/>
<WorkspaceExplorerOpenGesture enabled={chromeEnabled && isCompactLayout}>
<WindowChromeRegion
corners={isCompactLayout && chromeEnabled ? "both" : appChromeLayout.contentCorners}
@@ -460,7 +478,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
</WorkspaceExplorerOpenGesture>
</View>
</WindowLayoutSurface>
);
// Native panel gesture hosts outlive appearance keys, like native navigators.
@@ -548,8 +566,7 @@ function SidebarChrome({
}) {
const isCompactLayout = useIsCompactFormFactor();
const isMobileActive = useIsMobilePanelActive("agent-list");
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
const active = resolveSidebarActivity(visible, isCompactLayout, isMobileActive);
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
return (
<SidebarModelProvider
@@ -875,20 +892,31 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootOverlayProviders({ children }: { children: ReactNode }) {
return (
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>
{children}
<WindowLayoutActionsHost />
</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
);
}
function RootProviders({ children }: { children: ReactNode }) {
return (
<KeyboardActionDispatcherProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
<WindowLayoutProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<RootOverlayProviders>{children}</RootOverlayProviders>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
</WindowLayoutProvider>
</KeyboardActionDispatcherProvider>
);
}
@@ -1,13 +1,97 @@
import { describe, expect, it } from "vitest";
import {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
type WindowLayout,
} from "@/window-layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
describe("desktop sidebar layout", () => {
const geometry: WindowLayout = {
bounds: { x: 0, y: 0, width: 951, height: 669 },
verticalBarEdge: "trailing",
verticalBar: { x: 875, y: 160, width: 44, height: 450 },
divisions: [{ x: 465, y: 0, width: 21, height: 669 }],
};
it("places workspace action symbols in the reported trailing bar", () => {
expect(resolveWorkspaceActionPlacement(geometry)).toBe("vertical-bar");
expect(resolveWorkspaceActionPlacement(null)).toBe("header");
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBarEdge: "none" })).toBe(
"header",
);
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBar: null })).toBe("header");
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBarEdge: "leading" })).toBe(
"header",
);
});
it("shows the tab count only for the compact header selection", () => {
expect(resolveWorkspaceBarActions("compact")).toEqual(["tab-count", "overflow", "explorer"]);
expect(resolveWorkspaceBarActions("full")).toEqual(["overflow", "explorer"]);
});
it("allocates both panels around a live division without clamping to saved sidebar widths", () => {
const input = {
geometry,
windowWidth: 951,
windowHeight: 669,
leftInset: 0,
rightInset: 84,
};
expect(resolveDesktopDivisionLayout(input)).toEqual({
sidebarWidth: 465,
gap: 21,
contentWidth: 381,
});
expect(
resolveDesktopDivisionLayout({ ...input, geometry: { ...geometry, divisions: [] } }),
).toBeNull();
expect(
resolveDesktopDivisionLayout({ ...input, windowWidth: 669, windowHeight: 951 }),
).toBeNull();
expect(resolveDesktopDivisionLayout({ ...input, geometry: null })).toBeNull();
expect(
resolveDesktopDivisionLayout({
...input,
geometry: { ...geometry, divisions: [{ x: 0, y: 325, width: 951, height: 19 }] },
}),
).toBeNull();
});
it("activates a revealed folded sidebar while preserving closed desktop intent", () => {
expect(resolveSidebarActivity(true, false, false)).toBe(true);
expect(resolveSidebarActivity(false, false, false)).toBe(false);
expect(resolveSidebarActivity(true, true, false)).toBe(false);
expect(resolveSidebarActivity(true, true, true)).toBe(true);
});
it("temporarily reveals navigation for a division without changing the saved closed intent", () => {
const visibility = {
chromeEnabled: true,
isCompactLayout: false,
isMounted: true,
isOpen: false,
canShare: false,
};
expect(
resolveDesktopSidebarVisibility({
...visibility,
divisionLayout: { sidebarWidth: 465, gap: 21, contentWidth: 381 },
}),
).toBe(true);
expect(resolveDesktopSidebarVisibility({ ...visibility, divisionLayout: null })).toBe(false);
expect(visibility.isOpen).toBe(false);
});
it("keeps a retained sidebar hidden while app chrome is suppressed", () => {
expect(
resolveDesktopSidebarVisibility({
@@ -1,6 +1,7 @@
import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout";
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import type { DesktopDivisionLayout } from "@/window-layout";
const MIN_DESKTOP_CENTER_WIDTH = 400;
@@ -10,13 +11,13 @@ export function resolveDesktopSidebarVisibility(input: {
isMounted: boolean;
isOpen: boolean;
canShare: boolean;
divisionLayout?: DesktopDivisionLayout | null;
}): boolean {
return (
input.chromeEnabled &&
!input.isCompactLayout &&
input.isMounted &&
input.isOpen &&
input.canShare
(input.divisionLayout != null || (input.isOpen && input.canShare))
);
}
@@ -85,3 +86,11 @@ export function canDesktopAppSidebarShare(input: {
input.contentMinimumWidth
);
}
export function resolveSidebarActivity(
visible: boolean,
compact: boolean,
mobileActive: boolean,
): boolean {
return visible && (!compact || mobileActive);
}
+38 -23
View File
@@ -26,6 +26,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -528,6 +529,7 @@ function MobileSidebar({
insetsBottom,
closeSidebar,
}: MobileSidebarProps) {
const insets = useSafeAreaInsets();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
@@ -542,10 +544,10 @@ function MobileSidebar({
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton}
style={styles.mobileCloseButton(insets.right)}
/>
),
[labels.closeSidebar, closeSidebar],
[labels.closeSidebar, closeSidebar, insets.right],
);
const mobileSidebarInsetStyle = useMemo(
@@ -666,13 +668,22 @@ function DesktopSidebar({
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
});
const visibleSidebarWidth =
divisionLayout?.sidebarWidth ??
resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
});
const startWidthRef = useRef(visibleSidebarWidth);
const resizeWidth = useSharedValue(visibleSidebarWidth);
@@ -687,6 +698,7 @@ function DesktopSidebar({
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(divisionLayout === null)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => {
scheduleOnRN(showResizeGrip);
@@ -708,13 +720,14 @@ function DesktopSidebar({
viewportWidth,
});
})
.onEnd(() => {
runOnJS(setSidebarWidth)(resizeWidth.value);
.onEnd((_event, success) => {
if (success) runOnJS(setSidebarWidth)(resizeWidth.value);
})
.onFinalize(() => {
scheduleOnRN(hideResizeGrip);
}),
[
divisionLayout,
hideResizeGrip,
resizeWidth,
setSidebarWidth,
@@ -725,7 +738,7 @@ function DesktopSidebar({
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
width: divisionLayout?.sidebarWidth ?? resizeWidth.value,
}));
const desktopSidebarStyle = useMemo(
@@ -738,10 +751,10 @@ function DesktopSidebar({
);
const desktopSidebarBorderStyle = useMemo(
() => [
styles.desktopSidebarBorder,
styles.desktopSidebarBorder(divisionLayout !== null),
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom],
[insetsTop, insets.bottom, divisionLayout],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
@@ -812,12 +825,14 @@ function DesktopSidebar({
handleOpenHostSettings={handleOpenHostSettings}
/>
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
{divisionLayout === null ? (
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
) : null}
</View>
</Animated.View>
);
@@ -883,14 +898,14 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
mobileCloseButton: {
marginRight: theme.spacing[2],
},
desktopSidebarBorder: {
borderRightWidth: 1,
mobileCloseButton: (rightInset: number) => ({
marginRight: theme.spacing[2] + rightInset,
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
}),
sidebarDragArea: {
position: "relative",
},
@@ -1,4 +1,4 @@
import { useMemo, type ReactNode } from "react";
import { Fragment, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CompactHeader } from "@/components/headers/compact-header";
import {
@@ -6,6 +6,13 @@ import {
WorkspaceExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row";
import {
useWindowLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
WindowLayoutActions,
} from "@/window-layout";
import { IconButtonChromeProvider } from "@/components/ui/icon-button-chrome";
export { MobileWorkspaceTabSwitcher } from "./tab-switcher";
@@ -18,8 +25,11 @@ interface CompactWorkspaceHeaderProps {
onToggleExplorer: () => void;
explorerToggleLabel: string;
explorerToggleAccessibilityState: { expanded: boolean };
/** Tab switcher and workspace menu, before the explorer toggle. */
tabSwitcher: ReactNode;
/** Workspace overflow menu. */
children: ReactNode;
headerVariant: "compact" | "full";
actionsActive: boolean;
}
/** The compact header with the workspace's project row and controls. */
@@ -32,9 +42,13 @@ export function CompactWorkspaceHeader({
explorerToggleLabel,
explorerToggleAccessibilityState,
children,
tabSwitcher,
serverId,
headerVariant,
actionsActive,
}: CompactWorkspaceHeaderProps) {
const { t } = useTranslation();
const actionsInBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar";
const projectRow = useMemo(
() => (
<WorkspaceHeaderProjectRow
@@ -45,9 +59,10 @@ export function CompactWorkspaceHeader({
),
[subtitle, isSubtitleDistinct, serverId],
);
const actions = (
<>
{children}
const symbols = {
"tab-count": tabSwitcher,
overflow: children,
explorer: (
<WorkspaceExplorerToggle
onPress={onToggleExplorer}
label={explorerToggleLabel}
@@ -56,17 +71,25 @@ export function CompactWorkspaceHeader({
accessibilityState={explorerToggleAccessibilityState}
mobile
/>
</>
);
),
};
const actions = resolveWorkspaceBarActions(headerVariant).map((symbol) => (
<Fragment key={symbol}>{symbols[symbol]}</Fragment>
));
return (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actions}
/>
<IconButtonChromeProvider value="header-island">
{headerVariant === "compact" ? (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actionsInBar ? undefined : actions}
/>
) : null}
<WindowLayoutActions active={actionsActive}>{actions}</WindowLayoutActions>
</IconButtonChromeProvider>
);
}
@@ -8,3 +8,5 @@ export {
type UseWorkspaceScriptsMenuPagesInput,
type WorkspaceScriptsMenuPages,
} from "./workspace-scripts-menu-pages";
export { useWorkspaceScriptActions } from "./use-workspace-script-actions";
@@ -3,9 +3,9 @@ import * as Clipboard from "expo-clipboard";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useToast } from "@/contexts/toast-api-context";
import {
resolveWorkspaceScriptLink,
type WorkspaceScriptLinkKind,
@@ -34,6 +34,7 @@ export interface WorkspaceScriptActions {
}
export interface UseWorkspaceScriptActionsInput {
client: Pick<DaemonClient, "startWorkspaceScript" | "killTerminal"> | null;
serverId: string;
workspaceId: string;
scripts: WorkspaceDescriptor["scripts"];
@@ -45,10 +46,11 @@ export interface UseWorkspaceScriptActionsInput {
* lists them.
*
* A restart outlives the press that asked for it — the second half waits for the daemon to
* report the script stopped — so mount this with whatever owns the menu, not inside a menu page
* that unmounts when the menu closes.
* report the script stopped — so mount this once with the workspace screen and pass its actions to
* each menu surface. Header placement and menu lifetime must not own pending restarts.
*/
export function useWorkspaceScriptActions({
client,
serverId,
workspaceId,
scripts,
@@ -56,7 +58,6 @@ export function useWorkspaceScriptActions({
}: UseWorkspaceScriptActionsInput): WorkspaceScriptActions {
const { t } = useTranslation();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore(
(state) => state.byServerId[serverId] ?? null,
@@ -8,6 +8,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { ToastApiProvider } from "@/contexts/toast-api-context";
import { useWorkspaceScriptActions } from "./use-workspace-script-actions";
import { WorkspaceScriptsButton } from "./workspace-scripts-button";
void testI18n;
@@ -50,7 +52,13 @@ const {
return {
theme: hoistedTheme,
startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })),
startWorkspaceScriptMock: vi.fn(async () => ({
terminalId: "terminal-script-1",
requestId: "start-1",
workspaceId: "workspace-1",
scriptName: "dev",
error: null,
})),
killTerminalMock: vi.fn(async () => ({
terminalId: "terminal-script-1",
success: true,
@@ -113,20 +121,6 @@ vi.mock("@/workspace-service-routes/store", async () => {
};
});
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
sessions: {
"test-server": {
client: {
startWorkspaceScript: startWorkspaceScriptMock,
killTerminal: killTerminalMock,
},
},
},
}),
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ show: vi.fn(), error: vi.fn(), copied: copiedToastMock }),
}));
@@ -237,6 +231,34 @@ interface RenderScriptsOptions {
hideLabels?: boolean;
}
const testToast = { show: () => () => {}, error: () => {}, copied: copiedToastMock };
const scriptClient = {
startWorkspaceScript: startWorkspaceScriptMock,
killTerminal: killTerminalMock,
};
function ScriptsOwner({
scripts,
hideLabels,
}: {
scripts: WorkspaceScriptPayload[];
hideLabels?: boolean;
}) {
const actions = useWorkspaceScriptActions({
client: scriptClient,
serverId: "test-server",
workspaceId: "workspace-1",
scripts,
});
return (
<WorkspaceScriptsButton
actions={actions}
scripts={scripts}
liveTerminalIds={LIVE_TERMINAL_IDS}
hideLabels={hideLabels}
/>
);
}
function renderScripts(
scripts: WorkspaceScriptPayload[],
options: RenderScriptsOptions = {},
@@ -257,13 +279,9 @@ function renderScripts(
function element(nextScripts: WorkspaceScriptPayload[]): ReactElement {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceScriptsButton
serverId="test-server"
workspaceId="workspace-1"
scripts={nextScripts}
liveTerminalIds={LIVE_TERMINAL_IDS}
hideLabels={options.hideLabels}
/>
<ToastApiProvider api={testToast}>
<ScriptsOwner scripts={nextScripts} hideLabels={options.hideLabels} />
</ToastApiProvider>
</QueryClientProvider>
);
}
@@ -32,19 +32,14 @@ import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { extraMutedIconColorMapping } from "@/components/ui/icon-color";
import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks";
import { scriptToneColorMapping } from "./script-status";
import {
useWorkspaceScriptActions,
type WorkspaceScriptActions,
} from "./use-workspace-script-actions";
import type { WorkspaceScriptActions } from "./use-workspace-script-actions";
type RowActionIcon = "copy" | "open" | "restart" | "start" | "stop" | "terminal";
interface WorkspaceScriptsButtonProps {
serverId: string;
workspaceId: string;
actions: WorkspaceScriptActions;
scripts: WorkspaceDescriptor["scripts"];
liveTerminalIds?: readonly string[];
onScriptTerminalStarted?: (terminalId: string) => void;
onViewTerminal?: (terminalId: string) => void;
onOpenUrlInBrowserTab?: (url: string) => void;
hideLabels?: boolean;
@@ -448,22 +443,14 @@ function ScriptRow({
}
export function WorkspaceScriptsButton({
serverId,
workspaceId,
actions,
scripts,
liveTerminalIds = [],
onScriptTerminalStarted,
onViewTerminal,
onOpenUrlInBrowserTab,
hideLabels,
}: WorkspaceScriptsButtonProps): ReactElement | null {
const { t } = useTranslation();
const actions = useWorkspaceScriptActions({
serverId,
workspaceId,
scripts,
onScriptTerminalStarted,
});
const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]);
const triggerStyle = useCallback(
@@ -18,10 +18,7 @@ import type {
} from "@/utils/workspace-script-links";
import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks";
import { scriptToneColorMapping, summarizeScripts, type ScriptsSummary } from "./script-status";
import {
useWorkspaceScriptActions,
type WorkspaceScriptActions,
} from "./use-workspace-script-actions";
import type { WorkspaceScriptActions } from "./use-workspace-script-actions";
type WorkspaceScript = WorkspaceDescriptor["scripts"][number];
@@ -56,11 +53,9 @@ function scriptPageId(scriptName: string): string {
}
export interface UseWorkspaceScriptsMenuPagesInput {
serverId: string;
workspaceId: string;
actions: WorkspaceScriptActions;
scripts: WorkspaceDescriptor["scripts"];
liveTerminalIds: readonly string[];
onScriptTerminalStarted: (terminalId: string) => void;
onViewTerminal: (terminalId: string) => void;
onOpenUrlInBrowserTab: (url: string) => void;
}
@@ -80,25 +75,16 @@ export interface WorkspaceScriptsMenuPages {
* Every script gets a page whether or not it runs, so stopping one from its own page flips that
* page to its stopped state instead of pulling it out from under the user.
*
* The actions live here rather than in a page, so call this from whatever owns the menu: a restart
* finishes after the sheet has closed.
* The workspace screen owns the actions so a restart finishes across menu and header changes.
*/
export function useWorkspaceScriptsMenuPages({
serverId,
workspaceId,
actions,
scripts,
liveTerminalIds,
onScriptTerminalStarted,
onViewTerminal,
onOpenUrlInBrowserTab,
}: UseWorkspaceScriptsMenuPagesInput): WorkspaceScriptsMenuPages {
const { t } = useTranslation();
const actions = useWorkspaceScriptActions({
serverId,
workspaceId,
scripts,
onScriptTerminalStarted,
});
const summary = useMemo(() => summarizeScripts(scripts), [scripts]);
const trigger = useMemo(
@@ -7,6 +7,7 @@ import {
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useWindowLayout, resolveWorkspaceActionPlacement } from "@/window-layout";
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
import type { LayoutChangeEvent } from "react-native";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
@@ -62,7 +63,7 @@ import { SplitContainer } from "@/components/split-container";
import { RetainedPanel } from "@/components/retained-panel";
import { WorkspaceActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button";
import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
import { WorkspaceScriptsButton, useWorkspaceScriptActions } from "@/screens/workspace/scripts";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
import { useToast } from "@/contexts/toast-context";
@@ -502,6 +503,7 @@ function WorkspaceHeaderTitleBar({
onCopyBranchName,
onOpenSetupTab,
}: WorkspaceHeaderTitleBarProps) {
const actionsInBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar";
return (
<View style={styles.headerTitleContainer}>
{isLoading ? (
@@ -518,18 +520,20 @@ function WorkspaceHeaderTitleBar({
/>
</View>
)}
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenuDesktop
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
/>
</View>
{actionsInBar ? null : (
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenuDesktop
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
/>
</View>
)}
</View>
);
}
@@ -1022,8 +1026,11 @@ function WorkspaceScreenContent({
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const _insets = useSafeAreaInsets();
const workspaceActionsInBar =
resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar";
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const workspacePanelActive = usePanelStore((state) => state.mobilePanel.target === "agent");
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const layoutMode = useWorkspaceLayoutStore((state) => state.layoutMode);
const hasMacTrafficLights = useHasWindowChromeObstruction("top-left");
@@ -1213,6 +1220,13 @@ function WorkspaceScreenContent({
onTerminalCreateQueued: handleTerminalCreateQueued,
onTerminalCreateFailed: handleTerminalCreateFailed,
});
const scriptActions = useWorkspaceScriptActions({
client,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
scripts: workspaceScripts,
onScriptTerminalStarted: handleScriptTerminalStarted,
});
const { archiveAgent } = useArchiveAgent();
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
@@ -3189,11 +3203,9 @@ function WorkspaceScreenContent({
<PluginHeaderButtons serverId={normalizedServerId} workspaceId={normalizedWorkspaceId} />
{workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
actions={scriptActions}
scripts={workspaceDescriptor.scripts}
liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
hideLabels
@@ -3211,15 +3223,17 @@ function WorkspaceScreenContent({
{workspaceDirectory ? (
<>
<WorkspaceActions serverId={normalizedServerId} cwd={workspaceDirectory} />
<WorkspaceHeaderExplorerToggle
owner={explorerToggleOwner}
onPress={handleToggleExplorerSidebar}
label={explorerSidebarToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
style={styles.compactHeaderActionButton}
accessibilityState={explorerSidebarToggleAccessibilityState}
/>
{workspaceActionsInBar ? null : (
<WorkspaceHeaderExplorerToggle
owner={explorerToggleOwner}
onPress={handleToggleExplorerSidebar}
label={explorerSidebarToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
style={styles.compactHeaderActionButton}
accessibilityState={explorerSidebarToggleAccessibilityState}
/>
)}
</>
) : null}
</View>
@@ -3231,13 +3245,14 @@ function WorkspaceScreenContent({
workspaceDirectory,
activeFileLocation,
liveTerminalIds,
handleScriptTerminalStarted,
scriptActions,
handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
handleToggleExplorerSidebar,
explorerSidebarToggleLabel,
explorerSidebarToggleAccessibilityState,
explorerToggleOwner,
workspaceActionsInBar,
t,
],
);
@@ -3343,104 +3358,113 @@ function WorkspaceScreenContent({
);
const compactHeaderScripts = useMemo(
() => ({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
actions: scriptActions,
scripts: workspaceScripts,
liveTerminalIds,
onScriptTerminalStarted: handleScriptTerminalStarted,
onViewTerminal: handleViewScriptTerminal,
onOpenUrlInBrowserTab: handleOpenUrlInBrowserTab,
}),
[
normalizedServerId,
normalizedWorkspaceId,
workspaceScripts,
liveTerminalIds,
handleScriptTerminalStarted,
scriptActions,
handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
],
);
const compactWorkspaceHeader = useMemo(
() =>
isMobile ? (
<CompactWorkspaceHeader
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
serverId={normalizedServerId}
onToggleExplorer={handleToggleExplorerSidebar}
explorerToggleLabel={explorerSidebarToggleLabel}
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
>
<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}
/>
<WorkspaceHeaderMenuMobile
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>
) : null,
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
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>
),
[
canOpenImportSheet,
compactHeaderScripts,
createTerminalDisabled,
currentBranchName,
explorerSidebarToggleAccessibilityState,
explorerSidebarToggleLabel,
handleCloseOtherTabs,
handleCloseTabById,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleCopyAgentId,
handleCopyBranchName,
handleCopyFilePath,
handleCopyResumeCommand,
handleCopyTerminalId,
handleCopyWorkspacePath,
handleCreateBrowserTab,
handleCreateDraftTab,
handleCreateTerminal,
handleCreateTerminalWithProfile,
handleOpenSetupTab,
handleReloadAgent,
handleRenameTab,
handleSelectSwitcherTab,
handleToggleExplorerSidebar,
isMobile,
isRouteFocused,
isWorkspaceHeaderLoading,
isWorkspaceHeaderSubtitleDistinct,
normalizedServerId,
@@ -3448,10 +3472,11 @@ function WorkspaceScreenContent({
openImportSheet,
showCreateBrowserTab,
showWorkspaceSetup,
tabs,
workspaceDirectory,
workspaceHeaderSubtitle,
workspaceHeaderTitle,
workspacePanelActive,
workspaceTabSwitcher,
],
);
const desktopSplitContent = useMemo(() => {
@@ -3559,6 +3584,7 @@ function WorkspaceScreenContent({
</View>
) : (
<View style={styles.centerColumn}>
{compactWorkspaceHeader}
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
{shouldRenderDesktopPaneFallback ? (
@@ -0,0 +1,95 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { PortalProvider } from "@gorhom/portal";
import { afterEach, expect, it, vi } from "vitest";
import { Text } from "react-native";
import { page } from "vitest/browser";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import type { WindowLayout } from "./model";
import { WindowLayoutContext } from "./context";
import { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
import { WindowLayoutDivisionGap } from "./surface";
import { lightTheme } from "@/styles/theme";
vi.stubGlobal("React", React);
const layout = { sidebarWidth: 465, gap: 21, contentWidth: 381 };
const geometry: WindowLayout = {
bounds: { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight },
verticalBarEdge: "trailing",
verticalBar: { x: 20, y: 20, width: 44, height: 400 },
divisions: [],
};
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
afterEach(() => {
act(() => root.render(null));
});
it("paints the reserved division with the sidebar surface", () => {
act(() =>
root.render(<WindowLayoutDivisionGap layout={layout} sidebarVisible compact={false} />),
);
const gap = container.firstElementChild as HTMLElement;
const expected = document.createElement("div");
expected.style.backgroundColor = lightTheme.colors.surfaceSidebar;
expect(getComputedStyle(gap).backgroundColor).toBe(expected.style.backgroundColor);
expect(getComputedStyle(gap).borderRightWidth).toBe("1px");
expect(gap.getBoundingClientRect().width).toBe(21);
});
const explorerState = { expanded: false };
const noKeys: [] = [];
const noop = () => {};
it("preserves circular explorer chrome and closes a portaled menu when actions become inactive", async () => {
async function renderActions(active: boolean) {
await act(async () =>
root.render(
<PortalProvider>
<WindowLayoutContext.Provider value={geometry}>
<WindowLayoutActionsHost />
<WindowLayoutActions active={active}>
<HeaderToggleButton
onPress={noop}
accessibilityRole="button"
accessibilityLabel="Explorer"
tooltipLabel="Explorer"
tooltipSide="left"
tooltipKeys={noKeys}
accessibilityState={explorerState}
>
<Text>Explorer</Text>
</HeaderToggleButton>
<DropdownMenu>
<DropdownMenuTrigger accessibilityRole="button" accessibilityLabel="Bar menu">
<Text>Menu</Text>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={noop}>Bar menu item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</WindowLayoutActions>
</WindowLayoutContext.Provider>
</PortalProvider>,
),
);
}
await renderActions(true);
const explorer = container.querySelector('[aria-label="Explorer"]') as HTMLElement;
expect(getComputedStyle(explorer).borderRadius).toBe(`${lightTheme.borderRadius.full}px`);
await page.getByRole("button", { name: "Bar menu", exact: true }).click();
await expect.element(page.getByRole("menuitem", { name: "Bar menu item" })).toBeVisible();
await renderActions(false);
await expect.poll(() => document.querySelectorAll('[role="menuitem"]').length).toBe(0);
expect(
container.querySelector('[data-testid="workspace-vertical-actions"]')?.getBoundingClientRect()
.height,
).toBe(0);
});
@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import { Portal, PortalHost } from "@gorhom/portal";
import { StyleSheet, View } from "react-native";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { IconButtonChromeProvider } from "@/components/ui/icon-button-chrome";
import { useWindowLayout } from "./context";
import { resolveWorkspaceActionPlacement } from "./model";
const HOST = "window-layout-actions";
export function WindowLayoutActionsHost() {
return <PortalHost name={HOST} />;
}
/** The native bar guide is in window coordinates, so the host lives at the window root. */
export function WindowLayoutActions({
children,
active,
}: {
children: ReactNode;
active: boolean;
}) {
const layout = useWindowLayout();
if (resolveWorkspaceActionPlacement(layout) !== "vertical-bar" || !layout?.verticalBar) {
return null;
}
const bar = layout.verticalBar;
return (
<Portal hostName={HOST}>
<View
testID="workspace-vertical-actions"
style={[
styles.bar,
{ left: bar.x, top: bar.y, width: bar.width, height: bar.height },
!active && styles.hidden,
]}
pointerEvents="box-none"
>
<RetainedPanelActivity active={active}>
<IconButtonChromeProvider value="header-island">{children}</IconButtonChromeProvider>
</RetainedPanelActivity>
</View>
</Portal>
);
}
const styles = StyleSheet.create({
hidden: { display: "none" },
bar: { position: "absolute", alignItems: "center", gap: 8 },
});
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
export function useWindowLayout(): WindowLayout | null {
const layout = useContext(WindowLayoutContext);
const { width, height } = useWindowDimensions();
return layout &&
Math.abs(layout.bounds.width - width) <= 0.5 &&
Math.abs(layout.bounds.height - height) <= 0.5
? layout
: null;
}
@@ -0,0 +1,43 @@
import { useCallback, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { WindowLayoutContext } from "./context";
import type { WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
const GeometryProbe = requireNativeViewManager<
ViewProps & {
onGeometryChange: (event: NativeSyntheticEvent<WindowLayout>) => void;
}
>("PaseoWindowLayout");
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
const [geometry, setGeometry] = useState<WindowLayout | null>(null);
const onGeometryChange = useCallback(
(event: NativeSyntheticEvent<WindowLayout>) => setGeometry(event.nativeEvent),
[],
);
return (
<WindowLayoutContext.Provider value={geometry}>
<View style={styles.root}>
<GeometryProbe
pointerEvents="none"
style={StyleSheet.absoluteFill}
onGeometryChange={onGeometryChange}
/>
{children}
</View>
</WindowLayoutContext.Provider>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from "react";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
return children;
}
+65
View File
@@ -0,0 +1,65 @@
export interface WindowLayoutRect {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowLayout {
bounds: WindowLayoutRect;
verticalBarEdge: "none" | "leading" | "trailing";
verticalBar: WindowLayoutRect | null;
divisions: WindowLayoutRect[];
}
export interface DesktopDivisionLayout {
sidebarWidth: number;
gap: number;
contentWidth: number;
}
export function resolveWorkspaceActionPlacement(
layout: WindowLayout | null,
): "header" | "vertical-bar" {
return layout?.verticalBarEdge === "trailing" && layout.verticalBar !== null
? "vertical-bar"
: "header";
}
export function resolveWorkspaceBarActions(
headerVariant: "compact" | "full",
): readonly ("tab-count" | "overflow" | "explorer")[] {
return headerVariant === "compact"
? ["tab-count", "overflow", "explorer"]
: ["overflow", "explorer"];
}
/** One book-like partition owns allocation; horizontal folds retain the normal flow. */
export function resolveDesktopDivisionLayout(input: {
geometry: WindowLayout | null;
windowWidth: number;
windowHeight: number;
leftInset: number;
rightInset: number;
}): DesktopDivisionLayout | null {
const { geometry, windowWidth, windowHeight, leftInset, rightInset } = input;
if (
!geometry ||
Math.abs(geometry.bounds.width - windowWidth) > 0.5 ||
Math.abs(geometry.bounds.height - windowHeight) > 0.5
)
return null;
const divisions = geometry.divisions.filter(
(frame) =>
frame.width > 0 &&
frame.height > frame.width &&
frame.y < windowHeight &&
frame.y + frame.height > 0,
);
if (divisions.length !== 1) return null;
const division = divisions[0];
const sidebarWidth = division.x - leftInset;
const contentWidth = windowWidth - rightInset - division.x - division.width;
if (sidebarWidth <= 0 || contentWidth <= 0) return null;
return { sidebarWidth, gap: division.width, contentWidth };
}
@@ -0,0 +1,64 @@
import { useMemo, type ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { useWindowLayout } from "./context";
import { resolveWorkspaceActionPlacement, type DesktopDivisionLayout } from "./model";
/** Consume horizontal safe areas once, including compact content beside a vertical bar. */
export function WindowLayoutSurface({
children,
compact,
style,
}: {
children: ReactNode;
compact: boolean;
style: StyleProp<ViewStyle>;
}) {
const insets = useSafeAreaInsets();
const usesBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar";
const surfaceStyle = useMemo(
() => [
style,
(!compact || usesBar) && { paddingLeft: insets.left, paddingRight: insets.right },
],
[style, compact, usesBar, insets.left, insets.right],
);
const contentInsets = useMemo(
() => (compact && usesBar ? { ...insets, left: 0, right: 0 } : insets),
[compact, usesBar, insets],
);
return (
<View style={surfaceStyle}>
<SafeAreaInsetsContext.Provider value={contentInsets}>
{children}
</SafeAreaInsetsContext.Provider>
</View>
);
}
export function WindowLayoutDivisionGap({
layout,
sidebarVisible,
compact,
}: {
layout: DesktopDivisionLayout | null;
sidebarVisible: boolean;
compact: boolean;
}) {
const style = useMemo(
() => ({
width: layout ? layout.gap + (sidebarVisible ? 0 : layout.sidebarWidth) : 0,
}),
[layout, sidebarVisible],
);
return !compact && layout ? <View style={[styles.divisionGap, style]} /> : null;
}
const styles = StyleSheet.create((theme) => ({
divisionGap: {
backgroundColor: theme.colors.surfaceSidebar,
borderRightWidth: 1,
borderRightColor: theme.colors.border,
},
}));
@@ -1,4 +1,5 @@
import { lightHighlightColors } from "@getpaseo/highlight";
import { lightTheme } from "../src/styles/theme";
const testTheme = {
colorScheme: "light",
@@ -26,6 +27,7 @@ const testTheme = {
surface1: "#fafafa",
surface2: "#f4f4f5",
surface3: "#e4e4e7",
surfaceSidebar: lightTheme.colors.surfaceSidebar,
border: "#e4e4e7",
borderAccent: "#a1a1aa",
palette: {