mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
77
packages/app/src/components/ui/external-link.tsx
Normal file
77
packages/app/src/components/ui/external-link.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
94
packages/app/src/screens/settings/settings-group.tsx
Normal file
94
packages/app/src/screens/settings/settings-group.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user