From 4991efddf8c3349848c406d941f87599484fb8f3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 17:05:45 +0700 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Elsa Granger --- .../browser/compact-workspace-header.spec.ts | 47 ++++ .../native-sidebar-drag-release.ios.ad | 5 + .../e2e/support/helpers/workspace-setup.ts | 20 ++ .../expo-module.config.json | 4 + .../ios/PaseoWindowLayout.podspec | 15 ++ .../ios/PaseoWindowLayoutModule.swift | 110 +++++++++ packages/app/src/app/_layout.tsx | 68 ++++-- .../components/desktop-sidebar-layout.test.ts | 84 +++++++ .../src/components/desktop-sidebar-layout.ts | 13 +- packages/app/src/components/left-sidebar.tsx | 61 +++-- .../compact-workspace-header/index.tsx | 53 +++-- .../src/screens/workspace/scripts/index.ts | 2 + .../scripts/use-workspace-script-actions.ts | 11 +- .../scripts/workspace-scripts-button.test.tsx | 62 +++-- .../scripts/workspace-scripts-button.tsx | 19 +- .../scripts/workspace-scripts-menu-pages.tsx | 22 +- .../screens/workspace/workspace-screen.tsx | 222 ++++++++++-------- .../window-layout/actions.browser.test.tsx | 95 ++++++++ packages/app/src/window-layout/actions.tsx | 50 ++++ packages/app/src/window-layout/context.ts | 15 ++ packages/app/src/window-layout/index.ios.tsx | 43 ++++ packages/app/src/window-layout/index.tsx | 15 ++ packages/app/src/window-layout/model.ts | 65 +++++ packages/app/src/window-layout/surface.tsx | 64 +++++ .../app/test-stubs/react-native-unistyles.ts | 2 + 25 files changed, 948 insertions(+), 219 deletions(-) create mode 100644 packages/app/modules/paseo-window-layout/expo-module.config.json create mode 100644 packages/app/modules/paseo-window-layout/ios/PaseoWindowLayout.podspec create mode 100644 packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift create mode 100644 packages/app/src/window-layout/actions.browser.test.tsx create mode 100644 packages/app/src/window-layout/actions.tsx create mode 100644 packages/app/src/window-layout/context.ts create mode 100644 packages/app/src/window-layout/index.ios.tsx create mode 100644 packages/app/src/window-layout/index.tsx create mode 100644 packages/app/src/window-layout/model.ts create mode 100644 packages/app/src/window-layout/surface.tsx diff --git a/packages/app/e2e/browser/compact-workspace-header.spec.ts b/packages/app/e2e/browser/compact-workspace-header.spec.ts index be26dbb8d1..ca352e277e 100644 --- a/packages/app/e2e/browser/compact-workspace-header.spec.ts +++ b/packages/app/e2e/browser/compact-workspace-header.spec.ts @@ -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>, + 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(); + } +}); diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad index 359e89c5c9..d92c92771c 100644 --- a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad @@ -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}\"" diff --git a/packages/app/e2e/support/helpers/workspace-setup.ts b/packages/app/e2e/support/helpers/workspace-setup.ts index eda1d8d023..a89f52c70a 100644 --- a/packages/app/e2e/support/helpers/workspace-setup.ts +++ b/packages/app/e2e/support/helpers/workspace-setup.ts @@ -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}`); +} diff --git a/packages/app/modules/paseo-window-layout/expo-module.config.json b/packages/app/modules/paseo-window-layout/expo-module.config.json new file mode 100644 index 0000000000..369360a041 --- /dev/null +++ b/packages/app/modules/paseo-window-layout/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["ios"], + "ios": { "modules": ["PaseoWindowLayoutModule"] } +} diff --git a/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayout.podspec b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayout.podspec new file mode 100644 index 0000000000..6f2e20f24a --- /dev/null +++ b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayout.podspec @@ -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 diff --git a/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift new file mode 100644 index 0000000000..f5b673e6cc --- /dev/null +++ b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift @@ -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] + } +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 483de1c27d..29d63a6a05 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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 = {sidebarChrome}; } - const workspaceChromeStyle = useMemo( - () => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }], - [insets.left, insets.right, isCompactLayout], - ); const workspaceChrome = ( - + {!isCompactLayout ? ( {themedSidebarChrome} ) : null} + {children} - + ); // 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 ( + + + {children} + + + + + ); +} + function RootProviders({ children }: { children: ReactNode }) { return ( - - - - - - {children} - - - - - + + + + + {children} + + + + ); } diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts index 969bbb581f..f4ccec7e98 100644 --- a/packages/app/src/components/desktop-sidebar-layout.test.ts +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -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({ diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts index d8518bcb82..09e03bc02e 100644 --- a/packages/app/src/components/desktop-sidebar-layout.ts +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -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); +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 7f59312f7a..73c1fadb7b 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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} /> - + {divisionLayout === null ? ( + + ) : null} ); @@ -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", }, diff --git a/packages/app/src/screens/workspace/compact-workspace-header/index.tsx b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx index 2a7c4a6a57..1865bc5c93 100644 --- a/packages/app/src/screens/workspace/compact-workspace-header/index.tsx +++ b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx @@ -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( () => ( - {children} + const symbols = { + "tab-count": tabSwitcher, + overflow: children, + explorer: ( - - ); + ), + }; + const actions = resolveWorkspaceBarActions(headerVariant).map((symbol) => ( + {symbols[symbol]} + )); return ( - + + {headerVariant === "compact" ? ( + + ) : null} + {actions} + ); } diff --git a/packages/app/src/screens/workspace/scripts/index.ts b/packages/app/src/screens/workspace/scripts/index.ts index efff482e80..bbe223f520 100644 --- a/packages/app/src/screens/workspace/scripts/index.ts +++ b/packages/app/src/screens/workspace/scripts/index.ts @@ -8,3 +8,5 @@ export { type UseWorkspaceScriptsMenuPagesInput, type WorkspaceScriptsMenuPages, } from "./workspace-scripts-menu-pages"; + +export { useWorkspaceScriptActions } from "./use-workspace-script-actions"; diff --git a/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts b/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts index fb7809990f..6752f02719 100644 --- a/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts +++ b/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts @@ -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 | 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, diff --git a/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx index 290f424e97..dbe4b1ab92 100644 --- a/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx @@ -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 ( + + ); +} + function renderScripts( scripts: WorkspaceScriptPayload[], options: RenderScriptsOptions = {}, @@ -257,13 +279,9 @@ function renderScripts( function element(nextScripts: WorkspaceScriptPayload[]): ReactElement { return ( - + + + ); } diff --git a/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx index 2f58dedfca..8e922ee2ab 100644 --- a/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx @@ -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( diff --git a/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx index 3d14d9faff..0dd6fe5400 100644 --- a/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx @@ -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( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 2aa5a54bae..68810013d7 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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 ( {isLoading ? ( @@ -518,18 +520,20 @@ function WorkspaceHeaderTitleBar({ /> )} - - - + {actionsInBar ? null : ( + + + + )} ); } @@ -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({ {workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( - + {workspaceActionsInBar ? null : ( + + )} ) : null} @@ -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 ? ( - - - - - ) : null, + const workspaceTabSwitcher = useMemo( + () => ( + + ), [ activeTabKey, + createTerminalDisabled, + handleCloseOtherTabs, + handleCloseTabById, + handleCloseTabsToLeft, + handleCloseTabsToRight, + handleCopyAgentId, + handleCopyFilePath, + handleCopyResumeCommand, + handleCopyTerminalId, + handleCreateDraftTab, + handleCreateTerminal, + handleReloadAgent, + handleRenameTab, + handleSelectSwitcherTab, + normalizedServerId, + normalizedWorkspaceId, + tabs, + ], + ); + const compactWorkspaceHeader = useMemo( + () => ( + + + + ), + [ 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({ ) : ( + {compactWorkspaceHeader} {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} {shouldRenderDesktopPaneFallback ? ( diff --git a/packages/app/src/window-layout/actions.browser.test.tsx b/packages/app/src/window-layout/actions.browser.test.tsx new file mode 100644 index 0000000000..a7f3a4a75e --- /dev/null +++ b/packages/app/src/window-layout/actions.browser.test.tsx @@ -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(), + ); + 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( + + + + + + Explorer + + + + Menu + + + Bar menu item + + + + + , + ), + ); + } + 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); +}); diff --git a/packages/app/src/window-layout/actions.tsx b/packages/app/src/window-layout/actions.tsx new file mode 100644 index 0000000000..06fcf57633 --- /dev/null +++ b/packages/app/src/window-layout/actions.tsx @@ -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 ; +} + +/** 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 ( + + + + {children} + + + + ); +} + +const styles = StyleSheet.create({ + hidden: { display: "none" }, + bar: { position: "absolute", alignItems: "center", gap: 8 }, +}); diff --git a/packages/app/src/window-layout/context.ts b/packages/app/src/window-layout/context.ts new file mode 100644 index 0000000000..e2d0246a92 --- /dev/null +++ b/packages/app/src/window-layout/context.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; +import { useWindowDimensions } from "react-native"; +import type { WindowLayout } from "./model"; + +export const WindowLayoutContext = createContext(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; +} diff --git a/packages/app/src/window-layout/index.ios.tsx b/packages/app/src/window-layout/index.ios.tsx new file mode 100644 index 0000000000..237c9c8114 --- /dev/null +++ b/packages/app/src/window-layout/index.ios.tsx @@ -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) => void; + } +>("PaseoWindowLayout"); + +export function WindowLayoutProvider({ children }: { children: ReactNode }) { + const [geometry, setGeometry] = useState(null); + const onGeometryChange = useCallback( + (event: NativeSyntheticEvent) => setGeometry(event.nativeEvent), + [], + ); + return ( + + + + {children} + + + ); +} + +const styles = StyleSheet.create({ root: { flex: 1 } }); diff --git a/packages/app/src/window-layout/index.tsx b/packages/app/src/window-layout/index.tsx new file mode 100644 index 0000000000..c884d3e987 --- /dev/null +++ b/packages/app/src/window-layout/index.tsx @@ -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; +} diff --git a/packages/app/src/window-layout/model.ts b/packages/app/src/window-layout/model.ts new file mode 100644 index 0000000000..3e8d5b82ec --- /dev/null +++ b/packages/app/src/window-layout/model.ts @@ -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 }; +} diff --git a/packages/app/src/window-layout/surface.tsx b/packages/app/src/window-layout/surface.tsx new file mode 100644 index 0000000000..e9645c799e --- /dev/null +++ b/packages/app/src/window-layout/surface.tsx @@ -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; +}) { + 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 ( + + + {children} + + + ); +} + +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 ? : null; +} + +const styles = StyleSheet.create((theme) => ({ + divisionGap: { + backgroundColor: theme.colors.surfaceSidebar, + borderRightWidth: 1, + borderRightColor: theme.colors.border, + }, +})); diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index 5f37c3eaf8..38a86c7567 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -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: {