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