From eb3591cee8b113d6c37611fff549acca7213e0e1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 18 Aug 2026 09:38:30 +0200 Subject: [PATCH] refactor(app): split the workspace header menu per form factor Wide layouts make tabs from the tab strip's + menu, so the header menu listing new agent / new terminal / new browser duplicated it. Compact has no tab strip, so those actions have to live in the header there. One menu with an isMobile prop could not express that: the prop only sized the trigger, and the item list was shared. Two components now, with the workspace actions (import, copy path, copy branch name, show setup) in one component both render from the same callbacks so they cannot drift. --- docs/menus.md | 9 + .../src/components/terminal-profile-icon.tsx | 12 +- .../workspace/workspace-header-menu.tsx | 312 +++++++++++++++ .../screens/workspace/workspace-screen.tsx | 354 ++---------------- 4 files changed, 365 insertions(+), 322 deletions(-) create mode 100644 packages/app/src/screens/workspace/workspace-header-menu.tsx diff --git a/docs/menus.md b/docs/menus.md index 6cf31b342..6e9ef6d2d 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -26,6 +26,15 @@ when you have actually looked at it on a phone. `ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press. Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead. +## When the items differ per form factor + +A menu whose contents depend on what else is on screen gets one component per surface, not one +component with `isMobile` branches inside it. The workspace header menu is the example +(`packages/app/src/screens/workspace/workspace-header-menu.tsx`): compact has no tab strip, so it +carries new-tab actions, while wide leaves those to the strip's `+` menu and lists workspace +actions only. Items both surfaces share live in one component that each menu renders, from the same +callbacks, so the two can't drift. + ## Selecting an item on iOS An item that closes the menu runs its action after a fixed grace period on iOS, not immediately: diff --git a/packages/app/src/components/terminal-profile-icon.tsx b/packages/app/src/components/terminal-profile-icon.tsx index 61cf992b2..db3a216e7 100644 --- a/packages/app/src/components/terminal-profile-icon.tsx +++ b/packages/app/src/components/terminal-profile-icon.tsx @@ -23,9 +23,15 @@ function ProviderProfileIcon({ const ThemedProviderProfileIcon = withUnistyles(ProviderProfileIcon); -export function TerminalProfileIcon({ iconKey }: { iconKey: string | undefined }): ReactElement { +export function TerminalProfileIcon({ + iconKey, + size = 14, +}: { + iconKey: string | undefined; + size?: number; +}): ReactElement { if (!iconKey) { - return ; + return ; } - return ; + return ; } diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx new file mode 100644 index 000000000..7306e8b12 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -0,0 +1,312 @@ +import React, { useCallback, useMemo } from "react"; +import { View } from "react-native"; +import { useRouter, type Href } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { + Copy, + Ellipsis, + Globe, + Import as ImportIcon, + Settings, + SquarePen, +} from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { + getTerminalProfileIcon, + resolveTerminalProfiles, +} from "@getpaseo/protocol/terminal-profiles"; +import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; +import type { Theme } from "@/styles/theme"; + +const ThemedEllipsis = withUnistyles(Ellipsis); +const ThemedCopy = withUnistyles(Copy); +const ThemedSquarePen = withUnistyles(SquarePen); +const ThemedGlobe = withUnistyles(Globe); +const ThemedImport = withUnistyles(ImportIcon); +const ThemedSettings = withUnistyles(Settings); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const MENU_NEW_AGENT_ICON = ; +const MENU_NEW_BROWSER_ICON = ; +const MENU_NEW_TERMINAL_ICON = ; +const MENU_IMPORT_ICON = ; +const MENU_COPY_ICON = ; +const MENU_SETTINGS_ICON = ; + +/** + * The compact header buttons draw at 32pt, under the 44pt touch minimum, and sit flush against + * each other — so the slop can only grow vertically. Widening it would put two buttons' slop over + * the same pixels, which is a worse miss than a small target. + */ +const COMPACT_HEADER_BUTTON_HIT_SLOP = { top: 8, bottom: 8 } as const; + +/** + * Actions that belong to the workspace itself rather than to a tab. Both header menus render them, + * from the same callbacks, so the two surfaces can't drift. + */ +export interface WorkspaceHeaderWorkspaceActions { + currentBranchName: string | null; + showWorkspaceSetup: boolean; + importAgentDisabled: boolean; + copyPathDisabled: boolean; + onOpenImportSheet: () => void; + onCopyWorkspacePath: () => void; + onCopyBranchName: () => void; + onOpenSetupTab: () => void; +} + +function WorkspaceHeaderWorkspaceActionItems({ + currentBranchName, + showWorkspaceSetup, + importAgentDisabled, + copyPathDisabled, + onOpenImportSheet, + onCopyWorkspacePath, + onCopyBranchName, + onOpenSetupTab, +}: WorkspaceHeaderWorkspaceActions) { + const { t } = useTranslation(); + return ( + <> + + {t("workspace.header.actions.copyPath")} + + {currentBranchName ? ( + + {t("workspace.header.actions.copyBranchName")} + + ) : null} + + {t("workspace.header.actions.importSession")} + + {showWorkspaceSetup ? ( + <> + + + {t("workspace.header.actions.showSetup")} + + + ) : null} + + ); +} + +function WorkspaceHeaderMenuTriggerIcon({ hovered, open }: { hovered: boolean; open: boolean }) { + const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping; + return ; +} + +function renderTriggerIcon({ hovered, open }: { hovered: boolean; open: boolean }) { + return ; +} + +/** + * Wide layouts make tabs from the tab strip's `+` menu, so this one carries workspace actions only. + */ +export function WorkspaceHeaderMenuDesktop(props: WorkspaceHeaderWorkspaceActions) { + const { t } = useTranslation(); + return ( + + + {renderTriggerIcon} + + + + + + ); +} + +interface HeaderMenuProfileItemProps { + profile: TerminalProfile; + disabled: boolean; + onCreateTerminalWithProfile: (profile: TerminalProfile) => void; +} + +function HeaderMenuProfileItem({ + profile, + disabled, + onCreateTerminalWithProfile, +}: HeaderMenuProfileItemProps) { + const handleSelect = useCallback(() => { + onCreateTerminalWithProfile(profile); + }, [onCreateTerminalWithProfile, profile]); + + const leading = useMemo( + () => ( + + + + ), + [profile], + ); + + return ( + + {profile.name} + + ); +} + +export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions { + normalizedServerId: string; + showCreateBrowserTab: boolean; + createTerminalDisabled: boolean; + onCreateDraftTab: () => void; + onCreateTerminal: () => void; + onCreateTerminalWithProfile: (profile: TerminalProfile) => void; + onCreateBrowser: () => void; +} + +/** + * Compact layouts have no tab strip to launch from, so new tabs live here alongside the workspace + * actions. + */ +export function WorkspaceHeaderMenuMobile({ + normalizedServerId, + showCreateBrowserTab, + createTerminalDisabled, + onCreateDraftTab, + onCreateTerminal, + onCreateTerminalWithProfile, + onCreateBrowser, + ...workspaceActions +}: WorkspaceHeaderMenuMobileProps) { + const { t } = useTranslation(); + const router = useRouter(); + const { config } = useDaemonConfig(normalizedServerId); + const profiles = useMemo( + () => resolveTerminalProfiles(config?.terminalProfiles), + [config?.terminalProfiles], + ); + + const handleEditProfiles = useCallback(() => { + router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href); + }, [normalizedServerId, router]); + + return ( + + + {renderTriggerIcon} + + + + {t("workspace.header.actions.newAgent")} + + {showCreateBrowserTab ? ( + + {t("workspace.header.actions.newBrowser")} + + ) : null} + + + {t("workspace.tabs.actions.terminalProfilesMenu")} + + {t("workspace.header.actions.newTerminal")} + + {profiles.map((profile) => ( + + ))} + + + {t("workspace.tabs.actions.editTerminalProfiles")} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerActionButton: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + }, + compactHeaderActionButton: { + width: { + xs: theme.spacing[8], + md: buttonControlHeight.xs, + }, + height: { + xs: theme.spacing[8], + md: buttonControlHeight.xs, + }, + padding: 0, + borderRadius: theme.borderRadius.lg, + alignItems: "center", + justifyContent: "center", + }, + headerMenuProfileIconWrapper: { + width: 16, + height: 16, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 7d7f705ba..2725e16cd 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -19,17 +19,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; -import { - ChevronDown, - Copy, - Ellipsis, - Globe, - Import as ImportIcon, - PanelRight, - Settings, - SquarePen, - SquareTerminal, -} from "lucide-react-native"; +import { ChevronDown, PanelRight } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; @@ -43,14 +33,6 @@ import { HostBadge } from "@/hosts/host-badge"; import { useHostBadges } from "@/hosts/use-host-badges"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import type { ShortcutKey } from "@/utils/format-shortcut"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { FloatingPanelPortalHost, FloatingPanelPortalHostNameProvider, @@ -175,19 +157,13 @@ import { findAdjacentPane } from "@/utils/split-navigation"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { - buildHostRootRoute, - buildSettingsHostRoute, - buildSettingsHostSectionRoute, -} from "@/utils/host-routes"; +import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; -import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { - getTerminalProfileIcon, - resolveTerminalProfiles, -} from "@getpaseo/protocol/terminal-profiles"; -import { getProviderIcon } from "@/components/provider-icons"; + WorkspaceHeaderMenuDesktop, + WorkspaceHeaderMenuMobile, +} from "@/screens/workspace/workspace-header-menu"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, @@ -237,41 +213,15 @@ function buildWorkspaceFileLocation( } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedCopy = withUnistyles(Copy); -const ThemedSquarePen = withUnistyles(SquarePen); -const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const ThemedGlobe = withUnistyles(Globe); -const ThemedImport = withUnistyles(ImportIcon); -const ThemedSettings = withUnistyles(Settings); const ThemedPanelRight = withUnistyles(PanelRight); -interface DynamicProviderIconProps { - iconKey: string; - size: number; - color?: string; -} - -function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) { - const Icon = getProviderIcon(iconKey); - return ; -} - -const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon); - const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted, }); -const MENU_NEW_AGENT_ICON = ; -const MENU_NEW_TERMINAL_ICON = ; -const MENU_NEW_BROWSER_ICON = ; -const MENU_IMPORT_ICON = ; -const MENU_COPY_ICON = ; -const MENU_SETTINGS_ICON = ; const GATED_WORKSPACE_HEADER_LEFT = ; interface WorkspaceScreenProps { @@ -910,224 +860,6 @@ function useCloseTabs(): UseCloseTabsResult { return { closingTabIds, closeTab }; } -interface WorkspaceHeaderMenuProps { - normalizedServerId: string; - currentBranchName: string | null; - showWorkspaceSetup: boolean; - showCreateBrowserTab: boolean; - isMobile: boolean; - createTerminalDisabled: boolean; - importAgentDisabled: boolean; - copyPathDisabled: boolean; - menuNewAgentIcon: ReactElement; - menuNewTerminalIcon: ReactElement; - menuNewBrowserIcon: ReactElement; - menuImportIcon: ReactElement; - menuCopyIcon: ReactElement; - menuSettingsIcon: ReactElement; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfile) => void; - onCreateBrowser: () => void; - onOpenImportSheet: () => void; - onCopyWorkspacePath: () => void; - onCopyBranchName: () => void; - onOpenSetupTab: () => void; -} -interface HeaderMenuProfileItemProps { - profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; - disabled: boolean; - onCreateTerminalWithProfile: (profile: TerminalProfile) => void; -} - -function HeaderMenuProfileItem({ - profile, - disabled, - onCreateTerminalWithProfile, -}: HeaderMenuProfileItemProps) { - const handleSelect = useCallback(() => { - onCreateTerminalWithProfile(profile); - }, [onCreateTerminalWithProfile, profile]); - - const icon = getTerminalProfileIcon(profile); - - const leading = useMemo(() => { - if (!icon) { - return ( - - - - ); - } - return ( - - - - ); - }, [icon]); - - return ( - - {profile.name} - - ); -} - -/** - * The compact header buttons draw at 32pt, under the 44pt touch minimum, and sit flush against - * each other — so the slop can only grow vertically. Widening it would put two buttons' slop over - * the same pixels, which is a worse miss than a small target. - */ -const COMPACT_HEADER_BUTTON_HIT_SLOP = { top: 8, bottom: 8 } as const; - -function WorkspaceHeaderMenuTriggerIcon({ hovered, open }: { hovered: boolean; open: boolean }) { - const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping; - return ; -} - -function WorkspaceHeaderMenu({ - normalizedServerId, - currentBranchName, - showWorkspaceSetup, - showCreateBrowserTab, - isMobile, - createTerminalDisabled, - importAgentDisabled, - copyPathDisabled, - menuNewAgentIcon, - menuNewTerminalIcon, - menuNewBrowserIcon, - menuImportIcon, - menuCopyIcon, - menuSettingsIcon, - onCreateDraftTab, - onCreateTerminal, - onCreateTerminalWithProfile, - onCreateBrowser, - onOpenImportSheet, - onCopyWorkspacePath, - onCopyBranchName, - onOpenSetupTab, -}: WorkspaceHeaderMenuProps) { - const { t } = useTranslation(); - const router = useRouter(); - const { config } = useDaemonConfig(normalizedServerId); - const profiles = useMemo( - () => resolveTerminalProfiles(config?.terminalProfiles), - [config?.terminalProfiles], - ); - - const handleEditProfiles = useCallback(() => { - router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href); - }, [normalizedServerId, router]); - - const renderTriggerIcon = useCallback( - ({ hovered, open }: { hovered: boolean; open: boolean }) => ( - - ), - [], - ); - - return ( - - - {renderTriggerIcon} - - - - {t("workspace.header.actions.newAgent")} - - {showCreateBrowserTab ? ( - - {t("workspace.header.actions.newBrowser")} - - ) : null} - - {t("workspace.header.actions.importSession")} - - - {t("workspace.header.actions.copyPath")} - - {currentBranchName ? ( - - {t("workspace.header.actions.copyBranchName")} - - ) : null} - {showWorkspaceSetup ? ( - <> - - - {t("workspace.header.actions.showSetup")} - - - ) : null} - - {t("workspace.tabs.actions.terminalProfilesMenu")} - - {t("workspace.header.actions.newTerminal")} - - {profiles.map((profile) => ( - - ))} - - - {t("workspace.tabs.actions.editTerminalProfiles")} - - - - ); -} - /** * Which project the workspace belongs to, and which machine it runs on. * @@ -1185,12 +917,6 @@ interface WorkspaceHeaderTitleBarProps { createTerminalDisabled: boolean; importAgentDisabled: boolean; copyPathDisabled: boolean; - menuNewAgentIcon: ReactElement; - menuNewTerminalIcon: ReactElement; - menuNewBrowserIcon: ReactElement; - menuImportIcon: ReactElement; - menuCopyIcon: ReactElement; - menuSettingsIcon: ReactElement; onCreateDraftTab: () => void; onCreateTerminal: () => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void; @@ -1220,12 +946,6 @@ function WorkspaceHeaderTitleBar({ createTerminalDisabled, importAgentDisabled, copyPathDisabled, - menuNewAgentIcon, - menuNewTerminalIcon, - menuNewBrowserIcon, - menuImportIcon, - menuCopyIcon, - menuSettingsIcon, onCreateDraftTab, onCreateTerminal, onCreateTerminalWithProfile, @@ -1255,30 +975,36 @@ function WorkspaceHeaderTitleBar({ )} - + {isMobile ? ( + + ) : ( + + )} {isMobile && workspaceScripts.length > 0 ? (