From 2d03e86bcb4cf8f64511760776006e325d40f236 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 9 Jan 2026 19:00:41 +0700 Subject: [PATCH] feat: use mnemonic-id for worktree names Replace prompt-based slugifying with mnemonic-id package for generating human-readable worktree names like "hungry-hippo". Names are generated when the worktree toggle is enabled rather than derived from the prompt. --- package-lock.json | 8 ++ packages/app/package.json | 3 +- packages/app/src/app/index.tsx | 91 +++++++++++--- .../agent-form/agent-form-dropdowns.tsx | 119 +++++++++++++++++- .../app/src/components/create-agent-modal.tsx | 66 +++++++--- packages/server/package.json | 1 + packages/server/src/utils/worktree.ts | 14 +-- 7 files changed, 263 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9db70c76b..9918148e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15952,6 +15952,12 @@ "node": ">=10" } }, + "node_modules/mnemonic-id": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/mnemonic-id/-/mnemonic-id-3.2.7.tgz", + "integrity": "sha512-kysx9gAGbvrzuFYxKkcRjnsg/NK61ovJOV4F1cHTRl9T5leg+bo6WI0pWIvOFh1Z/yDL0cjA5R3EEGPPLDv/XA==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -21275,6 +21281,7 @@ "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", "lucide-react-native": "^0.546.0", + "mnemonic-id": "^3.2.7", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "^0.81.5", @@ -21335,6 +21342,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "mnemonic-id": "^3.2.7", "openai": "^4.20.0", "playwright": "^1.56.1", "tiny-invariant": "^1.3.3", diff --git a/packages/app/package.json b/packages/app/package.json index 9198ecf49..548a597f5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -15,7 +15,6 @@ "test": "vitest run" }, "dependencies": { - "@paseo/server": "*", "@boudra/expo-two-way-audio": "^0.1.3", "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.2.6", @@ -27,6 +26,7 @@ "@lezer/json": "^1.0.3", "@lezer/markdown": "^1.6.2", "@lezer/python": "^1.1.18", + "@paseo/server": "*", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -55,6 +55,7 @@ "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", "lucide-react-native": "^0.546.0", + "mnemonic-id": "^3.2.7", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "^0.81.5", diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index eb71b1cde..ca9c665f4 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createNameId } from "mnemonic-id"; import type { ImageAttachment } from "@/components/message-input"; import { View, Text, Pressable, ScrollView, + ActivityIndicator, } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -164,6 +166,8 @@ export default function HomeScreen() { const [isLoading, setIsLoading] = useState(false); const [promptText, setPromptText] = useState(""); const [useWorktree, setUseWorktree] = useState(false); + const [baseBranch, setBaseBranch] = useState(""); + const [worktreeSlug, setWorktreeSlug] = useState(""); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const handleFilesDropped = useCallback((files: ImageAttachment[]) => { @@ -305,12 +309,12 @@ export default function HomeScreen() { ? "No git repository detected. Git options are disabled for this directory." : null; - const slugifyWorktreeName = useCallback((input: string): string => { - return input - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - }, []); + const handleUseWorktreeChange = useCallback((value: boolean) => { + setUseWorktree(value); + if (value && !worktreeSlug) { + setWorktreeSlug(createNameId()); + } + }, [worktreeSlug]); const validateWorktreeName = useCallback( (name: string): { valid: boolean; error?: string } => { @@ -344,11 +348,10 @@ export default function HomeScreen() { if (!useWorktree || isNonGitDirectory) { return null; } - const slug = slugifyWorktreeName(promptText); - if (!slug) { + if (!worktreeSlug) { return null; } - const validation = validateWorktreeName(slug); + const validation = validateWorktreeName(worktreeSlug); if (!validation.valid) { return `Invalid worktree name: ${ validation.error ?? "Must use lowercase letters, numbers, or hyphens" @@ -358,11 +361,29 @@ export default function HomeScreen() { }, [ useWorktree, isNonGitDirectory, - promptText, - slugifyWorktreeName, + worktreeSlug, validateWorktreeName, ]); + const baseBranchError = useMemo(() => { + if (!useWorktree || isNonGitDirectory || !baseBranch) { + return null; + } + const branches = repoInfo?.branches ?? []; + if (branches.length === 0) { + return null; + } + const branchExists = branches.some((b) => b.name === baseBranch); + if (!branchExists) { + return `Branch "${baseBranch}" not found in repository`; + } + return null; + }, [useWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]); + + const handleBaseBranchChange = useCallback((value: string) => { + setBaseBranch(value); + }, []); + useEffect(() => { if (!shouldInspectRepo) { cancelRepoInfo(); @@ -447,6 +468,10 @@ export default function HomeScreen() { setErrorMessage(gitBlockingError); throw new Error(gitBlockingError); } + if (baseBranchError) { + setErrorMessage(baseBranchError); + throw new Error(baseBranchError); + } if (isLoading) { throw new Error("Already loading"); } @@ -464,11 +489,12 @@ export default function HomeScreen() { ...(modeId ? { modeId } : {}), ...(trimmedModel ? { model: trimmedModel } : {}), }; - const worktreeSlug = slugifyWorktreeName(trimmedPrompt); + const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined; const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug ? { createWorktree: true, worktreeSlug, + baseBranch: effectiveBaseBranch, } : undefined; @@ -487,8 +513,11 @@ export default function HomeScreen() { }, [ useWorktree, - slugifyWorktreeName, + baseBranch, + worktreeSlug, + repoInfo?.currentBranch, gitBlockingError, + baseBranchError, isDirectoryNotExists, isLoading, isNonGitDirectory, @@ -608,12 +637,16 @@ export default function HomeScreen() { {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( ) : null} @@ -780,6 +813,15 @@ export default function HomeScreen() { /> + + {isLoading ? ( + + + + Creating agent... + + + ) : null} ); @@ -925,4 +967,23 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, marginBottom: theme.spacing[2], }, + loadingOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.7)", + justifyContent: "center", + alignItems: "center", + }, + loadingContent: { + alignItems: "center", + gap: theme.spacing[4], + }, + loadingText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, })); diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 6209fdc72..bcd195cbd 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -11,7 +11,7 @@ import { useWindowDimensions, } from "react-native"; import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; -import { ChevronDown, ChevronRight } from "lucide-react-native"; +import { ChevronDown, ChevronRight, Pencil, Check, X } from "lucide-react-native"; import { theme as defaultTheme } from "@/styles/theme"; import type { AgentMode, @@ -741,9 +741,13 @@ export interface GitOptionsSectionProps { onUseWorktreeChange: (value: boolean) => void; worktreeSlug: string; currentBranch: string | null; + baseBranch: string; + onBaseBranchChange: (value: string) => void; + branches: Array<{ name: string; isCurrent: boolean }>; status: "idle" | "loading" | "ready" | "error"; repoError: string | null; gitValidationError: string | null; + baseBranchError: string | null; } export function GitOptionsSection({ @@ -751,11 +755,48 @@ export function GitOptionsSection({ onUseWorktreeChange, worktreeSlug, currentBranch, + baseBranch, + onBaseBranchChange, + branches, status, repoError, gitValidationError, + baseBranchError, }: GitOptionsSectionProps): ReactElement { const isLoading = status === "loading"; + const [isEditingBranch, setIsEditingBranch] = useState(false); + const [editedBranch, setEditedBranch] = useState(baseBranch); + const inputRef = useRef(null); + + useEffect(() => { + setEditedBranch(baseBranch); + }, [baseBranch]); + + useEffect(() => { + if (isEditingBranch) { + inputRef.current?.focus(); + } + }, [isEditingBranch]); + + const handleStartEdit = useCallback(() => { + setEditedBranch(baseBranch); + setIsEditingBranch(true); + }, [baseBranch]); + + const handleConfirmEdit = useCallback(() => { + const trimmed = editedBranch.trim(); + if (trimmed) { + onBaseBranchChange(trimmed); + } + setIsEditingBranch(false); + }, [editedBranch, onBaseBranchChange]); + + const handleCancelEdit = useCallback(() => { + setEditedBranch(baseBranch); + setIsEditingBranch(false); + }, [baseBranch]); + + const displayBranch = baseBranch || currentBranch || "HEAD"; return ( @@ -781,6 +822,42 @@ export function GitOptionsSection({ + {useWorktree ? ( + + Base branch: + {isEditingBranch ? ( + + + + + + + + + + ) : ( + + {displayBranch} + + + )} + + ) : null} + + {baseBranchError ? ( + {baseBranchError} + ) : null} + {repoError ? ( {repoError} ) : null} @@ -1044,6 +1121,46 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, }, + baseBranchRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + }, + baseBranchLabel: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + }, + baseBranchValueRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + baseBranchValue: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + baseBranchEditRow: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + baseBranchInput: { + flex: 1, + backgroundColor: theme.colors.background, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.md, + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + baseBranchIconButton: { + padding: theme.spacing[1], + }, desktopDropdownOverlay: { flex: 1, }, diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index 3cc2022a0..8efb946d8 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -5,6 +5,7 @@ import { useMemo, useCallback, } from "react"; +import { createNameId } from "mnemonic-id"; import type { ReactElement, ReactNode } from "react"; import { View, @@ -338,6 +339,8 @@ function AgentFlowModal({ const [isMounted, setIsMounted] = useState(isVisible); const [initialPrompt, setInitialPrompt] = useState(""); const [useWorktree, setUseWorktree] = useState(false); + const [baseBranch, setBaseBranch] = useState(""); + const [worktreeSlug, setWorktreeSlug] = useState(""); const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); const [openDropdown, setOpenDropdown] = useState(null); @@ -386,6 +389,8 @@ function AgentFlowModal({ const resetFormState = useCallback(() => { setInitialPrompt(""); setUseWorktree(false); + setBaseBranch(""); + setWorktreeSlug(""); setErrorMessage(""); setIsLoading(false); resetRepoInfo(); @@ -527,12 +532,12 @@ function AgentFlowModal({ }; }, [backdropOpacity]); - const slugifyWorktreeName = useCallback((input: string): string => { - return input - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - }, []); + const handleUseWorktreeChange = useCallback((value: boolean) => { + setUseWorktree(value); + if (value && !worktreeSlug) { + setWorktreeSlug(createNameId()); + } + }, [worktreeSlug]); const validateWorktreeName = useCallback( (name: string): { valid: boolean; error?: string } => { @@ -659,11 +664,10 @@ function AgentFlowModal({ if (!useWorktree || isNonGitDirectory) { return null; } - const slug = slugifyWorktreeName(initialPrompt); - if (!slug) { + if (!worktreeSlug) { return null; } - const validation = validateWorktreeName(slug); + const validation = validateWorktreeName(worktreeSlug); if (!validation.valid) { return `Invalid worktree name: ${ validation.error ?? "Must use lowercase letters, numbers, or hyphens" @@ -673,11 +677,29 @@ function AgentFlowModal({ }, [ useWorktree, isNonGitDirectory, - initialPrompt, - slugifyWorktreeName, + worktreeSlug, validateWorktreeName, ]); + const baseBranchError = useMemo(() => { + if (!useWorktree || isNonGitDirectory || !baseBranch) { + return null; + } + const branches = repoInfo?.branches ?? []; + if (branches.length === 0) { + return null; + } + const branchExists = branches.some((b) => b.name === baseBranch); + if (!branchExists) { + return `Branch "${baseBranch}" not found in repository`; + } + return null; + }, [useWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]); + + const handleBaseBranchChange = useCallback((value: string) => { + setBaseBranch(value); + }, []); + const handleCreate = useCallback(async () => { const trimmedPath = workingDir.trim(); if (!trimmedPath) { @@ -700,6 +722,11 @@ function AgentFlowModal({ return; } + if (baseBranchError) { + setErrorMessage(baseBranchError); + return; + } + if (!createAgent || !isTargetDaemonReady) { logOfflineDaemonAction("create"); setErrorMessage( @@ -733,11 +760,12 @@ function AgentFlowModal({ ...(trimmedModel ? { model: trimmedModel } : {}), }; - const worktreeSlug = slugifyWorktreeName(trimmedPrompt); + const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined; const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug ? { createWorktree: true, worktreeSlug, + baseBranch: effectiveBaseBranch, } : undefined; @@ -759,7 +787,9 @@ function AgentFlowModal({ workingDir, initialPrompt, useWorktree, - slugifyWorktreeName, + baseBranch, + worktreeSlug, + repoInfo?.currentBranch, selectedMode, modeOptions, logOfflineDaemonAction, @@ -772,6 +802,7 @@ function AgentFlowModal({ selectedDaemonId, isNonGitDirectory, gitBlockingError, + baseBranchError, selectedModel, ]); @@ -834,6 +865,7 @@ function AgentFlowModal({ workingDirIsEmpty || promptIsEmpty || Boolean(gitBlockingError) || + Boolean(baseBranchError) || isLoading || !isTargetDaemonReady; const headerPaddingTop = useMemo( @@ -991,12 +1023,16 @@ function AgentFlowModal({ {!isNonGitDirectory ? ( ) : null} diff --git a/packages/server/package.json b/packages/server/package.json index 42cfd6951..ae315beec 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -36,6 +36,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "mnemonic-id": "^3.2.7", "openai": "^4.20.0", "playwright": "^1.56.1", "tiny-invariant": "^1.3.3", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 193be9844..91eeb34cb 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -2,6 +2,7 @@ import { exec } from "child_process"; import { promisify } from "util"; import { existsSync, readFileSync, rmSync } from "fs"; import { join, basename, dirname } from "path"; +import { createNameId } from "mnemonic-id"; interface PaseoConfig { worktree?: { @@ -180,9 +181,8 @@ export function slugify(input: string): string { return truncated.replace(/-+$/, ""); } -function sanitizeWorktreeSlug(input: string): string { - const slug = slugify(input); - return slug.length > 0 ? slug : "worktree"; +function generateWorktreeSlug(): string { + return createNameId(); } @@ -206,7 +206,7 @@ export async function createWorktree({ // Determine worktree directory based on repo type let worktreePath: string; - const desiredSlug = sanitizeWorktreeSlug(worktreeSlug ?? branchName); + const desiredSlug = worktreeSlug || generateWorktreeSlug(); if (repoInfo.type === "bare") { worktreePath = join(repoInfo.path, desiredSlug); @@ -263,8 +263,8 @@ export async function createWorktree({ await execAsync(command, { cwd: repoInfo.path }); worktreePath = finalWorktreePath; - // Run setup commands from paseo.json if present - const paseoConfigPath = join(repoInfo.path, "paseo.json"); + // Run setup commands from paseo.json if present (look in source worktree, not bare repo) + const paseoConfigPath = join(cwd, "paseo.json"); if (existsSync(paseoConfigPath)) { let config: PaseoConfig; try { @@ -277,7 +277,7 @@ export async function createWorktree({ if (setupCommands && setupCommands.length > 0) { const setupEnv = { ...process.env, - PASEO_ROOT_PATH: repoInfo.path, + PASEO_ROOT_PATH: cwd, PASEO_WORKTREE_PATH: worktreePath, PASEO_BRANCH_NAME: newBranchName, };