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

View File

@@ -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,
},
}));

View File

@@ -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<MetadataPromptKey, MetadataPromptField> = {
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(
() => (
<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 isWriteFailed = writeError?.code === "write_failed";
const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts;
return (
<View>
<SettingsSection title="Worktree setup" testID="worktree-setup-section">
<View style={settingsStyles.card}>
<TextInput
testID="worktree-setup-input"
accessibilityLabel="Worktree setup commands"
multiline
value={draft.setupText}
onChangeText={handleSetupChange}
placeholder="npm install"
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
</SettingsSection>
<SettingsGroup
title="Worktree lifecycle hooks"
info={WORKTREE_GROUP_INFO}
testID="worktree-group"
>
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
<View style={settingsStyles.card}>
<TextInput
testID="worktree-setup-input"
accessibilityLabel="Worktree setup commands"
multiline
value={draft.setupText}
onChangeText={handleSetupChange}
placeholder="npm install"
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
</SettingsSection>
<SettingsSection title="Worktree teardown" testID="worktree-teardown-section">
<View style={settingsStyles.card}>
<TextInput
testID="worktree-teardown-input"
accessibilityLabel="Worktree teardown commands"
multiline
value={draft.teardownText}
onChangeText={handleTeardownChange}
placeholder="docker compose down"
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
</SettingsSection>
<SettingsSection
title="Teardown"
testID="worktree-teardown-section"
trailing={teardownDocsLink}
flush
>
<View style={settingsStyles.card}>
<TextInput
testID="worktree-teardown-input"
accessibilityLabel="Worktree teardown commands"
multiline
value={draft.teardownText}
onChangeText={handleTeardownChange}
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">
{draft.scripts.length === 0 ? (
<View style={settingsStyles.row}>
@@ -628,16 +679,19 @@ function ProjectConfigForm({
))
)}
</View>
</SettingsSection>
</SettingsGroup>
{METADATA_PROMPT_KEYS.map((key) => (
<MetadataPromptSection
key={key}
promptKey={key}
value={draft.metadataPrompts[key]}
onChange={handleMetadataPromptChange}
/>
))}
<SettingsGroup title="Metadata generation" info={METADATA_GROUP_INFO} testID="metadata-group">
{METADATA_PROMPT_KEYS.map((key, index) => (
<MetadataPromptSection
key={key}
promptKey={key}
value={draft.metadataPrompts[key]}
onChange={handleMetadataPromptChange}
flush={index === METADATA_PROMPT_KEYS.length - 1}
/>
))}
</SettingsGroup>
{isStale ? (
<View style={styles.calloutWrap}>
@@ -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 (
<SettingsSection title={meta.title} testID={meta.sectionTestID}>
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
<View style={settingsStyles.card}>
<TextInput
testID={meta.inputTestID}

View File

@@ -76,6 +76,7 @@ import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
buildHostOpenProjectRoute,
buildProjectsSettingsRoute,
@@ -812,6 +813,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const isCompactLayout = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const insetBottomStyle = useMemo(() => ({ 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 (
<View style={styles.container}>
<BackHeader title="Settings" onBack={handleBackToWorkspace} />
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
@@ -1108,7 +1114,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
titleAccessory={detailHeader?.titleAccessory}
onBack={detailBackHandler}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
</ScrollView>
{addHostModals}
@@ -1153,7 +1159,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}
leftStyle={desktopStyles.detailLeft}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
</ScrollView>
</View>

View File

@@ -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,
},
}));

View File

@@ -8,6 +8,11 @@ interface SettingsSectionProps {
trailing?: ReactNode;
testID?: string;
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;
}
@@ -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 (
<View style={sectionStyle} testID={testID}>
<View style={styles.header}>
@@ -47,4 +56,7 @@ const styles = StyleSheet.create((theme) => ({
content: {
gap: theme.spacing[3],
},
flush: {
marginBottom: 0,
},
}));