From 36a136d8114f59449adcbf2c1ce1aeaa2def2602 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 16 Jun 2026 14:56:07 +0700 Subject: [PATCH] Add shared trigger components and horizontal desktop workspace arguments - Add DropdownTrigger and ComboboxTrigger with an overridable chevron slot and a 1 px translateY caret nudge for consistent vertical alignment. - Convert text-based dropdown/combobox triggers across model selector, agent controls, mode control, settings, and new workspace to use the shared components. - Lay out New Workspace arguments horizontally on desktop without labels while keeping the mobile vertical stack; align the project icon left edge with the title. --- .../components/combined-model-selector.tsx | 68 ++-- .../src/components/ui/combobox-trigger.tsx | 71 ++++ .../app/src/components/ui/dropdown-menu.tsx | 2 +- .../src/components/ui/dropdown-trigger.tsx | 48 +++ .../app/src/composer/agent-controls/index.tsx | 31 +- .../composer/agent-controls/mode-control.tsx | 17 +- .../app/src/screens/new-workspace-screen.tsx | 359 +++++++++--------- packages/app/src/screens/settings-screen.tsx | 27 +- 8 files changed, 365 insertions(+), 258 deletions(-) create mode 100644 packages/app/src/components/ui/combobox-trigger.tsx create mode 100644 packages/app/src/components/ui/dropdown-trigger.tsx diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 2c9312528..7af64abab 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -12,14 +12,8 @@ import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; -import { - AlertTriangle, - ChevronDown, - ChevronRight, - Search, - Settings, - Star, -} from "lucide-react-native"; +import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { SheetHeader } from "@/components/adaptive-modal-sheet"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; @@ -784,35 +778,43 @@ export function CombinedModelSelector({ return ( <> - - {renderTrigger ? ( - renderTrigger({ + {renderTrigger ? ( + + {renderTrigger({ selectedModelLabel: triggerLabel, onPress: handleTriggerPress, disabled, isOpen, - }) - ) : ( - <> - {ProviderIcon ? ( - - ) : null} - - {triggerLabel} - - - - )} - + })} + + ) : ( + + {ProviderIcon ? ( + + ) : null} + + {triggerLabel} + + + )} ({ + color: theme.colors.foregroundMuted, +}); + +interface TriggerState { + pressed: boolean; + hovered: boolean; +} + +type TriggerStyleProp = StyleProp | ((state: TriggerState) => StyleProp); + +interface ComboboxTriggerProps extends Omit { + style?: TriggerStyleProp; + children?: ReactNode; + chevron?: ReactNode | null; +} + +export const ComboboxTrigger = forwardRef(function ComboboxTrigger( + { children, chevron, style, ...props }, + ref, +): ReactElement { + const pressableStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { + if (typeof style === "function") { + return style({ pressed, hovered }); + } + return style; + }, + [style], + ); + + return ( + + + {children} + {chevron !== null && + (chevron ?? ( + + + + ))} + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + chevronContainer: { + transform: [{ translateY: 1 }], + }, +})); diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 959f5fe82..bb77294ea 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -303,7 +303,7 @@ interface TriggerState { } type TriggerStyleProp = StyleProp | ((state: TriggerState) => StyleProp); -interface DropdownMenuTriggerProps extends Omit { +export interface DropdownMenuTriggerProps extends Omit { style?: TriggerStyleProp; children: ReactNode | ((state: TriggerState) => ReactNode); } diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx new file mode 100644 index 000000000..8c88ca84a --- /dev/null +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -0,0 +1,48 @@ +import type { ReactElement, ReactNode } from "react"; +import { View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ChevronDown } from "lucide-react-native"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; + +const ThemedChevronDown = withUnistyles(ChevronDown); + +interface DropdownTriggerProps extends Omit { + children?: ReactNode; + chevron?: ReactNode | null; +} + +const chevronColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +export function DropdownTrigger({ + children, + chevron, + ...props +}: DropdownTriggerProps): ReactElement { + return ( + + + {children} + {chevron !== null && + (chevron ?? ( + + + + ))} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + chevronContainer: { + transform: [{ translateY: 1 }], + }, +})); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 0680b9414..f771df5c1 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -20,7 +20,9 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; -import { Brain, ChevronDown, ListTodo, Settings2, ShieldCheck, Zap } from "lucide-react-native"; +import { Brain, ListTodo, Settings2, ShieldCheck, Zap } from "lucide-react-native"; +import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { @@ -37,12 +39,7 @@ import { toggleFavoriteModel, useFormPreferences, } from "@/hooks/use-form-preferences"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { DraftAgentModeControl, AgentModeControl } from "@/composer/agent-controls/mode-control"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; @@ -764,7 +761,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { <> {providerOptions && providerOptions.length > 0 ? ( <> - {displayProvider} - - + - {displayThinking} - - + {t(getAgentControlHintKey("thinking"))} @@ -1173,7 +1168,7 @@ function DesktopFeatureItem({ - {selectedOption?.label ?? feature.label} - - + {getFeatureTooltip(feature)} @@ -1288,7 +1282,7 @@ function SheetFeatureItem({ open={openSelector === featureSelector} onOpenChange={handleFeatureOpenChange} > - {selectedOption?.label ?? feature.label} - - + {feature.options.map((option) => ( - {Icon ? : null} {selectedModeLabel} - - + {triggerLabel} - ); } @@ -197,7 +197,7 @@ function RefPickerTrigger({ return ( - - + {tooltipLabel} @@ -247,7 +247,7 @@ function ProjectPickerTrigger({ return ( - {label} - - + Choose project @@ -652,7 +651,7 @@ function IsolationPickerTrigger({ iconSize: number; }) { return ( - {label} - - + ); } -// Each row aligns its label glyph with the screen heading glyph and lets the -// label's natural width push the control. The label and control sit side by -// side with no held horizontal space. -function LabeledRow({ label, children }: { label: string; children: React.ReactNode }) { - return ( - - {label} - {children} - - ); +// Wraps a single argument control in the mobile vertical stack. On desktop the +// controls are laid out in one horizontal row, so no per-control wrapper is used. +function FormRow({ children }: { children: React.ReactNode }) { + return {children}; } interface WorkspaceBackingState { @@ -1533,155 +1525,167 @@ export function NewWorkspaceScreen({ const backingTriggerLabel = backingLabel(t, effectiveBacking); - const formStack = useMemo( - () => ( - - - - - - - - {/* The Isolation row keeps its height for non-git projects so switching - projects never shifts the form; worktree backing is git-only, so a - non-git project renders an invisible spacer matching the trigger - height exactly. */} - {canCreateWorktree ? ( - - - - - - - ) : ( - - )} - {/* The Base row keeps its height so toggling Isolation never shifts the - form; on Local backing it renders an invisible spacer with no label - or control, matching the trigger height exactly. */} - {showRefPicker ? ( - - - - - - - ) : ( - - )} + const formStack = useMemo(() => { + const projectControl = ( + + + - ), - [ - backingOptions, - backingPickerOpen, - backingTriggerLabel, - badgePressableStyle, - canCreateWorktree, - effectiveBacking, - handleBackingPickerOpenChange, - handlePickerOpenChange, - handleProjectPickerOpenChange, - handleSelectBackingOption, - handleSelectOption, - handleSelectProjectOption, - isPending, - openBackingPicker, - openPicker, - openProjectPicker, - options, - pickerEmptyText, - pickerOpen, - projectPickerOpen, - projectPickerOptions, - projectTriggerLabel, - projectIconDataByProjectKey, - renderBackingOption, - renderPickerOption, - renderProjectOption, - selectedItem, - selectedOptionId, - selectedProject, - selectedProjectOptionId, - selectedSourceDirectory, - setPickerSearchQuery, - showRefPicker, - t, - theme.colors.foregroundMuted, - theme.iconSize.sm, - triggerLabel, - ], - ); + ); + + const isolationControl = canCreateWorktree ? ( + + + + + ) : null; + + const baseControl = showRefPicker ? ( + + + + + ) : null; + + if (isCompact) { + return ( + + {projectControl} + {/* The Isolation row keeps its height for non-git projects so switching + projects never shifts the form; worktree backing is git-only, so a + non-git project renders an invisible spacer matching the trigger + height exactly. */} + {isolationControl ? ( + {isolationControl} + ) : ( + + )} + {/* The Base row keeps its height so toggling Isolation never shifts the + form; on Local backing it renders an invisible spacer with no label + or control, matching the trigger height exactly. */} + {baseControl ? {baseControl} : } + + ); + } + + return ( + + {projectControl} + {isolationControl} + {baseControl} + + ); + }, [ + backingOptions, + backingPickerOpen, + backingTriggerLabel, + badgePressableStyle, + canCreateWorktree, + effectiveBacking, + handleBackingPickerOpenChange, + handlePickerOpenChange, + handleProjectPickerOpenChange, + handleSelectBackingOption, + handleSelectOption, + handleSelectProjectOption, + isCompact, + isPending, + openBackingPicker, + openPicker, + openProjectPicker, + options, + pickerEmptyText, + pickerOpen, + projectIconDataByProjectKey, + projectPickerOpen, + projectPickerOptions, + projectTriggerLabel, + renderBackingOption, + renderPickerOption, + renderProjectOption, + selectedItem, + selectedOptionId, + selectedProject, + selectedProjectOptionId, + selectedSourceDirectory, + setPickerSearchQuery, + showRefPicker, + t, + theme.colors.foregroundMuted, + theme.iconSize.sm, + triggerLabel, + ]); const composerFooter = useMemo( () => ( @@ -1804,19 +1808,24 @@ const styles = StyleSheet.create((theme) => ({ marginBottom: theme.spacing[8], gap: theme.spacing[2], }, + formStackDesktop: { + flexDirection: "row", + alignItems: "center", + marginBottom: theme.spacing[8], + // The badge adds its own left padding; offset it so the project icon's left + // edge lands exactly on the "New workspace" title's left edge. + paddingLeft: theme.spacing[4], + gap: theme.spacing[2], + }, // The row's left inset matches the heading's text x (composerTitleContainer - // paddingLeft) so each label glyph aligns with the "New workspace" glyph. The - // label keeps its natural width and the control sits immediately beside it. + // paddingLeft) so the control aligns with the "New workspace" glyph. The badge + // adds its own left padding, so the row inset is reduced by that amount. row: { flexDirection: "row", alignItems: "center", - paddingLeft: theme.spacing[6], + paddingLeft: theme.spacing[4], gap: theme.spacing[1], }, - rowLabel: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, baseSpacer: { height: BADGE_HEIGHT, }, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 50151bb76..c50bae368 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -26,7 +26,6 @@ import type { TFunction } from "i18next"; import { Buffer } from "buffer"; import { ArrowLeft, - ChevronDown, Monitor, Settings, Palette, @@ -43,6 +42,8 @@ import { FolderGit2, SquareTerminal, } from "lucide-react-native"; +import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { ScreenTitle } from "@/components/headers/screen-title"; @@ -78,12 +79,7 @@ import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts- import { Button } from "@/components/ui/button"; import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; @@ -312,10 +308,8 @@ function GeneralSection({ handleLanguageChange, handleTerminalScrollbackLinesChange, }: GeneralSectionProps) { - const { theme } = useUnistyles(); const { t, i18n } = useTranslation(); const activeLocale = getActiveLocale(i18n.language); - const iconColor = theme.colors.foregroundMuted; const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]); const sendBehaviorDescriptionKey = settings.sendBehavior === "interrupt" @@ -377,14 +371,13 @@ function GeneralSection({ {t("settings.general.language.description")} - {selectedLanguageLabel} - - + {LANGUAGE_OPTIONS.map((option) => ( - + {getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)} - - + {SERVICE_URL_BEHAVIOR_VALUES.map((value) => ( - {activeHost?.label ?? t("settings.groups.host")} - - +