From 7fcf3c1e1fbf7f4e139f516375c1c1211ea03260 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 15:20:28 -0800 Subject: [PATCH] feat: Mobile improvements and Add selective file staging and improve branch switching --- .../src/routes/worktree/routes/commit.ts | 18 +- .../routes/worktree/routes/list-branches.ts | 3 + .../routes/worktree/routes/switch-branch.ts | 302 +++++++++-- .../src/services/auto-loop-coordinator.ts | 6 +- apps/ui/index.html | 76 ++- apps/ui/public/manifest.json | 44 ++ apps/ui/public/sw.js | 373 +++++++++++++ apps/ui/src/app.tsx | 32 ++ .../dialogs/sandbox-rejection-screen.tsx | 2 +- apps/ui/src/components/ui/dialog.tsx | 2 +- apps/ui/src/components/views/board-view.tsx | 58 +- .../dialogs/commit-worktree-dialog.tsx | 503 +++++++++++++++++- .../dialogs/view-worktree-changes-dialog.tsx | 2 +- .../board-view/hooks/use-board-actions.ts | 8 +- .../board-view/init-script-indicator.tsx | 2 +- .../components/branch-switch-dropdown.tsx | 83 ++- .../worktree-panel/hooks/use-branches.ts | 2 +- .../hooks/use-worktree-actions.ts | 15 +- .../views/board-view/worktree-panel/types.ts | 8 + .../worktree-panel/worktree-panel.tsx | 12 +- .../src/components/views/dashboard-view.tsx | 2 +- .../src/components/views/logged-out-view.tsx | 2 +- apps/ui/src/components/views/login-view.tsx | 8 +- .../ui/src/components/views/overview-view.tsx | 2 +- .../mobile-terminal-controls.tsx | 310 +++++++++++ .../views/terminal-view/terminal-panel.tsx | 27 + .../hooks/mutations/use-worktree-mutations.ts | 55 +- apps/ui/src/hooks/use-auto-mode.ts | 7 +- apps/ui/src/hooks/use-event-recency.ts | 19 +- apps/ui/src/hooks/use-mobile-visibility.ts | 127 +++++ .../src/hooks/use-virtual-keyboard-resize.ts | 64 +++ apps/ui/src/lib/electron.ts | 4 +- apps/ui/src/lib/http-api-client.ts | 4 +- apps/ui/src/lib/mobile-detect.ts | 75 +++ apps/ui/src/lib/query-client.ts | 76 ++- apps/ui/src/renderer.tsx | 129 +++++ apps/ui/src/routes/__root.tsx | 16 +- apps/ui/src/store/app-store.ts | 5 +- apps/ui/src/styles/font-imports.ts | 323 +++++++---- apps/ui/src/styles/global.css | 35 ++ apps/ui/src/types/electron.d.ts | 3 +- apps/ui/vite.config.mts | 118 +++- 42 files changed, 2706 insertions(+), 256 deletions(-) create mode 100644 apps/ui/public/manifest.json create mode 100644 apps/ui/public/sw.js create mode 100644 apps/ui/src/components/views/terminal-view/mobile-terminal-controls.tsx create mode 100644 apps/ui/src/hooks/use-mobile-visibility.ts create mode 100644 apps/ui/src/hooks/use-virtual-keyboard-resize.ts create mode 100644 apps/ui/src/lib/mobile-detect.ts diff --git a/apps/server/src/routes/worktree/routes/commit.ts b/apps/server/src/routes/worktree/routes/commit.ts index f33cd94b..7571fd91 100644 --- a/apps/server/src/routes/worktree/routes/commit.ts +++ b/apps/server/src/routes/worktree/routes/commit.ts @@ -15,9 +15,10 @@ const execAsync = promisify(exec); export function createCommitHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, message } = req.body as { + const { worktreePath, message, files } = req.body as { worktreePath: string; message: string; + files?: string[]; }; if (!worktreePath || !message) { @@ -44,8 +45,19 @@ export function createCommitHandler() { return; } - // Stage all changes - await execAsync('git add -A', { cwd: worktreePath }); + // Stage changes - either specific files or all changes + if (files && files.length > 0) { + // Reset any previously staged changes first + await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => { + // Ignore errors from reset (e.g., if nothing is staged) + }); + // Stage only the selected files + const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' '); + await execAsync(`git add ${escapedFiles}`, { cwd: worktreePath }); + } else { + // Stage all changes (original behavior) + await execAsync('git add -A', { cwd: worktreePath }); + } // Create commit await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 2e6a34f5..30fdcb1d 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -92,6 +92,9 @@ export function createListBranchesHandler() { // Skip HEAD pointers like "origin/HEAD" if (cleanName.includes('/HEAD')) return; + // Skip bare remote names without a branch (e.g. "origin" by itself) + if (!cleanName.includes('/')) return; + // Only add remote branches if a branch with the exact same name isn't already // in the list. This avoids duplicates if a local branch is named like a remote one. // Note: We intentionally include remote branches even when a local branch with the diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts index 63be752b..beb380ad 100644 --- a/apps/server/src/routes/worktree/routes/switch-branch.ts +++ b/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -1,9 +1,15 @@ /** * POST /switch-branch endpoint - Switch to an existing branch * - * Simple branch switching. - * If there are uncommitted changes, the switch will fail and - * the user should commit first. + * Handles branch switching with automatic stash/reapply of local changes. + * If there are uncommitted changes, they are stashed before switching and + * reapplied after. If the stash pop results in merge conflicts, returns + * a special response code so the UI can create a conflict resolution task. + * + * For remote branches (e.g., "origin/feature"), automatically creates a + * local tracking branch and checks it out. + * + * Also fetches the latest remote refs after switching. * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts @@ -16,14 +22,14 @@ import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); -function isUntrackedLine(line: string): boolean { - return line.startsWith('?? '); -} - function isExcludedWorktreeLine(line: string): boolean { return line.includes('.worktrees/') || line.endsWith('.worktrees'); } +function isUntrackedLine(line: string): boolean { + return line.startsWith('?? '); +} + function isBlockingChangeLine(line: string): boolean { if (!line.trim()) return false; if (isExcludedWorktreeLine(line)) return false; @@ -46,18 +52,130 @@ async function hasUncommittedChanges(cwd: string): Promise { } /** - * Get a summary of uncommitted changes for user feedback - * Excludes .worktrees/ directory + * Check if there are any changes at all (including untracked) that should be stashed */ -async function getChangesSummary(cwd: string): Promise { +async function hasAnyChanges(cwd: string): Promise { try { - const { stdout } = await execAsync('git status --short', { cwd }); - const lines = stdout.trim().split('\n').filter(isBlockingChangeLine); - if (lines.length === 0) return ''; - if (lines.length <= 5) return lines.join(', '); - return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`; + const { stdout } = await execAsync('git status --porcelain', { cwd }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => { + if (!line.trim()) return false; + if (isExcludedWorktreeLine(line)) return false; + return true; + }); + return lines.length > 0; } catch { - return 'unknown changes'; + return false; + } +} + +/** + * Stash all local changes (including untracked files) + * Returns true if a stash was created, false if there was nothing to stash + */ +async function stashChanges(cwd: string, message: string): Promise { + try { + // Get stash count before + const { stdout: beforeCount } = await execAsync('git stash list', { cwd }); + const countBefore = beforeCount + .trim() + .split('\n') + .filter((l) => l.trim()).length; + + // Stash including untracked files + await execAsync(`git stash push --include-untracked -m "${message}"`, { cwd }); + + // Get stash count after to verify something was stashed + const { stdout: afterCount } = await execAsync('git stash list', { cwd }); + const countAfter = afterCount + .trim() + .split('\n') + .filter((l) => l.trim()).length; + + return countAfter > countBefore; + } catch { + return false; + } +} + +/** + * Pop the most recent stash entry + * Returns an object indicating success and whether there were conflicts + */ +async function popStash( + cwd: string +): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> { + try { + const { stdout, stderr } = await execAsync('git stash pop', { cwd }); + const output = `${stdout}\n${stderr}`; + // Check for conflict markers in the output + if (output.includes('CONFLICT') || output.includes('Merge conflict')) { + return { success: false, hasConflicts: true }; + } + return { success: true, hasConflicts: false }; + } catch (error) { + const errorMsg = getErrorMessage(error); + if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) { + return { success: false, hasConflicts: true, error: errorMsg }; + } + return { success: false, hasConflicts: false, error: errorMsg }; + } +} + +/** + * Fetch latest from all remotes (silently, with timeout) + */ +async function fetchRemotes(cwd: string): Promise { + try { + await execAsync('git fetch --all --quiet', { + cwd, + timeout: 15000, // 15 second timeout + }); + } catch { + // Ignore fetch errors - we may be offline + } +} + +/** + * Parse a remote branch name like "origin/feature-branch" into its parts + */ +function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null { + const slashIndex = branchName.indexOf('/'); + if (slashIndex === -1) return null; + return { + remote: branchName.substring(0, slashIndex), + branch: branchName.substring(slashIndex + 1), + }; +} + +/** + * Check if a branch name refers to a remote branch + */ +async function isRemoteBranch(cwd: string, branchName: string): Promise { + try { + const { stdout } = await execAsync('git branch -r --format="%(refname:short)"', { cwd }); + const remoteBranches = stdout + .trim() + .split('\n') + .map((b) => b.trim().replace(/^['"]|['"]$/g, '')) + .filter((b) => b); + return remoteBranches.includes(branchName); + } catch { + return false; + } +} + +/** + * Check if a local branch already exists + */ +async function localBranchExists(cwd: string, branchName: string): Promise { + try { + await execAsync(`git rev-parse --verify "refs/heads/${branchName}"`, { cwd }); + return true; + } catch { + return false; } } @@ -91,53 +209,133 @@ export function createSwitchBranchHandler() { }); const previousBranch = currentBranchOutput.trim(); - if (previousBranch === branchName) { + // Determine the actual target branch name for checkout + let targetBranch = branchName; + let isRemote = false; + + // Check if this is a remote branch (e.g., "origin/feature-branch") + if (await isRemoteBranch(worktreePath, branchName)) { + isRemote = true; + const parsed = parseRemoteBranch(branchName); + if (parsed) { + // If a local branch with the same name already exists, just switch to it + if (await localBranchExists(worktreePath, parsed.branch)) { + targetBranch = parsed.branch; + } else { + // Will create a local tracking branch from the remote + targetBranch = parsed.branch; + } + } + } + + if (previousBranch === targetBranch) { res.json({ success: true, result: { previousBranch, - currentBranch: branchName, - message: `Already on branch '${branchName}'`, + currentBranch: targetBranch, + message: `Already on branch '${targetBranch}'`, }, }); return; } - // Check if branch exists + // Check if target branch exists (locally or as remote ref) + if (!isRemote) { + try { + await execAsync(`git rev-parse --verify "${branchName}"`, { + cwd: worktreePath, + }); + } catch { + res.status(400).json({ + success: false, + error: `Branch '${branchName}' does not exist`, + }); + return; + } + } + + // Stash local changes if any exist + const hadChanges = await hasAnyChanges(worktreePath); + let didStash = false; + + if (hadChanges) { + const stashMessage = `automaker-branch-switch: ${previousBranch} → ${targetBranch}`; + didStash = await stashChanges(worktreePath, stashMessage); + } + try { - await execAsync(`git rev-parse --verify ${branchName}`, { - cwd: worktreePath, - }); - } catch { - res.status(400).json({ - success: false, - error: `Branch '${branchName}' does not exist`, - }); - return; + // Switch to the target branch + if (isRemote) { + const parsed = parseRemoteBranch(branchName); + if (parsed) { + if (await localBranchExists(worktreePath, parsed.branch)) { + // Local branch exists, just checkout + await execAsync(`git checkout "${parsed.branch}"`, { cwd: worktreePath }); + } else { + // Create local tracking branch from remote + await execAsync(`git checkout -b "${parsed.branch}" "${branchName}"`, { + cwd: worktreePath, + }); + } + } + } else { + await execAsync(`git checkout "${targetBranch}"`, { cwd: worktreePath }); + } + + // Fetch latest from remotes after switching + await fetchRemotes(worktreePath); + + // Reapply stashed changes if we stashed earlier + let hasConflicts = false; + let conflictMessage = ''; + + if (didStash) { + const popResult = await popStash(worktreePath); + if (popResult.hasConflicts) { + hasConflicts = true; + conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`; + } else if (!popResult.success) { + // Stash pop failed for a non-conflict reason - the stash is still there + conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`; + } + } + + if (hasConflicts) { + res.json({ + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: conflictMessage, + hasConflicts: true, + stashedChanges: true, + }, + }); + } else { + const stashNote = didStash ? ' (local changes stashed and reapplied)' : ''; + res.json({ + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: `Switched to branch '${targetBranch}'${stashNote}`, + hasConflicts: false, + stashedChanges: didStash, + }, + }); + } + } catch (checkoutError) { + // If checkout failed and we stashed, try to restore the stash + if (didStash) { + try { + await popStash(worktreePath); + } catch { + // Ignore errors restoring stash - it's still in the stash list + } + } + throw checkoutError; } - - // Check for uncommitted changes - if (await hasUncommittedChanges(worktreePath)) { - const summary = await getChangesSummary(worktreePath); - res.status(400).json({ - success: false, - error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`, - code: 'UNCOMMITTED_CHANGES', - }); - return; - } - - // Switch to the target branch - await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath }); - - res.json({ - success: true, - result: { - previousBranch, - currentBranch: branchName, - message: `Switched to branch '${branchName}'`, - }, - }); } catch (error) { logError(error, 'Switch branch failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 3310b2d6..64a3bd2f 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -387,8 +387,10 @@ export class AutoLoopCoordinator { const projectId = settings.projects?.find((p) => p.path === projectPath)?.id; const autoModeByWorktree = settings.autoModeByWorktree; if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { - const normalizedBranch = - branchName === null || branchName === 'main' ? '__main__' : branchName; + // branchName is already normalized to null for the primary branch by callers + // (e.g., checkWorktreeCapacity, startAutoLoopForProject), so we only + // need to convert null to '__main__' for the worktree key lookup + const normalizedBranch = branchName === null ? '__main__' : branchName; const worktreeId = `${projectId}::${normalizedBranch}`; if ( worktreeId in autoModeByWorktree && diff --git a/apps/ui/index.html b/apps/ui/index.html index 49a7aa1e..3f12c9b0 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -4,18 +4,84 @@ Automaker - Autonomous AI Development Studio - + + + + + + + + + + + + + + +