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: {