Files
paseo/packages/app/src/screens/workspace/workspace-header-menu.tsx
T
Mohamed Boudra eb3591cee8 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.
2026-08-18 09:38:30 +02:00

313 lines
10 KiB
TypeScript

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 = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <TerminalProfileIcon iconKey={undefined} size={16} />;
const MENU_IMPORT_ICON = <ThemedImport size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
/**
* 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 (
<>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={MENU_COPY_ICON}
disabled={copyPathDisabled}
onSelect={onCopyWorkspacePath}
>
{t("workspace.header.actions.copyPath")}
</DropdownMenuItem>
{currentBranchName ? (
<DropdownMenuItem
testID="workspace-header-copy-branch-name"
leading={MENU_COPY_ICON}
onSelect={onCopyBranchName}
>
{t("workspace.header.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="workspace-header-import-agent"
leading={MENU_IMPORT_ICON}
disabled={importAgentDisabled}
onSelect={onOpenImportSheet}
>
{t("workspace.header.actions.importSession")}
</DropdownMenuItem>
{showWorkspaceSetup ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-show-setup"
leading={MENU_SETTINGS_ICON}
onSelect={onOpenSetupTab}
>
{t("workspace.header.actions.showSetup")}
</DropdownMenuItem>
</>
) : null}
</>
);
}
function WorkspaceHeaderMenuTriggerIcon({ hovered, open }: { hovered: boolean; open: boolean }) {
const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
return <ThemedEllipsis size={16} uniProps={colorMapping} />;
}
function renderTriggerIcon({ hovered, open }: { hovered: boolean; open: boolean }) {
return <WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} />;
}
/**
* 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 (
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={styles.headerActionButton}
accessibilityRole="button"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
>
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={220} testID="workspace-header-menu">
<WorkspaceHeaderWorkspaceActionItems {...props} />
</DropdownMenuContent>
</DropdownMenu>
);
}
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(
() => (
<View style={styles.headerMenuProfileIconWrapper}>
<TerminalProfileIcon iconKey={getTerminalProfileIcon(profile)} size={16} />
</View>
),
[profile],
);
return (
<DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
{profile.name}
</DropdownMenuItem>
);
}
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 (
<DropdownMenu compactMode="sheet">
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={styles.compactHeaderActionButton}
hitSlop={COMPACT_HEADER_BUTTON_HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
>
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
width={220}
testID="workspace-header-menu"
sheetTitle={t("workspace.header.actions.workspaceActions")}
>
<DropdownMenuItem
testID="workspace-header-new-agent"
leading={MENU_NEW_AGENT_ICON}
onSelect={onCreateDraftTab}
>
{t("workspace.header.actions.newAgent")}
</DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-header-new-browser"
leading={MENU_NEW_BROWSER_ICON}
onSelect={onCreateBrowser}
>
{t("workspace.header.actions.newBrowser")}
</DropdownMenuItem>
) : null}
<WorkspaceHeaderWorkspaceActionItems {...workspaceActions} />
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={MENU_NEW_TERMINAL_ICON}
disabled={createTerminalDisabled}
onSelect={onCreateTerminal}
>
{t("workspace.header.actions.newTerminal")}
</DropdownMenuItem>
{profiles.map((profile) => (
<HeaderMenuProfileItem
key={profile.id}
profile={profile}
disabled={createTerminalDisabled}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-edit-terminal-profiles"
onSelect={handleEditProfiles}
>
{t("workspace.tabs.actions.editTerminalProfiles")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
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,
},
}));