From ca503c1560e7c0a0f22c91b079ac07869322f445 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 19:31:19 +0800 Subject: [PATCH] feat(app): grouped project settings with docs links + scrollbar fix (#837) * feat(app): group project settings into Worktree / Scripts / Metadata Adds SettingsGroup component (title + info tooltip + trailing slot) and restructures the project settings screen into three categorized groups with explanatory tooltips. Metadata prompt labels drop the "prompt" suffix now that they live under a "Metadata generation" group, and placeholders read as example instructions a real user would write. Applies useWebScrollbarStyle to the settings ScrollViews. * Add ExternalLink component and docs links to project settings --- .../app/src/components/ui/external-link.tsx | 77 +++++++++ .../src/screens/project-settings-screen.tsx | 151 ++++++++++++------ packages/app/src/screens/settings-screen.tsx | 12 +- .../src/screens/settings/settings-group.tsx | 94 +++++++++++ .../src/screens/settings/settings-section.tsx | 14 +- 5 files changed, 296 insertions(+), 52 deletions(-) create mode 100644 packages/app/src/components/ui/external-link.tsx create mode 100644 packages/app/src/screens/settings/settings-group.tsx diff --git a/packages/app/src/components/ui/external-link.tsx b/packages/app/src/components/ui/external-link.tsx new file mode 100644 index 000000000..057aa197c --- /dev/null +++ b/packages/app/src/components/ui/external-link.tsx @@ -0,0 +1,77 @@ +import { useCallback, type ReactNode } from "react"; +import { Pressable, Text } from "react-native"; +import { ArrowUpRight } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { openExternalUrl } from "@/utils/open-external-url"; + +interface ExternalLinkProps { + href: string; + label: string; + tooltip?: ReactNode; + testID?: string; + accessibilityLabel?: string; +} + +/** + * Inline "Docs ↗" affordance — muted text + arrow-top-right icon, opens the + * URL via the platform's external opener. Wrap in a Tooltip when there's a + * one-line hint worth surfacing on hover/tap. + */ +export function ExternalLink({ + href, + label, + tooltip, + testID, + accessibilityLabel, +}: ExternalLinkProps) { + const { theme } = useUnistyles(); + const handlePress = useCallback(() => { + void openExternalUrl(href); + }, [href]); + + const trigger = ( + + {label} + + + ); + + if (!tooltip) { + return trigger; + } + + return ( + + {trigger} + + {tooltip} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + label: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + maxWidth: 280, + lineHeight: theme.fontSize.sm * 1.4, + }, +})); diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index bd2ee2570..b62ff3c84 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -19,9 +19,11 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Alert } from "@/components/ui/alert"; +import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; @@ -53,31 +55,41 @@ interface MetadataPromptField { const METADATA_PROMPT_FIELDS: Record = { agentTitle: { - title: "Agent title prompt", - placeholder: "Keep titles short and action-oriented.", + title: "Agent titles", + placeholder: "Keep titles imperative and under 40 characters", sectionTestID: "metadata-prompt-agent-title-section", inputTestID: "metadata-prompt-agent-title-input", }, branchName: { - title: "Branch name prompt", - placeholder: "Prefix branches with feat/ or fix/.", + title: "Branch names", + placeholder: "Prefix branches with feat/ or fix/, mb/ for personal branches", sectionTestID: "metadata-prompt-branch-name-section", inputTestID: "metadata-prompt-branch-name-input", }, commitMessage: { - title: "Commit message prompt", - placeholder: "Use Conventional Commits.", + title: "Commit messages", + placeholder: "Use Conventional Commits with a scope", sectionTestID: "metadata-prompt-commit-message-section", inputTestID: "metadata-prompt-commit-message-input", }, pullRequest: { - title: "Pull request prompt", - placeholder: "Include risk notes and a test plan.", + title: "Pull requests", + placeholder: "Lead with a one-paragraph summary, include a Test plan section", sectionTestID: "metadata-prompt-pull-request-section", inputTestID: "metadata-prompt-pull-request-input", }, }; +const WORKTREE_GROUP_INFO = + "Commands that run when a worktree is created or torn down for this project"; +const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees"; +const WORKTREE_DOCS_TOOLTIP = + "See docs for more details and the environment variables available to these commands"; +const SCRIPTS_GROUP_INFO = + "Long-running services and one-off commands you can launch from any agent in this project"; +const METADATA_GROUP_INFO = + "Project-specific instructions injected into the AI prompts Paseo uses to generate metadata — use them to enforce your team's conventions like branch naming, commit style, or PR format"; + const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host."; const HOST_SWITCHER_LABEL = "Switch host"; @@ -574,43 +586,82 @@ function ProjectConfigForm({ [handleAddScript], ); + const setupDocsLink = useMemo( + () => ( + + ), + [], + ); + const teardownDocsLink = useMemo( + () => ( + + ), + [], + ); + const isStale = writeError?.code === "stale_project_config"; const isWriteFailed = writeError?.code === "write_failed"; const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts; return ( - - - - - + + + + + + - - - - - + + + + + + - + {draft.scripts.length === 0 ? ( @@ -628,16 +679,19 @@ function ProjectConfigForm({ )) )} - + - {METADATA_PROMPT_KEYS.map((key) => ( - - ))} + + {METADATA_PROMPT_KEYS.map((key, index) => ( + + ))} + {isStale ? ( @@ -825,16 +879,17 @@ interface MetadataPromptSectionProps { promptKey: MetadataPromptKey; value: string; onChange: (key: MetadataPromptKey, text: string) => void; + flush?: boolean; } -function MetadataPromptSection({ promptKey, value, onChange }: MetadataPromptSectionProps) { +function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPromptSectionProps) { const meta = METADATA_PROMPT_FIELDS[promptKey]; const handleChange = useCallback( (text: string) => onChange(promptKey, text), [onChange, promptKey], ); return ( - + ({ paddingBottom: insets.bottom }), [insets.bottom]); + const webScrollbarStyle = useWebScrollbarStyle(); + const scrollViewStyle = useMemo( + () => [styles.scrollView, webScrollbarStyle], + [webScrollbarStyle], + ); const hosts = useHosts(); const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds); @@ -1079,7 +1085,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { return ( - + - + {content} {addHostModals} @@ -1153,7 +1159,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { } leftStyle={desktopStyles.detailLeft} /> - + {content} diff --git a/packages/app/src/screens/settings/settings-group.tsx b/packages/app/src/screens/settings/settings-group.tsx new file mode 100644 index 000000000..175e60c51 --- /dev/null +++ b/packages/app/src/screens/settings/settings-group.tsx @@ -0,0 +1,94 @@ +import { useMemo, type ReactNode } from "react"; +import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { Info } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface SettingsGroupProps { + title: string; + info?: ReactNode; + trailing?: ReactNode; + testID?: string; + style?: StyleProp; + children: ReactNode; +} + +/** + * Top-level grouping above one or more SettingsSection blocks. Use when a + * settings screen has more than one logical area — the group title carries the + * category, the optional info tooltip explains it, and the inner sections keep + * their muted iOS-style labels. + */ +export function SettingsGroup({ + title, + info, + trailing, + testID, + style, + children, +}: SettingsGroupProps) { + const { theme } = useUnistyles(); + const groupStyle = useMemo(() => [styles.group, style], [style]); + return ( + + + + {title} + {info ? ( + + + + + + + + {info} + + + ) : null} + + {trailing} + + {children} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + group: { + marginBottom: theme.spacing[8], + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + marginBottom: theme.spacing[4], + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + }, + infoButton: { + padding: theme.spacing[1], + marginLeft: -theme.spacing[1], + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + maxWidth: 280, + lineHeight: theme.fontSize.sm * 1.4, + }, +})); diff --git a/packages/app/src/screens/settings/settings-section.tsx b/packages/app/src/screens/settings/settings-section.tsx index f363a0875..709b09745 100644 --- a/packages/app/src/screens/settings/settings-section.tsx +++ b/packages/app/src/screens/settings/settings-section.tsx @@ -8,6 +8,11 @@ interface SettingsSectionProps { trailing?: ReactNode; testID?: string; style?: StyleProp; + /** + * Drops the section's bottom margin. Use when the section is the last child + * of a SettingsGroup, so the group's own bottom margin owns the trailing gap. + */ + flush?: boolean; children: ReactNode; } @@ -21,9 +26,13 @@ export function SettingsSection({ trailing, testID, style, + flush, children, }: SettingsSectionProps) { - const sectionStyle = useMemo(() => [settingsStyles.section, style], [style]); + const sectionStyle = useMemo( + () => [settingsStyles.section, flush ? styles.flush : null, style], + [flush, style], + ); return ( @@ -47,4 +56,7 @@ const styles = StyleSheet.create((theme) => ({ content: { gap: theme.spacing[3], }, + flush: { + marginBottom: 0, + }, }));