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
This commit is contained in:
Mohamed Boudra
2026-05-09 19:31:19 +08:00
committed by GitHub
parent c34fb26359
commit ca503c1560
5 changed files with 296 additions and 52 deletions
@@ -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 = (
<Pressable
onPress={handlePress}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={accessibilityLabel ?? label}
testID={testID}
style={styles.trigger}
>
<Text style={styles.label}>{label}</Text>
<ArrowUpRight size={12} color={theme.colors.foregroundMuted} />
</Pressable>
);
if (!tooltip) {
return trigger;
}
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" align="end" offset={6}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</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,
},
}));
@@ -19,9 +19,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
@@ -53,31 +55,41 @@ interface MetadataPromptField {
const METADATA_PROMPT_FIELDS: Record<MetadataPromptKey, MetadataPromptField> = { const METADATA_PROMPT_FIELDS: Record<MetadataPromptKey, MetadataPromptField> = {
agentTitle: { agentTitle: {
title: "Agent title prompt", title: "Agent titles",
placeholder: "Keep titles short and action-oriented.", placeholder: "Keep titles imperative and under 40 characters",
sectionTestID: "metadata-prompt-agent-title-section", sectionTestID: "metadata-prompt-agent-title-section",
inputTestID: "metadata-prompt-agent-title-input", inputTestID: "metadata-prompt-agent-title-input",
}, },
branchName: { branchName: {
title: "Branch name prompt", title: "Branch names",
placeholder: "Prefix branches with feat/ or fix/.", placeholder: "Prefix branches with feat/ or fix/, mb/ for personal branches",
sectionTestID: "metadata-prompt-branch-name-section", sectionTestID: "metadata-prompt-branch-name-section",
inputTestID: "metadata-prompt-branch-name-input", inputTestID: "metadata-prompt-branch-name-input",
}, },
commitMessage: { commitMessage: {
title: "Commit message prompt", title: "Commit messages",
placeholder: "Use Conventional Commits.", placeholder: "Use Conventional Commits with a scope",
sectionTestID: "metadata-prompt-commit-message-section", sectionTestID: "metadata-prompt-commit-message-section",
inputTestID: "metadata-prompt-commit-message-input", inputTestID: "metadata-prompt-commit-message-input",
}, },
pullRequest: { pullRequest: {
title: "Pull request prompt", title: "Pull requests",
placeholder: "Include risk notes and a test plan.", placeholder: "Lead with a one-paragraph summary, include a Test plan section",
sectionTestID: "metadata-prompt-pull-request-section", sectionTestID: "metadata-prompt-pull-request-section",
inputTestID: "metadata-prompt-pull-request-input", 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 NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host.";
const HOST_SWITCHER_LABEL = "Switch host"; const HOST_SWITCHER_LABEL = "Switch host";
@@ -574,43 +586,82 @@ function ProjectConfigForm({
[handleAddScript], [handleAddScript],
); );
const setupDocsLink = useMemo(
() => (
<ExternalLink
href={WORKTREE_DOCS_URL}
label="Docs"
tooltip={WORKTREE_DOCS_TOOLTIP}
testID="worktree-setup-docs-link"
/>
),
[],
);
const teardownDocsLink = useMemo(
() => (
<ExternalLink
href={WORKTREE_DOCS_URL}
label="Docs"
tooltip={WORKTREE_DOCS_TOOLTIP}
testID="worktree-teardown-docs-link"
/>
),
[],
);
const isStale = writeError?.code === "stale_project_config"; const isStale = writeError?.code === "stale_project_config";
const isWriteFailed = writeError?.code === "write_failed"; const isWriteFailed = writeError?.code === "write_failed";
const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts; const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts;
return ( return (
<View> <View>
<SettingsSection title="Worktree setup" testID="worktree-setup-section"> <SettingsGroup
<View style={settingsStyles.card}> title="Worktree lifecycle hooks"
<TextInput info={WORKTREE_GROUP_INFO}
testID="worktree-setup-input" testID="worktree-group"
accessibilityLabel="Worktree setup commands" >
multiline <SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
value={draft.setupText} <View style={settingsStyles.card}>
onChangeText={handleSetupChange} <TextInput
placeholder="npm install" testID="worktree-setup-input"
placeholderTextColor={styles.placeholderColor.color} accessibilityLabel="Worktree setup commands"
style={styles.lifecycleInput} multiline
/> value={draft.setupText}
</View> onChangeText={handleSetupChange}
</SettingsSection> placeholder="npm install"
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
</SettingsSection>
<SettingsSection title="Worktree teardown" testID="worktree-teardown-section"> <SettingsSection
<View style={settingsStyles.card}> title="Teardown"
<TextInput testID="worktree-teardown-section"
testID="worktree-teardown-input" trailing={teardownDocsLink}
accessibilityLabel="Worktree teardown commands" flush
multiline >
value={draft.teardownText} <View style={settingsStyles.card}>
onChangeText={handleTeardownChange} <TextInput
placeholder="docker compose down" testID="worktree-teardown-input"
placeholderTextColor={styles.placeholderColor.color} accessibilityLabel="Worktree teardown commands"
style={styles.lifecycleInput} multiline
/> value={draft.teardownText}
</View> onChangeText={handleTeardownChange}
</SettingsSection> placeholder="docker compose down"
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
</SettingsSection>
</SettingsGroup>
<SettingsSection title="Scripts" testID="scripts-section" trailing={scriptsTrailing}> <SettingsGroup
title="Scripts"
info={SCRIPTS_GROUP_INFO}
trailing={scriptsTrailing}
testID="scripts-group"
>
<View style={settingsStyles.card} testID="scripts-list"> <View style={settingsStyles.card} testID="scripts-list">
{draft.scripts.length === 0 ? ( {draft.scripts.length === 0 ? (
<View style={settingsStyles.row}> <View style={settingsStyles.row}>
@@ -628,16 +679,19 @@ function ProjectConfigForm({
)) ))
)} )}
</View> </View>
</SettingsSection> </SettingsGroup>
{METADATA_PROMPT_KEYS.map((key) => ( <SettingsGroup title="Metadata generation" info={METADATA_GROUP_INFO} testID="metadata-group">
<MetadataPromptSection {METADATA_PROMPT_KEYS.map((key, index) => (
key={key} <MetadataPromptSection
promptKey={key} key={key}
value={draft.metadataPrompts[key]} promptKey={key}
onChange={handleMetadataPromptChange} value={draft.metadataPrompts[key]}
/> onChange={handleMetadataPromptChange}
))} flush={index === METADATA_PROMPT_KEYS.length - 1}
/>
))}
</SettingsGroup>
{isStale ? ( {isStale ? (
<View style={styles.calloutWrap}> <View style={styles.calloutWrap}>
@@ -825,16 +879,17 @@ interface MetadataPromptSectionProps {
promptKey: MetadataPromptKey; promptKey: MetadataPromptKey;
value: string; value: string;
onChange: (key: MetadataPromptKey, text: string) => void; 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 meta = METADATA_PROMPT_FIELDS[promptKey];
const handleChange = useCallback( const handleChange = useCallback(
(text: string) => onChange(promptKey, text), (text: string) => onChange(promptKey, text),
[onChange, promptKey], [onChange, promptKey],
); );
return ( return (
<SettingsSection title={meta.title} testID={meta.sectionTestID}> <SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<TextInput <TextInput
testID={meta.inputTestID} testID={meta.inputTestID}
+9 -3
View File
@@ -76,6 +76,7 @@ import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { import {
buildHostOpenProjectRoute, buildHostOpenProjectRoute,
buildProjectsSettingsRoute, buildProjectsSettingsRoute,
@@ -812,6 +813,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); const insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollViewStyle = useMemo(
() => [styles.scrollView, webScrollbarStyle],
[webScrollbarStyle],
);
const hosts = useHosts(); const hosts = useHosts();
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds); const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
@@ -1079,7 +1085,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title="Settings" onBack={handleBackToWorkspace} /> <BackHeader title="Settings" onBack={handleBackToWorkspace} />
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}> <ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
<SettingsSidebar <SettingsSidebar
view={view} view={view}
onSelectSection={handleSelectSection} onSelectSection={handleSelectSection}
@@ -1108,7 +1114,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
titleAccessory={detailHeader?.titleAccessory} titleAccessory={detailHeader?.titleAccessory}
onBack={detailBackHandler} onBack={detailBackHandler}
/> />
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}> <ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View> <View style={styles.content}>{content}</View>
</ScrollView> </ScrollView>
{addHostModals} {addHostModals}
@@ -1153,7 +1159,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
} }
leftStyle={desktopStyles.detailLeft} leftStyle={desktopStyles.detailLeft}
/> />
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}> <ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View> <View style={styles.content}>{content}</View>
</ScrollView> </ScrollView>
</View> </View>
@@ -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<ViewStyle>;
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 (
<View style={groupStyle} testID={testID}>
<View style={styles.header}>
<View style={styles.titleRow}>
<Text style={styles.title}>{title}</Text>
{info ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={`About ${title}`}
testID={testID ? `${testID}-info` : undefined}
hitSlop={8}
style={styles.infoButton}
>
<Info size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="start" offset={8}>
<Text style={styles.tooltipText}>{info}</Text>
</TooltipContent>
</Tooltip>
) : null}
</View>
{trailing}
</View>
{children}
</View>
);
}
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,
},
}));
@@ -8,6 +8,11 @@ interface SettingsSectionProps {
trailing?: ReactNode; trailing?: ReactNode;
testID?: string; testID?: string;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
/**
* 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; children: ReactNode;
} }
@@ -21,9 +26,13 @@ export function SettingsSection({
trailing, trailing,
testID, testID,
style, style,
flush,
children, children,
}: SettingsSectionProps) { }: SettingsSectionProps) {
const sectionStyle = useMemo(() => [settingsStyles.section, style], [style]); const sectionStyle = useMemo(
() => [settingsStyles.section, flush ? styles.flush : null, style],
[flush, style],
);
return ( return (
<View style={sectionStyle} testID={testID}> <View style={sectionStyle} testID={testID}>
<View style={styles.header}> <View style={styles.header}>
@@ -47,4 +56,7 @@ const styles = StyleSheet.create((theme) => ({
content: { content: {
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
flush: {
marginBottom: 0,
},
})); }));