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
@@ -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}