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 ? (