From 3ba778eea71f61f1be3b0a32486f496022a8fd62 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 9 Jan 2026 08:40:16 +0700 Subject: [PATCH] wip: local changes and task backlog - Refactor agent-form-dropdowns component - Simplify app index routing - Update agent-input-area and message-input - Improve worktree utility with tests - Add task backlog for upcoming work --- .tasks/0cf32223.md | 8 + .tasks/10019663.md | 8 + .tasks/16ccfef4.md | 14 + .tasks/19f85855.md | 14 + .tasks/1ec7463f.md | 8 + .tasks/1f019827.md | 8 + .tasks/253b2ff3.md | 14 + .tasks/270b2936.md | 8 + .tasks/2addaa50.md | 8 + .tasks/341ed6c1.md | 8 + .tasks/3913ce12.md | 8 + .tasks/484edbb7.md | 8 + .tasks/5f407d28.md | 8 + .tasks/6f89a5fe.md | 8 + .tasks/7ddb69c7.md | 8 + .tasks/86e8a5ef.md | 8 + .tasks/8ee26288.md | 8 + .tasks/8f95bc29.md | 8 + .tasks/9328120f.md | 14 + .tasks/933d0b5b.md | 8 + .tasks/95cc65b4.md | 8 + .tasks/983d44bf.md | 8 + .tasks/9be15eb4.md | 14 + .tasks/ab05b25a.md | 8 + .tasks/ab0baaa5.md | 8 + .tasks/de2e028a.md | 14 + .tasks/e0b95c57.md | 8 + .tasks/e8ae632a.md | 14 + .tasks/ef288598.md | 8 + .tasks/f0fe2219.md | 8 + .tasks/f20a93b0.md | 8 + packages/app/src/app/index.tsx | 229 +----- .../agent-form/agent-form-dropdowns.tsx | 698 +++++++++--------- .../app/src/components/agent-input-area.tsx | 10 +- packages/app/src/components/message-input.tsx | 4 +- packages/server/src/utils/worktree.test.ts | 156 ++++ packages/server/src/utils/worktree.ts | 155 ++-- 37 files changed, 935 insertions(+), 607 deletions(-) create mode 100644 .tasks/0cf32223.md create mode 100644 .tasks/10019663.md create mode 100644 .tasks/16ccfef4.md create mode 100644 .tasks/19f85855.md create mode 100644 .tasks/1ec7463f.md create mode 100644 .tasks/1f019827.md create mode 100644 .tasks/253b2ff3.md create mode 100644 .tasks/270b2936.md create mode 100644 .tasks/2addaa50.md create mode 100644 .tasks/341ed6c1.md create mode 100644 .tasks/3913ce12.md create mode 100644 .tasks/484edbb7.md create mode 100644 .tasks/5f407d28.md create mode 100644 .tasks/6f89a5fe.md create mode 100644 .tasks/7ddb69c7.md create mode 100644 .tasks/86e8a5ef.md create mode 100644 .tasks/8ee26288.md create mode 100644 .tasks/8f95bc29.md create mode 100644 .tasks/9328120f.md create mode 100644 .tasks/933d0b5b.md create mode 100644 .tasks/95cc65b4.md create mode 100644 .tasks/983d44bf.md create mode 100644 .tasks/9be15eb4.md create mode 100644 .tasks/ab05b25a.md create mode 100644 .tasks/ab0baaa5.md create mode 100644 .tasks/de2e028a.md create mode 100644 .tasks/e0b95c57.md create mode 100644 .tasks/e8ae632a.md create mode 100644 .tasks/ef288598.md create mode 100644 .tasks/f0fe2219.md create mode 100644 .tasks/f20a93b0.md create mode 100644 packages/server/src/utils/worktree.test.ts diff --git a/.tasks/0cf32223.md b/.tasks/0cf32223.md new file mode 100644 index 000000000..9c12633ef --- /dev/null +++ b/.tasks/0cf32223.md @@ -0,0 +1,8 @@ +--- +id: 0cf32223 +title: Auto-link URLs in rendered agent markdown +status: open +deps: [] +created: 2026-01-08T16:22:32.604Z +--- + diff --git a/.tasks/10019663.md b/.tasks/10019663.md new file mode 100644 index 000000000..7d367242d --- /dev/null +++ b/.tasks/10019663.md @@ -0,0 +1,8 @@ +--- +id: 10019663 +title: Replace all daemon logging with Pino + pino-pretty +status: open +deps: [] +created: 2026-01-08T16:19:19.748Z +--- + diff --git a/.tasks/16ccfef4.md b/.tasks/16ccfef4.md new file mode 100644 index 000000000..80b7c89f7 --- /dev/null +++ b/.tasks/16ccfef4.md @@ -0,0 +1,14 @@ +--- +id: 16ccfef4 +title: Fix bottom tool call sheet - horizontal scroll & remove status badge +status: open +deps: [] +created: 2026-01-08T16:29:01.677Z +--- + + +## Notes + +**2026-01-08T16:29:16.522Z** + +Two issues: 1) Horizontal scroll views (code blocks) not working properly. 2) Remove ugly status badge. diff --git a/.tasks/19f85855.md b/.tasks/19f85855.md new file mode 100644 index 000000000..548ab0272 --- /dev/null +++ b/.tasks/19f85855.md @@ -0,0 +1,14 @@ +--- +id: 19f85855 +title: Fix explorer sidebar not closing on left swipe - git diff block captures gesture +status: done +deps: [] +created: 2026-01-08T16:23:49.127Z +--- + + +## Notes + +**2026-01-08T16:23:54.581Z** + +Git diff block bounces instead of allowing sidebar close. It's capturing the swipe gesture unnecessarily. diff --git a/.tasks/1ec7463f.md b/.tasks/1ec7463f.md new file mode 100644 index 000000000..a05b9946f --- /dev/null +++ b/.tasks/1ec7463f.md @@ -0,0 +1,8 @@ +--- +id: 1ec7463f +title: Fix explorer sidebar not closing on right swipe - git diff block captures gesture +status: open +deps: [] +created: 2026-01-08T16:24:03.783Z +--- + diff --git a/.tasks/1f019827.md b/.tasks/1f019827.md new file mode 100644 index 000000000..4c9015c2f --- /dev/null +++ b/.tasks/1f019827.md @@ -0,0 +1,8 @@ +--- +id: 1f019827 +title: Daemon client supports all daemon actions & frontend uses it +status: open +deps: [933d0b5b, 3913ce12] +created: 2026-01-08T16:15:00.253Z +--- + diff --git a/.tasks/253b2ff3.md b/.tasks/253b2ff3.md new file mode 100644 index 000000000..87c978070 --- /dev/null +++ b/.tasks/253b2ff3.md @@ -0,0 +1,14 @@ +--- +id: 253b2ff3 +title: Clickable file paths in chat open bottom sheet for fast preview +status: open +deps: [] +created: 2026-01-08T16:22:16.892Z +--- + + +## Notes + +**2026-01-08T16:22:21.730Z** + +Improve file path detection in chat messages. On click, open bottom sheet with file preview instead of navigating away. diff --git a/.tasks/270b2936.md b/.tasks/270b2936.md new file mode 100644 index 000000000..f0019134d --- /dev/null +++ b/.tasks/270b2936.md @@ -0,0 +1,8 @@ +--- +id: 270b2936 +title: Backend: thinking effort selector with TDD +status: open +deps: [] +created: 2026-01-08T16:13:41.705Z +--- + diff --git a/.tasks/2addaa50.md b/.tasks/2addaa50.md new file mode 100644 index 000000000..78f947423 --- /dev/null +++ b/.tasks/2addaa50.md @@ -0,0 +1,8 @@ +--- +id: 2addaa50 +title: Implement notifications for agent completion and permission requests +status: open +deps: [] +created: 2026-01-08T16:30:14.182Z +--- + diff --git a/.tasks/341ed6c1.md b/.tasks/341ed6c1.md new file mode 100644 index 000000000..8e6cbc6da --- /dev/null +++ b/.tasks/341ed6c1.md @@ -0,0 +1,8 @@ +--- +id: 341ed6c1 +title: Bring back realtime voice support +status: open +deps: [] +created: 2026-01-08T16:34:14.986Z +--- + diff --git a/.tasks/3913ce12.md b/.tasks/3913ce12.md new file mode 100644 index 000000000..a1659e6eb --- /dev/null +++ b/.tasks/3913ce12.md @@ -0,0 +1,8 @@ +--- +id: 3913ce12 +title: Refactor frontend to use daemon client instead of manual WS code +status: open +deps: [933d0b5b] +created: 2026-01-08T16:15:13.577Z +--- + diff --git a/.tasks/484edbb7.md b/.tasks/484edbb7.md new file mode 100644 index 000000000..81ef2db27 --- /dev/null +++ b/.tasks/484edbb7.md @@ -0,0 +1,8 @@ +--- +id: 484edbb7 +title: Remove integrated sub-agent UI & move to optional MCP +status: open +deps: [ab05b25a, 7ddb69c7, ab0baaa5] +created: 2026-01-08T16:16:58.358Z +--- + diff --git a/.tasks/5f407d28.md b/.tasks/5f407d28.md new file mode 100644 index 000000000..92adedbff --- /dev/null +++ b/.tasks/5f407d28.md @@ -0,0 +1,8 @@ +--- +id: 5f407d28 +title: Remove old git diff and file browser screens - keep only file explorer +status: open +deps: [] +created: 2026-01-08T16:21:35.362Z +--- + diff --git a/.tasks/6f89a5fe.md b/.tasks/6f89a5fe.md new file mode 100644 index 000000000..b12577749 --- /dev/null +++ b/.tasks/6f89a5fe.md @@ -0,0 +1,8 @@ +--- +id: 6f89a5fe +title: Frontend: wire up model switcher UI +status: open +deps: [e0b95c57] +created: 2026-01-08T16:13:38.608Z +--- + diff --git a/.tasks/7ddb69c7.md b/.tasks/7ddb69c7.md new file mode 100644 index 000000000..d82e95444 --- /dev/null +++ b/.tasks/7ddb69c7.md @@ -0,0 +1,8 @@ +--- +id: 7ddb69c7 +title: Remove auto-injected MCPs (Playwright, etc) - make user-configured +status: open +deps: [] +created: 2026-01-08T16:17:08.670Z +--- + diff --git a/.tasks/86e8a5ef.md b/.tasks/86e8a5ef.md new file mode 100644 index 000000000..7e5023798 --- /dev/null +++ b/.tasks/86e8a5ef.md @@ -0,0 +1,8 @@ +--- +id: 86e8a5ef +title: Improve rendering of Claude plans and permission prompts +status: open +deps: [] +created: 2026-01-08T16:27:59.892Z +--- + diff --git a/.tasks/8ee26288.md b/.tasks/8ee26288.md new file mode 100644 index 000000000..08aaca529 --- /dev/null +++ b/.tasks/8ee26288.md @@ -0,0 +1,8 @@ +--- +id: 8ee26288 +title: Frontend: wire up thinking effort selector UI +status: open +deps: [270b2936] +created: 2026-01-08T16:13:43.356Z +--- + diff --git a/.tasks/8f95bc29.md b/.tasks/8f95bc29.md new file mode 100644 index 000000000..6d4aea5e8 --- /dev/null +++ b/.tasks/8f95bc29.md @@ -0,0 +1,8 @@ +--- +id: 8f95bc29 +title: Replace bottom sheet tool calls with inline expansion on desktop +status: open +deps: [] +created: 2026-01-08T16:25:09.874Z +--- + diff --git a/.tasks/9328120f.md b/.tasks/9328120f.md new file mode 100644 index 000000000..52b307120 --- /dev/null +++ b/.tasks/9328120f.md @@ -0,0 +1,14 @@ +--- +id: 9328120f +title: Fix desktop chat scroll - text captures scroll instead of container +status: open +deps: [] +created: 2026-01-08T16:26:52.180Z +--- + + +## Notes + +**2026-01-08T16:26:57.949Z** + +When hovering over text in agent chat and scrolling, the text element captures the scroll event instead of letting the chat container scroll. diff --git a/.tasks/933d0b5b.md b/.tasks/933d0b5b.md new file mode 100644 index 000000000..fc7933b45 --- /dev/null +++ b/.tasks/933d0b5b.md @@ -0,0 +1,8 @@ +--- +id: 933d0b5b +title: Extend daemon client to support all daemon actions +status: open +deps: [] +created: 2026-01-08T16:15:09.344Z +--- + diff --git a/.tasks/95cc65b4.md b/.tasks/95cc65b4.md new file mode 100644 index 000000000..d1bee3c3e --- /dev/null +++ b/.tasks/95cc65b4.md @@ -0,0 +1,8 @@ +--- +id: 95cc65b4 +title: Clean up settings page - remove unused settings +status: open +deps: [] +created: 2026-01-08T16:20:19.483Z +--- + diff --git a/.tasks/983d44bf.md b/.tasks/983d44bf.md new file mode 100644 index 000000000..3ff2a77e7 --- /dev/null +++ b/.tasks/983d44bf.md @@ -0,0 +1,8 @@ +--- +id: 983d44bf +title: Implement thinking effort selector +status: open +deps: [270b2936, 8ee26288] +created: 2026-01-08T16:13:25.916Z +--- + diff --git a/.tasks/9be15eb4.md b/.tasks/9be15eb4.md new file mode 100644 index 000000000..0f8aa9776 --- /dev/null +++ b/.tasks/9be15eb4.md @@ -0,0 +1,14 @@ +--- +id: 9be15eb4 +title: Replace LLM title generation with set_title MCP tool +status: open +deps: [] +created: 2026-01-08T16:18:40.494Z +--- + + +## Notes + +**2026-01-08T16:18:46.448Z** + +Remove custom LLM call for title generation. Inject a set_title MCP tool that agents can call themselves to update their title based on current work. diff --git a/.tasks/ab05b25a.md b/.tasks/ab05b25a.md new file mode 100644 index 000000000..8819006c6 --- /dev/null +++ b/.tasks/ab05b25a.md @@ -0,0 +1,8 @@ +--- +id: ab05b25a +title: Remove sub-agent UI from frontend +status: open +deps: [] +created: 2026-01-08T16:17:06.426Z +--- + diff --git a/.tasks/ab0baaa5.md b/.tasks/ab0baaa5.md new file mode 100644 index 000000000..f481b1c41 --- /dev/null +++ b/.tasks/ab0baaa5.md @@ -0,0 +1,8 @@ +--- +id: ab0baaa5 +title: Implement --experimental-mcp flag or separate CLI for agent control MCP +status: open +deps: [] +created: 2026-01-08T16:17:11.571Z +--- + diff --git a/.tasks/de2e028a.md b/.tasks/de2e028a.md new file mode 100644 index 000000000..33c57243e --- /dev/null +++ b/.tasks/de2e028a.md @@ -0,0 +1,14 @@ +--- +id: de2e028a +title: Fix agent chat scroll behavior during streaming +status: open +deps: [] +created: 2026-01-08T16:25:46.095Z +--- + + +## Notes + +**2026-01-08T16:25:52.346Z** + +Two issues: 1) Can't scroll while agent is streaming. 2) Scroll position isn't stable during streaming - jumps around. diff --git a/.tasks/e0b95c57.md b/.tasks/e0b95c57.md new file mode 100644 index 000000000..cd0e957de --- /dev/null +++ b/.tasks/e0b95c57.md @@ -0,0 +1,8 @@ +--- +id: e0b95c57 +title: Backend: model switcher with TDD +status: open +deps: [] +created: 2026-01-08T16:13:34.137Z +--- + diff --git a/.tasks/e8ae632a.md b/.tasks/e8ae632a.md new file mode 100644 index 000000000..e11897184 --- /dev/null +++ b/.tasks/e8ae632a.md @@ -0,0 +1,14 @@ +--- +id: e8ae632a +title: Allow selecting all text in assistant message across elements +status: open +deps: [] +created: 2026-01-08T16:29:44.186Z +--- + + +## Notes + +**2026-01-08T16:29:50.182Z** + +Currently can only select text within individual elements. Cannot select across paragraphs, lists, or other markdown elements. diff --git a/.tasks/ef288598.md b/.tasks/ef288598.md new file mode 100644 index 000000000..2266371d6 --- /dev/null +++ b/.tasks/ef288598.md @@ -0,0 +1,8 @@ +--- +id: ef288598 +title: Implement model switcher for agent screen +status: open +deps: [e0b95c57, 6f89a5fe] +created: 2026-01-08T16:13:24.234Z +--- + diff --git a/.tasks/f0fe2219.md b/.tasks/f0fe2219.md new file mode 100644 index 000000000..0cb1bf2f1 --- /dev/null +++ b/.tasks/f0fe2219.md @@ -0,0 +1,8 @@ +--- +id: f0fe2219 +title: Reorganize agent input area buttons - send, dictate, realtime toggle +status: open +deps: [] +created: 2026-01-08T16:34:56.837Z +--- + diff --git a/.tasks/f20a93b0.md b/.tasks/f20a93b0.md new file mode 100644 index 000000000..d60212071 --- /dev/null +++ b/.tasks/f20a93b0.md @@ -0,0 +1,8 @@ +--- +id: f20a93b0 +title: Add agent detail bottom sheet (agent ID, provider session ID, etc) +status: open +deps: [] +created: 2026-01-08T16:21:00.578Z +--- + diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 500458435..eb71b1cde 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -158,18 +158,12 @@ export default function HomeScreen() { : undefined; const [openDropdown, setOpenDropdown] = useState< - "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent" | null + "host" | "provider" | "mode" | "model" | "agent" | null >(null); const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); const [promptText, setPromptText] = useState(""); - const [baseBranch, setBaseBranch] = useState(""); - const [isolationMode, setIsolationMode] = useState<"none" | "branch" | "worktree">("none"); - const [branchName, setBranchName] = useState(""); - const [worktreeSlug, setWorktreeSlug] = useState(""); - const [branchNameEdited, setBranchNameEdited] = useState(false); - const [worktreeSlugEdited, setWorktreeSlugEdited] = useState(false); - const shouldSyncBaseBranchRef = useRef(true); + const [useWorktree, setUseWorktree] = useState(false); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const handleFilesDropped = useCallback((files: ImageAttachment[]) => { @@ -179,11 +173,8 @@ export default function HomeScreen() { const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { addImagesRef.current = addImages; }, []); - - const createNewBranch = isolationMode === "branch" || isolationMode === "worktree"; - const createWorktree = isolationMode === "worktree"; const openDropdownSheet = useCallback( - (key: "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent") => { + (key: "host" | "provider" | "mode" | "model" | "agent") => { setOpenDropdown(key); }, [] @@ -350,59 +341,26 @@ export default function HomeScreen() { ); const gitBlockingError = useMemo(() => { - if (isNonGitDirectory) { + if (!useWorktree || isNonGitDirectory) { return null; } - const trimmedBase = baseBranch.trim(); - const currentBranch = repoInfo?.currentBranch ?? ""; - const isCustomBase = - trimmedBase.length > 0 && - (currentBranch.length === 0 || trimmedBase !== currentBranch); - const requiresBase = createNewBranch || createWorktree || isCustomBase; - - if (requiresBase && !trimmedBase) { - return "Select a base branch before launching the agent"; + const slug = slugifyWorktreeName(promptText); + if (!slug) { + return null; } - - if (createNewBranch) { - const slug = branchName.trim(); - const validation = validateWorktreeName(slug); - if (!slug || !validation.valid) { - return `Invalid branch name: ${ - validation.error ?? "Must use lowercase letters, numbers, hyphens, or forward slashes" - }`; - } + const validation = validateWorktreeName(slug); + if (!validation.valid) { + return `Invalid worktree name: ${ + validation.error ?? "Must use lowercase letters, numbers, or hyphens" + }`; } - - if (createWorktree) { - const slug = (worktreeSlug || branchName).trim(); - const validation = validateWorktreeName(slug); - if (!slug || !validation.valid) { - return `Invalid worktree name: ${ - validation.error ?? "Must use lowercase letters, numbers, or hyphens" - }`; - } - } - - if (!createWorktree && repoInfo?.isDirty) { - const intendsCheckout = - createNewBranch || - (trimmedBase.length > 0 && trimmedBase !== repoInfo.currentBranch); - if (intendsCheckout) { - return "Working directory has uncommitted changes. Clean up or create a worktree first."; - } - } - return null; }, [ - baseBranch, - branchName, - createNewBranch, - createWorktree, + useWorktree, isNonGitDirectory, - repoInfo, + promptText, + slugifyWorktreeName, validateWorktreeName, - worktreeSlug, ]); useEffect(() => { @@ -429,48 +387,10 @@ export default function HomeScreen() { ]); useEffect(() => { - if (!repoInfo) { - return; + if (isNonGitDirectory && useWorktree) { + setUseWorktree(false); } - setBaseBranch((prev) => { - if (shouldSyncBaseBranchRef.current || prev.trim().length === 0) { - shouldSyncBaseBranchRef.current = false; - return repoInfo.currentBranch ?? ""; - } - return prev; - }); - }, [repoInfo]); - - useEffect(() => { - if (!isNonGitDirectory) { - return; - } - if ( - isolationMode !== "none" || - baseBranch.trim().length > 0 || - branchName.trim().length > 0 || - worktreeSlug.trim().length > 0 - ) { - setIsolationMode("none"); - setBaseBranch(""); - setBranchName(""); - setWorktreeSlug(""); - setBranchNameEdited(false); - setWorktreeSlugEdited(false); - shouldSyncBaseBranchRef.current = true; - } - }, [ - baseBranch, - branchName, - isolationMode, - isNonGitDirectory, - worktreeSlug, - ]); - - const handleBaseBranchChange = useCallback((value: string) => { - setBaseBranch(value); - shouldSyncBaseBranchRef.current = false; - }, []); + }, [isNonGitDirectory, useWorktree]); const renderConfigRow = useCallback( ({ label, value, meta, onPress, disabled }: ConfigRowProps) => ( @@ -491,29 +411,6 @@ export default function HomeScreen() { ), [theme.colors.mutedForeground] ); - const renderDropdownTrigger = useCallback( - ({ - label, - value, - placeholder, - onPress, - disabled, - }: { - label: string; - value: string; - placeholder: string; - onPress: () => void; - disabled?: boolean; - }) => - renderConfigRow({ - label, - value: value || placeholder, - onPress, - disabled, - }), - [renderConfigRow] - ); - const pendingRequestIdRef = useRef(null); const sessionMethods = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.methods : undefined @@ -567,26 +464,13 @@ export default function HomeScreen() { ...(modeId ? { modeId } : {}), ...(trimmedModel ? { model: trimmedModel } : {}), }; - const trimmedBaseBranch = baseBranch.trim(); - const shouldIncludeBase = - trimmedBaseBranch.length > 0 || - createNewBranch || - createWorktree; - const gitOptions = - shouldIncludeBase && !isNonGitDirectory - ? { - ...(trimmedBaseBranch ? { baseBranch: trimmedBaseBranch } : {}), - ...(createNewBranch - ? { createNewBranch: true, newBranchName: branchName.trim() } - : {}), - ...(createWorktree - ? { - createWorktree: true, - worktreeSlug: (worktreeSlug || branchName).trim(), - } - : {}), - } - : undefined; + const worktreeSlug = slugifyWorktreeName(trimmedPrompt); + const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug + ? { + createWorktree: true, + worktreeSlug, + } + : undefined; void persistFormPreferences(); @@ -602,10 +486,8 @@ export default function HomeScreen() { }); }, [ - baseBranch, - branchName, - createNewBranch, - createWorktree, + useWorktree, + slugifyWorktreeName, gitBlockingError, isDirectoryNotExists, isLoading, @@ -618,7 +500,6 @@ export default function HomeScreen() { selectedServerId, sessionMethods, workingDir, - worktreeSlug, ] ); @@ -656,7 +537,6 @@ export default function HomeScreen() { } pendingRequestIdRef.current = null; setIsLoading(false); - setPromptText(""); router.replace({ pathname: "/agent/[serverId]/[agentId]", params: { @@ -709,15 +589,9 @@ export default function HomeScreen() { openDropdownSheet("workingDir")} - onClose={closeDropdown} disabled={false} suggestedPaths={agentWorkingDirSuggestions} onSelectPath={setWorkingDirFromUser} - label="Working Directory" - wrapInContainer={false} - renderTrigger={renderDropdownTrigger} /> {isDirectoryNotExists && ( @@ -733,54 +607,13 @@ export default function HomeScreen() { })} {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( { - setIsolationMode(mode); - if (mode === "none") { - setBranchName(""); - setWorktreeSlug(""); - setBranchNameEdited(false); - setWorktreeSlugEdited(false); - } else if (mode === "branch") { - if (!branchNameEdited) { - const slug = slugifyWorktreeName(baseBranch || ""); - setBranchName(slug); - } - } else if (mode === "worktree") { - if (!worktreeSlugEdited) { - const slug = slugifyWorktreeName( - branchName || baseBranch || "" - ); - setWorktreeSlug(slug); - } - } - }} - branchName={branchName} - onBranchNameChange={(value) => { - setBranchName(slugifyWorktreeName(value)); - setBranchNameEdited(true); - }} - worktreeSlug={worktreeSlug} - onWorktreeSlugChange={(value) => { - setWorktreeSlug(slugifyWorktreeName(value)); - setWorktreeSlugEdited(true); - }} gitValidationError={gitBlockingError} - isBaseDropdownOpen={openDropdown === "baseBranch"} - onToggleBaseDropdown={() => openDropdownSheet("baseBranch")} - onCloseDropdown={closeDropdown} /> ) : null} 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 1cdcbeefa..6209fdc72 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -8,9 +8,10 @@ import { Modal, TextInput, ActivityIndicator, + useWindowDimensions, } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { ChevronDown } from "lucide-react-native"; +import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; import { theme as defaultTheme } from "@/styles/theme"; import type { AgentMode, @@ -97,6 +98,57 @@ export function DropdownField({ ); } +interface SelectFieldProps { + label: string; + value: string; + placeholder?: string; + onPress: () => void; + disabled?: boolean; + errorMessage?: string | null; + warningMessage?: string | null; + helperText?: string | null; + controlRef?: React.RefObject; +} + +export function SelectField({ + label, + value, + placeholder, + onPress, + disabled, + errorMessage, + warningMessage, + helperText, + controlRef, +}: SelectFieldProps): ReactElement { + return ( + + + + {label} + + {value || placeholder || "Select..."} + + + + + {errorMessage ? {errorMessage} : null} + {warningMessage ? {warningMessage} : null} + {!errorMessage && !warningMessage && helperText ? ( + {helperText} + ) : null} + + ); +} + interface DropdownSheetProps { title: string; visible: boolean; @@ -135,49 +187,159 @@ export function DropdownSheet({ ); } +interface AdaptiveSelectProps { + title: string; + visible: boolean; + onClose: () => void; + children: ReactNode; + anchorRef: React.RefObject; +} + +export function AdaptiveSelect({ + title, + visible, + onClose, + children, + anchorRef, +}: AdaptiveSelectProps): ReactElement { + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const [dropdownPosition, setDropdownPosition] = useState({ + top: 0, + left: 0, + width: 0, + }); + + useEffect(() => { + if (!visible || isMobile) { + return; + } + + const anchor = anchorRef.current; + if (!anchor) { + return; + } + + anchor.measureInWindow((x, y, width, height) => { + const verticalOffset = 4; + const horizontalMargin = 16; + const maxDropdownHeight = 400; + + let top = y + height + verticalOffset; + const left = Math.max(horizontalMargin, Math.min(x, windowWidth - width - horizontalMargin)); + + if (top + maxDropdownHeight > windowHeight - horizontalMargin) { + top = y - maxDropdownHeight - verticalOffset; + if (top < horizontalMargin) { + top = y + height + verticalOffset; + } + } + + setDropdownPosition({ top, left, width }); + }); + }, [visible, isMobile, anchorRef, windowWidth, windowHeight]); + + if (isMobile) { + return ( + + + + + + {title} + + {children} + + + + + ); + } + + return ( + + + + + + {children} + + + + + ); +} + interface AssistantDropdownProps { providerDefinitions: AgentProviderDefinition[]; selectedProvider: AgentProvider; disabled: boolean; - isOpen: boolean; - onOpen: () => void; - onClose: () => void; onSelect: (provider: AgentProvider) => void; - label?: string; - placeholder?: string; - sheetTitle?: string; - renderTrigger?: DropdownTriggerRenderer; - wrapInContainer?: boolean; } export function AssistantDropdown({ providerDefinitions, selectedProvider, disabled, - isOpen, - onOpen, - onClose, onSelect, - label = "Assistant", - placeholder = "Select assistant", - sheetTitle = "Choose Assistant", - renderTrigger, - wrapInContainer = true, }: AssistantDropdownProps): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const selectedDefinition = providerDefinitions.find( (definition) => definition.id === selectedProvider ); - const field = ( + + const handleOpen = useCallback(() => setIsOpen(true), []); + const handleClose = useCallback(() => setIsOpen(false), []); + + return ( <> - - + {providerDefinitions.map((definition) => { const isSelected = definition.id === selectedProvider; return ( @@ -189,7 +351,7 @@ export function AssistantDropdown({ ]} onPress={() => { onSelect(definition.id); - onClose(); + handleClose(); }} > {definition.label} @@ -201,72 +363,63 @@ export function AssistantDropdown({ ); })} - + ); - - if (!wrapInContainer) { - return <>{field}; - } - - return {field}; } interface PermissionsDropdownProps { modeOptions: AgentMode[]; selectedMode: string; disabled: boolean; - isOpen: boolean; - onOpen: () => void; - onClose: () => void; onSelect: (modeId: string) => void; - label?: string; - placeholder?: string; - sheetTitle?: string; - renderTrigger?: DropdownTriggerRenderer; - wrapInContainer?: boolean; } export function PermissionsDropdown({ modeOptions, selectedMode, disabled, - isOpen, - onOpen, - onClose, onSelect, - label = "Permissions", - placeholder, - sheetTitle = "Permissions", - renderTrigger, - wrapInContainer = true, }: PermissionsDropdownProps): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const hasOptions = modeOptions.length > 0; const selectedModeLabel = hasOptions ? modeOptions.find((mode) => mode.id === selectedMode)?.label ?? modeOptions[0]?.label ?? "Default" : "Automatic"; - const placeholderLabel = hasOptions - ? placeholder ?? "Select permissions" - : "Automatic"; - const field = ( + + const handleOpen = useCallback(() => { + if (hasOptions) { + setIsOpen(true); + } + }, [hasOptions]); + const handleClose = useCallback(() => setIsOpen(false), []); + + return ( <> - {}} + placeholder={hasOptions ? "Select permissions" : "Automatic"} + onPress={handleOpen} disabled={disabled || !hasOptions} helperText={ hasOptions ? undefined : "This assistant does not expose selectable permissions." } - renderTrigger={renderTrigger} + controlRef={anchorRef} /> {hasOptions ? ( - + {modeOptions.map((mode) => { const isSelected = mode.id === selectedMode; return ( @@ -278,7 +431,7 @@ export function PermissionsDropdown({ ]} onPress={() => { onSelect(mode.id); - onClose(); + handleClose(); }} > {mode.label} @@ -290,16 +443,10 @@ export function PermissionsDropdown({ ); })} - + ) : null} ); - - if (!wrapInContainer) { - return <>{field}; - } - - return {field}; } interface ModelDropdownProps { @@ -307,15 +454,9 @@ interface ModelDropdownProps { selectedModel: string; isLoading: boolean; error: string | null; - isOpen: boolean; - onOpen: () => void; - onClose: () => void; onSelect: (modelId: string) => void; onClear: () => void; onRefresh: () => void; - label?: string; - renderTrigger?: DropdownTriggerRenderer; - wrapInContainer?: boolean; } export function ModelDropdown({ @@ -323,16 +464,13 @@ export function ModelDropdown({ selectedModel, isLoading, error, - isOpen, - onOpen, - onClose, onSelect, onClear, onRefresh, - label = "Model", - renderTrigger, - wrapInContainer = true, }: ModelDropdownProps): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const selectedLabel = selectedModel ? models.find((model) => model.id === selectedModel)?.label ?? selectedModel : "Automatic"; @@ -345,24 +483,27 @@ export function ModelDropdown({ ? "This assistant did not expose selectable models." : undefined; - const field = ( + const handleOpen = useCallback(() => setIsOpen(true), []); + const handleClose = useCallback(() => setIsOpen(false), []); + + return ( <> - - + { onClear(); - onClose(); + handleClose(); }} > @@ -383,7 +524,7 @@ export function ModelDropdown({ ]} onPress={() => { onSelect(model.id); - onClose(); + handleClose(); }} > {model.label} @@ -411,47 +552,34 @@ export function ModelDropdown({ ) : null} - + ); - - if (!wrapInContainer) { - return <>{field}; - } - - return {field}; } interface WorkingDirectoryDropdownProps { workingDir: string; errorMessage: string; - isOpen: boolean; - onOpen: () => void; - onClose: () => void; disabled: boolean; suggestedPaths: string[]; onSelectPath: (value: string) => void; - label?: string; - renderTrigger?: DropdownTriggerRenderer; - wrapInContainer?: boolean; } export function WorkingDirectoryDropdown({ workingDir, errorMessage, - isOpen, - onOpen, - onClose, disabled, suggestedPaths, onSelectPath, - label = "Working Directory", - renderTrigger, - wrapInContainer = true, }: WorkingDirectoryDropdownProps): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); const inputRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); + const handleOpen = useCallback(() => setIsOpen(true), []); + const handleClose = useCallback(() => setIsOpen(false), []); + useEffect(() => { if (isOpen) { setSearchQuery(""); @@ -477,18 +605,18 @@ export function WorkingDirectoryDropdown({ const handleSelect = useCallback( (path: string) => { onSelectPath(path); - onClose(); + handleClose(); }, - [onClose, onSelectPath] + [handleClose, onSelectPath] ); - const field = ( + return ( <> - - + ) : null} - + ); - - if (!wrapInContainer) { - return <>{field}; - } - - return {field}; } interface ToggleRowProps { @@ -609,217 +736,53 @@ export function ToggleRow({ ); } -export type IsolationMode = "none" | "branch" | "worktree"; - -interface IsolationControlProps { - value: IsolationMode; - onChange: (mode: IsolationMode) => void; - disabled?: boolean; -} - -export function IsolationControl({ - value, - onChange, - disabled, -}: IsolationControlProps): ReactElement { - const options: Array<{ id: IsolationMode; label: string }> = [ - { id: "none", label: "None" }, - { id: "branch", label: "Branch" }, - { id: "worktree", label: "Worktree" }, - ]; - - return ( - - {options.map((option) => { - const isSelected = option.id === value; - return ( - { - if (!disabled) { - onChange(option.id); - } - }} - style={[ - styles.segmentedOption, - isSelected && styles.segmentedOptionSelected, - ]} - > - - {option.label} - - - ); - })} - - ); -} - export interface GitOptionsSectionProps { - baseBranch: string; - onBaseBranchChange: (value: string) => void; - branches: Array<{ name: string; isCurrent: boolean }>; + useWorktree: boolean; + onUseWorktreeChange: (value: boolean) => void; + worktreeSlug: string; + currentBranch: string | null; status: "idle" | "loading" | "ready" | "error"; repoError: string | null; - helperText?: string | null; - warning: string | null; - isolationMode: IsolationMode; - onIsolationModeChange: (mode: IsolationMode) => void; - branchName: string; - onBranchNameChange: (value: string) => void; - worktreeSlug: string; - onWorktreeSlugChange: (value: string) => void; gitValidationError: string | null; - isGitDisabled?: boolean; - isBaseDropdownOpen: boolean; - onToggleBaseDropdown: () => void; - onCloseDropdown: () => void; } export function GitOptionsSection({ - baseBranch, - onBaseBranchChange, - branches, + useWorktree, + onUseWorktreeChange, + worktreeSlug, + currentBranch, status, repoError, - helperText, - warning, - isolationMode, - onIsolationModeChange, - branchName, - onBranchNameChange, - worktreeSlug, - onWorktreeSlugChange, gitValidationError, - isGitDisabled, - isBaseDropdownOpen, - onToggleBaseDropdown, - onCloseDropdown, }: GitOptionsSectionProps): ReactElement { - const [branchSearch, setBranchSearch] = useState(""); - const branchFilter = branchSearch.trim().toLowerCase(); - const filteredBranches = - branchFilter.length === 0 - ? branches - : branches.filter((branch) => - branch.name.toLowerCase().includes(branchFilter) - ); - const maxVisible = 30; - const currentBranchLabel = - branches.find((branch) => branch.isCurrent)?.name ?? ""; - const baseInputRef = useRef(null); - const gitInputsDisabled = Boolean(isGitDisabled) || status === "loading"; - - // Show warning only for "branch" mode (requires clean state for checkout) - // Worktree mode doesn't require clean state - const showDirtyWarning = isolationMode === "branch" && warning; - - useEffect(() => { - if (isBaseDropdownOpen) { - setBranchSearch(""); - baseInputRef.current?.focus(); - } - }, [isBaseDropdownOpen]); + const isLoading = status === "loading"; return ( - - - + onUseWorktreeChange(!useWorktree)} + disabled={isLoading} + style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]} > - - {status === "loading" ? ( - - - Inspecting repository… - - ) : filteredBranches.length === 0 ? ( - - {branchFilter.length === 0 - ? "No branches detected yet." - : "No branches match your search."} + + {useWorktree ? : null} + + + Create worktree + + {isLoading + ? "Inspecting repository…" + : useWorktree && worktreeSlug + ? `Will create: ${worktreeSlug}` + : currentBranch + ? `Run isolated from ${currentBranch}` + : "Run in an isolated directory"} - ) : ( - - {filteredBranches.slice(0, maxVisible).map((branch) => { - const isActive = branch.name === baseBranch; - return ( - { - onBaseBranchChange(branch.name); - onCloseDropdown(); - }} - > - - {branch.name} - {branch.isCurrent ? " (current)" : ""} - - - ); - })} - - )} - {filteredBranches.length > maxVisible ? ( - - Showing first {maxVisible} matches. Keep typing to narrow it down. - - ) : null} - + + - - Isolation - - - - {isolationMode !== "none" ? ( - + {repoError ? ( + {repoError} ) : null} {gitValidationError ? ( @@ -1016,32 +979,97 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - segmentedControl: { - flexDirection: "row", - backgroundColor: theme.colors.muted, - borderRadius: theme.borderRadius.lg, - padding: theme.spacing[1], + selectFieldContainer: { + gap: theme.spacing[2], }, - segmentedControlDisabled: { + selectFieldControl: { + flexDirection: "row", + alignItems: "center", + backgroundColor: theme.colors.background, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + }, + selectFieldControlDisabled: { opacity: theme.opacity[50], }, - segmentedOption: { + selectFieldContent: { flex: 1, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", + gap: theme.spacing[1], }, - segmentedOptionSelected: { - backgroundColor: theme.colors.background, - }, - segmentedOptionText: { + selectFieldLabel: { color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + selectFieldValue: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + selectFieldPlaceholder: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.base, + }, + gitOptionsContainer: { + gap: theme.spacing[3], + }, + worktreeToggle: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + backgroundColor: theme.colors.background, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + }, + worktreeToggleDisabled: { + opacity: theme.opacity[50], + }, + worktreeToggleContent: { + flex: 1, + gap: theme.spacing[1], + }, + worktreeToggleLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, - segmentedOptionTextSelected: { - color: theme.colors.foreground, + worktreeToggleDescription: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + }, + desktopDropdownOverlay: { + flex: 1, + }, + desktopDropdownBackdrop: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + desktopDropdownContainer: { + backgroundColor: theme.colors.popover, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + maxHeight: 400, + }, + desktopDropdownScroll: { + maxHeight: 400, + }, + desktopDropdownScrollContent: { + padding: theme.spacing[2], }, })); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 4a9c75c04..038fd14bc 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -222,17 +222,19 @@ export function AgentInputArea({ } const isControlled = value !== undefined; - if (!isControlled) { - setUserInput(""); - } setSelectedImages([]); setIsProcessing(true); try { await submitMessage(trimmedMessage, imageAttachments); + // Clear input only after successful submission + if (isControlled) { + onChangeText?.(""); + } else { + setUserInput(""); + } } catch (error) { console.error("[AgentInput] Failed to send message:", error); - setUserInput(trimmedMessage); if (imageAttachments) { setSelectedImages(imageAttachments); } diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 539692522..1f01dd5b8 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -156,7 +156,6 @@ export const MessageInput = forwardRef( if (shouldAutoSend) { const imageAttachments = images.length > 0 ? images : undefined; onSubmit({ text: nextValue, images: imageAttachments }); - onChangeText(""); } else { onChangeText(nextValue); } @@ -295,10 +294,10 @@ export const MessageInput = forwardRef( }; if (isAgentRunning && onQueue) { onQueue(payload); + onChangeText(""); } else { onSubmit(payload); } - onChangeText(""); // Reset input height inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); @@ -420,7 +419,6 @@ export const MessageInput = forwardRef( forceSend: true, }; onSubmit(payload); - onChangeText(""); inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); return; diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts new file mode 100644 index 000000000..4750bb68a --- /dev/null +++ b/packages/server/src/utils/worktree.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createWorktree } from "./worktree"; +import { execSync } from "child_process"; +import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +describe("createWorktree", () => { + let tempDir: string; + let repoDir: string; + + beforeEach(() => { + // Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS) + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-test-"))); + repoDir = join(tempDir, "test-repo"); + + // Create a git repo with an initial commit + execSync(`mkdir -p ${repoDir}`); + execSync("git init", { cwd: repoDir }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir }); + execSync("git config user.name 'Test'", { cwd: repoDir }); + execSync("echo 'hello' > file.txt", { cwd: repoDir }); + execSync("git add .", { cwd: repoDir }); + execSync("git commit -m 'initial'", { cwd: repoDir }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("creates a worktree for the current branch (main)", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "hello-world", + }); + + expect(result.worktreePath).toBe(join(tempDir, "test-repo-hello-world")); + expect(existsSync(result.worktreePath)).toBe(true); + expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); + }); + + it("creates a worktree with a new branch", async () => { + const result = await createWorktree({ + branchName: "feature-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "my-feature", + }); + + expect(result.worktreePath).toBe(join(tempDir, "test-repo-my-feature")); + expect(existsSync(result.worktreePath)).toBe(true); + + // Verify branch was created + const branches = execSync("git branch", { cwd: repoDir }).toString(); + expect(branches).toContain("feature-branch"); + }); + + it("fails with invalid branch name", async () => { + await expect( + createWorktree({ + branchName: "INVALID_UPPERCASE", + cwd: repoDir, + worktreeSlug: "test", + }) + ).rejects.toThrow("Invalid branch name"); + }); + + it("handles branch name collision by adding suffix", async () => { + // Create a branch named "hello" first + execSync("git branch hello", { cwd: repoDir }); + + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "hello", + }); + + // Should create branch "hello-1" since "hello" exists + expect(result.worktreePath).toBe(join(tempDir, "test-repo-hello")); + expect(existsSync(result.worktreePath)).toBe(true); + + const branches = execSync("git branch", { cwd: repoDir }).toString(); + expect(branches).toContain("hello-1"); + }); + + it("handles multiple collisions", async () => { + // Create branches "hello" and "hello-1" + execSync("git branch hello", { cwd: repoDir }); + execSync("git branch hello-1", { cwd: repoDir }); + + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "hello", + }); + + expect(existsSync(result.worktreePath)).toBe(true); + + const branches = execSync("git branch", { cwd: repoDir }).toString(); + expect(branches).toContain("hello-2"); + }); + + it("runs setup commands from paseo.json", async () => { + // Create paseo.json with setup commands + const paseoConfig = { + worktree: { + setup: [ + 'echo "root=$PASEO_ROOT_PATH" > setup.log', + 'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log', + 'echo "branch=$PASEO_BRANCH_NAME" >> setup.log', + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync("git add paseo.json && git commit -m 'add paseo.json'", { cwd: repoDir }); + + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "setup-test", + }); + + expect(existsSync(result.worktreePath)).toBe(true); + + // Verify setup ran and env vars were available + const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8"); + expect(setupLog).toContain(`root=${repoDir}`); + expect(setupLog).toContain(`worktree=${result.worktreePath}`); + expect(setupLog).toContain("branch=setup-test"); + }); + + it("cleans up worktree if setup command fails", async () => { + // Create paseo.json with failing setup command + const paseoConfig = { + worktree: { + setup: ["exit 1"], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync("git add paseo.json && git commit -m 'add paseo.json'", { cwd: repoDir }); + + const expectedWorktreePath = join(tempDir, "test-repo-fail-test"); + + await expect( + createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "fail-test", + }) + ).rejects.toThrow("Worktree setup command failed"); + + // Verify worktree was cleaned up + expect(existsSync(expectedWorktreePath)).toBe(false); + }); +}); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 7a2699e18..13c2cdbba 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,8 +1,14 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { existsSync, readFileSync } from "fs"; +import { existsSync, readFileSync, rmSync } from "fs"; import { join, basename, dirname } from "path"; +interface PaseoConfig { + worktree?: { + setup?: string[]; + }; +} + const execAsync = promisify(exec); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, @@ -165,37 +171,6 @@ function sanitizeWorktreeSlug(input: string): string { return slug.length > 0 ? slug : "worktree"; } -/** - * Check if a worktree already exists for a branch - */ -async function findExistingWorktree( - branchName: string, - repoPath: string -): Promise { - try { - const { stdout } = await execAsync("git worktree list --porcelain", { - cwd: repoPath, - }); - const lines = stdout.split("\n"); - - let currentWorktree: string | null = null; - - for (const line of lines) { - if (line.startsWith("worktree ")) { - currentWorktree = line.substring("worktree ".length); - } else if ( - line === `branch refs/heads/${branchName}` && - currentWorktree - ) { - return currentWorktree; - } - } - - return null; - } catch (error) { - return null; - } -} /** * Create a git worktree with proper naming conventions @@ -228,52 +203,98 @@ export async function createWorktree({ } // Check if branch already exists + let branchExists = false; try { await execAsync( `git show-ref --verify --quiet refs/heads/${branchName}`, { cwd: repoInfo.path } ); - - // Branch exists, check for existing worktree - const existingWorktree = await findExistingWorktree( - branchName, - repoInfo.path - ); - - if (existingWorktree) { - if (existsSync(existingWorktree)) { - throw new Error( - `Worktree already exists at: ${existingWorktree}. Use 'git worktree remove ${existingWorktree}' to remove it first.` - ); - } else { - // Prune stale worktree reference - await execAsync("git worktree prune", { cwd: repoInfo.path }); - } - } - - // Create worktree using existing branch - await execAsync(`git worktree add "${worktreePath}" "${branchName}"`, { - cwd: repoInfo.path, - }); - } catch (error) { - // Branch doesn't exist, create new branch and worktree - const baseArg = baseBranch ? ` "${baseBranch}"` : ""; - const command = `git worktree add "${worktreePath}" -b "${branchName}"${baseArg}`; - await execAsync(command, { cwd: repoInfo.path }); + branchExists = true; + } catch { + branchExists = false; } - // Copy .env file if it exists - const envSource = - repoInfo.type === "bare" - ? join(repoInfo.path, "main", ".env") - : join(repoInfo.path, ".env"); + // Always create a new branch for the worktree + // If branchName already exists, use it as base and create worktree-slug as branch name + // If branchName doesn't exist, create it from baseBranch + const base = branchExists ? branchName : (baseBranch ?? "HEAD"); + const candidateBranch = branchExists ? desiredSlug : branchName; - if (existsSync(envSource)) { - await execAsync(`cp "${envSource}" "${worktreePath}/.env"`); + // Find unique branch name if collision + let newBranchName = candidateBranch; + let suffix = 1; + while (true) { + try { + await execAsync( + `git show-ref --verify --quiet refs/heads/${newBranchName}`, + { cwd: repoInfo.path } + ); + // Branch exists, try with suffix + newBranchName = `${candidateBranch}-${suffix}`; + suffix++; + } catch { + break; + } + } + + // Also handle worktree path collision + let finalWorktreePath = worktreePath; + let pathSuffix = 1; + while (existsSync(finalWorktreePath)) { + finalWorktreePath = `${worktreePath}-${pathSuffix}`; + pathSuffix++; + } + + const command = `git worktree add "${finalWorktreePath}" -b "${newBranchName}" "${base}"`; + await execAsync(command, { cwd: repoInfo.path }); + worktreePath = finalWorktreePath; + + // Run setup commands from paseo.json if present + const paseoConfigPath = join(repoInfo.path, "paseo.json"); + if (existsSync(paseoConfigPath)) { + let config: PaseoConfig; + try { + config = JSON.parse(readFileSync(paseoConfigPath, "utf8")); + } catch { + throw new Error(`Failed to parse paseo.json`); + } + + const setupCommands = config.worktree?.setup; + if (setupCommands && setupCommands.length > 0) { + const setupEnv = { + ...process.env, + PASEO_ROOT_PATH: repoInfo.path, + PASEO_WORKTREE_PATH: worktreePath, + PASEO_BRANCH_NAME: newBranchName, + }; + + for (const cmd of setupCommands) { + try { + await execAsync(cmd, { + cwd: worktreePath, + env: setupEnv, + shell: "/bin/bash", + }); + } catch (error) { + // Cleanup worktree on setup failure + try { + await execAsync(`git worktree remove "${worktreePath}" --force`, { + cwd: repoInfo.path, + }); + } catch { + // If git worktree remove fails, try rmSync + rmSync(worktreePath, { recursive: true, force: true }); + } + throw new Error( + `Worktree setup command failed: ${cmd}\n${error instanceof Error ? error.message : String(error)}` + ); + } + } + } } return { - branchName, + branchName: newBranchName, worktreePath, repoType: repoInfo.type, repoPath: repoInfo.path,