diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index b7cea03c..8bf58b38 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -135,6 +135,27 @@ fn sanitize_projects(value: &Value) -> Option { } } + // Preserve worktreeDefaults + if let Some(Value::Object(wt)) = obj.get("worktreeDefaults") { + let mut defaults = serde_json::Map::new(); + if let Some(Value::String(s)) = wt.get("branchPrefix") { + if !s.trim().is_empty() { + defaults.insert("branchPrefix".to_string(), json!(s.trim())); + } + } + if let Some(Value::String(s)) = wt.get("baseBranch") { + if !s.trim().is_empty() { + defaults.insert("baseBranch".to_string(), json!(s.trim())); + } + } + if let Some(Value::Bool(b)) = wt.get("autoCreateWorktree") { + defaults.insert("autoCreateWorktree".to_string(), json!(b)); + } + if !defaults.is_empty() { + project.insert("worktreeDefaults".to_string(), Value::Object(defaults)); + } + } + result.push(Value::Object(project)); } diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 07caf3f9..882404ff 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings'; import { AboutSettings } from './AboutSettings'; import { SessionRetentionSettings } from './SessionRetentionSettings'; import { DefaultsSettings } from './DefaultsSettings'; +import { WorktreeSectionContent } from './WorktreeSectionContent'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; import { isWebRuntime } from '@/lib/desktop'; @@ -50,6 +51,8 @@ export const OpenChamberPage: React.FC = ({ section }) => return ; case 'sessions': return ; + case 'worktree': + return ; default: return null; } diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx index 2c67d8f9..1e47b200 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx @@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { AboutSettings } from './AboutSettings'; import { cn } from '@/lib/utils'; -export type OpenChamberSection = 'visual' | 'chat' | 'sessions'; +export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree'; interface OpenChamberSidebarProps { selectedSection: OpenChamberSection; @@ -34,6 +34,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [ label: 'Sessions', items: ['Defaults', 'Retention'], }, + { + id: 'worktree', + label: 'Worktree', + items: ['Branch', 'Setup'], + }, ]; export const OpenChamberSidebar: React.FC = ({ diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx new file mode 100644 index 00000000..58efd7e0 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -0,0 +1,571 @@ +import React from 'react'; +import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useGitBranches, useIsGitRepo } from '@/stores/useGitStore'; +import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi'; +import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; +import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService'; +import { sessionEvents } from '@/lib/sessionEvents'; +import type { WorktreeMetadata } from '@/types/worktree'; + +type BranchOption = { + value: string; + label: string; + group: 'special' | 'local' | 'remote'; +}; + +export const WorktreeSectionContent: React.FC = () => { + const activeProject = useProjectsStore((state) => state.getActiveProject()); + const updateWorktreeDefaults = useProjectsStore((state) => state.updateWorktreeDefaults); + + const projectPath = activeProject?.path ?? null; + const worktreeDefaults = activeProject?.worktreeDefaults; + + const isGitRepoFromStore = useIsGitRepo(projectPath); + const branchesFromStore = useGitBranches(projectPath); + + const { sessions, getWorktreeMetadata } = useSessionStore(); + + const [branchPrefix, setBranchPrefix] = React.useState(worktreeDefaults?.branchPrefix ?? ''); + const [baseBranch, setBaseBranch] = React.useState(worktreeDefaults?.baseBranch ?? 'HEAD'); + const [setupCommands, setSetupCommands] = React.useState([]); + const [isLoadingCommands, setIsLoadingCommands] = React.useState(false); + const [isLoadingGit, setIsLoadingGit] = React.useState(false); + const [isGitRepoLocal, setIsGitRepoLocal] = React.useState(null); + const [branchesLocal, setBranchesLocal] = React.useState<{ all: string[]; current: string } | null>(null); + const [availableWorktrees, setAvailableWorktrees] = React.useState([]); + const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false); + + const WORKTREE_ROOT = '.openchamber'; + + const joinWorktreePath = React.useCallback((projectDirectory: string, slug: string): string => { + const normalizedProject = projectDirectory.replace(/\\/g, '/').replace(/\/+$/, ''); + const base = !normalizedProject || normalizedProject === '/' + ? `/${WORKTREE_ROOT}` + : `${normalizedProject}/${WORKTREE_ROOT}`; + return slug ? `${base}/${slug}` : base; + }, []); + + const refreshWorktrees = React.useCallback(async () => { + if (!projectPath || isGitRepoLocal === false) return; + + try { + const worktrees = await listWorktrees(projectPath); + const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectPath, info)); + const worktreeRoot = joinWorktreePath(projectPath, ''); + const worktreePrefix = `${worktreeRoot}/`; + const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix)); + setAvailableWorktrees(filtered); + } catch { + // Ignore errors + } + }, [projectPath, isGitRepoLocal, joinWorktreePath]); + + // Load git info when project changes + React.useEffect(() => { + if (!projectPath) return; + + let cancelled = false; + setIsLoadingGit(true); + setIsLoadingWorktrees(true); + setIsGitRepoLocal(null); + setBranchesLocal(null); + setAvailableWorktrees([]); + + (async () => { + try { + const repoStatus = await checkIsGitRepository(projectPath); + if (cancelled) return; + setIsGitRepoLocal(repoStatus); + + if (repoStatus) { + const [branchData, worktrees] = await Promise.all([ + getGitBranches(projectPath), + listWorktrees(projectPath).catch(() => []), + ]); + + if (!cancelled) { + if (branchData) { + setBranchesLocal({ all: branchData.all, current: branchData.current }); + } + + // Filter worktrees to only show those under .openchamber + const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectPath, info)); + const worktreeRoot = `${projectPath.replace(/\\/g, '/').replace(/\/+$/, '')}/${WORKTREE_ROOT}`; + const worktreePrefix = `${worktreeRoot}/`; + const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix)); + setAvailableWorktrees(filtered); + } + } + } catch { + // Ignore errors + } finally { + if (!cancelled) { + setIsLoadingGit(false); + setIsLoadingWorktrees(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectPath]); + + // Load setup commands + React.useEffect(() => { + if (!projectPath) return; + + let cancelled = false; + setIsLoadingCommands(true); + + (async () => { + try { + const commands = await getWorktreeSetupCommands(projectPath); + if (!cancelled) { + setSetupCommands(commands.length > 0 ? commands : ['']); + } + } catch { + if (!cancelled) { + setSetupCommands(['']); + } + } finally { + if (!cancelled) { + setIsLoadingCommands(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectPath]); + + // Sync local state with store when project changes + React.useEffect(() => { + setBranchPrefix(worktreeDefaults?.branchPrefix ?? ''); + setBaseBranch(worktreeDefaults?.baseBranch ?? 'HEAD'); + }, [worktreeDefaults]); + + // Use local branches if available, otherwise fall back to store + const branches = branchesLocal ?? branchesFromStore; + const isGitRepo = isGitRepoLocal ?? isGitRepoFromStore; + + const branchOptions = React.useMemo(() => { + const options: BranchOption[] = []; + const headLabel = branches?.current + ? `Current (HEAD: ${branches.current})` + : 'Current (HEAD)'; + options.push({ value: 'HEAD', label: headLabel, group: 'special' }); + + if (branches) { + const localBranches = branches.all + .filter((name: string) => !name.startsWith('remotes/')) + .sort((a: string, b: string) => a.localeCompare(b)); + + localBranches.forEach((name: string) => { + options.push({ value: name, label: name, group: 'local' }); + }); + + const remoteBranches = branches.all + .filter((name: string) => name.startsWith('remotes/')) + .map((name: string) => name.replace(/^remotes\//, '')) + .sort((a: string, b: string) => a.localeCompare(b)); + + remoteBranches.forEach((name: string) => { + options.push({ value: name, label: name, group: 'remote' }); + }); + } + + return options; + }, [branches]); + + // Track pending changes for save-on-unmount + const pendingBranchPrefixRef = React.useRef(null); + + const handleBranchPrefixChange = React.useCallback((value: string) => { + setBranchPrefix(value); + pendingBranchPrefixRef.current = value; + }, []); + + const saveBranchPrefix = React.useCallback((value: string) => { + if (!activeProject?.id) return; + updateWorktreeDefaults(activeProject.id, { branchPrefix: value }); + pendingBranchPrefixRef.current = null; + }, [activeProject?.id, updateWorktreeDefaults]); + + const handleBranchPrefixBlur = React.useCallback(() => { + saveBranchPrefix(branchPrefix); + }, [branchPrefix, saveBranchPrefix]); + + // Save pending changes on unmount + React.useEffect(() => { + return () => { + if (pendingBranchPrefixRef.current !== null && activeProject?.id) { + updateWorktreeDefaults(activeProject.id, { branchPrefix: pendingBranchPrefixRef.current }); + } + }; + }, [activeProject?.id, updateWorktreeDefaults]); + + const handleBaseBranchChange = React.useCallback((value: string) => { + setBaseBranch(value); + if (!activeProject?.id) return; + updateWorktreeDefaults(activeProject.id, { baseBranch: value }); + }, [activeProject?.id, updateWorktreeDefaults]); + + const handleSetupCommandChange = React.useCallback((index: number, value: string) => { + setSetupCommands((prev) => { + const next = [...prev]; + next[index] = value; + return next; + }); + }, []); + + const handleAddCommand = React.useCallback(() => { + setSetupCommands((prev) => [...prev, '']); + }, []); + + const handleRemoveCommand = React.useCallback((index: number) => { + setSetupCommands((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const saveSetupCommands = React.useCallback(async () => { + if (!projectPath) return; + const filtered = setupCommands.filter((cmd) => cmd.trim().length > 0); + await saveWorktreeSetupCommands(projectPath, filtered); + }, [projectPath, setupCommands]); + + // Save setup commands on blur + const handleCommandBlur = React.useCallback(() => { + saveSetupCommands(); + }, [saveSetupCommands]); + + // Delete worktree handler + const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => { + const normalizedWorktreePath = worktree.path.replace(/\\/g, '/').replace(/\/+$/, ''); + + // Find sessions linked to this worktree by: + // 1. Worktree metadata path match + // 2. Session directory match + const directSessions = sessions.filter((session) => { + // Check worktree metadata + const metadata = getWorktreeMetadata(session.id); + if (metadata?.path === worktree.path) { + return true; + } + + // Check session directory + const sessionDir = (session as { directory?: string }).directory; + if (sessionDir) { + const normalizedSessionDir = sessionDir.replace(/\\/g, '/').replace(/\/+$/, ''); + if (normalizedSessionDir === normalizedWorktreePath) { + return true; + } + } + + return false; + }); + + // Build a set of session IDs that are directly linked + const directSessionIds = new Set(directSessions.map((s) => s.id)); + + // Find all subsessions recursively + const findSubsessions = (parentIds: Set): typeof sessions => { + const subsessions = sessions.filter((session) => { + const parentID = (session as { parentID?: string | null }).parentID; + return parentID && parentIds.has(parentID); + }); + if (subsessions.length === 0) { + return []; + } + const subsessionIds = new Set(subsessions.map((s) => s.id)); + return [...subsessions, ...findSubsessions(subsessionIds)]; + }; + + const allSubsessions = findSubsessions(directSessionIds); + + // Dedupe sessions (in case same session matched both ways) + const seenIds = new Set(); + const allSessions = [...directSessions, ...allSubsessions].filter((session) => { + if (seenIds.has(session.id)) { + return false; + } + seenIds.add(session.id); + return true; + }); + + sessionEvents.requestDelete({ + sessions: allSessions, + mode: 'worktree', + worktree, + }); + }, [sessions, getWorktreeMetadata]); + + + + // Refresh worktrees when sessions change (after deletion) + const sessionsKey = React.useMemo(() => sessions.map(s => s.id).join(','), [sessions]); + React.useEffect(() => { + if (isGitRepoLocal && projectPath) { + refreshWorktrees(); + } + }, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]); + + if (!projectPath) { + return ( +
+
+

Worktree settings

+

+ Select a project to configure worktree defaults. +

+
+
+ ); + } + + if (isLoadingGit) { + return ( +
+
+

Worktree settings

+

+ Loading... +

+
+
+ ); + } + + if (isGitRepo === false) { + return ( +
+
+

Worktree settings

+

+ Worktree settings are only available for Git repositories. +

+
+
+ ); + } + + return ( +
+ {/* Branch prefix */} +
+
+
+

Branch prefix

+ + + + + + Prefix for auto-generated branch names when creating new worktrees. + + +
+

+ e.g. feature, bugfix, wip (no trailing slash) +

+
+ + handleBranchPrefixChange(e.target.value)} + onBlur={handleBranchPrefixBlur} + placeholder="feature" + className="max-w-xs" + /> +
+ + {/* Default base branch */} +
+
+
+

Base branch

+ + + + + + Default branch to create new worktrees from. + + +
+

+ Default branch for new worktree branches +

+
+ + +
+ + {/* Setup commands */} +
+
+

Setup commands

+

+ Run automatically when a new worktree is created. + Use $ROOT_WORKTREE_PATH for the project root. +

+
+ + {isLoadingCommands ? ( +

Loading...

+ ) : ( +
+ {setupCommands.map((command, index) => ( +
+ handleSetupCommandChange(index, e.target.value)} + onBlur={handleCommandBlur} + placeholder="e.g., bun install" + className="flex-1 font-mono text-xs" + /> + +
+ ))} + +
+ )} +
+ + {/* Existing worktrees */} +
+
+
+

Existing worktrees

+ + + + + + Worktrees created under .openchamber directory. + Deleting a worktree will also remove any linked sessions. + + +
+

+ Manage worktrees for this project +

+
+ + {isLoadingWorktrees ? ( +

Loading worktrees...

+ ) : availableWorktrees.length === 0 ? ( +

+ No worktrees found under .openchamber +

+ ) : ( +
+ {availableWorktrees.map((worktree) => ( +
+
+

+ {worktree.label || worktree.branch || 'Detached HEAD'} +

+

+ {worktree.relativePath || worktree.path} +

+
+ +
+ ))} +
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 410942e1..238e51c8 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -1,16 +1,5 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { toast } from 'sonner'; import { @@ -21,8 +10,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { RiAddLine, RiArrowDownSLine, RiCheckboxBlankLine, RiCheckboxLine, RiCloseLine, RiDeleteBinLine } from '@remixicon/react'; +import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { DirectoryExplorerDialog } from './DirectoryExplorerDialog'; import { cn, formatPathForDisplay } from '@/lib/utils'; @@ -30,51 +18,20 @@ import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; import { archiveWorktree, - createWorktree, getWorktreeStatus, - listWorktrees as listGitWorktrees, - mapWorktreeToMetadata, - removeWorktree, - runWorktreeSetupCommands, } from '@/lib/git/worktreeService'; -import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; -import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi'; +import { ensureOpenChamberIgnored } from '@/lib/gitApi'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { isDesktopRuntime } from '@/lib/desktop'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { sessionEvents } from '@/lib/sessionEvents'; -const WORKTREE_ROOT = '.openchamber'; - const renderToastDescription = (text?: string) => text ? {text} : undefined; -const sanitizeBranchNameInput = (value: string): string => { - return value - .trim() - .replace(/\s+/g, '-') - .replace(/[^A-Za-z0-9._/-]/g, '-') - .replace(/-+/g, '-') - .replace(/\/{2,}/g, '/') - .replace(/\/-+/g, '/') - .replace(/-+\//g, '/') - .replace(/^[-/]+/, '') - .replace(/[-/]+$/, ''); -}; - -const sanitizeWorktreeSlug = (value: string): string => { - return value - .trim() - .replace(/[^A-Za-z0-9._-]+/g, '-') - .replace(/^[-_]+|[-_]+$/g, '') - .slice(0, 120); -}; - const normalizeProjectDirectory = (path: string | null | undefined): string => { if (!path) { return ''; @@ -86,24 +43,6 @@ const normalizeProjectDirectory = (path: string | null | undefined): string => { return replaced.replace(/\/+$/, ''); }; -const joinWorktreePath = (projectDirectory: string, slug: string): string => { - const normalizedProject = normalizeProjectDirectory(projectDirectory); - const cleanSlug = sanitizeWorktreeSlug(slug); - const base = - !normalizedProject || normalizedProject === '/' - ? `/${WORKTREE_ROOT}` - : `${normalizedProject}/${WORKTREE_ROOT}`; - return cleanSlug ? `${base}/${cleanSlug}` : base; -}; - -type WorktreeBaseOption = { - value: string; - label: string; - group: 'special' | 'local' | 'remote'; -}; - -type WorktreeCreateMode = 'new' | 'existing'; - type DeleteDialogState = { sessions: Session[]; dateLabel?: string; @@ -114,76 +53,32 @@ type DeleteDialogState = { export const SessionDialogs: React.FC = () => { const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false); const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false); - const [worktreeCreateMode, setWorktreeCreateMode] = React.useState('new'); - const [branchName, setBranchName] = React.useState(''); - const [existingWorktreeBranch, setExistingWorktreeBranch] = React.useState(''); - const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState('HEAD'); - const [availableWorktreeBaseBranches, setAvailableWorktreeBaseBranches] = React.useState([ - { value: 'HEAD', label: 'Current (HEAD)', group: 'special' }, - ]); - const [isLoadingWorktreeBaseBranches, setIsLoadingWorktreeBaseBranches] = React.useState(false); - const [availableWorktrees, setAvailableWorktrees] = React.useState([]); - const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false); - const [worktreeError, setWorktreeError] = React.useState(null); - const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false); - const [isGitRepository, setIsGitRepository] = React.useState(null); - const [mainWorktreeBranch, setMainWorktreeBranch] = React.useState(null); - const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false); - const [worktreeManagerProjectId, setWorktreeManagerProjectId] = React.useState(null); const ensuredIgnoreDirectories = React.useRef>(new Set()); const [deleteDialog, setDeleteDialog] = React.useState(null); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState>([]); const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false); const [isProcessingDelete, setIsProcessingDelete] = React.useState(false); - const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false); - const [setupCommands, setSetupCommands] = React.useState([]); - const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false); const { - sessions, - createSession, deleteSession, deleteSessions, loadSessions, - initializeNewOpenChamberSession, - setWorktreeMetadata, - setSessionDirectory, getWorktreeMetadata, - isLoading, } = useSessionStore(); - const { currentDirectory, homeDirectory, isHomeReady, setDirectory } = useDirectoryStore(); + const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); const { projects, addProject, activeProjectId } = useProjectsStore(); const { requestAccess, startAccessing } = useFileSystemAccess(); - const { agents } = useConfigStore(); - const { isSessionCreateDialogOpen, setSessionCreateDialogOpen } = useUIStore(); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const useMobileOverlay = isMobile || isTablet || hasTouchInput; const projectDirectory = React.useMemo(() => { - const targetProjectId = worktreeManagerProjectId ?? activeProjectId; - const targetProject = targetProjectId - ? projects.find((project) => project.id === targetProjectId) ?? null + const targetProject = activeProjectId + ? projects.find((project) => project.id === activeProjectId) ?? null : null; const targetPath = targetProject?.path ?? currentDirectory; return normalizeProjectDirectory(targetPath); - }, [activeProjectId, currentDirectory, projects, worktreeManagerProjectId]); - const sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]); - const worktreeTargetBranch = React.useMemo( - () => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName), - [existingWorktreeBranch, sanitizedNewBranchName, worktreeCreateMode], - ); - const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(worktreeTargetBranch), [worktreeTargetBranch]); - const isGitRepo = isGitRepository === true; - const selectedWorktreeBaseLabel = React.useMemo(() => { - const match = availableWorktreeBaseBranches.find((option) => option.value === worktreeBaseBranch); - if (match) { - return match.label; - } - if (worktreeBaseBranch === 'HEAD') { - return 'Current (HEAD)'; - } - return worktreeBaseBranch; - }, [availableWorktreeBaseBranches, worktreeBaseBranch]); + }, [activeProjectId, currentDirectory, projects]); + const hasDirtyWorktrees = React.useMemo( () => (deleteDialog?.worktree?.status?.isDirty ?? false) || @@ -293,164 +188,6 @@ export const SessionDialogs: React.FC = () => { startAccessing, ]); - React.useEffect(() => { - if (!isSessionCreateDialogOpen) { - setWorktreeManagerProjectId(null); - setWorktreeCreateMode('new'); - setBranchName(''); - setExistingWorktreeBranch(''); - setWorktreeBaseBranch('HEAD'); - setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); - setIsLoadingWorktreeBaseBranches(false); - setAvailableWorktrees([]); - setWorktreeError(null); - setIsLoadingWorktrees(false); - setIsCheckingGitRepository(false); - setIsGitRepository(null); - setIsCreatingWorktree(false); - setSetupCommands([]); - setIsSetupCommandsOpen(false); - return; - } - - if (!projectDirectory) { - setWorktreeCreateMode('new'); - setExistingWorktreeBranch(''); - setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); - setWorktreeBaseBranch('HEAD'); - setAvailableWorktrees([]); - setIsGitRepository(null); - setIsCheckingGitRepository(false); - return; - } - - let cancelled = false; - setIsLoadingWorktrees(true); - setIsLoadingWorktreeBaseBranches(true); - setIsCheckingGitRepository(true); - setWorktreeError(null); - - (async () => { - try { - const repoStatus = await checkIsGitRepository(projectDirectory); - if (cancelled) { - return; - } - setIsGitRepository(repoStatus); - - if (!repoStatus) { - setWorktreeCreateMode('new'); - setExistingWorktreeBranch(''); - setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); - setWorktreeBaseBranch('HEAD'); - setAvailableWorktrees([]); - setWorktreeError(null); - } else { - const [worktrees, branches] = await Promise.all([ - listGitWorktrees(projectDirectory), - getGitBranches(projectDirectory).catch(() => null), - ]); - if (cancelled) { - return; - } - - const worktreeBaseOptions: WorktreeBaseOption[] = []; - const headLabel = branches?.current - ? `Current (HEAD: ${branches.current})` - : 'Current (HEAD)'; - worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' }); - - // Store the main worktree's current branch for exclusion - setMainWorktreeBranch(branches?.current ?? null); - - if (branches) { - const localBranches = branches.all - .filter((name) => !name.startsWith('remotes/')) - .sort((a, b) => a.localeCompare(b)); - const defaultExistingBranch = branches.current && !branches.current.startsWith('remotes/') - ? branches.current - : (localBranches[0] ?? ''); - setExistingWorktreeBranch((previous) => { - const trimmed = previous.trim(); - if (trimmed && localBranches.includes(trimmed)) { - return trimmed; - } - return defaultExistingBranch; - }); - - localBranches.forEach((name) => { - worktreeBaseOptions.push({ value: name, label: name, group: 'local' }); - }); - - const remoteBranches = branches.all - .filter((name) => name.startsWith('remotes/')) - .map((name) => name.replace(/^remotes\//, '')) - .sort((a, b) => a.localeCompare(b)); - remoteBranches.forEach((name) => { - worktreeBaseOptions.push({ value: name, label: name, group: 'remote' }); - }); - } - - setAvailableWorktreeBaseBranches(worktreeBaseOptions); - setWorktreeBaseBranch((previous) => - worktreeBaseOptions.some((option) => option.value === previous) ? previous : 'HEAD' - ); - - const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectDirectory, info)); - const worktreeRoot = joinWorktreePath(projectDirectory, ''); - const worktreePrefix = `${worktreeRoot}/`; - const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix)); - setAvailableWorktrees(filtered); - } - } catch (error) { - if (cancelled) { - return; - } - const message = error instanceof Error ? error.message : 'Failed to load worktrees'; - setWorktreeError(message); - } finally { - if (!cancelled) { - setIsLoadingWorktrees(false); - setIsLoadingWorktreeBaseBranches(false); - setIsCheckingGitRepository(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [isSessionCreateDialogOpen, projectDirectory]); - - // Load setup commands when dialog opens - React.useEffect(() => { - if (!isSessionCreateDialogOpen || !projectDirectory) { - return; - } - - let cancelled = false; - setIsLoadingSetupCommands(true); - - (async () => { - try { - const commands = await getWorktreeSetupCommands(projectDirectory); - if (!cancelled) { - setSetupCommands(commands); - } - } catch { - // Ignore errors, just start with empty commands - } finally { - if (!cancelled) { - setIsLoadingSetupCommands(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [isSessionCreateDialogOpen, projectDirectory]); - const openDeleteDialog = React.useCallback((payload: { sessions: Session[]; dateLabel?: string; mode?: 'session' | 'worktree'; worktree?: WorktreeMetadata | null }) => { setDeleteDialog({ sessions: payload.sessions, @@ -479,19 +216,6 @@ export const SessionDialogs: React.FC = () => { }); }, []); - React.useEffect(() => { - return sessionEvents.onCreateRequest((request) => { - const projectId = typeof request?.projectId === 'string' && request.projectId.trim() ? request.projectId : null; - setWorktreeManagerProjectId(projectId); - setWorktreeCreateMode('new'); - setBranchName(''); - setExistingWorktreeBranch(''); - setWorktreeBaseBranch('HEAD'); - setWorktreeError(null); - setSessionCreateDialogOpen(true); - }); - }, [setSessionCreateDialogOpen]); - React.useEffect(() => { if (!deleteDialog) { setDeleteDialogSummaries([]); @@ -555,241 +279,6 @@ export const SessionDialogs: React.FC = () => { } }, [canRemoveRemoteBranches]); - const handleBranchInputChange = React.useCallback((value: string) => { - setBranchName(value); - setWorktreeError(null); - }, []); - - const refreshWorktrees = React.useCallback(async () => { - if (!projectDirectory || !isGitRepository) { - return; - } - try { - const worktrees = await listGitWorktrees(projectDirectory); - const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectDirectory, info)); - const worktreeRoot = joinWorktreePath(projectDirectory, ''); - const worktreePrefix = `${worktreeRoot}/`; - const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix)); - setAvailableWorktrees(filtered); - } catch { /* ignored */ } - }, [projectDirectory, isGitRepository]); - - // Branches already used in worktrees (cannot be reused for existing branch selection) - // Includes both .openchamber worktrees and the main workspace's current branch - const branchesInWorktrees = React.useMemo(() => { - const branches = availableWorktrees - .map((wt) => wt.branch?.replace(/^refs\/heads\//, '') || wt.label) - .filter(Boolean); - // Also exclude the main worktree's current branch - if (mainWorktreeBranch) { - branches.push(mainWorktreeBranch); - } - return new Set(branches); - }, [availableWorktrees, mainWorktreeBranch]); - - // Available branches for existing branch selection (local + remote, excluding those already in worktrees) - const availableExistingBranches = React.useMemo(() => - availableWorktreeBaseBranches.filter((option) => { - if (option.group !== 'local' && option.group !== 'remote') return false; - - // For local branches, check direct match - if (option.group === 'local') { - return !branchesInWorktrees.has(option.value); - } - - // For remote branches (e.g., "origin/main"), extract the branch name after the remote prefix - // and check if that local branch is already in a worktree - const remoteMatch = option.value.match(/^[^/]+\/(.+)$/); - const localBranchName = remoteMatch ? remoteMatch[1] : option.value; - return !branchesInWorktrees.has(localBranchName); - }), - [availableWorktreeBaseBranches, branchesInWorktrees] - ); - - // Auto-select first available branch if current selection is not available - React.useEffect(() => { - if (availableExistingBranches.length === 0) { - setExistingWorktreeBranch(''); - return; - } - const isCurrentSelectionAvailable = availableExistingBranches.some( - (option) => option.value === existingWorktreeBranch - ); - if (!isCurrentSelectionAvailable) { - setExistingWorktreeBranch(availableExistingBranches[0].value); - } - }, [availableExistingBranches, existingWorktreeBranch]); - - const prevDeleteDialogRef = React.useRef(null); - React.useEffect(() => { - - if (prevDeleteDialogRef.current?.mode === 'worktree' && !deleteDialog) { - refreshWorktrees(); - } - prevDeleteDialogRef.current = deleteDialog; - }, [deleteDialog, refreshWorktrees]); - - const validateWorktreeCreation = React.useCallback((): boolean => { - if (!projectDirectory) { - const message = 'Select a project directory first.'; - setWorktreeError(message); - toast.error(message); - return false; - } - - const normalizedBranch = worktreeTargetBranch; - const slugValue = sanitizedWorktreeSlug; - if (!normalizedBranch) { - const message = - worktreeCreateMode === 'existing' - ? 'Select an existing branch for the new worktree.' - : 'Provide a branch name for the new worktree.'; - setWorktreeError(message); - toast.error(message); - return false; - } - if (!slugValue) { - const message = 'Provide a branch name that can be used as a folder.'; - setWorktreeError(message); - toast.error(message); - return false; - } - const prospectivePath = joinWorktreePath(projectDirectory, slugValue); - if (availableWorktrees.some((worktree) => worktree.path === prospectivePath)) { - const message = 'A worktree with this folder already exists.'; - setWorktreeError(message); - toast.error(message); - return false; - } - - setWorktreeError(null); - return true; - }, [projectDirectory, worktreeCreateMode, worktreeTargetBranch, sanitizedWorktreeSlug, availableWorktrees]); - - const handleCreateWorktree = async () => { - if (isCreatingWorktree || isLoading) { - return; - } - - if (!validateWorktreeCreation()) { - return; - } - - setIsCreatingWorktree(true); - setWorktreeError(null); - - let cleanupMetadata: WorktreeMetadata | null = null; - - try { - const normalizedBranch = worktreeTargetBranch; - const slugValue = sanitizedWorktreeSlug; - const shouldCreateBranch = worktreeCreateMode === 'new'; - const startPoint = shouldCreateBranch && worktreeBaseBranch && worktreeBaseBranch !== 'HEAD' - ? worktreeBaseBranch - : undefined; - const metadata = await createWorktree({ - projectDirectory, - worktreeSlug: slugValue, - branch: normalizedBranch, - createBranch: shouldCreateBranch, - startPoint, - }); - cleanupMetadata = metadata; - const status = await getWorktreeStatus(metadata.path).catch(() => undefined); - const createdMetadata = status ? { ...metadata, status } : metadata; - - const session = await createSession(undefined, metadata.path); - if (!session) { - await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined); - const message = 'Failed to create session for worktree'; - setWorktreeError(message); - toast.error(message); - return; - } - - initializeNewOpenChamberSession(session.id, agents); - setSessionDirectory(session.id, metadata.path); - setWorktreeMetadata(session.id, createdMetadata); - - // Ensure directory-scoped caches and session lists include the new worktree. - setDirectory(metadata.path, { showOverlay: false }); - - // Refresh sessions list so sidebar shows the new session immediately - try { - await loadSessions(); - } catch { - // ignore - } - - await refreshWorktrees(); - setBranchName(''); - setExistingWorktreeBranch(''); - - // Save setup commands if any were configured - const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0); - if (commandsToRun.length > 0) { - // Save commands to config (fire and forget) - saveWorktreeSetupCommands(projectDirectory, commandsToRun).catch(() => { - console.warn('Failed to save worktree setup commands'); - }); - - // Run setup commands in background (non-blocking) - toast.success('Worktree created', { - description: renderToastDescription(`Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`), - }); - - runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun).then((result) => { - if (result.success) { - toast.success('Setup commands completed', { - description: renderToastDescription(`All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`), - }); - } else { - const failed = result.results.filter(r => !r.success); - const succeeded = result.results.filter(r => r.success); - toast.error('Setup commands failed', { - description: renderToastDescription( - `${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` + - (succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : '') - ), - }); - } - }).catch(() => { - toast.error('Setup commands failed', { - description: renderToastDescription('Could not execute setup commands.'), - }); - }); - } else { - toast.success('Worktree created'); - } - - // Close dialog after successful creation - setSessionCreateDialogOpen(false); - } catch (error) { - if (cleanupMetadata) { - await removeWorktree({ projectDirectory, path: cleanupMetadata.path, force: true }).catch(() => undefined); - } - const message = error instanceof Error ? error.message : 'Failed to create worktree'; - setWorktreeError(message); - toast.error(message); - } finally { - setIsCreatingWorktree(false); - } - }; - - const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => { - - const worktreeSessions = sessions.filter((session) => { - const metadata = getWorktreeMetadata(session.id); - return metadata?.path === worktree.path; - }); - - sessionEvents.requestDelete({ - sessions: worktreeSessions, - mode: 'worktree', - worktree, - }); - }, [sessions, getWorktreeMetadata]); - const handleConfirmDelete = React.useCallback(async () => { if (!deleteDialog) { return; @@ -879,322 +368,6 @@ export const SessionDialogs: React.FC = () => { } }, [deleteDialog, deleteDialogShouldRemoveRemote, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, projectDirectory, loadSessions]); - // Special value for "New branch" option in the unified branch selector - const NEW_BRANCH_VALUE = '__new_branch__'; - - const worktreeManagerBody = ( -
- {/* Create worktree section */} -
-
-

Create worktree

-

- Branch-specific directory under {WORKTREE_ROOT} -

-
- -
- -
- - - {/* Branch name input - inline when "New branch" is selected */} - {worktreeCreateMode === 'new' && ( - handleBranchInputChange(e.target.value)} - placeholder="feature/new-branch" - className="h-8 flex-1 min-w-0 typography-meta text-foreground placeholder:text-muted-foreground/70" - disabled={!isGitRepo || isCheckingGitRepository} - onKeyDown={(e) => { - if (e.key === 'Enter' && !isCreatingWorktree) { - handleCreateWorktree(); - } - }} - /> - )} -
- - {/* Base branch selector - only shown when "New branch" is selected */} - {worktreeCreateMode === 'new' && ( - <> - - - - )} - - {/* Preview info */} - {worktreeTargetBranch ? ( -

- {worktreeCreateMode === 'existing' ? ( - <> - Uses branch{' '} - {worktreeTargetBranch} - - ) : ( - <> - Creates{' '} - {worktreeTargetBranch} - {' '}from{' '} - {selectedWorktreeBaseLabel} - - )} -

- ) : null} -
- - {worktreeError &&

{worktreeError}

} - {!isGitRepo && !isCheckingGitRepository && ( -

- Current directory is not a Git repository. -

- )} -
- - {/* Setup commands section */} - - -

- Setup commands - {setupCommands.filter(cmd => cmd.trim()).length > 0 && ( - - {' '}({setupCommands.filter(cmd => cmd.trim()).length} configured) - - )} -

- -
- -
-

- Commands run in the new worktree. Use $ROOT_WORKTREE_PATH for project root. -

- {isLoadingSetupCommands ? ( -

Loading...

- ) : ( -
- {setupCommands.map((command, index) => ( -
- { - const newCommands = [...setupCommands]; - newCommands[index] = e.target.value; - setSetupCommands(newCommands); - }} - placeholder="e.g., bun install" - className="h-8 flex-1 font-mono text-xs" - /> - -
- ))} - -
- )} -
-
-
- - {/* Existing worktrees section */} -
-

Existing worktrees

- - {isLoadingWorktrees ? ( -

Loading worktrees…

- ) : availableWorktrees.length === 0 ? ( -

- No worktrees found under {WORKTREE_ROOT} -

- ) : ( -
- {availableWorktrees.map((worktree) => ( -
-

- {worktree.label || worktree.branch || 'Detached HEAD'} -

- -
- ))} -
- )} -
-
- ); - - const worktreeManagerActions = ( - <> - - - - ); - const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null; const deleteDialogDescription = deleteDialog ? deleteDialog.mode === 'worktree' @@ -1209,15 +382,20 @@ export const SessionDialogs: React.FC = () => {
{deleteDialog.sessions.length > 0 && (
+ {isWorktreeDelete && ( + + {deleteDialog.sessions.length === 1 ? 'Linked session' : 'Linked sessions'} + + )}
    - {deleteDialog.sessions.slice(0, 3).map((session) => ( + {deleteDialog.sessions.slice(0, 5).map((session) => (
  • {session.title || 'Untitled Session'}
  • ))} - {deleteDialog.sessions.length > 3 && ( + {deleteDialog.sessions.length > 5 && (
  • - +{deleteDialog.sessions.length - 3} more + +{deleteDialog.sessions.length - 5} more
  • )}
@@ -1305,31 +483,14 @@ export const SessionDialogs: React.FC = () => { ); + const deleteDialogTitle = isWorktreeDelete + ? 'Delete worktree' + : deleteDialog?.sessions.length === 1 + ? 'Delete session' + : 'Delete sessions'; + return ( <> - {useMobileOverlay ? ( - setSessionCreateDialogOpen(false)} - title="Worktree Manager" - footer={
{worktreeManagerActions}
} - > -
- {worktreeManagerBody} -
-
- ) : ( - - - - Worktree Manager - - {worktreeManagerBody} - {worktreeManagerActions} - - - )} - {useMobileOverlay ? ( { } closeDeleteDialog(); }} - title={deleteDialog?.sessions.length === 1 ? 'Delete session' : 'Delete sessions'} + title={deleteDialogTitle} footer={
{deleteDialogActions}
} >
@@ -1363,7 +524,7 @@ export const SessionDialogs: React.FC = () => { > - {deleteDialog?.sessions.length === 1 ? 'Delete session' : 'Delete sessions'} + {deleteDialogTitle} {deleteDialogDescription && {deleteDialogDescription}} {deleteDialogBody} diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index c88d8fe4..208119e9 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -17,13 +17,13 @@ import { useDeviceInfo } from '@/lib/device'; import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { getModifierLabel } from '@/lib/utils'; +import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; export const CommandPalette: React.FC = () => { const { isCommandPaletteOpen, setCommandPaletteOpen, setHelpDialogOpen, - setSessionCreateDialogOpen, setActiveMainTab, setSettingsDialogOpen, setSessionSwitcherOpen, @@ -66,9 +66,9 @@ export const CommandPalette: React.FC = () => { handleClose(); }; - const handleOpenAdvancedSession = () => { - setSessionCreateDialogOpen(true); + const handleCreateWorktreeSession = () => { handleClose(); + createWorktreeSession(); }; const { isMobile } = useDeviceInfo(); @@ -135,7 +135,7 @@ export const CommandPalette: React.FC = () => { New Session {getModifierLabel()} + N - + New Session with Worktree Shift + {getModifierLabel()} + N diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 4c071b50..c2a7b486 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -5,6 +5,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { hasModifier } from '@/lib/utils'; +import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; export const useKeyboardShortcuts = () => { const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore(); @@ -13,7 +14,6 @@ export const useKeyboardShortcuts = () => { toggleHelpDialog, toggleSidebar, setSessionSwitcherOpen, - setSessionCreateDialogOpen, setActiveMainTab, setSettingsDialogOpen, setModelSelectorOpen, @@ -82,10 +82,13 @@ export const useKeyboardShortcuts = () => { if (hasModifier(e) && e.key.toLowerCase() === 'n') { e.preventDefault(); if (e.shiftKey) { - setSessionCreateDialogOpen(true); + // Shift+Cmd/Ctrl+N creates a new session with auto-generated worktree + setActiveMainTab('chat'); + setSessionSwitcherOpen(false); + createWorktreeSession(); return; } - + // Cmd/Ctrl+N opens a new session without worktree setActiveMainTab('chat'); setSessionSwitcherOpen(false); openNewSessionDraft(); @@ -138,7 +141,6 @@ export const useKeyboardShortcuts = () => { isCommandPaletteOpen, isHelpDialogOpen, isSessionSwitcherOpen, - isSessionCreateDialogOpen, isAboutDialogOpen, activeMainTab, isModelSelectorOpen, @@ -150,7 +152,7 @@ export const useKeyboardShortcuts = () => { } // Skip if any overlay open or not on chat tab - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen; + const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; const isChatActive = activeMainTab === 'chat'; if (hasOverlay || !isChatActive) { @@ -168,7 +170,6 @@ export const useKeyboardShortcuts = () => { isCommandPaletteOpen, isHelpDialogOpen, isSessionSwitcherOpen, - isSessionCreateDialogOpen, isAboutDialogOpen, activeMainTab, } = useUIStore.getState(); @@ -182,7 +183,7 @@ export const useKeyboardShortcuts = () => { } // Check if any overlay is open or not on chat tab - don't process abort - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen; + const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; const isChatActive = activeMainTab === 'chat'; if (hasOverlay || !isChatActive) { @@ -238,7 +239,6 @@ export const useKeyboardShortcuts = () => { toggleHelpDialog, toggleSidebar, setSessionSwitcherOpen, - setSessionCreateDialogOpen, setActiveMainTab, setSettingsDialogOpen, setModelSelectorOpen, diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 04d6784a..d035254e 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -8,6 +8,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { sessionEvents } from '@/lib/sessionEvents'; import { isDesktopRuntime } from '@/lib/desktop'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; +import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; const MENU_ACTION_EVENT = 'openchamber:menu-action'; @@ -16,7 +17,7 @@ type MenuAction = | 'settings' | 'command-palette' | 'new-session' - | 'worktree-creator' + | 'new-worktree-session' | 'change-workspace' | 'open-git-tab' | 'open-diff-tab' @@ -38,7 +39,6 @@ export const useMenuActions = ( toggleHelpDialog, toggleSidebar, setSessionSwitcherOpen, - setSessionCreateDialogOpen, setActiveMainTab, setSettingsDialogOpen, setAboutDialogOpen, @@ -108,8 +108,10 @@ export const useMenuActions = ( openNewSessionDraft(); break; - case 'worktree-creator': - setSessionCreateDialogOpen(true); + case 'new-worktree-session': + setActiveMainTab('chat'); + setSessionSwitcherOpen(false); + createWorktreeSession(); break; case 'change-workspace': @@ -202,7 +204,6 @@ export const useMenuActions = ( toggleHelpDialog, toggleSidebar, setSessionSwitcherOpen, - setSessionCreateDialogOpen, setActiveMainTab, setSettingsDialogOpen, setAboutDialogOpen, diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 3e41fad9..f9339c03 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -334,12 +334,19 @@ export interface FilesAPI { execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>; } +export interface WorktreeDefaults { + branchPrefix?: string; // e.g. "feature", "bugfix" (no trailing slash) + baseBranch?: string; // e.g. "main", "develop", or "HEAD" + autoCreateWorktree?: boolean; // future: skip dialog, create worktree automatically +} + export interface ProjectEntry { id: string; path: string; label?: string; addedAt?: number; lastOpenedAt?: number; + worktreeDefaults?: WorktreeDefaults; } export interface SettingsPayload { diff --git a/packages/ui/src/lib/git/branchNameGenerator.ts b/packages/ui/src/lib/git/branchNameGenerator.ts new file mode 100644 index 00000000..bedce8a7 --- /dev/null +++ b/packages/ui/src/lib/git/branchNameGenerator.ts @@ -0,0 +1,76 @@ +/** + * Branch name generator utility for auto-generating friendly branch names. + * Uses Ubuntu-style adjective-noun word pairs for memorable, collision-resistant naming. + */ + +import { getGitBranches } from '@/lib/gitApi'; + +const ADJECTIVES = [ + 'artful', 'bionic', 'cosmic', 'disco', 'focal', 'groovy', 'jammy', 'kinetic', + 'lunar', 'noble', 'bold', 'brave', 'calm', 'eager', 'gentle', 'happy', 'keen', + 'lively', 'merry', 'swift', 'warm', 'wise', 'bright', 'clever', 'daring', + 'agile', 'crisp', 'fresh', 'lucid', 'quick', 'sharp', 'vivid', 'zealous', +]; + +const NOUNS = [ + 'aardvark', 'beaver', 'chipmunk', 'dolphin', 'falcon', 'gopher', 'hedgehog', + 'jackal', 'koala', 'lemur', 'mongoose', 'narwhal', 'otter', 'pangolin', + 'quokka', 'raccoon', 'salamander', 'toucan', 'walrus', 'yak', 'zebra', + 'badger', 'condor', 'dingo', 'egret', 'ferret', 'gecko', 'heron', 'iguana', +]; + +/** + * Generate a random branch slug (e.g., "cosmic-dolphin", "noble-raccoon"). + */ +export function generateBranchSlug(): string { + const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; + return `${adjective}-${noun}`; +} + +/** + * Generate a branch name with optional prefix. + * @param prefix - Optional prefix like "feature", "bugfix" (no trailing slash) + * @returns Full branch name like "feature/cosmic-dolphin" or just "cosmic-dolphin" + */ +export function generateBranchName(prefix?: string): string { + const slug = generateBranchSlug(); + if (prefix && prefix.trim()) { + const cleanPrefix = prefix.trim().replace(/\/+$/, ''); + return `${cleanPrefix}/${slug}`; + } + return slug; +} + +/** + * Generate a unique branch name that doesn't conflict with existing branches. + * @param projectDirectory - Project directory to check for existing branches + * @param prefix - Optional branch prefix + * @param maxAttempts - Maximum attempts to generate a unique name (default: 10) + * @returns Unique branch name, or null if all attempts failed + */ +export async function generateUniqueBranchName( + projectDirectory: string, + prefix?: string, + maxAttempts: number = 10 +): Promise { + let existingBranches: Set; + + try { + const branches = await getGitBranches(projectDirectory); + existingBranches = new Set(branches?.all ?? []); + } catch { + // If we can't get branches, just generate without checking + return generateBranchName(prefix); + } + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const candidate = generateBranchName(prefix); + if (!existingBranches.has(candidate)) { + return candidate; + } + } + + // All attempts exhausted, return null + return null; +} diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index f6d810ad..8686a2d3 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -130,6 +130,23 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin ) { project.lastOpenedAt = candidate.lastOpenedAt; } + // Preserve worktreeDefaults + if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') { + const wt = candidate.worktreeDefaults as Record; + const defaults: Record = {}; + if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) { + defaults.branchPrefix = wt.branchPrefix.trim(); + } + if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) { + defaults.baseBranch = wt.baseBranch.trim(); + } + if (typeof wt.autoCreateWorktree === 'boolean') { + defaults.autoCreateWorktree = wt.autoCreateWorktree; + } + if (Object.keys(defaults).length > 0) { + (project as unknown as Record).worktreeDefaults = defaults; + } + } result.push(project); } diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts new file mode 100644 index 00000000..e758f760 --- /dev/null +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -0,0 +1,183 @@ +/** + * Utility for creating a new session with an auto-generated worktree. + * This is a standalone function that can be called from keyboard shortcuts, + * menu actions, or other non-hook contexts. + */ + +import { toast } from 'sonner'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { checkIsGitRepository } from '@/lib/gitApi'; +import { generateUniqueBranchName } from '@/lib/git/branchNameGenerator'; +import { + createWorktree, + getWorktreeStatus, + removeWorktree, + runWorktreeSetupCommands, +} from '@/lib/git/worktreeService'; +import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; + +const sanitizeWorktreeSlug = (value: string): string => { + return value + .trim() + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^[-_]+|[-_]+$/g, '') + .slice(0, 120); +}; + +// Track if we're currently creating a worktree session +let isCreatingWorktreeSession = false; + +/** + * Create a new session with an auto-generated worktree. + * Uses project's worktree defaults (branch prefix, base branch) from settings. + * + * @returns The created session, or null if creation failed + */ +export async function createWorktreeSession(): Promise<{ id: string } | null> { + if (isCreatingWorktreeSession) { + return null; + } + + const activeProject = useProjectsStore.getState().getActiveProject(); + if (!activeProject?.path) { + toast.error('No active project', { + description: 'Please select a project first.', + }); + return null; + } + + const projectDirectory = activeProject.path; + + // Check if it's a git repo + let isGitRepo = false; + try { + isGitRepo = await checkIsGitRepository(projectDirectory); + } catch { + // Ignore errors, treat as not a git repo + } + + if (!isGitRepo) { + toast.error('Not a Git repository', { + description: 'Worktrees can only be created in Git repositories.', + }); + return null; + } + + isCreatingWorktreeSession = true; + + try { + // Get worktree defaults from project settings + const worktreeDefaults = activeProject.worktreeDefaults; + const branchPrefix = worktreeDefaults?.branchPrefix; + const baseBranch = worktreeDefaults?.baseBranch; + + // Generate a unique branch name + const branchName = await generateUniqueBranchName(projectDirectory, branchPrefix); + if (!branchName) { + toast.error('Failed to generate branch name', { + description: 'Could not generate a unique branch name. Please try again.', + }); + return null; + } + + const worktreeSlug = sanitizeWorktreeSlug(branchName); + + // Determine start point (base branch) + const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined; + + // Create the worktree + const metadata = await createWorktree({ + projectDirectory, + worktreeSlug, + branch: branchName, + createBranch: true, + startPoint, + }); + + // Get worktree status + const status = await getWorktreeStatus(metadata.path).catch(() => undefined); + const createdMetadata = status ? { ...metadata, status } : metadata; + + // Create the session + const sessionStore = useSessionStore.getState(); + const session = await sessionStore.createSession(undefined, metadata.path); + if (!session) { + // Clean up the worktree if session creation failed + await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined); + toast.error('Failed to create session', { + description: 'Could not create a session for the worktree.', + }); + return null; + } + + // Initialize the session + const agents = useConfigStore.getState().agents; + sessionStore.initializeNewOpenChamberSession(session.id, agents); + sessionStore.setSessionDirectory(session.id, metadata.path); + sessionStore.setWorktreeMetadata(session.id, createdMetadata); + + // Update directory + useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false }); + + // Refresh sessions list + try { + await sessionStore.loadSessions(); + } catch { + // Ignore + } + + // Get and run setup commands + const setupCommands = await getWorktreeSetupCommands(projectDirectory); + const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0); + + if (commandsToRun.length > 0) { + toast.success('Worktree created', { + description: `Branch: ${branchName}. Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`, + }); + + // Run setup commands in background + runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun).then((result) => { + if (result.success) { + toast.success('Setup commands completed', { + description: `All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`, + }); + } else { + const failed = result.results.filter(r => !r.success); + const succeeded = result.results.filter(r => r.success); + toast.error('Setup commands failed', { + description: `${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` + + (succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : ''), + }); + } + }).catch(() => { + toast.error('Setup commands failed', { + description: 'Could not execute setup commands.', + }); + }); + } else { + toast.success('Worktree created', { + description: `Branch: ${branchName}`, + }); + } + + return session; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create worktree session'; + toast.error('Failed to create worktree', { + description: message, + }); + return null; + } finally { + isCreatingWorktreeSession = false; + } +} + +/** + * Check if a worktree session is currently being created. + */ +export function isCreatingWorktree(): boolean { + return isCreatingWorktreeSession; +} diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 175daa50..b7ee3056 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; -import type { ProjectEntry } from '@/lib/api/types'; +import type { ProjectEntry, WorktreeDefaults } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { getSafeStorage } from './utils/safeStorage'; @@ -27,6 +27,7 @@ interface ProjectsStore { validateProjectPath: (path: string) => ProjectPathValidationResult; synchronizeFromSettings: (settings: DesktopSettings) => void; getActiveProject: () => ProjectEntry | null; + updateWorktreeDefaults: (projectId: string, defaults: Partial) => void; } const safeStorage = getSafeStorage(); @@ -120,6 +121,22 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => { if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) { project.lastOpenedAt = candidate.lastOpenedAt; } + if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') { + const wt = candidate.worktreeDefaults as Record; + const defaults: WorktreeDefaults = {}; + if (typeof wt.branchPrefix === 'string') { + defaults.branchPrefix = wt.branchPrefix; + } + if (typeof wt.baseBranch === 'string') { + defaults.baseBranch = wt.baseBranch; + } + if (typeof wt.autoCreateWorktree === 'boolean') { + defaults.autoCreateWorktree = wt.autoCreateWorktree; + } + if (Object.keys(defaults).length > 0) { + project.worktreeDefaults = defaults; + } + } result.push(project); } @@ -434,6 +451,45 @@ export const useProjectsStore = create()( } return projects.find((project) => project.id === activeProjectId) ?? null; }, + + updateWorktreeDefaults: (projectId: string, defaults: Partial) => { + if (vscodeWorkspace) { + return; + } + const { projects, activeProjectId } = get(); + const target = projects.find((project) => project.id === projectId); + if (!target) { + return; + } + + const merged: WorktreeDefaults = { ...target.worktreeDefaults }; + if (defaults.branchPrefix !== undefined) { + if (defaults.branchPrefix.trim()) { + merged.branchPrefix = defaults.branchPrefix.trim(); + } else { + delete merged.branchPrefix; + } + } + if (defaults.baseBranch !== undefined) { + if (defaults.baseBranch.trim()) { + merged.baseBranch = defaults.baseBranch.trim(); + } else { + delete merged.baseBranch; + } + } + if (defaults.autoCreateWorktree !== undefined) { + merged.autoCreateWorktree = defaults.autoCreateWorktree; + } + + const nextProjects = projects.map((project) => + project.id === projectId + ? { ...project, worktreeDefaults: Object.keys(merged).length > 0 ? merged : undefined } + : project + ); + + set({ projects: nextProjects }); + persistProjects(nextProjects, activeProjectId); + }, }), { name: 'projects-store' }) ); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index e414fbec..e870aff0 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -484,13 +484,33 @@ const sanitizeProjects = (input) => { seenIds.add(id); seenPaths.add(normalizedPath); - result.push({ + const project = { id, path: normalizedPath, ...(label ? { label } : {}), ...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}), ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}), - }); + }; + + // Preserve worktreeDefaults + if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') { + const wt = candidate.worktreeDefaults; + const defaults = {}; + if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) { + defaults.branchPrefix = wt.branchPrefix.trim(); + } + if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) { + defaults.baseBranch = wt.baseBranch.trim(); + } + if (typeof wt.autoCreateWorktree === 'boolean') { + defaults.autoCreateWorktree = wt.autoCreateWorktree; + } + if (Object.keys(defaults).length > 0) { + project.worktreeDefaults = defaults; + } + } + + result.push(project); } return result;