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