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,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