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/<projectId>.json
Deprecate .openchamber legacy paths and adapt UI to new config
This commit is contained in:
Bohdan Triapitsyn
2026-01-27 20:00:07 +02:00
parent 63a4c32dfa
commit 415b043326
32 changed files with 1514 additions and 897 deletions
@@ -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<MultiRunLauncherProps> = ({
// 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<ProjectRef | null>(() => {
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<boolean>(() => {
if (typeof window === 'undefined') {
@@ -117,10 +115,29 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
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<string>('HEAD');
@@ -138,14 +155,14 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
// 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<MultiRunLauncherProps> = ({
})();
return () => { cancelled = true; };
}, [projectDirectory]);
}, [projectRef]);
const handleAddModel = (model: ModelSelectionWithId) => {
if (selectedModels.length >= MAX_MODELS) {
@@ -287,17 +304,15 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<div className="flex flex-col h-full bg-background">
{/* Header - same height as app header (h-12 = 48px) */}
<header
onMouseDown={handleDragStart}
className={cn(
'flex h-12 items-center justify-between border-b app-region-drag',
'flex h-12 items-center justify-between border-b app-region-drag select-none',
desktopHeaderPaddingClass
)}
style={{ borderColor: 'var(--interactive-border)' }}
>
<div
className={cn(
'flex items-center gap-3',
isDesktopApp && isMacPlatform && isSidebarOpen && 'pl-4'
)}
className="flex items-center gap-3"
>
<h1 className="typography-ui-label font-medium">New Multi-Run</h1>
</div>
@@ -391,7 +406,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<CollapsibleContent>
<div className="pt-2 space-y-2">
<p className="typography-micro text-muted-foreground/70">
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_PROJECT_PATH</code> for project root.
</p>
{isLoadingSetupCommands ? (
<p className="typography-meta text-muted-foreground/70">Loading...</p>
@@ -567,7 +582,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
'Creating...'
) : (
<>
<RiPlayLine className="h-4 w-4 mr-2" />
Start ({selectedModels.length} models)
</>
)}
@@ -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<string[]>([]);
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
@@ -49,41 +50,32 @@ export const WorktreeSectionContent: React.FC = () => {
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
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<BranchOption[]>(() => {
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<string | null>(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 (
<p className="typography-meta text-muted-foreground">
Loading...
</p>
);
}
if (isGitRepo === false) {
if (isGitRepoLocal === false) {
return (
<p className="typography-meta text-muted-foreground">
Worktree settings are only available for Git repositories.
@@ -350,36 +334,8 @@ export const WorktreeSectionContent: React.FC = () => {
return (
<div className="space-y-6">
{/* Branch prefix */}
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Branch prefix</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Prefix for auto-generated branch names when creating new worktrees.
</TooltipContent>
</Tooltip>
</div>
<p className="typography-meta text-muted-foreground">
e.g. feature, bugfix, wip (no trailing slash)
</p>
</div>
<Input
value={branchPrefix}
onChange={(e) => handleBranchPrefixChange(e.target.value)}
onBlur={handleBranchPrefixBlur}
placeholder="feature"
className="max-w-xs"
/>
</div>
{/* Default base branch */}
<div className="space-y-4 border-t border-border/40 pt-6">
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Base branch</h3>
@@ -397,55 +353,59 @@ export const WorktreeSectionContent: React.FC = () => {
</p>
</div>
<Select value={baseBranch} onValueChange={handleBaseBranchChange}>
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Default</SelectLabel>
{branchOptions
.filter((option) => option.group === 'special')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
{isLoadingGit ? (
<p className="typography-meta text-muted-foreground">Loading...</p>
) : (
<Select value={baseBranch} onValueChange={handleBaseBranchChange}>
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Default</SelectLabel>
{branchOptions
.filter((option) => option.group === 'special')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
{branchOptions.some((option) => option.group === 'local') && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Local branches</SelectLabel>
{branchOptions
.filter((option) => option.group === 'local')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
)}
{branchOptions.some((option) => option.group === 'local') && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Local branches</SelectLabel>
{branchOptions
.filter((option) => option.group === 'local')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
)}
{branchOptions.some((option) => option.group === 'remote') && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Remote branches</SelectLabel>
{branchOptions
.filter((option) => option.group === 'remote')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
)}
</SelectContent>
</Select>
{branchOptions.some((option) => option.group === 'remote') && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Remote branches</SelectLabel>
{branchOptions
.filter((option) => option.group === 'remote')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
)}
</SelectContent>
</Select>
)}
</div>
{/* Setup commands */}
@@ -453,8 +413,9 @@ export const WorktreeSectionContent: React.FC = () => {
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3>
<p className="typography-meta text-muted-foreground">
Run automatically when a new worktree is created.
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_WORKTREE_PATH</code> for the project root.
Run automatically inside the new worktree directory when a worktree is created.
<br />
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root.
</p>
</div>
@@ -471,16 +432,14 @@ export const WorktreeSectionContent: React.FC = () => {
placeholder="e.g., bun install"
className="flex-1 font-mono text-xs"
/>
<button
type="button"
onClick={() => {
<button
type="button"
onClick={() => {
handleRemoveCommand(index);
// Save after removing
setTimeout(saveSetupCommands, 0);
}}
className="flex-shrink-0 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Remove command"
>
}}
className="flex-shrink-0 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Remove command"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
@@ -507,8 +466,8 @@ export const WorktreeSectionContent: React.FC = () => {
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Worktrees created under <code className="font-mono text-xs">.openchamber</code> directory.
Deleting a worktree will also remove any linked sessions.
SDK worktrees live outside the repo (OpenCode-managed). Legacy <code className="font-mono text-xs">.openchamber</code> worktrees are still supported.
Deleting a worktree also removes linked sessions.
</TooltipContent>
</Tooltip>
</div>
@@ -521,7 +480,7 @@ export const WorktreeSectionContent: React.FC = () => {
<p className="typography-meta text-muted-foreground">Loading worktrees...</p>
) : availableWorktrees.length === 0 ? (
<p className="typography-meta text-muted-foreground/70">
No worktrees found under <code className="font-mono text-xs">.openchamber</code>
No worktrees found for this project
</p>
) : (
<div className="space-y-1">
@@ -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"
>
<div className="flex-1 min-w-0">
<p className="typography-meta text-foreground truncate">
{worktree.label || worktree.branch || 'Detached HEAD'}
</p>
<div className="flex items-center gap-2 min-w-0">
<p className="typography-meta text-foreground truncate min-w-0">
{worktree.label || worktree.branch || 'Detached HEAD'}
</p>
<span className="typography-micro text-muted-foreground/60 px-1.5 py-[1px] rounded bg-sidebar-accent/40 flex-shrink-0 self-center leading-none">
{worktree.source === 'sdk' ? 'OpenCode' : 'OpenChamber'}
</span>
</div>
<p className="typography-micro text-muted-foreground/60 truncate">
{worktree.relativePath || worktree.path}
{worktree.source === 'sdk'
? formatPathForDisplay(worktree.path, homeDirectory)
: (worktree.relativePath || worktree.path)}
</p>
</div>
<button
@@ -1,28 +1,29 @@
import React from 'react';
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import {
RiCheckLine,
RiCloseLine,
RiDeleteBinLine,
RiGitBranchLine,
RiSearchLine,
RiFolderLine,
RiAddLine,
RiArrowRightSLine,
RiLoader4Line,
RiPencilLine,
RiSearchLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { getGitBranches, listGitWorktrees } from '@/lib/gitApi';
import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi';
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
interface Project {
export interface BranchPickerProject {
id: string;
path: string;
normalizedPath: string;
@@ -32,108 +33,155 @@ interface Project {
interface BranchPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projects: Project[];
activeProjectId: string | null;
project: BranchPickerProject | null;
}
interface ProjectBranchData {
branches: GitBranch | null;
worktrees: GitWorktreeInfo[];
loading: boolean;
error: string | null;
}
const displayProjectName = (project: BranchPickerProject): string =>
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
export function BranchPickerDialog({
open,
onOpenChange,
projects,
activeProjectId,
}: BranchPickerDialogProps) {
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
const [searchQuery, setSearchQuery] = React.useState('');
const [projectData, setProjectData] = React.useState<Map<string, ProjectBranchData>>(new Map());
const [expandedProjects, setExpandedProjects] = React.useState<Set<string>>(new Set());
const [branches, setBranches] = React.useState<GitBranch | null>(null);
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [creatingWorktree, setCreatingWorktree] = React.useState<string | null>(null);
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
const [editingBranch, setEditingBranch] = React.useState<string | null>(null);
const [editValue, setEditValue] = React.useState('');
const [renamingBranchKey, setRenamingBranchKey] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!project) return;
setLoading(true);
setError(null);
try {
const [b, w] = await Promise.all([
getGitBranches(project.path),
listGitWorktrees(project.path),
]);
setBranches(b);
setWorktrees(w);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
setBranches(null);
setWorktrees([]);
} finally {
setLoading(false);
}
}, [project]);
React.useEffect(() => {
if (!open) {
setSearchQuery('');
setConfirmingDelete(null);
setForceDeleteBranch(null);
setEditingBranch(null);
setEditValue('');
setRenamingBranchKey(null);
return;
}
void refresh();
}, [open, refresh]);
const activeProject = projects.find(p => p.id === activeProjectId);
if (activeProject) {
setExpandedProjects(new Set([activeProject.id]));
}
projects.forEach(async (project) => {
setProjectData(prev => {
const next = new Map(prev);
next.set(project.id, { branches: null, worktrees: [], loading: true, error: null });
return next;
});
try {
const [branches, worktrees] = await Promise.all([
getGitBranches(project.path),
listGitWorktrees(project.path),
]);
setProjectData(prev => {
const next = new Map(prev);
next.set(project.id, { branches, worktrees, loading: false, error: null });
return next;
});
} catch (err) {
setProjectData(prev => {
const next = new Map(prev);
next.set(project.id, {
branches: null,
worktrees: [],
loading: false,
error: err instanceof Error ? err.message : 'Failed to load',
});
return next;
});
}
});
}, [open, projects, activeProjectId]);
const toggleProject = (projectId: string) => {
setExpandedProjects(prev => {
const next = new Set(prev);
if (next.has(projectId)) {
next.delete(projectId);
} else {
next.add(projectId);
}
return next;
});
const filterBranches = (list: string[], query: string): string[] => {
if (!query.trim()) return list;
const lower = query.toLowerCase();
return list.filter((b) => b.toLowerCase().includes(lower));
};
const handleCreateWorktree = async (project: Project, branchName: string) => {
const key = `${project.id}:${branchName}`;
setCreatingWorktree(key);
const handleCreateWorktree = async (branchName: string) => {
if (!project) return;
setCreatingWorktree(branchName);
try {
await createWorktreeSessionForBranch(project.path, branchName);
onOpenChange(false);
} catch (err) {
console.error('Failed to create worktree:', err);
toast.error('Failed to create worktree', {
description: err instanceof Error ? err.message : 'Create failed',
});
} finally {
setCreatingWorktree(null);
}
};
const filterBranches = (branches: string[], query: string): string[] => {
if (!query.trim()) return branches;
const lowerQuery = query.toLowerCase();
return branches.filter(b => b.toLowerCase().includes(lowerQuery));
};
const beginRename = React.useCallback((branchName: string) => {
setEditingBranch(branchName);
setEditValue(branchName);
}, []);
const gitRepoProjects = projects.filter(p => {
const data = projectData.get(p.id);
return data && !data.error && (data.loading || data.branches);
});
const cancelRename = React.useCallback(() => {
setEditingBranch(null);
setEditValue('');
setRenamingBranchKey(null);
}, []);
const cancelDelete = React.useCallback(() => {
setConfirmingDelete(null);
setForceDeleteBranch(null);
}, []);
const commitRename = React.useCallback(async (oldName: string) => {
if (!project) return;
const newName = editValue.trim();
if (!newName || newName === oldName) {
cancelRename();
return;
}
setRenamingBranchKey(oldName);
try {
const result = await renameBranch(project.path, oldName, newName);
if (!result?.success) {
throw new Error('Rename rejected');
}
await refresh();
cancelRename();
toast.success('Branch renamed', { description: `${oldName} -> ${newName}` });
} catch (err) {
toast.error('Failed to rename branch', {
description: err instanceof Error ? err.message : 'Rename failed',
});
setRenamingBranchKey(null);
}
}, [project, editValue, refresh, cancelRename]);
const handleDeleteBranch = React.useCallback(async (branchName: string) => {
if (!project) return;
setDeletingBranch(branchName);
try {
const force = forceDeleteBranch === branchName;
const result = await deleteGitBranch(project.path, { branch: branchName, force });
if (!result?.success) {
throw new Error('Delete rejected');
}
await refresh();
toast.success('Branch deleted', { description: branchName });
setConfirmingDelete(null);
setForceDeleteBranch(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Delete failed';
// If branch isn't merged, prompt for force delete on next confirm.
if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) {
setForceDeleteBranch(branchName);
toast.error('Branch not merged', {
description: 'Confirm again to force delete',
});
} else {
toast.error('Failed to delete branch', { description: message });
}
} finally {
setDeletingBranch(null);
}
}, [project, refresh, forceDeleteBranch]);
const worktreeBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean));
const allBranches = branches?.all || [];
const filteredBranches = filterBranches(allBranches, searchQuery);
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -141,10 +189,10 @@ export function BranchPickerDialog({
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGitBranchLine className="h-5 w-5" />
Branches & Worktrees
Manage Branches
</DialogTitle>
<DialogDescription>
Start a new worktree session from any local branch
{project ? `Local branches for ${displayProjectName(project)}` : 'Select a project'}
</DialogDescription>
</DialogHeader>
@@ -160,137 +208,225 @@ export function BranchPickerDialog({
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1">
{gitRepoProjects.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No git repositories found
{!project ? (
<div className="text-center py-8 text-muted-foreground">No project selected</div>
) : loading ? (
<div className="px-2 py-2 text-muted-foreground text-sm">Loading branches...</div>
) : error ? (
<div className="px-2 py-2 text-destructive text-sm">{error}</div>
) : localBranches.length === 0 ? (
<div className="px-2 py-2 text-muted-foreground text-sm">
{searchQuery ? 'No matching branches' : 'No branches found'}
</div>
) : (
gitRepoProjects.map((project) => {
const data = projectData.get(project.id);
const isExpanded = expandedProjects.has(project.id);
const branches = data?.branches;
const worktrees = data?.worktrees || [];
const worktreeBranches = new Set(worktrees.map(w => w.branch).filter(Boolean));
localBranches.map((branchName) => {
const details = branches?.branches[branchName];
const isCurrent = Boolean(details?.current);
const isCreating = creatingWorktree === branchName;
const isDeleting = deletingBranch === branchName;
const isRenaming = renamingBranchKey === branchName;
const hasAttachedWorktree = worktreeBranches.has(branchName);
const isEditing = editingBranch === branchName;
const isConfirming = confirmingDelete === branchName;
const isForceDelete = forceDeleteBranch === branchName;
const allBranches = branches?.all || [];
const filteredBranches = filterBranches(allBranches, searchQuery);
const localBranches = filteredBranches
.filter(b => !b.startsWith('remotes/'))
.filter(b => !worktreeBranches.has(b));
const disableDelete = Boolean(isCurrent || hasAttachedWorktree || isDeleting || isRenaming || isEditing);
const disableRename = Boolean(hasAttachedWorktree || isDeleting || isRenaming || isEditing);
return (
<div key={project.id} className="rounded-md">
<button
type="button"
onClick={() => toggleProject(project.id)}
className="w-full flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 transition-colors rounded-md"
>
<RiArrowRightSLine
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
<RiFolderLine className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm truncate flex-1 text-left">
{project.label || project.normalizedPath.split('/').pop() || project.normalizedPath}
</span>
{data?.loading && (
<RiLoader4Line className="h-4 w-4 text-muted-foreground animate-spin" />
)}
{branches && (
<span className="text-xs text-muted-foreground">
{localBranches.length} branches
</span>
)}
</button>
{isExpanded && (
<div className="mt-1 space-y-1 pl-6">
{data?.loading ? (
<div className="px-2 py-2 text-muted-foreground text-sm">
Loading branches...
</div>
) : data?.error ? (
<div className="px-2 py-2 text-destructive text-sm">
{data.error}
</div>
) : localBranches.length === 0 ? (
<div className="px-2 py-2 text-muted-foreground text-sm">
{searchQuery ? 'No matching branches' : 'No branches found'}
</div>
<div
key={branchName}
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 rounded-md overflow-hidden"
>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 min-w-0">
{isEditing ? (
<form
className="flex w-full items-center min-w-0"
onSubmit={(event) => {
event.preventDefault();
void commitRename(branchName);
}}
>
<input
value={editValue}
onChange={(event) => 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);
}
}}
/>
</form>
) : (
localBranches.map((branchName) => {
const branchDetails = branches?.branches[branchName];
const isCurrent = branchDetails?.current;
const isCreating = creatingWorktree === `${project.id}:${branchName}`;
<span className={cn('text-sm truncate', isCurrent && 'font-medium text-primary')}>
{branchName}
</span>
)}
return (
<div
key={branchName}
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 rounded-md overflow-hidden"
>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 min-w-0">
<span className={cn(
'text-sm truncate',
isCurrent && 'font-medium text-primary'
)}>
{branchName}
</span>
{isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
current
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{branchDetails?.commit && (
<span className="font-mono">
{branchDetails.commit.slice(0, 7)}
</span>
)}
{branchDetails?.ahead !== undefined && branchDetails.ahead > 0 && (
<span className="text-[color:var(--status-success)]">
{branchDetails.ahead}
</span>
)}
{branchDetails?.behind !== undefined && branchDetails.behind > 0 && (
<span className="text-[color:var(--status-warning)]">
{branchDetails.behind}
</span>
)}
</div>
</div>
{isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
current
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleCreateWorktree(project, branchName)}
disabled={isCreating}
className="inline-flex h-7 px-2 items-center justify-center text-xs rounded-md bg-primary/10 hover:bg-primary/20 text-primary transition-colors disabled:opacity-50 flex-shrink-0"
>
{isCreating ? (
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<RiAddLine className="h-3.5 w-3.5 mr-1" />
Worktree
</>
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">
Create worktree for this branch
</TooltipContent>
</Tooltip>
</div>
);
})
{hasAttachedWorktree && !isEditing && (
<span className="text-xs bg-muted/40 text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
worktree
</span>
)}
</div>
)}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{details?.commit ? (
<span className="font-mono">{details.commit.slice(0, 7)}</span>
) : null}
{typeof details?.ahead === 'number' && details.ahead > 0 ? (
<span className="text-[color:var(--status-success)]">{details.ahead}</span>
) : null}
{typeof details?.behind === 'number' && details.behind > 0 ? (
<span className="text-[color:var(--status-warning)]">{details.behind}</span>
) : null}
</div>
</div>
{!isEditing && !isConfirming ? (
<div className="flex items-center gap-1 flex-shrink-0">
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleCreateWorktree(branchName)}
disabled={isCreating}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 hover:bg-primary/20 text-primary transition-colors disabled:opacity-50"
aria-label="Create worktree from"
>
{isCreating ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiGitBranchLine className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">Create worktree from</TooltipContent>
</Tooltip>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => beginRename(branchName)}
disabled={disableRename}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Rename"
>
<RiPencilLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setConfirmingDelete(branchName)}
disabled={disableDelete}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label="Delete"
>
{isDeleting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiDeleteBinLine className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{isCurrent
? 'Delete (current branch)'
: hasAttachedWorktree
? 'Delete (remove worktree first)'
: 'Delete'}
</TooltipContent>
</Tooltip>
</div>
) : null}
{isEditing ? (
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() => void commitRename(branchName)}
disabled={isRenaming}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Confirm rename"
>
{isRenaming ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiCheckLine className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={cancelRename}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel rename"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
) : null}
{!isEditing && isConfirming ? (
<div className="flex items-center gap-1 flex-shrink-0">
<span className={cn(
'text-xs mr-1',
isForceDelete ? 'text-destructive' : 'text-muted-foreground'
)}>
{isForceDelete ? 'Force delete?' : 'Delete?'}
</span>
<button
type="button"
onClick={() => void handleDeleteBranch(branchName)}
disabled={isDeleting}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-50',
isForceDelete
? 'bg-destructive/10 text-destructive hover:bg-destructive/15'
: 'hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
)}
aria-label="Confirm delete"
>
{isDeleting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiCheckLine className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={cancelDelete}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel delete"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
) : null}
</div>
);
})
@@ -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<Set<string>>(new Set());
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
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 ? (
<div className="flex w-full items-center justify-between gap-3">
{deleteRemoteBranchAction}
<div className="flex flex-col items-start gap-1">
{deleteRemoteBranchAction}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
@@ -276,12 +276,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
New Session in Worktree
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
<DropdownMenuItem onClick={onOpenBranchPicker}>
<RiGitRepositoryLine className="mr-1.5 h-4 w-4" />
Browse Branches
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && (
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
<RiGithubLine className="mr-1.5 h-4 w-4" />
@@ -300,6 +294,12 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
New Multi-Run
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
<DropdownMenuItem onClick={onOpenBranchPicker}>
<RiGitRepositoryLine className="mr-1.5 h-4 w-4" />
Manage Branches
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
@@ -420,6 +420,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
const [branchPickerProjectId, setBranchPickerProjectId] = React.useState<string | null>(null);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
@@ -619,7 +620,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return next;
});
})
.catch(() => {
.catch(async () => {
// SDK worktrees can be outside UI runtime FS permissions.
// Probe via OpenCode API instead of local FS.
const looksLikeSdkWorktree =
directory.includes('/opencode/worktree/') ||
directory.includes('/.opencode/data/worktree/') ||
directory.includes('/.local/share/opencode/worktree/');
if (looksLikeSdkWorktree) {
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
if (ok) {
setDirectoryStatus((prev) => {
const next = new Map(prev);
if (next.get(directory) === 'exists') {
return prev;
}
next.set(directory, 'exists');
return next;
});
return;
}
}
setDirectoryStatus((prev) => {
const next = new Map(prev);
if (next.get(directory) === 'missing') {
@@ -1653,7 +1676,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
createWorktreeSession();
}}
onOpenBranchPicker={() => setBranchPickerOpen(true)}
onOpenBranchPicker={() => {
setBranchPickerProjectId(projectKey);
setBranchPickerOpen(true);
}}
onNewSessionFromGitHubIssue={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
@@ -1712,8 +1738,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<BranchPickerDialog
open={branchPickerOpen}
onOpenChange={setBranchPickerOpen}
projects={normalizedProjects}
activeProjectId={activeProjectId}
project={branchPickerProjectId
? normalizedProjects.find((p) => p.id === branchPickerProjectId) ?? null
: null}
/>
<GitHubIssuePickerDialog
@@ -22,6 +22,7 @@ import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSe
import { AgentSelector } from '@/components/multirun/AgentSelector';
import { isIMECompositionEvent } from '@/lib/ime';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import type { ProjectRef } from '@/lib/openchamberConfig';
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
/** Max file size in bytes (10MB) */
@@ -87,39 +88,36 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
// 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<ProjectRef | null>(() => {
// VS Code panel should always use the current workspace root.
if (isVSCodeRuntime && vscodeWorkspaceFolder) {
return vscodeWorkspaceFolder;
return { id: `vscode:${vscodeWorkspaceFolder}`, path: vscodeWorkspaceFolder };
}
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 (currentDirectory) {
return { id: `path:${currentDirectory}`, path: currentDirectory };
}
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 null;
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder, isVSCodeRuntime]);
// 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);
}
@@ -133,7 +131,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
})();
return () => { cancelled = true; };
}, [projectDirectory]);
}, [projectRef]);
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
if (selectedModels.length >= MAX_MODELS) {
@@ -331,7 +329,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
<CollapsibleContent>
<div className="pt-2 space-y-2">
<p className="typography-micro text-muted-foreground/70">
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_PROJECT_PATH</code> for project root.
</p>
{isLoadingSetupCommands ? (
<p className="typography-meta text-muted-foreground/70">Loading...</p>