From 9a85710b746a7897e4f9dcc9cccbee93c99c637e Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Mon, 11 May 2026 15:40:11 -0700 Subject: [PATCH 1/8] fix: enable Browse button in Add Workspace dialog showBrowseButton was hardcoded to false, preventing users from using the native folder picker. Set to true so the Browse button appears and opens the OS folder dialog via the existing WebSocket handler. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/ProjectPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ProjectPicker.tsx b/frontend/src/components/ProjectPicker.tsx index 36c1a6d..17b0543 100644 --- a/frontend/src/components/ProjectPicker.tsx +++ b/frontend/src/components/ProjectPicker.tsx @@ -185,7 +185,7 @@ export function ProjectPicker({ onSelect, wsRef, requestRecentWorkspaces, clearR onRemoveRecent={handleRemoveRecent} onBrowse={handleBrowse} isBrowsing={isBrowsing} - showBrowseButton={false} + showBrowseButton={true} defaultBaseDirectory={defaultBaseDirectory} /> )} From b17bce84f39a820c6278100b98fd57e9ec71e202 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Mon, 11 May 2026 16:23:36 -0700 Subject: [PATCH 2/8] feat: drag-and-drop folders to add workspaces, fix browse button - Add drag-and-drop support on the workspace panel: drop a folder from the OS file explorer to add it as a workspace. In Electron, the full path is extracted directly. In the browser, opens the path input modal. - Fix Browse button: use REST endpoint instead of blocking WebSocket execFileSync which froze the server. Only show Browse in Electron mode where the native dialog works reliably. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/ProjectPicker.tsx | 29 +++++++++------ frontend/src/components/WorkspacePanel.css | 22 ++++++++++++ frontend/src/components/WorkspacePanel.tsx | 42 +++++++++++++++++++++- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/ProjectPicker.tsx b/frontend/src/components/ProjectPicker.tsx index 17b0543..0304b28 100644 --- a/frontend/src/components/ProjectPicker.tsx +++ b/frontend/src/components/ProjectPicker.tsx @@ -156,16 +156,25 @@ export function ProjectPicker({ onSelect, wsRef, requestRecentWorkspaces, clearR setShowPathInput(false); }; - const handleBrowse = useCallback(() => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) { - console.warn('[ProjectPicker] WebSocket not ready for browse'); - return; - } - console.log('[ProjectPicker] Requesting native folder picker via backend'); + const handleBrowse = useCallback(async () => { + console.log('[ProjectPicker] Requesting native folder picker via REST endpoint'); setIsBrowsing(true); - ws.send(JSON.stringify({ type: 'workspace:browseFolder', payload: {} })); - }, [wsRef]); + try { + const res = await fetch(`${getApiBaseUrl()}/api/browse-folder`, { method: 'POST' }); + const data = await res.json(); + if (data.success && data.path) { + console.log('[ProjectPicker] Browse selected path:', data.path); + onSelect(data.path); + setShowPathInput(false); + } else { + console.log('[ProjectPicker] Browse cancelled'); + } + } catch (err) { + console.error('[ProjectPicker] Browse failed:', err); + } finally { + setIsBrowsing(false); + } + }, [onSelect]); const handleRemoveRecent = (workspaceId: string) => { console.log('[ProjectPicker] Removing recent workspace:', workspaceId); @@ -185,7 +194,7 @@ export function ProjectPicker({ onSelect, wsRef, requestRecentWorkspaces, clearR onRemoveRecent={handleRemoveRecent} onBrowse={handleBrowse} isBrowsing={isBrowsing} - showBrowseButton={true} + showBrowseButton={!!window.electronAPI} defaultBaseDirectory={defaultBaseDirectory} /> )} diff --git a/frontend/src/components/WorkspacePanel.css b/frontend/src/components/WorkspacePanel.css index 17ef801..f4ce518 100644 --- a/frontend/src/components/WorkspacePanel.css +++ b/frontend/src/components/WorkspacePanel.css @@ -8,6 +8,28 @@ /* Never exceed viewport width — ensures header spans exactly the screen width */ max-width: 100vw; overflow-x: hidden; + position: relative; +} + +.workspace-panel.drag-over { + outline: 2px dashed var(--accent-secondary, #00d4ff); + outline-offset: -4px; +} + +.workspace-drop-overlay { + position: absolute; + inset: 0; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + background: rgba(0, 212, 255, 0.08); + color: var(--accent-secondary, #00d4ff); + font-size: 14px; + font-weight: 500; + pointer-events: none; } .workspace-panel-header { diff --git a/frontend/src/components/WorkspacePanel.tsx b/frontend/src/components/WorkspacePanel.tsx index d278700..6281879 100644 --- a/frontend/src/components/WorkspacePanel.tsx +++ b/frontend/src/components/WorkspacePanel.tsx @@ -1681,8 +1681,48 @@ export function WorkspacePanel({ }; + const [isDragOver, setIsDragOver] = useState(false); + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + // Electron gives full paths via file.path + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0] as any; + const fullPath = file.path; // Electron only + if (fullPath) { + onCreateWorkspace(fullPath); + return; + } + } + // Browser fallback: open the path input modal + handleAddWorkspace(); + }; + return ( -
+
+ {isDragOver && ( +
+ + Drop folder to add workspace +
+ )}

Workspaces

From eb8f83aaa574648f086b2b11f928372b2fccf54c Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Tue, 12 May 2026 23:24:43 -0700 Subject: [PATCH 3/8] fix: prevent tsx watch restarts from MCP sync + browse improvements Root cause: syncWorkspaceMcpConfigs wrote .mcp.json to the claudia project root on every startup, triggering tsx watch to restart the server in an infinite loop. Now skips syncing to Claudia's own workspace directory. Also: - Fix Browse button: add -STA flag for Windows PowerShell folder dialog, remember last browsed path across sessions, kdialog fallback on Linux - Re-enable Browse button in Add Workspace dialog - Fix drag-and-drop: only activate for external OS drops (Files type), internal workspace reordering drags pass through unaffected Co-Authored-By: Claude Opus 4.6 --- backend/src/server.ts | 28 ++++++++++++++++++---- backend/src/task-spawner.ts | 10 +++++++- frontend/src/components/ProjectPicker.tsx | 2 +- frontend/src/components/WorkspacePanel.tsx | 11 +++++---- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index 92b54bc..d63d997 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2200,17 +2200,34 @@ export async function createApp(basePath?: string) { const platform = process.platform; let cmd: string; let args: string[]; + const lastBrowsed = workspaceStore.getLastBrowsedPath(); if (platform === 'darwin') { + const scriptParts = ['POSIX path of (choose folder with prompt "Select a workspace folder"']; + if (lastBrowsed) { + const safe = lastBrowsed.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + scriptParts.push(` default location POSIX file "${safe}"`); + } + scriptParts.push(')'); cmd = 'osascript'; - args = ['-e', 'POSIX path of (choose folder with prompt "Select reference folder")']; + args = ['-e', scriptParts.join('')]; } else if (platform === 'win32') { + const initialDir = lastBrowsed + ? `$f.SelectedPath = [System.IO.Path]::GetFullPath("${lastBrowsed.replace(/"/g, '')}"); ` + : ''; cmd = 'powershell'; - args = ['-Command', `Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = 'Select reference folder'; if ($f.ShowDialog() -eq 'OK') { $f.SelectedPath } else { '' }`]; + args = ['-STA', '-NoProfile', '-Command', `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::EnableVisualStyles(); $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = 'Select a workspace folder'; $f.ShowNewFolderButton = $true; ${initialDir}if ($f.ShowDialog() -eq 'OK') { $f.SelectedPath } else { '' }`]; } else { - // Linux - try zenity, then kdialog - cmd = 'zenity'; - args = ['--file-selection', '--directory', '--title=Select reference folder']; + // Linux - try zenity first, fall back to kdialog + try { + require('child_process').execFileSync('which', ['zenity'], { stdio: 'ignore' }); + cmd = 'zenity'; + args = ['--file-selection', '--directory', '--title=Select a workspace folder']; + if (lastBrowsed) args.push(`--filename=${lastBrowsed}/`); + } catch { + cmd = 'kdialog'; + args = ['--getexistingdirectory', lastBrowsed || process.env['HOME'] || '/', '--title', 'Select a workspace folder']; + } } const child = spawn(cmd, args); @@ -2221,6 +2238,7 @@ export async function createApp(basePath?: string) { child.on('close', (code: number | null) => { const path = stdout.trim(); if (code === 0 && path) { + workspaceStore.setLastBrowsedPath(path); res.json({ success: true, path }); } else { // User cancelled or error diff --git a/backend/src/task-spawner.ts b/backend/src/task-spawner.ts index 2c2a6f0..a776655 100644 --- a/backend/src/task-spawner.ts +++ b/backend/src/task-spawner.ts @@ -2,7 +2,7 @@ import { spawn, IPty } from 'node-pty'; import { EventEmitter } from 'events'; import { Task, TaskState, TaskGitState, WaitingInputType, BackendType, PORTS, TaskTokenUsage } from '@claudia/shared'; import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; +import { dirname, join, resolve } from 'path'; import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSync, appendFileSync, statSync, openSync, readSync, closeSync } from 'fs'; import { tmpdir } from 'os'; import { execSync } from 'child_process'; @@ -625,11 +625,19 @@ export class TaskSpawner extends EventEmitter { enabledMcpjsonServers: serverNames }; + // The Claudia project root — skip syncing to our own directory to avoid + // triggering tsx watch restarts from file writes in the project tree. + const selfRoot = resolve(join(__dirname, '..', '..')); + for (const workspaceId of workspaceIds) { if (!existsSync(workspaceId)) { logger.warn('Workspace does not exist, skipping MCP sync', { workspaceId }); continue; } + if (resolve(workspaceId) === selfRoot) { + logger.info('Skipping MCP sync for Claudia\'s own workspace (prevents tsx watch restart)', { workspaceId }); + continue; + } // Write .mcp.json const workspaceMcpFile = `${workspaceId}/.mcp.json`; diff --git a/frontend/src/components/ProjectPicker.tsx b/frontend/src/components/ProjectPicker.tsx index 0304b28..3e5ecbf 100644 --- a/frontend/src/components/ProjectPicker.tsx +++ b/frontend/src/components/ProjectPicker.tsx @@ -194,7 +194,7 @@ export function ProjectPicker({ onSelect, wsRef, requestRecentWorkspaces, clearR onRemoveRecent={handleRemoveRecent} onBrowse={handleBrowse} isBrowsing={isBrowsing} - showBrowseButton={!!window.electronAPI} + showBrowseButton={true} defaultBaseDirectory={defaultBaseDirectory} /> )} diff --git a/frontend/src/components/WorkspacePanel.tsx b/frontend/src/components/WorkspacePanel.tsx index 6281879..7ebe361 100644 --- a/frontend/src/components/WorkspacePanel.tsx +++ b/frontend/src/components/WorkspacePanel.tsx @@ -1681,20 +1681,23 @@ export function WorkspacePanel({ }; + // External drag-and-drop (from OS file explorer) to add workspaces. + // Only activates when dataTransfer contains files — internal workspace + // reordering drags don't have files so they pass through unaffected. const [isDragOver, setIsDragOver] = useState(false); + const isExternalDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('Files'); const handleDragOver = (e: React.DragEvent) => { + if (!isExternalDrag(e)) return; // let internal drags pass through e.preventDefault(); - e.stopPropagation(); setIsDragOver(true); }; const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); + if (!isExternalDrag(e)) return; setIsDragOver(false); }; const handleDrop = (e: React.DragEvent) => { + if (!isExternalDrag(e)) return; // let internal drops pass through e.preventDefault(); - e.stopPropagation(); setIsDragOver(false); // Electron gives full paths via file.path const files = e.dataTransfer.files; From c5834dff3921033be3ffebe67a728f38d168e8b9 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Wed, 13 May 2026 08:31:15 -0700 Subject: [PATCH 4/8] fix: increase history retention to 10MB/5MB-tail, send 2MB to clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 512KB caps were too aggressive — users lost scrollback history after rotation. Disk files now cap at 10MB (rotate keeping 5MB tail), and clients receive up to 2MB of history for scrollback. Memory loading on reconnect remains capped at 512KB to prevent OOM. Co-Authored-By: Claude Opus 4.6 --- backend/src/task-spawner.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/task-spawner.ts b/backend/src/task-spawner.ts index a776655..bc33b05 100644 --- a/backend/src/task-spawner.ts +++ b/backend/src/task-spawner.ts @@ -334,18 +334,18 @@ export class TaskSpawner extends EventEmitter { ? envReapInterval : 10 * 60 * 1000; - // History file cap config. Default: rotate at 2MB, keep last 512KB tail. - // The 512KB keep-tail matches MAX_HISTORY_TO_SEND (what clients see) - // and MAX_RECONNECT_HISTORY (what we load on reconnect), so no memory - // is wasted storing history that will never be displayed. + // History file cap config. Default: rotate at 10MB, keep last 5MB tail. + // Files on disk are kept large to preserve full scrollback history. + // Memory loading on reconnect is capped separately (MAX_RECONNECT_HISTORY + // = 512KB) so large files don't cause OOM. const envMaxBytes = parseInt(process.env.HISTORY_FILE_MAX_BYTES || '', 10); this.historyFileMaxBytes = !isNaN(envMaxBytes) && envMaxBytes >= 0 ? envMaxBytes - : 2 * 1024 * 1024; + : 10 * 1024 * 1024; const envKeepBytes = parseInt(process.env.HISTORY_FILE_KEEP_BYTES || '', 10); this.historyFileKeepBytes = !isNaN(envKeepBytes) && envKeepBytes > 0 ? envKeepBytes - : 512 * 1024; + : 5 * 1024 * 1024; // Initialize backend based on config this.backendType = configStore?.getBackend() || 'claude-code'; @@ -3069,7 +3069,7 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have private getCombinedHistory(task: InternalTask): string | null { // Limit history sent to frontend. Smaller = faster xterm rendering. // 512KB is ~128 full terminal screens, well beyond what's visually useful. - const MAX_HISTORY_TO_SEND = 512 * 1024; // 512KB max + const MAX_HISTORY_TO_SEND = 2 * 1024 * 1024; // 2MB max // Handle lazy loading from file if (!task.previousHistory && !task.lazyHistoryBase64) { From 25fabae4492a4fd41536a56b6570445427aaa531 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Wed, 13 May 2026 08:39:27 -0700 Subject: [PATCH 5/8] fix: suppress scrollbar-induced resize oscillation causing garbled text When a scrollbar appears/disappears during active output, the container width changes by ~15px, flipping cols by 1-2. This caused Claude Code's TUI to re-render at alternating widths, producing overlapping garbled text. Fix: - Skip resize events where cols changed by <= 2 (scrollbar noise) - Track last sent cols/rows to deduplicate - Increase ResizeObserver debounce from 50ms to 150ms - Use fitTerminal() (fit + refresh) to clear artifacts after resize Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/TerminalView.tsx | 33 ++++++++++++++---------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx index b34f2d4..4d1fc3a 100644 --- a/frontend/src/components/TerminalView.tsx +++ b/frontend/src/components/TerminalView.tsx @@ -268,9 +268,21 @@ export function TerminalView({ task, wsRef, workspace, isMobile }: TerminalViewP // that trigger Claude TUI redraws interleaving with history output. let initPhase = true; + // Track last sent dimensions to prevent resize oscillation. + // When a scrollbar appears/disappears, the container width changes by ~15px + // which flips cols by 1-2. This causes Claude Code's TUI to re-render at + // alternating widths, producing garbled overlapping text. We suppress resizes + // that change cols by <= 2 to break this feedback loop. + let lastSentCols = 0; + let lastSentRows = 0; + // Handle resize - sync to backend term.onResize(({ cols, rows }) => { if (initPhase) return; // Skip during init — we send one resize after fit + // Suppress small col changes (scrollbar oscillation) + if (Math.abs(cols - lastSentCols) <= 2 && rows === lastSentRows) return; + lastSentCols = cols; + lastSentRows = rows; if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'task:resize', @@ -442,6 +454,8 @@ export function TerminalView({ task, wsRef, workspace, isMobile }: TerminalViewP // Send ONE definitive resize to the backend with the correct dimensions const { cols, rows } = term; + lastSentCols = cols; + lastSentRows = rows; if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'task:resize', @@ -460,20 +474,13 @@ export function TerminalView({ task, wsRef, workspace, isMobile }: TerminalViewP }); }); - // ResizeObserver for container changes + // ResizeObserver for container changes — use fitTerminal() which does + // fit() + refresh() to clear rendering artifacts from the previous width. + // 150ms debounce prevents rapid-fire resizes during layout transitions. let resizeTimeout: number; const resizeObserver = new ResizeObserver(() => { - // Debounce resize if (resizeTimeout) window.clearTimeout(resizeTimeout); - resizeTimeout = window.setTimeout(() => { - if (fitAddonRef.current) { - try { - fitAddonRef.current.fit(); - } catch (e) { - console.warn('[TerminalView] Resize fit failed:', e); - } - } - }, 50); + resizeTimeout = window.setTimeout(fitTerminal, 150); }); resizeObserver.observe(terminalRef.current); @@ -481,9 +488,7 @@ export function TerminalView({ task, wsRef, workspace, isMobile }: TerminalViewP // Window resize fallback const handleWindowResize = () => { if (resizeTimeout) window.clearTimeout(resizeTimeout); - resizeTimeout = window.setTimeout(() => { - fitAddonRef.current?.fit(); - }, 50); + resizeTimeout = window.setTimeout(fitTerminal, 150); }; window.addEventListener('resize', handleWindowResize); From 07168817065bb3ebaf0acd0d7d8929a9839e93de Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Wed, 13 May 2026 08:45:55 -0700 Subject: [PATCH 6/8] docs: add git worktree support design plan References #59 Co-Authored-By: Claude Opus 4.6 --- docs/plans/git-worktree-support.md | 1219 ++++++++++++++++++++++++++++ 1 file changed, 1219 insertions(+) create mode 100644 docs/plans/git-worktree-support.md diff --git a/docs/plans/git-worktree-support.md b/docs/plans/git-worktree-support.md new file mode 100644 index 0000000..c70dfe1 --- /dev/null +++ b/docs/plans/git-worktree-support.md @@ -0,0 +1,1219 @@ +# Git Worktree Support in Claudia + +**Status**: Draft +**Author**: Claude +**Date**: 2026-04-22 + +--- + +## Executive Summary + +Git worktrees let you check out multiple branches of the same repo into separate directories simultaneously. This is a natural fit for Claudia's multi-agent model — today, if you spawn 3 tasks in one workspace, they all compete over the same branch and working tree. With worktree support, each task can operate on its own branch in its own directory, with full git isolation, zero conflicts, and clean merges. + +This plan designs worktree support **from the user's experience inward**, ensuring the feature feels like a natural extension of Claudia rather than a bolted-on git power tool. + +--- + +## 1. The Problem + +### What breaks today without worktrees + +| Scenario | What happens | Impact | +|----------|-------------|--------| +| Two tasks edit files in same workspace | Race conditions, overwritten changes | Lost work | +| Task A commits while Task B is mid-edit | Dirty working tree blocks Task B's commit | Task failure | +| User wants to review PR while feature task runs | Must wait or manually stash | Productivity loss | +| User wants parallel feature branches | Must create separate clones | Disk waste, no shared objects | + +### Why worktrees (not clones) + +- **Shared `.git` object store** — no duplicate downloads, instant creation +- **Branch locking** — git prevents two worktrees from checking out the same branch (safety) +- **First-class git concept** — `git worktree add/remove/list/prune` are built-in +- **Claude Code already supports them** — Claude's `/worktree` command creates worktrees natively + +--- + +## 2. User Experience Design + +### 2.1 Core UX Principle + +> *"Worktrees are workspaces that know they're siblings."* + +A worktree appears as a regular workspace in Claudia's sidebar, but with visual cues linking it to its parent repo. Users who don't know git worktrees can still use them — they just see "isolated branch workspaces." + +### 2.2 User Flows + +#### Flow A: Create a worktree from workspace menu + +``` +User right-clicks workspace "my-app" (on branch main) + -> Menu: "New Worktree..." + -> Modal: + Branch name: [feature/auth ] <- auto-prefixed, autocomplete from remote + Base branch: [main v] <- dropdown: main, develop, etc. + [ ] Start from clean state (no uncommitted changes) + [Create Worktree] + -> New workspace appears: "my-app > feature/auth" + -> User creates tasks in it normally +``` + +#### Flow B: Auto-worktree on task creation (opt-in) + +``` +Workspace "my-app" has setting: "Isolate tasks in worktrees" [toggle] +User creates task: "Add OAuth login" + -> Claudia auto-creates worktree: .claudia-worktrees/my-app/task-{short-id}/ + -> Branch: claudia/task-{short-id} (auto-named) + -> Task runs in isolated worktree + -> When task completes + user archives -> offer to merge/delete worktree +``` + +#### Flow C: Quick branch-switch via worktree + +``` +User clicks branch badge "main" on workspace + -> Popover shows: + Current: main + ---------- + Worktrees: + feature/auth (2 tasks) [Open] + fix/typo (0 tasks) [Open] [trash] + ---------- + [+ New Worktree] + [Manage Worktrees...] +``` + +#### Flow D: Worktree cleanup + +``` +User clicks "Manage Worktrees..." or right-click -> "Worktrees" + -> Panel shows all worktrees for this repo: + +----------------------------------------------+ + | Worktrees for my-app | + | | + | DIR main (primary) /code/my-app | + | 3 tasks - active | + | | + | BRANCH feature/auth .claudia-worktrees/ | + | 2 tasks - last active 2h ago [Remove] | + | | + | BRANCH fix/typo .claudia-worktrees/ | + | 0 tasks - stale [Remove] | + | | + | WARNING 1 orphaned worktree [Prune All] | + +----------------------------------------------+ +``` + +### 2.3 Visual Design in Sidebar + +``` ++-- Workspaces --------------------+ +| | +| v DIR my-app main (3) | <- parent repo +| +- Task: Fix login bug | +| +- Task: Update deps | +| | +| v BRANCH my-app > feature/auth(2)| <- worktree (badged) +| +- Task: Add OAuth provider | +| +- Task: Write auth tests | +| | +| > BRANCH my-app > fix/typo (0) | <- collapsed, no tasks +| | +| v DIR other-project develop (1) | <- unrelated workspace +| +- Task: Refactor API | +| | ++-----------------------------------+ +``` + +Key visual cues: +- **Branch icon** distinguishes worktrees from regular workspaces +- **"parent > branch"** naming shows the relationship +- **Worktrees grouped near parent** in sidebar (not scattered) +- **Branch badge always visible** (already exists, works naturally) + +--- + +## 3. Data Model Changes + +### 3.1 Shared Types (`shared/src/index.ts`) + +```typescript +// NEW: Worktree metadata +export interface WorktreeInfo { + path: string; // Absolute path to worktree directory + branch: string; // Branch checked out in this worktree + isMain: boolean; // Is this the primary working tree? + commitHash: string; // Current HEAD + isLocked?: boolean; // Locked worktrees can't be removed + lockedReason?: string; // Why it's locked + prunable?: boolean; // Worktree dir deleted but not pruned + taskCount?: number; // Number of active Claudia tasks in this worktree +} + +// EXTENDED: Workspace gains optional worktree awareness +export interface Workspace { + id: string; // Full path (unchanged) + name: string; // Folder name (unchanged) + createdAt: string; + systemPrompt?: string; + displayName?: string; + references?: WorkspaceReference[]; + + // NEW: Worktree fields + worktreeParentId?: string; // If this workspace IS a worktree, points to parent workspace ID + worktreeBranch?: string; // Branch name for this worktree + autoWorktree?: boolean; // If true, new tasks auto-create worktrees + worktreeBasePath?: string; // Where to create worktrees (default: .claudia-worktrees/) +} +``` + +### 3.2 WebSocket Messages (new) + +```typescript +export type WSMessageType = + // ... existing ... + // Worktree management + | 'worktree:list' // Request list of worktrees for a workspace + | 'worktree:listed' // Response with worktree list + | 'worktree:create' // Create a new worktree + | 'worktree:created' // Worktree created successfully + | 'worktree:remove' // Remove a worktree + | 'worktree:removed' // Worktree removed successfully + | 'worktree:error' // Error in worktree operation + | 'worktree:prune' // Prune stale worktrees + | 'worktree:pruned' // Prune completed +``` + +### 3.3 REST Endpoints (new) + +``` +GET /api/workspaces/:id/worktrees -> List worktrees for repo +POST /api/workspaces/:id/worktrees -> Create worktree +DELETE /api/workspaces/:id/worktrees/:branch -> Remove worktree +POST /api/workspaces/:id/worktrees/prune -> Prune orphaned worktrees +``` + +REST is preferred over WebSocket here because these are request/response operations (not streaming), and REST gives us proper HTTP status codes for error handling. + +--- + +## 4. Backend Implementation + +### 4.1 New File: `backend/src/worktree-manager.ts` + +Central module for all git worktree operations. Wraps `git worktree` commands with safety checks. + +```typescript +export class WorktreeManager { + + // Resolve whether a path is inside a worktree, and find the main repo + async getWorktreeRoot(cwd: string): Promise<{ + mainWorktree: string; // Path to primary working tree + currentWorktree: string; // Path to cwd's worktree (may equal mainWorktree) + isWorktree: boolean; // true if cwd is a linked worktree (not main) + }>; + + // List all worktrees for a repository + async listWorktrees(repoPath: string): Promise; + + // Create a new worktree + async createWorktree(opts: { + repoPath: string; // Main repo (or any worktree of it) + branch: string; // Branch to create/checkout + baseBranch?: string; // Branch to base off (default: current HEAD) + targetDir?: string; // Where to put it (default: auto-generated) + createBranch?: boolean; // Create new branch (true) or checkout existing (false) + }): Promise<{ path: string; branch: string }>; + + // Remove a worktree + async removeWorktree(opts: { + repoPath: string; + worktreePath: string; + force?: boolean; // Force remove even with changes + }): Promise; + + // Prune stale worktree references + async pruneWorktrees(repoPath: string): Promise; + + // Lock/unlock a worktree (prevent accidental deletion) + async lockWorktree(worktreePath: string, reason?: string): Promise; + async unlockWorktree(worktreePath: string): Promise; + + // Check if a branch is already checked out in any worktree + async isBranchInWorktree(repoPath: string, branch: string): Promise; +} +``` + +**Key implementation details:** + +```typescript +// Parsing `git worktree list --porcelain` output +async listWorktrees(repoPath: string): Promise { + const { stdout } = await execFileAsync('git', + ['worktree', 'list', '--porcelain'], + { cwd: repoPath } + ); + + // Output format: + // worktree /path/to/main + // HEAD abc123 + // branch refs/heads/main + // + // worktree /path/to/feature + // HEAD def456 + // branch refs/heads/feature + // + + return parseWorktreeListOutput(stdout); +} +``` + +**Worktree directory strategy:** + +``` +# Default location (configurable per workspace): +{repoPath}/.claudia-worktrees/{branch-slug}/ + +# Example: +/home/user/code/my-app/ <- main worktree +/home/user/code/my-app/.claudia-worktrees/feature-auth/ <- linked worktree +/home/user/code/my-app/.claudia-worktrees/fix-typo/ <- linked worktree +``` + +Add `.claudia-worktrees/` to `.gitignore` automatically on first worktree creation. + +### 4.2 Changes to `git-utils.ts` + +```typescript +// NEW: Detect if a directory is a linked worktree +export async function isLinkedWorktree(cwd: string): Promise { + try { + // In a linked worktree, .git is a FILE containing "gitdir: ..." + // In main worktree, .git is a DIRECTORY + const gitPath = join(cwd, '.git'); + const stat = await fs.stat(gitPath); + return stat.isFile(); // file = linked worktree, directory = main + } catch { + return false; + } +} + +// NEW: Get the main working tree path from any worktree +export async function getMainWorktreePath(cwd: string): Promise { + const { stdout } = await execFileAsync('git', + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd } + ); + // Returns path to shared .git directory + // For main worktree: /path/to/repo/.git + // For linked worktree: /path/to/repo/.git + // Strip trailing /.git to get main worktree path + return resolve(stdout.trim().replace(/[\/\\]\.git[\/\\]?$/, '')); +} + +// UNCHANGED: getCurrentBranch — works identically in worktrees +// UNCHANGED: getHeadCommit — works identically in worktrees +// UNCHANGED: captureGitStateBefore — works identically (each worktree has own HEAD) +``` + +### 4.3 Changes to `workspace-store.ts` + +```typescript +class WorkspaceStore { + // NEW: When adding a workspace, detect if it's a worktree + async addWorkspace(dirPath: string): Promise { + const resolved = resolve(dirPath); + // ... existing validation ... + + // Detect worktree status + let worktreeParentId: string | undefined; + let worktreeBranch: string | undefined; + + if (await isLinkedWorktree(resolved)) { + const mainPath = await getMainWorktreePath(resolved); + worktreeParentId = mainPath; // Points to parent workspace + worktreeBranch = await getCurrentBranch(resolved) ?? undefined; + } + + const workspace: Workspace = { + id: resolved, + name: basename(resolved), + createdAt: new Date().toISOString(), + worktreeParentId, + worktreeBranch, + }; + + // Auto-set displayName for worktrees: "parent-name > branch" + if (worktreeParentId) { + const parentName = this.getWorkspace(worktreeParentId)?.displayName + ?? basename(worktreeParentId); + workspace.displayName = `${parentName} > ${worktreeBranch}`; + } + + this.config.workspaces.push(workspace); + this.saveConfig(); + return workspace; + } + + // NEW: Get all worktrees associated with a workspace + getWorktreeChildren(workspaceId: string): Workspace[] { + return this.config.workspaces.filter(w => w.worktreeParentId === workspaceId); + } + + // NEW: Sort workspaces so worktrees appear after their parent + getSortedWorkspaces(): Workspace[] { + const result: Workspace[] = []; + const worktreeMap = new Map(); + + // Group worktrees by parent + for (const ws of this.config.workspaces) { + if (ws.worktreeParentId) { + const children = worktreeMap.get(ws.worktreeParentId) ?? []; + children.push(ws); + worktreeMap.set(ws.worktreeParentId, children); + } + } + + // Interleave: parent, then its worktrees, then next workspace + for (const ws of this.config.workspaces) { + if (!ws.worktreeParentId) { + result.push(ws); + const children = worktreeMap.get(ws.id) ?? []; + result.push(...children); + } + } + + return result; + } + + // NEW: Remove workspace + clean up worktree on disk + async removeWorktreeWorkspace(workspaceId: string, force = false): Promise { + const ws = this.getWorkspace(workspaceId); + if (!ws?.worktreeParentId) throw new Error('Not a worktree workspace'); + + // Check for active tasks + // ... safety checks ... + + // Remove the git worktree + const manager = new WorktreeManager(); + await manager.removeWorktree({ + repoPath: ws.worktreeParentId, + worktreePath: ws.id, + force, + }); + + // Remove from workspace list + this.deleteWorkspace(workspaceId); + } +} +``` + +### 4.4 Changes to `server.ts` + +New REST endpoints: + +```typescript +// List worktrees for a workspace/repo +app.get('/api/workspaces/:workspaceId/worktrees', async (req, res) => { + const { workspaceId } = req.params; + const decodedId = decodeURIComponent(workspaceId); + + try { + const manager = new WorktreeManager(); + const worktrees = await manager.listWorktrees(decodedId); + + // Enrich with Claudia task counts + for (const wt of worktrees) { + wt.taskCount = taskSpawner.getTasksForWorkspace(wt.path).length; + } + + res.json({ worktrees }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Create a worktree +app.post('/api/workspaces/:workspaceId/worktrees', async (req, res) => { + const { workspaceId } = req.params; + const { branch, baseBranch, createBranch } = req.body; + + try { + const manager = new WorktreeManager(); + const result = await manager.createWorktree({ + repoPath: decodeURIComponent(workspaceId), + branch, + baseBranch, + createBranch: createBranch ?? true, + }); + + // Auto-register as workspace + const workspace = await workspaceStore.addWorkspace(result.path); + + // Broadcast to all clients + broadcastToAll({ + type: 'workspace:created', + payload: { workspace } + }); + + res.json({ workspace, worktreePath: result.path }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +// Remove a worktree +app.delete('/api/workspaces/:workspaceId/worktrees/:branch', async (req, res) => { + // ... validation, safety checks, removal ... +}); +``` + +New WebSocket message handlers (for real-time UX): + +```typescript +case 'worktree:create': { + // Same logic as REST POST, but via WebSocket for live feedback + const { workspaceId, branch, baseBranch } = payload; + // ... create worktree, register workspace, broadcast ... +} + +case 'worktree:remove': { + // Check for active tasks, warn or block + const { workspaceId, worktreePath, force } = payload; + const activeTasks = taskSpawner.getTasksForWorkspace(worktreePath); + if (activeTasks.length > 0 && !force) { + ws.send(JSON.stringify({ + type: 'worktree:error', + payload: { + error: `Cannot remove: ${activeTasks.length} active tasks`, + activeTasks: activeTasks.map(t => t.id), + } + })); + return; + } + // ... proceed with removal ... +} +``` + +### 4.5 Changes to `task-spawner.ts` + +```typescript +// In createTask(): +async createTask(prompt, workspaceId, systemPrompt, cols, rows) { + const workspace = this.workspaceStore.getWorkspace(workspaceId); + + // AUTO-WORKTREE MODE: if workspace has autoWorktree enabled + if (workspace?.autoWorktree) { + const manager = new WorktreeManager(); + const taskId = generateId(); // pre-generate task ID for branch name + const branchSlug = `claudia/${taskId.slice(0, 8)}`; + + try { + const wt = await manager.createWorktree({ + repoPath: workspaceId, + branch: branchSlug, + createBranch: true, + }); + + // Register worktree as workspace + const wtWorkspace = await this.workspaceStore.addWorkspace(wt.path); + + // Spawn task in worktree instead of parent + workspaceId = wt.path; + + console.log(`[TaskSpawner] Auto-created worktree ${wt.path} for task`); + } catch (err) { + console.warn(`[TaskSpawner] Auto-worktree failed, falling back:`, err); + // Fall through to normal task creation in parent workspace + } + } + + // ... rest of existing createTask logic (unchanged) ... +} +``` + +### 4.6 Enhanced git-status endpoint + +```typescript +// UPDATED: git-status response includes worktree info +export async function getGitStatus(cwd: string): Promise<{ + branch: string | null; + isGitRepo: boolean; + isWorktree: boolean; // NEW + mainWorktreePath?: string; // NEW + worktreeCount?: number; // NEW +}> { + const isRepo = await isGitRepo(cwd); + if (!isRepo) return { branch: null, isGitRepo: false, isWorktree: false }; + + const branch = await getCurrentBranch(cwd); + const isWt = await isLinkedWorktree(cwd); + + let mainWorktreePath: string | undefined; + let worktreeCount: number | undefined; + + if (isWt) { + mainWorktreePath = await getMainWorktreePath(cwd); + } + + // Count worktrees (lightweight) + try { + const { stdout } = await execFileAsync('git', ['worktree', 'list'], { cwd }); + worktreeCount = stdout.trim().split('\n').filter(l => l.trim()).length; + } catch { /* ignore */ } + + return { branch, isGitRepo: true, isWorktree: isWt, mainWorktreePath, worktreeCount }; +} +``` + +--- + +## 5. Frontend Implementation + +### 5.1 Worktree State in Store (`taskStore.ts`) + +```typescript +interface TaskStore { + // ... existing ... + + // NEW: Worktree state + worktreesCache: Map; // workspaceId -> worktrees + worktreeLoading: Set; // workspaceIds currently loading + + // NEW: Actions + fetchWorktrees: (workspaceId: string) => Promise; + createWorktree: (workspaceId: string, branch: string, baseBranch?: string) => Promise; + removeWorktree: (workspaceId: string, worktreePath: string) => Promise; +} +``` + +### 5.2 WorkspacePanel Changes + +**Workspace sorting** — group worktrees after their parent: + +```typescript +// In WorkspacePanel render: +const sortedWorkspaces = useMemo(() => { + const parents: Workspace[] = []; + const childMap = new Map(); + + for (const ws of workspaces) { + if (ws.worktreeParentId) { + const siblings = childMap.get(ws.worktreeParentId) ?? []; + siblings.push(ws); + childMap.set(ws.worktreeParentId, siblings); + } else { + parents.push(ws); + } + } + + const result: Workspace[] = []; + for (const parent of parents) { + result.push(parent); + result.push(...(childMap.get(parent.id) ?? [])); + } + return result; +}, [workspaces]); +``` + +**Workspace header — worktree indicator:** + +```tsx +// In WorkspaceSection header: +
+ {workspace.worktreeParentId ? ( + + ) : ( + + )} + + + {workspace.displayName ?? workspace.name} + + + {branchName && ( + + + {branchName} + + )} + + {/* NEW: Worktree count badge on parent workspaces */} + {!workspace.worktreeParentId && worktreeCount > 0 && ( + setShowWorktreePopover(true)} + > + + {worktreeCount} + + )} +
+``` + +**Workspace menu — new worktree actions:** + +```tsx +// In workspace context menu, after existing items: +{isGitRepo && !workspace.worktreeParentId && ( + <> + + setShowWorktreeModal(true)}> + + New Worktree... + + setShowWorktreeManager(true)}> + + Manage Worktrees + + + + Auto-isolate tasks + + + +)} + +{workspace.worktreeParentId && ( + <> + + navigateToParent()}> + + Go to Parent Workspace + + removeWorktree()} danger> + + Remove Worktree + + +)} +``` + +### 5.3 New Component: `WorktreeCreateModal.tsx` + +```tsx +function WorktreeCreateModal({ workspace, onClose, onCreate }) { + const [branchName, setBranchName] = useState(''); + const [baseBranch, setBaseBranch] = useState('main'); + const [createNew, setCreateNew] = useState(true); + const [remoteBranches, setRemoteBranches] = useState([]); + + // Fetch remote branches for autocomplete + useEffect(() => { fetchBranches(workspace.id); }, []); + + return ( + +
+ + Create new branch + Checkout existing branch + + + + + + + {createNew && ( + +