feat: add branch picker dialog for creating worktree sessions (#160)

- Add BranchPickerDialog component to browse branches across all projects
- Filter out branches that already have worktrees
- Show commit hash and ahead/behind indicators for each branch
- Add createWorktreeSessionForBranch() for specific project/branch
- Add worktree and branch picker buttons in sidebar project header

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Nils
2026-01-17 12:54:23 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 033e370c55
commit 7aa4d79b1d
3 changed files with 526 additions and 2 deletions
@@ -0,0 +1,299 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
RiGitBranchLine,
RiSearchLine,
RiFolderLine,
RiAddLine,
RiArrowRightSLine,
RiLoader4Line,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { getGitBranches, listGitWorktrees } 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 {
id: string;
path: string;
normalizedPath: string;
label?: string;
}
interface BranchPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projects: Project[];
activeProjectId: string | null;
}
interface ProjectBranchData {
branches: GitBranch | null;
worktrees: GitWorktreeInfo[];
loading: boolean;
error: string | null;
}
export function BranchPickerDialog({
open,
onOpenChange,
projects,
activeProjectId,
}: 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 [creatingWorktree, setCreatingWorktree] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) {
setSearchQuery('');
return;
}
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 handleCreateWorktree = async (project: Project, branchName: string) => {
const key = `${project.id}:${branchName}`;
setCreatingWorktree(key);
try {
await createWorktreeSessionForBranch(project.path, branchName);
onOpenChange(false);
} catch (err) {
console.error('Failed to create worktree:', err);
} 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 gitRepoProjects = projects.filter(p => {
const data = projectData.get(p.id);
return data && !data.error && (data.loading || data.branches);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl max-h-[80vh] flex flex-col overflow-hidden gap-3">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Branches & Worktrees</DialogTitle>
</DialogHeader>
<div className="relative flex-shrink-0">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search branches..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-2">
{gitRepoProjects.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No git repositories 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));
const allBranches = branches?.all || [];
const filteredBranches = filterBranches(allBranches, searchQuery);
const localBranches = filteredBranches
.filter(b => !b.startsWith('remotes/'))
.filter(b => !worktreeBranches.has(b));
return (
<div key={project.id} className="rounded-lg border">
<button
type="button"
onClick={() => toggleProject(project.id)}
className="w-full flex items-center gap-2 p-3 hover:bg-muted/50 transition-colors rounded-t-lg"
>
<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="border-t">
{data?.loading ? (
<div className="p-4 text-center text-muted-foreground text-sm">
Loading branches...
</div>
) : data?.error ? (
<div className="p-4 text-center text-destructive text-sm">
{data.error}
</div>
) : localBranches.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm">
{searchQuery ? 'No matching branches' : 'No branches found'}
</div>
) : (
<div className="divide-y overflow-hidden">
{localBranches.map((branchName) => {
const branchDetails = branches?.branches[branchName];
const isCurrent = branchDetails?.current;
const isCreating = creatingWorktree === `${project.id}:${branchName}`;
return (
<div
key={branchName}
className="flex items-center gap-2 px-3 py-2 hover:bg-muted/30 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>
<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>
);
})}
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -39,7 +39,9 @@ import {
RiFileCopyLine,
RiFolderAddLine,
RiGitBranchLine,
RiGitRepositoryLine,
RiLinkUnlinkM,
RiMore2Line,
RiPencilAiLine,
RiShare2Line,
@@ -59,6 +61,7 @@ import { checkIsGitRepository } from '@/lib/gitApi';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { isVSCodeRuntime } from '@/lib/desktop';
import { BranchPickerDialog } from './BranchPickerDialog';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
@@ -135,6 +138,7 @@ interface SortableProjectItemProps {
onHoverChange: (hovered: boolean) => void;
onNewSession: () => void;
onNewWorktreeSession?: () => void;
onOpenBranchPicker?: () => void;
onOpenMultiRunLauncher: () => void;
onClose: () => void;
sentinelRef: (el: HTMLDivElement | null) => void;
@@ -158,6 +162,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onHoverChange,
onNewSession,
onNewWorktreeSession,
onOpenBranchPicker,
onOpenMultiRunLauncher,
onClose,
sentinelRef,
@@ -257,7 +262,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
</DropdownMenuContent>
</DropdownMenu>
{isRepo && onNewWorktreeSession && !settingsAutoCreateWorktree && (
{isRepo && !hideDirectoryControls && onNewWorktreeSession && !settingsAutoCreateWorktree && (
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -267,7 +272,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onNewWorktreeSession();
}}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground flex-shrink-0',
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100',
)}
aria-label="New session in worktree"
@@ -280,6 +285,29 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
</TooltipContent>
</Tooltip>
)}
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenBranchPicker();
}}
className={cn(
'inline-flex h-6 w-6 items-center justify-center text-muted-foreground hover:text-foreground flex-shrink-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100',
)}
aria-label="Browse branches"
>
<RiGitRepositoryLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>Browse branches</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -367,6 +395,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
@@ -1592,6 +1621,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
createWorktreeSession();
}}
onOpenBranchPicker={() => setBranchPickerOpen(true)}
onOpenMultiRunLauncher={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
@@ -1627,6 +1657,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
)}
</ScrollableOverlay>
<BranchPickerDialog
open={branchPickerOpen}
onOpenChange={setBranchPickerOpen}
projects={normalizedProjects}
activeProjectId={activeProjectId}
/>
</div>
);
};
@@ -249,3 +249,192 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
export function isCreatingWorktree(): boolean {
return isCreatingWorktreeSession;
}
/**
* Create a new session with a worktree for a specific branch.
* Unlike createWorktreeSession(), this allows specifying the project and branch explicitly.
*
* @param projectDirectory - The root directory of the git repository
* @param branchName - The name of the branch to create a worktree for
* @returns The created session, or null if creation failed
*/
export async function createWorktreeSessionForBranch(
projectDirectory: string,
branchName: string
): Promise<{ id: string } | null> {
if (isCreatingWorktreeSession) {
return null;
}
// Check if it's a git repo
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectDirectory);
} catch {
// Ignore errors, treat as not a git repo
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
isCreatingWorktreeSession = true;
startConfigUpdate("Creating worktree session...");
try {
// Use the branch name as the worktree slug (sanitized)
const worktreeSlug = sanitizeWorktreeSlug(branchName);
// Create the worktree - don't create a new branch, use existing one
const metadata = await createWorktree({
projectDirectory,
worktreeSlug,
branch: branchName,
createBranch: false, // Use existing branch
});
// Get worktree status
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
const createdMetadata = status ? { ...metadata, status } : metadata;
// Create the session
const sessionStore = useSessionStore.getState();
const session = await sessionStore.createSession(undefined, metadata.path);
if (!session) {
// Clean up the worktree if session creation failed
await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined);
toast.error('Failed to create session', {
description: 'Could not create a session for the worktree.',
});
return null;
}
// Initialize the session
const configState = useConfigStore.getState();
const agents = configState.agents;
sessionStore.initializeNewOpenChamberSession(session.id, agents);
sessionStore.setSessionDirectory(session.id, metadata.path);
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
// Apply default agent and model settings
try {
const visibleAgents = configState.getVisibleAgents();
let agentName: string | undefined;
// Priority: settingsDefaultAgent → build → first visible
if (configState.settingsDefaultAgent) {
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
if (settingsAgent) {
agentName = settingsAgent.name;
}
}
if (!agentName) {
agentName =
visibleAgents.find((agent) => agent.name === 'build')?.name ||
visibleAgents[0]?.name;
}
if (agentName) {
// 1. Update global UI state
configState.setAgent(agentName);
// 2. Persist to session context so it sticks after reload/switch
useContextStore.getState().saveSessionAgentSelection(session.id, agentName);
// 3. Handle default model for the agent if set in global settings
const settingsDefaultModel = configState.settingsDefaultModel;
if (settingsDefaultModel) {
const parts = settingsDefaultModel.split('/');
if (parts.length === 2) {
const [providerId, modelId] = parts;
// Validate model exists (optional, but good practice)
const modelMetadata = configState.getModelMetadata(providerId, modelId);
if (modelMetadata) {
useContextStore.getState().saveSessionModelSelection(session.id, providerId, modelId);
// Also save the specific agent's model preference for this session
useContextStore.getState().saveAgentModelForSession(session.id, agentName, providerId, modelId);
// Seed default variant into session context so ModelControls restore logic
// doesn't wipe it on first switch to the new session.
const settingsDefaultVariant = configState.settingsDefaultVariant;
if (settingsDefaultVariant) {
const provider = configState.providers.find((p) => p.id === providerId);
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelId) as
| { variants?: Record<string, unknown> }
| undefined;
const variants = model?.variants;
if (variants && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
configState.setCurrentVariant(settingsDefaultVariant);
useContextStore
.getState()
.saveAgentModelVariantForSession(session.id, agentName, providerId, modelId, settingsDefaultVariant);
}
}
}
}
}
}
} catch {
// Ignore errors setting default agent
}
// Update directory
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
// Refresh sessions list
try {
await sessionStore.loadSessions();
} catch {
// Ignore
}
// Get and run setup commands
const setupCommands = await getWorktreeSetupCommands(projectDirectory);
const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0);
if (commandsToRun.length > 0) {
toast.success('Worktree created', {
description: `Branch: ${branchName}. Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`,
});
// Run setup commands in background
runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun).then((result) => {
if (result.success) {
toast.success('Setup commands completed', {
description: `All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`,
});
} else {
const failed = result.results.filter(r => !r.success);
const succeeded = result.results.filter(r => r.success);
toast.error('Setup commands failed', {
description: `${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` +
(succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : ''),
});
}
}).catch(() => {
toast.error('Setup commands failed', {
description: 'Could not execute setup commands.',
});
});
} else {
toast.success('Worktree created', {
description: `Branch: ${branchName}`,
});
}
return session;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create worktree session';
toast.error('Failed to create worktree', {
description: message,
});
return null;
} finally {
finishConfigUpdate();
isCreatingWorktreeSession = false;
}
}