From 415b043326ed86454e66c1529c1dc95bd3dd96fc Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 27 Jan 2026 19:59:48 +0200 Subject: [PATCH] feat: migrate to OpenCode SDK worktrees with per-project config Add SDK-based worktree management that lists and starts SDK worktrees Migrate per-project setup to ~/.config/openchamber/.json Deprecate .openchamber legacy paths and adapt UI to new config --- bun.lock | 18 +- package.json | 2 +- .../desktop/src-tauri/src/commands/files.rs | 39 +- .../desktop/src-tauri/src/commands/git.rs | 1 + packages/desktop/src/api/git.ts | 1 + packages/ui/package.json | 2 +- .../components/multirun/MultiRunLauncher.tsx | 62 +- .../openchamber/WorktreeSectionContent.tsx | 342 +++++------ .../components/session/BranchPickerDialog.tsx | 558 +++++++++++------- .../src/components/session/SessionDialogs.tsx | 49 +- .../src/components/session/SessionSidebar.tsx | 47 +- .../agent-manager/AgentManagerEmptyState.tsx | 30 +- packages/ui/src/lib/api/types.ts | 1 - packages/ui/src/lib/git/worktreeService.ts | 23 +- packages/ui/src/lib/gitApi.ts | 1 + packages/ui/src/lib/gitApiHttp.ts | 1 + packages/ui/src/lib/openchamberConfig.ts | 437 +++++++++++--- packages/ui/src/lib/opencode/client.ts | 40 ++ packages/ui/src/lib/persistence.ts | 3 - packages/ui/src/lib/worktreeSessionCreator.ts | 226 ++----- .../ui/src/lib/worktrees/worktreeManager.ts | 258 ++++++++ packages/ui/src/stores/sessionStore.ts | 50 +- packages/ui/src/stores/useAgentGroupsStore.ts | 56 +- packages/ui/src/stores/useMultiRunStore.ts | 107 +--- packages/ui/src/stores/useProjectsStore.ts | 10 - packages/ui/src/types/worktree.ts | 10 + packages/vscode/package.json | 2 +- packages/vscode/src/bridge.ts | 6 +- packages/vscode/src/gitService.ts | 1 + packages/web/package.json | 2 +- packages/web/server/index.js | 25 +- packages/web/server/lib/git-service.js | 1 + 32 files changed, 1514 insertions(+), 897 deletions(-) create mode 100644 packages/ui/src/lib/worktrees/worktreeManager.ts diff --git a/bun.lock b/bun.lock index 9bbc121c..f174921e 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.34", + "@opencode-ai/sdk": "^1.1.36", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -95,7 +95,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.5.7", + "version": "1.5.8", "dependencies": { "@openchamber/ui": "workspace:*", "@tauri-apps/plugin-notification": "^2.3.3", @@ -118,7 +118,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.5.7", + "version": "1.5.8", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -148,7 +148,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.34", + "@opencode-ai/sdk": "^1.1.36", "@pierre/diffs": "^1.0.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -212,10 +212,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.5.7", + "version": "1.5.8", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.34", + "@opencode-ai/sdk": "^1.1.36", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -235,7 +235,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.5.7", + "version": "1.5.8", "bin": { "openchamber": "./bin/cli.js", }, @@ -244,7 +244,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.34", + "@opencode-ai/sdk": "^1.1.36", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -860,7 +860,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.34", "", {}, "sha512-ToR20PJSiuLEY2WnJpBH8X1qmfCcmSoP4qk/TXgIr/yDnmlYmhCwk2ruA540RX4A2hXi2LJXjAqpjeRxxtLNCQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.36", "", {}, "sha512-feNHWnbxhg03TI2QrWnw3Chc0eYrWSDSmHIy/ejpSVfcKlfXREw1Tpg0L4EjrpeSc4jB1eM673dh+WM/Ko2SFQ=="], "@pierre/diffs": ["@pierre/diffs@1.0.5", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-QcFhO6BW1Zz3BP+WFuH1tO2DjFJY5Sb6NmRjmEoVeEu1AVOd3HoUEFTnztxgxn+2c2ZFyFZP+6T4X/g8LDuZLw=="], diff --git a/package.json b/package.json index c63960c0..83eb960a 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.34", + "@opencode-ai/sdk": "^1.1.36", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index a53a556c..7026f462 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -605,6 +605,11 @@ async fn resolve_sandboxed_path( .await .map_err(FsCommandError::from)?; + // Allow OpenChamber per-project config under ~/.config/openchamber. + if is_within_openchamber_user_config(&canonicalized) { + return Ok(canonicalized); + } + if !workspace_roots.is_empty() && !workspace_roots .iter() @@ -637,11 +642,15 @@ async fn resolve_creatable_path( fallback_root.join(candidate) }; + // Allow OpenChamber per-project config under ~/.config/openchamber. + // Needed because Desktop FS commands are sandboxed to workspace roots. + if is_within_openchamber_user_config(&absolute) { + return Ok(absolute); + } + let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?; - let canonical_parent = fs::canonicalize(parent) - .await - .map_err(FsCommandError::from)?; + let canonical_parent = canonicalize_existing_ancestor(&parent.to_path_buf()).await?; if !workspace_roots.is_empty() && !workspace_roots @@ -722,6 +731,30 @@ fn default_home_directory() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) } +fn openchamber_user_config_root() -> PathBuf { + default_home_directory().join(".config").join("openchamber") +} + +fn is_within_openchamber_user_config(path: &PathBuf) -> bool { + path.starts_with(&openchamber_user_config_root()) +} + +async fn canonicalize_existing_ancestor(path: &PathBuf) -> Result { + let mut current = Some(path.as_path()); + while let Some(candidate) = current { + match fs::canonicalize(candidate).await { + Ok(canon) => return Ok(canon), + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + return Err(FsCommandError::from(err)); + } + } + } + current = candidate.parent(); + } + Err(FsCommandError::NotDirectory) +} + fn clamp_search_limit(value: Option) -> usize { let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT); limit.clamp(1, MAX_FILE_SEARCH_LIMIT) diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index 383f2e0d..d9d016a1 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -1474,6 +1474,7 @@ pub async fn remove_git_worktree( #[tauri::command] pub async fn ensure_openchamber_ignored( + // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. directory: String, state: State<'_, DesktopRuntime>, ) -> Result<(), String> { diff --git a/packages/desktop/src/api/git.ts b/packages/desktop/src/api/git.ts index 87660d2f..09083db3 100644 --- a/packages/desktop/src/api/git.ts +++ b/packages/desktop/src/api/git.ts @@ -145,6 +145,7 @@ export const createDesktopGitAPI = (): GitAPI => ({ }, async ensureOpenChamberIgnored(directory: string): Promise { + // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. return safeGitInvoke('ensure_openchamber_ignored', { directory }); }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 4d62e24a..61fc98ea 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.34", + "@opencode-ai/sdk": "^1.1.36", "@pierre/diffs": "^1.0.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index a4bb626e..78ed41f6 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react'; +import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,9 +10,9 @@ import { cn } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; import { useSessionStore } from '@/stores/useSessionStore'; -import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; +import type { ProjectRef } from '@/lib/openchamberConfig'; import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun'; import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect'; import { BranchSelector, useBranchOptions } from './BranchSelector'; @@ -75,23 +75,21 @@ export const MultiRunLauncher: React.FC = ({ // Get project directory for setup commands const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); - const projectDirectory = React.useMemo(() => { + const projectRef = React.useMemo(() => { if (activeProjectId) { const project = projects.find((p) => p.id === activeProjectId); - if (project?.path) return project.path; + if (project?.path) { + return { id: project.id, path: project.path }; + } } const base = currentDirectory ?? vscodeWorkspaceFolder; - if (!base) return null; + if (!base) { + return null; + } - const normalized = base.replace(/\\/g, '/').replace(/\/+$/, '') || base; - const marker = '/.openchamber/'; - const markerIndex = normalized.indexOf(marker); - if (markerIndex > 0) return normalized.slice(0, markerIndex); - if (normalized.endsWith('/.openchamber')) return normalized.slice(0, normalized.length - '/.openchamber'.length); - return normalized; + return { id: `path:${base}`, path: base }; }, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]); - const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const [isDesktopApp, setIsDesktopApp] = React.useState(() => { if (typeof window === 'undefined') { @@ -117,10 +115,29 @@ export const MultiRunLauncher: React.FC = ({ const desktopHeaderPaddingClass = React.useMemo(() => { if (isDesktopApp && isMacPlatform) { - return isSidebarOpen ? 'pl-0' : 'pl-[8.0rem]'; + // Match main app header: reserve space for Mac traffic lights. + return 'pl-[5.75rem]'; } return 'pl-3'; - }, [isDesktopApp, isMacPlatform, isSidebarOpen]); + }, [isDesktopApp, isMacPlatform]); + + const handleDragStart = React.useCallback(async (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) { + return; + } + if (e.button !== 0) { + return; + } + if (isDesktopApp) { + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const window = getCurrentWindow(); + await window.startDragging(); + } catch { + // ignore + } + } + }, [isDesktopApp]); // Use the BranchSelector hook for branch state management const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState('HEAD'); @@ -138,14 +155,14 @@ export const MultiRunLauncher: React.FC = ({ // Load setup commands from config React.useEffect(() => { - if (!projectDirectory) return; + if (!projectRef) return; let cancelled = false; setIsLoadingSetupCommands(true); (async () => { try { - const commands = await getWorktreeSetupCommands(projectDirectory); + const commands = await getWorktreeSetupCommands(projectRef); if (!cancelled) { setSetupCommands(commands); } @@ -159,7 +176,7 @@ export const MultiRunLauncher: React.FC = ({ })(); return () => { cancelled = true; }; - }, [projectDirectory]); + }, [projectRef]); const handleAddModel = (model: ModelSelectionWithId) => { if (selectedModels.length >= MAX_MODELS) { @@ -287,17 +304,15 @@ export const MultiRunLauncher: React.FC = ({
{/* Header - same height as app header (h-12 = 48px) */}

New Multi-Run

@@ -391,7 +406,7 @@ export const MultiRunLauncher: React.FC = ({

- Commands run in each new worktree. Use $ROOT_WORKTREE_PATH for project root. + Commands run in each new worktree. Use $ROOT_PROJECT_PATH for project root.

{isLoadingSetupCommands ? (

Loading...

@@ -567,7 +582,6 @@ export const MultiRunLauncher: React.FC = ({ 'Creating...' ) : ( <> - Start ({selectedModels.length} models) )} diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index 7fb62d43..c94bffd4 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -14,12 +14,14 @@ import { 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 { useGitBranches } from '@/stores/useGitStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi'; import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; -import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService'; +import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import { sessionEvents } from '@/lib/sessionEvents'; import type { WorktreeMetadata } from '@/types/worktree'; +import { formatPathForDisplay } from '@/lib/utils'; type BranchOption = { value: string; @@ -34,12 +36,11 @@ export const WorktreeSectionContent: React.FC = () => { const projectPath = activeProject?.path ?? null; const worktreeDefaults = activeProject?.worktreeDefaults; - const isGitRepoFromStore = useIsGitRepo(projectPath); const branchesFromStore = useGitBranches(projectPath); const { sessions, getWorktreeMetadata } = useSessionStore(); + const homeDirectory = useDirectoryStore((state) => state.homeDirectory); - 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); @@ -49,41 +50,32 @@ export const WorktreeSectionContent: React.FC = () => { 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 projectRef = React.useMemo(() => { + if (!activeProject?.id || !projectPath) { + return null; + } + return { id: activeProject.id, path: projectPath }; + }, [activeProject?.id, projectPath]); const refreshWorktrees = React.useCallback(async () => { - if (!projectPath || isGitRepoLocal === false) return; + if (!projectRef || 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); + const worktrees = await listProjectWorktrees(projectRef); + setAvailableWorktrees(worktrees); } catch { // Ignore errors } - }, [projectPath, isGitRepoLocal, joinWorktreePath]); + }, [projectRef, isGitRepoLocal]); - // Load git info when project changes + // Load repo + branch info React.useEffect(() => { if (!projectPath) return; let cancelled = false; setIsLoadingGit(true); - setIsLoadingWorktrees(true); setIsGitRepoLocal(null); setBranchesLocal(null); - setAvailableWorktrees([]); (async () => { try { @@ -91,31 +83,18 @@ export const WorktreeSectionContent: React.FC = () => { 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); - } + if (!repoStatus) { + return; } + + const branchData = await getGitBranches(projectPath); + if (cancelled) return; + setBranchesLocal({ all: branchData.all, current: branchData.current }); } catch { // Ignore errors } finally { if (!cancelled) { setIsLoadingGit(false); - setIsLoadingWorktrees(false); } } })(); @@ -125,16 +104,53 @@ export const WorktreeSectionContent: React.FC = () => { }; }, [projectPath]); + // Load existing worktrees + React.useEffect(() => { + if (!projectRef) { + setAvailableWorktrees([]); + setIsLoadingWorktrees(false); + return; + } + + if (isGitRepoLocal === false) { + setAvailableWorktrees([]); + setIsLoadingWorktrees(false); + return; + } + + let cancelled = false; + setIsLoadingWorktrees(true); + setAvailableWorktrees([]); + + (async () => { + try { + const worktrees = await listProjectWorktrees(projectRef); + if (cancelled) return; + setAvailableWorktrees(worktrees); + } catch { + // ignore + } finally { + if (!cancelled) { + setIsLoadingWorktrees(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectRef, isGitRepoLocal]); + // Load setup commands React.useEffect(() => { - if (!projectPath) return; + if (!projectRef) return; let cancelled = false; setIsLoadingCommands(true); (async () => { try { - const commands = await getWorktreeSetupCommands(projectPath); + const commands = await getWorktreeSetupCommands(projectRef); if (!cancelled) { setSetupCommands(commands.length > 0 ? commands : ['']); } @@ -152,17 +168,15 @@ export const WorktreeSectionContent: React.FC = () => { return () => { cancelled = true; }; - }, [projectPath]); + }, [projectRef]); // 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[] = []; @@ -193,33 +207,6 @@ export const WorktreeSectionContent: React.FC = () => { 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; @@ -238,20 +225,25 @@ export const WorktreeSectionContent: React.FC = () => { setSetupCommands((prev) => [...prev, '']); }, []); - const handleRemoveCommand = React.useCallback((index: number) => { - setSetupCommands((prev) => prev.filter((_, i) => i !== index)); - }, []); + const persistSetupCommands = React.useCallback(async (commands: string[]) => { + if (!projectRef) return; + const filtered = commands.filter((cmd) => cmd.trim().length > 0); + await saveWorktreeSetupCommands(projectRef, filtered); + }, [projectRef]); - const saveSetupCommands = React.useCallback(async () => { - if (!projectPath) return; - const filtered = setupCommands.filter((cmd) => cmd.trim().length > 0); - await saveWorktreeSetupCommands(projectPath, filtered); - }, [projectPath, setupCommands]); + const handleRemoveCommand = React.useCallback((index: number) => { + setSetupCommands((prev) => { + const next = prev.filter((_, i) => i !== index); + // Keep at least 1 row in UI, but persist empty config when all removed. + void persistSetupCommands(next); + return next.length > 0 ? next : ['']; + }); + }, [persistSetupCommands]); // Save setup commands on blur const handleCommandBlur = React.useCallback(() => { - saveSetupCommands(); - }, [saveSetupCommands]); + void persistSetupCommands(setupCommands); + }, [persistSetupCommands, setupCommands]); // Delete worktree handler const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => { @@ -332,15 +324,7 @@ export const WorktreeSectionContent: React.FC = () => { ); } - if (isLoadingGit) { - return ( -

- Loading... -

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

Worktree settings are only available for Git repositories. @@ -350,36 +334,8 @@ export const WorktreeSectionContent: React.FC = () => { 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

@@ -397,55 +353,59 @@ export const WorktreeSectionContent: React.FC = () => {

- + + + + + + Default + {branchOptions + .filter((option) => option.group === 'special') + .map((option) => ( + + {option.label} + + ))} + - {branchOptions.some((option) => option.group === 'local') && ( - <> - - - Local branches - {branchOptions - .filter((option) => option.group === 'local') - .map((option) => ( - - {option.label} - - ))} - - - )} + {branchOptions.some((option) => option.group === 'local') && ( + <> + + + Local branches + {branchOptions + .filter((option) => option.group === 'local') + .map((option) => ( + + {option.label} + + ))} + + + )} - {branchOptions.some((option) => option.group === 'remote') && ( - <> - - - Remote branches - {branchOptions - .filter((option) => option.group === 'remote') - .map((option) => ( - - {option.label} - - ))} - - - )} - - + {branchOptions.some((option) => option.group === 'remote') && ( + <> + + + Remote branches + {branchOptions + .filter((option) => option.group === 'remote') + .map((option) => ( + + {option.label} + + ))} + + + )} + + + )}
{/* Setup commands */} @@ -453,8 +413,9 @@ export const WorktreeSectionContent: React.FC = () => {

Setup commands

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

@@ -471,16 +432,14 @@ export const WorktreeSectionContent: React.FC = () => { placeholder="e.g., bun install" className="flex-1 font-mono text-xs" /> -
@@ -507,8 +466,8 @@ export const WorktreeSectionContent: React.FC = () => { - Worktrees created under .openchamber directory. - Deleting a worktree will also remove any linked sessions. + SDK worktrees live outside the repo (OpenCode-managed). Legacy .openchamber worktrees are still supported. + Deleting a worktree also removes linked sessions.
@@ -521,7 +480,7 @@ export const WorktreeSectionContent: React.FC = () => {

Loading worktrees...

) : availableWorktrees.length === 0 ? (

- No worktrees found under .openchamber + No worktrees found for this project

) : (
@@ -531,11 +490,18 @@ export const WorktreeSectionContent: React.FC = () => { className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-sidebar-accent/30 transition-colors group" >
-

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

+
+

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

+ + {worktree.source === 'sdk' ? 'OpenCode' : 'OpenChamber'} + +

- {worktree.relativePath || worktree.path} + {worktree.source === 'sdk' + ? formatPathForDisplay(worktree.path, homeDirectory) + : (worktree.relativePath || worktree.path)}

- {isExpanded && ( -
- {data?.loading ? ( -
- Loading branches... -
- ) : data?.error ? ( -
- {data.error} -
- ) : localBranches.length === 0 ? ( -
- {searchQuery ? 'No matching branches' : 'No branches found'} -
+
+ + +
+
+ {isEditing ? ( +
{ + event.preventDefault(); + void commitRename(branchName); + }} + > + setEditValue(event.target.value)} + className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground" + autoFocus + placeholder="Rename branch" + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelRename(); + } + if (event.key === 'Enter') { + event.preventDefault(); + void commitRename(branchName); + } + }} + /> +
) : ( - localBranches.map((branchName) => { - const branchDetails = branches?.branches[branchName]; - const isCurrent = branchDetails?.current; - const isCreating = creatingWorktree === `${project.id}:${branchName}`; + + {branchName} + + )} - return ( -
- -
-
- - {branchName} - - {isCurrent && ( - - current - - )} -
-
- {branchDetails?.commit && ( - - {branchDetails.commit.slice(0, 7)} - - )} - {branchDetails?.ahead !== undefined && branchDetails.ahead > 0 && ( - - ↑{branchDetails.ahead} - - )} - {branchDetails?.behind !== undefined && branchDetails.behind > 0 && ( - - ↓{branchDetails.behind} - - )} -
-
+ {isCurrent && ( + + current + + )} - - - - - - Create worktree for this branch - - -
- ); - }) + {hasAttachedWorktree && !isEditing && ( + + worktree + )}
- )} + +
+ {details?.commit ? ( + {details.commit.slice(0, 7)} + ) : null} + {typeof details?.ahead === 'number' && details.ahead > 0 ? ( + ↑{details.ahead} + ) : null} + {typeof details?.behind === 'number' && details.behind > 0 ? ( + ↓{details.behind} + ) : null} +
+
+ + {!isEditing && !isConfirming ? ( +
+ + + + + Create worktree from + + + + + + + + {hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'} + + + + + + + + + {isCurrent + ? 'Delete (current branch)' + : hasAttachedWorktree + ? 'Delete (remove worktree first)' + : 'Delete'} + + +
+ ) : null} + + {isEditing ? ( +
+ + +
+ ) : null} + + {!isEditing && isConfirming ? ( +
+ + {isForceDelete ? 'Force delete?' : 'Delete?'} + + + +
+ ) : null}
); }) diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 82abe3b3..80818cfe 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -17,10 +17,9 @@ import { cn, formatPathForDisplay } from '@/lib/utils'; import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; import { - archiveWorktree, getWorktreeStatus, } from '@/lib/git/worktreeService'; -import { ensureOpenChamberIgnored } from '@/lib/gitApi'; +import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -53,7 +52,6 @@ type DeleteDialogState = { export const SessionDialogs: React.FC = () => { const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false); const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false); - const ensuredIgnoreDirectories = React.useRef>(new Set()); const [deleteDialog, setDeleteDialog] = React.useState(null); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState>([]); const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false); @@ -103,20 +101,7 @@ export const SessionDialogs: React.FC = () => { const removeRemoteOptionDisabled = isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches; - React.useEffect(() => { - if (!projectDirectory) { - return; - } - if (ensuredIgnoreDirectories.current.has(projectDirectory)) { - return; - } - ensureOpenChamberIgnored(projectDirectory) - .then(() => ensuredIgnoreDirectories.current.add(projectDirectory)) - .catch((error) => { - console.warn('Failed to ensure .openchamber directory is ignored:', error); - ensuredIgnoreDirectories.current.delete(projectDirectory); - }); - }, [projectDirectory]); + // NOTE: stop auto-modifying .gitignore for legacy `.openchamber`. React.useEffect(() => { loadSessions(); @@ -291,13 +276,11 @@ export const SessionDialogs: React.FC = () => { if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; - await archiveWorktree({ - projectDirectory: projectDirectory, - path: deleteDialog.worktree.path, - branch: deleteDialog.worktree.branch, - force: true, - deleteRemote: shouldRemoveRemote, - }); + await removeProjectWorktree( + { id: activeProjectId || `path:${projectDirectory}`, path: projectDirectory }, + deleteDialog.worktree, + { deleteRemoteBranch: shouldRemoveRemote, force: true } + ); const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.'; toast.success('Worktree removed', { description: renderToastDescription(archiveNote), @@ -374,7 +357,19 @@ export const SessionDialogs: React.FC = () => { } finally { setIsProcessingDelete(false); } - }, [deleteDialog, deleteDialogShouldRemoveRemote, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, projectDirectory, loadSessions]); + }, [ + deleteDialog, + deleteDialogShouldRemoveRemote, + deleteSession, + deleteSessions, + closeDeleteDialog, + shouldArchiveWorktree, + isWorktreeDelete, + canRemoveRemoteBranches, + projectDirectory, + activeProjectId, + loadSessions, + ]); const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null; const deleteDialogDescription = deleteDialog @@ -492,7 +487,9 @@ export const SessionDialogs: React.FC = () => { const deleteDialogActions = isWorktreeDelete ? (
- {deleteRemoteBranchAction} +
+ {deleteRemoteBranchAction} +