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.
This commit is contained in:
Mohamed Boudra
2026-01-09 19:00:41 +07:00
parent b90c17a919
commit 2d03e86bcb
7 changed files with 263 additions and 39 deletions

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 ? (
<GitOptionsSection
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
worktreeSlug={slugifyWorktreeName(promptText)}
onUseWorktreeChange={handleUseWorktreeChange}
worktreeSlug={worktreeSlug}
currentBranch={repoInfo?.currentBranch ?? null}
baseBranch={baseBranch}
onBaseBranchChange={handleBaseBranchChange}
branches={repoInfo?.branches ?? []}
status={repoInfoStatus}
repoError={repoInfoError}
gitValidationError={gitBlockingError}
baseBranchError={baseBranchError}
/>
) : null}
</View>
@@ -780,6 +813,15 @@ export default function HomeScreen() {
/>
</View>
</View>
{isLoading ? (
<View style={styles.loadingOverlay}>
<View style={styles.loadingContent}>
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.loadingText}>Creating agent...</Text>
</View>
</View>
) : null}
</View>
</FileDropZone>
);
@@ -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,
},
}));

View File

@@ -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<TextInput>(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 (
<View style={styles.gitOptionsContainer}>
@@ -781,6 +822,42 @@ export function GitOptionsSection({
</View>
</Pressable>
{useWorktree ? (
<View style={styles.baseBranchRow}>
<Text style={styles.baseBranchLabel}>Base branch:</Text>
{isEditingBranch ? (
<View style={styles.baseBranchEditRow}>
<TextInput
ref={inputRef}
style={styles.baseBranchInput}
value={editedBranch}
onChangeText={setEditedBranch}
autoCapitalize="none"
autoCorrect={false}
placeholder="branch name"
placeholderTextColor={defaultTheme.colors.mutedForeground}
onSubmitEditing={handleConfirmEdit}
/>
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={16} color={defaultTheme.colors.palette.green[500]} />
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={16} color={defaultTheme.colors.mutedForeground} />
</Pressable>
</View>
) : (
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
<Pencil size={14} color={defaultTheme.colors.mutedForeground} />
</Pressable>
)}
</View>
) : null}
{baseBranchError ? (
<Text style={styles.errorText}>{baseBranchError}</Text>
) : null}
{repoError ? (
<Text style={styles.errorText}>{repoError}</Text>
) : 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,
},

View File

@@ -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<DropdownKey | null>(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 ? (
<GitOptionsSection
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
worktreeSlug={slugifyWorktreeName(initialPrompt)}
onUseWorktreeChange={handleUseWorktreeChange}
worktreeSlug={worktreeSlug}
currentBranch={repoInfo?.currentBranch ?? null}
baseBranch={baseBranch}
onBaseBranchChange={handleBaseBranchChange}
branches={repoInfo?.branches ?? []}
status={repoInfoStatus}
repoError={repoInfoError}
gitValidationError={gitBlockingError}
baseBranchError={baseBranchError}
/>
) : null}
</ScrollView>

View File

@@ -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",

View File

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