From 255e555afbc9d617d6eca102405529b160e34aa0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 8 Dec 2025 17:57:55 -0800 Subject: [PATCH 1/3] Implement branch picker that allows you to create or pick the branch that is used during task creation Supports both worktree and direct/branch mode --- apps/array/src/main/preload.ts | 6 + apps/array/src/main/services/git.ts | 61 ++++ .../services/workspace/workspaceService.ts | 26 +- .../folder-picker/components/FolderPicker.tsx | 17 +- .../task-detail/components/BranchSelect.tsx | 263 ++++++++++++++++++ .../task-detail/components/TaskInput.tsx | 41 ++- .../task-detail/hooks/useTaskCreation.ts | 11 +- .../workspace/stores/workspaceStore.ts | 6 +- apps/array/src/renderer/types/electron.d.ts | 3 + apps/array/src/shared/types.ts | 1 + packages/agent/src/worktree-manager.ts | 6 +- 11 files changed, 421 insertions(+), 20 deletions(-) create mode 100644 apps/array/src/renderer/features/task-detail/components/BranchSelect.tsx diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index d9abff99..6aaabe0f 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -282,6 +282,12 @@ contextBridge.exposeInMainWorld("electronAPI", { }> => ipcRenderer.invoke("get-diff-stats", repoPath), getCurrentBranch: (repoPath: string): Promise => ipcRenderer.invoke("get-current-branch", repoPath), + getDefaultBranch: (repoPath: string): Promise => + ipcRenderer.invoke("get-default-branch", repoPath), + getAllBranches: (repoPath: string): Promise => + ipcRenderer.invoke("get-all-branches", repoPath), + createBranch: (repoPath: string, branchName: string): Promise => + ipcRenderer.invoke("create-branch", repoPath, branchName), discardFileChanges: ( repoPath: string, filePath: string, diff --git a/apps/array/src/main/services/git.ts b/apps/array/src/main/services/git.ts index 22dc5fb5..5c05c441 100644 --- a/apps/array/src/main/services/git.ts +++ b/apps/array/src/main/services/git.ts @@ -153,6 +153,36 @@ export const getDefaultBranch = async ( } }; +export const getAllBranches = async ( + directoryPath: string, +): Promise => { + try { + const { stdout } = await execAsync( + 'git branch --list --format="%(refname:short)"', + { + cwd: directoryPath, + }, + ); + return stdout + .trim() + .split("\n") + .filter(Boolean) + .map((branch) => branch.trim()) + .filter((branch) => !branch.startsWith("array/")); + } catch { + return []; + } +}; + +export const createBranch = async ( + directoryPath: string, + branchName: string, +): Promise => { + await execAsync(`git checkout -b "${branchName}"`, { + cwd: directoryPath, + }); +}; + const getChangedFiles = async (directoryPath: string): Promise> => { const changedFiles = new Set(); @@ -785,6 +815,37 @@ export function registerGitIpc( }, ); + ipcMain.handle( + "get-default-branch", + async ( + _event: IpcMainInvokeEvent, + directoryPath: string, + ): Promise => { + return getDefaultBranch(directoryPath); + }, + ); + + ipcMain.handle( + "get-all-branches", + async ( + _event: IpcMainInvokeEvent, + directoryPath: string, + ): Promise => { + return getAllBranches(directoryPath); + }, + ); + + ipcMain.handle( + "create-branch", + async ( + _event: IpcMainInvokeEvent, + directoryPath: string, + branchName: string, + ): Promise => { + return createBranch(directoryPath, branchName); + }, + ); + ipcMain.handle( "discard-file-changes", async ( diff --git a/apps/array/src/main/services/workspace/workspaceService.ts b/apps/array/src/main/services/workspace/workspaceService.ts index aec98269..9977cbe4 100644 --- a/apps/array/src/main/services/workspace/workspaceService.ts +++ b/apps/array/src/main/services/workspace/workspaceService.ts @@ -84,7 +84,8 @@ export class WorkspaceService { async createWorkspace( options: CreateWorkspaceOptions, ): Promise { - const { taskId, mainRepoPath, folderId, folderPath, mode } = options; + const { taskId, mainRepoPath, folderId, folderPath, mode, branch } = + options; log.info( `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode})`, ); @@ -117,6 +118,25 @@ export class WorkspaceService { // Root mode: skip worktree creation entirely if (mode === "root") { + // try to create the branch, if it already exists just switch to it + if (branch) { + try { + log.info(`Creating/switching to branch ${branch} for task ${taskId}`); + try { + await execAsync(`git checkout -b "${branch}"`, { cwd: folderPath }); + log.info(`Created and switched to new branch ${branch}`); + } catch (_error) { + await execAsync(`git checkout "${branch}"`, { cwd: folderPath }); + log.info(`Switched to existing branch ${branch}`); + } + } catch (error) { + log.error(`Failed to create/switch to branch ${branch}:`, error); + throw new Error( + `Failed to create/switch to branch ${branch}: ${String(error)}`, + ); + } + } + // Save task association without worktree const associations = getTaskAssociations(); const existingIndex = associations.findIndex((a) => a.taskId === taskId); @@ -219,7 +239,9 @@ export class WorkspaceService { let worktree: WorktreeInfo; try { - worktree = await worktreeManager.createWorktree(); + worktree = await worktreeManager.createWorktree({ + baseBranch: branch ?? undefined, + }); log.info( `Created worktree: ${worktree.worktreeName} at ${worktree.worktreePath}`, ); diff --git a/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx b/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx index 4e7d4366..53579373 100644 --- a/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx +++ b/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx @@ -61,12 +61,7 @@ export function FolderPicker({ onClick={handleOpenFilePicker} > - + @@ -90,13 +86,8 @@ export function FolderPicker({ + + + ); + } + + return ( + + + + + + +
+ setSearchQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ + + {defaultBranch && filteredBranches.includes(defaultBranch) && ( + onChange(defaultBranch)}> + + + + {defaultBranch} + + + + )} + + {filteredBranches.filter((branch) => branch !== defaultBranch).length > + 0 && ( + <> + {defaultBranch && filteredBranches.includes(defaultBranch) && ( + + )} + {filteredBranches + .filter((branch) => branch !== defaultBranch) + .map((branch) => ( + onChange(branch)} + > + + + {branch} + + + ))} + + )} + + {hasMoreBranches && ( + + + Type to filter {branches.length - MAX_DISPLAYED_BRANCHES} more... + + + )} + + {filteredBranches.length === 0 && searchQuery && ( + + + No branches match "{searchQuery}" + + + )} + + + + + + + {searchQuery ? `Create "${searchQuery}"` : "Create new branch..."} + + + +
+
+ ); +} diff --git a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx index d0d5fa21..c7315853 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx @@ -11,6 +11,7 @@ import { useTaskDirectoryStore } from "@stores/taskDirectoryStore"; import { useEffect, useState } from "react"; import { useEditorSetup } from "../hooks/useEditorSetup"; import { useTaskCreation } from "../hooks/useTaskCreation"; +import { BranchSelect } from "./BranchSelect"; import { type RunMode, RunModeSelect } from "./RunModeSelect"; import { SuggestedTasks } from "./SuggestedTasks"; import { TaskInputEditor } from "./TaskInputEditor"; @@ -24,7 +25,7 @@ export function TaskInput() { const { view } = useNavigationStore(); const { lastUsedDirectory } = useTaskDirectoryStore(); - const { folders } = useRegisteredFoldersStore(); + const { folders, isLoaded: foldersLoaded } = useRegisteredFoldersStore(); const { lastUsedRunMode, lastUsedLocalWorkspaceMode } = useSettingsStore(); const [selectedDirectory, setSelectedDirectory] = useState( @@ -38,6 +39,36 @@ export function TaskInput() { ); const [localWorkspaceMode, setLocalWorkspaceMode] = useState(lastUsedLocalWorkspaceMode); + const [selectedBranch, setSelectedBranch] = useState(null); + + useEffect(() => { + if (!foldersLoaded || selectedDirectory) return; + + if (view.folderId) { + const folder = folders.find((f) => f.id === view.folderId); + if (folder) { + setSelectedDirectory(folder.path); + return; + } + } + + if (lastUsedDirectory) { + const folderExists = folders.some((f) => f.path === lastUsedDirectory); + if (folderExists) { + setSelectedDirectory(lastUsedDirectory); + } else if (folders.length > 0) { + setSelectedDirectory(folders[0].path); + } + } else if (folders.length > 0) { + setSelectedDirectory(folders[0].path); + } + }, [ + foldersLoaded, + folders, + lastUsedDirectory, + view.folderId, + selectedDirectory, + ]); const { githubIntegration } = useRepositoryIntegration(); @@ -70,6 +101,7 @@ export function TaskInput() { selectedRepository, githubIntegrationId: githubIntegration?.id, workspaceMode: effectiveWorkspaceMode, + branch: selectedBranch, }); return ( @@ -140,6 +172,13 @@ export function TaskInput() { {import.meta.env.DEV && ( )} + {selectedDirectory && ( + + )}
Promise; // Operations @@ -165,6 +166,7 @@ const useWorkspaceStoreBase = create()((set, get) => { taskId: string, repoPath: string, mode: WorkspaceMode = "worktree", + branch?: string | null, ) => { // Return existing workspace if it exists const existing = get().workspaces[taskId]; @@ -189,7 +191,7 @@ const useWorkspaceStoreBase = create()((set, get) => { worktreePath: null, worktreeName: null, branchName: null, - baseBranch: null, + baseBranch: branch ?? null, createdAt: new Date().toISOString(), terminalSessionIds: [], hasStartScripts: false, @@ -206,6 +208,7 @@ const useWorkspaceStoreBase = create()((set, get) => { folderId: folder.id, folderPath: repoPath, mode: "cloud", + branch: branch ?? undefined, }); return cloudWorkspace; @@ -258,6 +261,7 @@ const useWorkspaceStoreBase = create()((set, get) => { folderId: folder.id, folderPath: repoPath, mode, + branch: branch ?? undefined, }); if (!workspaceInfo) { diff --git a/apps/array/src/renderer/types/electron.d.ts b/apps/array/src/renderer/types/electron.d.ts index 375e60b2..07645224 100644 --- a/apps/array/src/renderer/types/electron.d.ts +++ b/apps/array/src/renderer/types/electron.d.ts @@ -195,6 +195,9 @@ declare global { linesRemoved: number; }>; getCurrentBranch: (repoPath: string) => Promise; + getDefaultBranch: (repoPath: string) => Promise; + getAllBranches: (repoPath: string) => Promise; + createBranch: (repoPath: string, branchName: string) => Promise; discardFileChanges: ( repoPath: string, filePath: string, diff --git a/apps/array/src/shared/types.ts b/apps/array/src/shared/types.ts index 97db44fc..61b81217 100644 --- a/apps/array/src/shared/types.ts +++ b/apps/array/src/shared/types.ts @@ -70,6 +70,7 @@ export interface CreateWorkspaceOptions { folderId: string; folderPath: string; mode: WorkspaceMode; + branch?: string; } export interface ScriptExecutionResult { diff --git a/packages/agent/src/worktree-manager.ts b/packages/agent/src/worktree-manager.ts index d27db5b4..e25e6b20 100644 --- a/packages/agent/src/worktree-manager.ts +++ b/packages/agent/src/worktree-manager.ts @@ -628,7 +628,9 @@ export class WorktreeManager { } } - async createWorktree(): Promise { + async createWorktree(options?: { + baseBranch?: string; + }): Promise { // Only modify .git/info/exclude when using in-repo storage if (!this.usesExternalPath()) { await this.ensureArrayDirIgnored(); @@ -644,7 +646,7 @@ export class WorktreeManager { const worktreeName = await this.generateUniqueWorktreeName(); const worktreePath = this.getWorktreePath(worktreeName); const branchName = `array/${worktreeName}`; - const baseBranch = await this.getDefaultBranch(); + const baseBranch = options?.baseBranch ?? (await this.getDefaultBranch()); this.logger.info("Creating worktree", { worktreeName, From 569aabe6e2ae07cc8bfb7e4eed9450db12905c48 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 9 Dec 2025 10:57:56 -0800 Subject: [PATCH 2/3] Validate that the last used directory still exists when rehydrating it --- .../task-detail/components/TaskInput.tsx | 31 +------------------ .../src/renderer/stores/taskDirectoryStore.ts | 15 +++++++++ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx index c7315853..1588e382 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx @@ -25,7 +25,7 @@ export function TaskInput() { const { view } = useNavigationStore(); const { lastUsedDirectory } = useTaskDirectoryStore(); - const { folders, isLoaded: foldersLoaded } = useRegisteredFoldersStore(); + const { folders } = useRegisteredFoldersStore(); const { lastUsedRunMode, lastUsedLocalWorkspaceMode } = useSettingsStore(); const [selectedDirectory, setSelectedDirectory] = useState( @@ -41,35 +41,6 @@ export function TaskInput() { useState(lastUsedLocalWorkspaceMode); const [selectedBranch, setSelectedBranch] = useState(null); - useEffect(() => { - if (!foldersLoaded || selectedDirectory) return; - - if (view.folderId) { - const folder = folders.find((f) => f.id === view.folderId); - if (folder) { - setSelectedDirectory(folder.path); - return; - } - } - - if (lastUsedDirectory) { - const folderExists = folders.some((f) => f.path === lastUsedDirectory); - if (folderExists) { - setSelectedDirectory(lastUsedDirectory); - } else if (folders.length > 0) { - setSelectedDirectory(folders[0].path); - } - } else if (folders.length > 0) { - setSelectedDirectory(folders[0].path); - } - }, [ - foldersLoaded, - folders, - lastUsedDirectory, - view.folderId, - selectedDirectory, - ]); - const { githubIntegration } = useRepositoryIntegration(); useEffect(() => { diff --git a/apps/array/src/renderer/stores/taskDirectoryStore.ts b/apps/array/src/renderer/stores/taskDirectoryStore.ts index a97e47ed..77080b40 100644 --- a/apps/array/src/renderer/stores/taskDirectoryStore.ts +++ b/apps/array/src/renderer/stores/taskDirectoryStore.ts @@ -10,6 +10,7 @@ interface TaskDirectoryState { getTaskDirectory: (taskId: string, repoKey?: string) => string | null; setRepoDirectory: (repoKey: string, directory: string) => void; clearRepoDirectory: (repoKey: string) => void; + validateLastUsedDirectory: () => Promise; } const isValidPath = (path: string): boolean => { @@ -54,6 +55,17 @@ export const useTaskDirectoryStore = create()( repoDirectories: omitKey(state.repoDirectories, repoKey), })); }, + + validateLastUsedDirectory: async () => { + const { lastUsedDirectory } = get(); + if (!lastUsedDirectory) return; + + const exists = + await window.electronAPI?.validateRepo(lastUsedDirectory); + if (!exists) { + set({ lastUsedDirectory: null }); + } + }, }), { name: "task-directory-mappings", @@ -84,6 +96,9 @@ export const useTaskDirectoryStore = create()( state.repoDirectories = cleanedRepoDirs; state.lastUsedDirectory = cleanedLastUsed; } + + // Validate that lastUsedDirectory still exists on disk + state.validateLastUsedDirectory(); }, }, ), From eae1dce92bdc3314b29a4e8acb6f8a8ea3b0f290 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 10 Dec 2025 11:56:31 -0800 Subject: [PATCH 3/3] empty