import { useCallback, useMemo, useState, type ReactElement } from "react"; import { Pressable, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { useRouter } from "expo-router"; import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ProfileIcon } from "@/workspace-pins/launch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ICON_SIZE } from "@/styles/theme"; import { formatResolvedCommand, getTerminalProfileIcon, substitutePrompt, } from "@getpaseo/protocol/terminal-profiles"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { Theme } from "@/styles/theme"; import { BLANK_TERMINAL_PROFILE_ID, CHAT_LAUNCH_TARGET, isBlankTerminalTarget, resolveLaunchProfile, terminalLaunchTarget, type LaunchTarget, } from "./target"; const ThemedMessageCircle = withUnistyles(MessageCircle); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); const chatIcon = ; const blankTerminalIcon = ; /** Owns its own leading icon and select callback so neither is rebuilt per render of the menu. */ function LaunchProfileMenuItem({ profile, selected, onSelect, }: { profile: TerminalProfile; selected: boolean; onSelect: (profileId: string) => void; }): ReactElement { const handleSelect = useCallback(() => onSelect(profile.id), [onSelect, profile.id]); const leading = useMemo( () => , [profile], ); // What the profile runs before any prompt is typed, so the row says what it // actually launches rather than making you open settings to find out. Nested // in the label rather than passed as `description` so it sits inline right // after the name and truncates with it on one line. const commandPreview = useMemo( () => formatResolvedCommand(substitutePrompt(profile, "")), [profile], ); return ( {profile.name} {commandPreview} ); } export interface LaunchControlProps { serverId: string; target: LaunchTarget; onChange: (target: LaunchTarget) => void; profiles: readonly TerminalProfile[]; disabled?: boolean; /** * The meta row's shared badge style. Passed in rather than redeclared so this * trigger stays pixel-identical to the project, host, and branch triggers it * sits beside; a local copy drifts the moment one of them changes. */ badgePressableStyle: React.ComponentProps["style"]; } function TriggerIcon({ target, profile, }: { target: LaunchTarget; profile: TerminalProfile | null; }): ReactElement { if (target.kind === "chat") { return ; } if (profile) { return ; } return ; } /** * The meta-row control choosing what New workspace submits to: the chat agent, * or a terminal. One trigger shaped like every other chip on that row, opening * a menu whose Chat and Terminal groups are what keep a terminal profile named * "Codex" from reading as a chat Codex agent. */ export function LaunchControl({ serverId, target, onChange, profiles, disabled = false, badgePressableStyle, }: LaunchControlProps) { const { t } = useTranslation(); const router = useRouter(); const [open, setOpen] = useState(false); const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]); // A profile's own name and logo say nothing about where it runs: a row // reading "(openai logo) Codex" is indistinguishable from a chat Codex agent. // Leading with the noun fixes that, and de-emphasising the profile name keeps // "Terminal" the thing you read first. const triggerLabel = target.kind === "chat" ? t("newWorkspace.launch.chat") : t("newWorkspace.launch.terminal"); const selectChat = useCallback(() => onChange(CHAT_LAUNCH_TARGET), [onChange]); const selectBlankTerminal = useCallback( () => onChange(terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID)), [onChange], ); const selectTerminalProfile = useCallback( (profileId: string) => onChange(terminalLaunchTarget(profileId)), [onChange], ); const openProfileSettings = useCallback(() => { router.push(buildSettingsHostSectionRoute(serverId, "terminals")); }, [router, serverId]); return ( {/* The trigger is the Pressable itself, styled by the meta row's shared badge style, so it lands pixel-identical to the project, host, and branch chips it sits beside. */} {/* MenuTrigger is not a forwardRef component and pins its own ref, so the tooltip anchors to a wrapping View instead. Suppressed while the menu is open so the two surfaces never overlap. */} {triggerLabel} {selectedProfile ? ( {selectedProfile.name} ) : null} {t("newWorkspace.tooltips.launch")} {/* Chat leads with no label of its own: a group label over a single row is noise, and the sheet title already says what the menu is on compact. Terminal keeps its label because that grouping is what stops a profile named Codex reading as a chat Codex agent. */} {t("newWorkspace.launch.chat")} {t("newWorkspace.launch.terminal")} {t("newWorkspace.launch.terminal")} {profiles.map((profile) => ( ))} {t("newWorkspace.launch.manageProfiles")} ); } const styles = StyleSheet.create((theme) => ({ iconBox: { width: theme.iconSize.md, height: theme.iconSize.md, alignItems: "center", justifyContent: "center", flexShrink: 0, }, label: { minWidth: 0, fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1, }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, profileName: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundExtraMuted, }, command: { fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, }));