feat: implement worktree session creation and management

- Added `createWorktreeSession` utility to handle the creation of new sessions with auto-generated worktrees.
- Introduced branch name generation utilities for creating unique and friendly branch names.
- Updated `CommandPalette` to trigger worktree session creation via a command.
- Refactored keyboard shortcuts to support new worktree session creation with Shift + Cmd/Ctrl + N.
- Enhanced project settings to include worktree defaults (branch prefix, base branch, auto-create option).
- Updated persistence logic to preserve worktree defaults when saving project settings.
- Modified menu actions to initiate new worktree sessions.
- Ensured proper handling of existing branches to avoid conflicts during worktree creation.
This commit is contained in:
Bohdan Triapitsyn
2026-01-07 22:06:28 +02:00
parent 4f6eea9ea5
commit d92a1ab297
14 changed files with 1004 additions and 883 deletions
@@ -135,6 +135,27 @@ fn sanitize_projects(value: &Value) -> Option<Value> {
}
}
// Preserve worktreeDefaults
if let Some(Value::Object(wt)) = obj.get("worktreeDefaults") {
let mut defaults = serde_json::Map::new();
if let Some(Value::String(s)) = wt.get("branchPrefix") {
if !s.trim().is_empty() {
defaults.insert("branchPrefix".to_string(), json!(s.trim()));
}
}
if let Some(Value::String(s)) = wt.get("baseBranch") {
if !s.trim().is_empty() {
defaults.insert("baseBranch".to_string(), json!(s.trim()));
}
}
if let Some(Value::Bool(b)) = wt.get("autoCreateWorktree") {
defaults.insert("autoCreateWorktree".to_string(), json!(b));
}
if !defaults.is_empty() {
project.insert("worktreeDefaults".to_string(), Value::Object(defaults));
}
}
result.push(Value::Object(project));
}
@@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
import { AboutSettings } from './AboutSettings';
import { SessionRetentionSettings } from './SessionRetentionSettings';
import { DefaultsSettings } from './DefaultsSettings';
import { WorktreeSectionContent } from './WorktreeSectionContent';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device';
import { isWebRuntime } from '@/lib/desktop';
@@ -50,6 +51,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
return <ChatSectionContent />;
case 'sessions':
return <SessionsSectionContent />;
case 'worktree':
return <WorktreeSectionContent />;
default:
return null;
}
@@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { AboutSettings } from './AboutSettings';
import { cn } from '@/lib/utils';
export type OpenChamberSection = 'visual' | 'chat' | 'sessions';
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree';
interface OpenChamberSidebarProps {
selectedSection: OpenChamberSection;
@@ -34,6 +34,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
label: 'Sessions',
items: ['Defaults', 'Retention'],
},
{
id: 'worktree',
label: 'Worktree',
items: ['Branch', 'Setup'],
},
];
export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
@@ -0,0 +1,571 @@
import React from 'react';
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useGitBranches, useIsGitRepo } from '@/stores/useGitStore';
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
import { sessionEvents } from '@/lib/sessionEvents';
import type { WorktreeMetadata } from '@/types/worktree';
type BranchOption = {
value: string;
label: string;
group: 'special' | 'local' | 'remote';
};
export const WorktreeSectionContent: React.FC = () => {
const activeProject = useProjectsStore((state) => state.getActiveProject());
const updateWorktreeDefaults = useProjectsStore((state) => state.updateWorktreeDefaults);
const projectPath = activeProject?.path ?? null;
const worktreeDefaults = activeProject?.worktreeDefaults;
const isGitRepoFromStore = useIsGitRepo(projectPath);
const branchesFromStore = useGitBranches(projectPath);
const { sessions, getWorktreeMetadata } = useSessionStore();
const [branchPrefix, setBranchPrefix] = React.useState(worktreeDefaults?.branchPrefix ?? '');
const [baseBranch, setBaseBranch] = React.useState(worktreeDefaults?.baseBranch ?? 'HEAD');
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
const [isLoadingGit, setIsLoadingGit] = React.useState(false);
const [isGitRepoLocal, setIsGitRepoLocal] = React.useState<boolean | null>(null);
const [branchesLocal, setBranchesLocal] = React.useState<{ all: string[]; current: string } | null>(null);
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 refreshWorktrees = React.useCallback(async () => {
if (!projectPath || isGitRepoLocal === false) return;
try {
const worktrees = await listWorktrees(projectPath);
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectPath, info));
const worktreeRoot = joinWorktreePath(projectPath, '');
const worktreePrefix = `${worktreeRoot}/`;
const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix));
setAvailableWorktrees(filtered);
} catch {
// Ignore errors
}
}, [projectPath, isGitRepoLocal, joinWorktreePath]);
// Load git info when project changes
React.useEffect(() => {
if (!projectPath) return;
let cancelled = false;
setIsLoadingGit(true);
setIsLoadingWorktrees(true);
setIsGitRepoLocal(null);
setBranchesLocal(null);
setAvailableWorktrees([]);
(async () => {
try {
const repoStatus = await checkIsGitRepository(projectPath);
if (cancelled) return;
setIsGitRepoLocal(repoStatus);
if (repoStatus) {
const [branchData, worktrees] = await Promise.all([
getGitBranches(projectPath),
listWorktrees(projectPath).catch(() => []),
]);
if (!cancelled) {
if (branchData) {
setBranchesLocal({ all: branchData.all, current: branchData.current });
}
// Filter worktrees to only show those under .openchamber
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectPath, info));
const worktreeRoot = `${projectPath.replace(/\\/g, '/').replace(/\/+$/, '')}/${WORKTREE_ROOT}`;
const worktreePrefix = `${worktreeRoot}/`;
const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix));
setAvailableWorktrees(filtered);
}
}
} catch {
// Ignore errors
} finally {
if (!cancelled) {
setIsLoadingGit(false);
setIsLoadingWorktrees(false);
}
}
})();
return () => {
cancelled = true;
};
}, [projectPath]);
// Load setup commands
React.useEffect(() => {
if (!projectPath) return;
let cancelled = false;
setIsLoadingCommands(true);
(async () => {
try {
const commands = await getWorktreeSetupCommands(projectPath);
if (!cancelled) {
setSetupCommands(commands.length > 0 ? commands : ['']);
}
} catch {
if (!cancelled) {
setSetupCommands(['']);
}
} finally {
if (!cancelled) {
setIsLoadingCommands(false);
}
}
})();
return () => {
cancelled = true;
};
}, [projectPath]);
// Sync local state with store when project changes
React.useEffect(() => {
setBranchPrefix(worktreeDefaults?.branchPrefix ?? '');
setBaseBranch(worktreeDefaults?.baseBranch ?? 'HEAD');
}, [worktreeDefaults]);
// Use local branches if available, otherwise fall back to store
const branches = branchesLocal ?? branchesFromStore;
const isGitRepo = isGitRepoLocal ?? isGitRepoFromStore;
const branchOptions = React.useMemo<BranchOption[]>(() => {
const options: BranchOption[] = [];
const headLabel = branches?.current
? `Current (HEAD: ${branches.current})`
: 'Current (HEAD)';
options.push({ value: 'HEAD', label: headLabel, group: 'special' });
if (branches) {
const localBranches = branches.all
.filter((name: string) => !name.startsWith('remotes/'))
.sort((a: string, b: string) => a.localeCompare(b));
localBranches.forEach((name: string) => {
options.push({ value: name, label: name, group: 'local' });
});
const remoteBranches = branches.all
.filter((name: string) => name.startsWith('remotes/'))
.map((name: string) => name.replace(/^remotes\//, ''))
.sort((a: string, b: string) => a.localeCompare(b));
remoteBranches.forEach((name: string) => {
options.push({ value: name, label: name, group: 'remote' });
});
}
return options;
}, [branches]);
// Track pending changes for save-on-unmount
const pendingBranchPrefixRef = React.useRef<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;
updateWorktreeDefaults(activeProject.id, { baseBranch: value });
}, [activeProject?.id, updateWorktreeDefaults]);
const handleSetupCommandChange = React.useCallback((index: number, value: string) => {
setSetupCommands((prev) => {
const next = [...prev];
next[index] = value;
return next;
});
}, []);
const handleAddCommand = React.useCallback(() => {
setSetupCommands((prev) => [...prev, '']);
}, []);
const handleRemoveCommand = React.useCallback((index: number) => {
setSetupCommands((prev) => prev.filter((_, i) => i !== index));
}, []);
const saveSetupCommands = React.useCallback(async () => {
if (!projectPath) return;
const filtered = setupCommands.filter((cmd) => cmd.trim().length > 0);
await saveWorktreeSetupCommands(projectPath, filtered);
}, [projectPath, setupCommands]);
// Save setup commands on blur
const handleCommandBlur = React.useCallback(() => {
saveSetupCommands();
}, [saveSetupCommands]);
// Delete worktree handler
const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => {
const normalizedWorktreePath = worktree.path.replace(/\\/g, '/').replace(/\/+$/, '');
// Find sessions linked to this worktree by:
// 1. Worktree metadata path match
// 2. Session directory match
const directSessions = sessions.filter((session) => {
// Check worktree metadata
const metadata = getWorktreeMetadata(session.id);
if (metadata?.path === worktree.path) {
return true;
}
// Check session directory
const sessionDir = (session as { directory?: string }).directory;
if (sessionDir) {
const normalizedSessionDir = sessionDir.replace(/\\/g, '/').replace(/\/+$/, '');
if (normalizedSessionDir === normalizedWorktreePath) {
return true;
}
}
return false;
});
// Build a set of session IDs that are directly linked
const directSessionIds = new Set(directSessions.map((s) => s.id));
// Find all subsessions recursively
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
const subsessions = sessions.filter((session) => {
const parentID = (session as { parentID?: string | null }).parentID;
return parentID && parentIds.has(parentID);
});
if (subsessions.length === 0) {
return [];
}
const subsessionIds = new Set(subsessions.map((s) => s.id));
return [...subsessions, ...findSubsessions(subsessionIds)];
};
const allSubsessions = findSubsessions(directSessionIds);
// Dedupe sessions (in case same session matched both ways)
const seenIds = new Set<string>();
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
if (seenIds.has(session.id)) {
return false;
}
seenIds.add(session.id);
return true;
});
sessionEvents.requestDelete({
sessions: allSessions,
mode: 'worktree',
worktree,
});
}, [sessions, getWorktreeMetadata]);
// Refresh worktrees when sessions change (after deletion)
const sessionsKey = React.useMemo(() => sessions.map(s => s.id).join(','), [sessions]);
React.useEffect(() => {
if (isGitRepoLocal && projectPath) {
refreshWorktrees();
}
}, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]);
if (!projectPath) {
return (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
<p className="typography-meta text-muted-foreground">
Select a project to configure worktree defaults.
</p>
</div>
</div>
);
}
if (isLoadingGit) {
return (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
<p className="typography-meta text-muted-foreground">
Loading...
</p>
</div>
</div>
);
}
if (isGitRepo === false) {
return (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
<p className="typography-meta text-muted-foreground">
Worktree settings are only available for Git repositories.
</p>
</div>
</div>
);
}
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-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Base branch</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">
Default branch to create new worktrees from.
</TooltipContent>
</Tooltip>
</div>
<p className="typography-meta text-muted-foreground">
Default branch for new worktree branches
</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>
{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>
</div>
{/* Setup commands */}
<div className="space-y-4 border-t border-border/40 pt-6">
<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.
</p>
</div>
{isLoadingCommands ? (
<p className="typography-meta text-muted-foreground">Loading...</p>
) : (
<div className="space-y-2">
{setupCommands.map((command, index) => (
<div key={index} className="flex gap-2">
<Input
value={command}
onChange={(e) => handleSetupCommandChange(index, e.target.value)}
onBlur={handleCommandBlur}
placeholder="e.g., bun install"
className="flex-1 font-mono text-xs"
/>
<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"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
))}
<button
type="button"
onClick={handleAddCommand}
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
>
<RiAddLine className="h-3.5 w-3.5" />
Add command
</button>
</div>
)}
</div>
{/* Existing worktrees */}
<div className="space-y-4 border-t border-border/40 pt-6">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Existing worktrees</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">
Worktrees created under <code className="font-mono text-xs">.openchamber</code> directory.
Deleting a worktree will also remove any linked sessions.
</TooltipContent>
</Tooltip>
</div>
<p className="typography-meta text-muted-foreground">
Manage worktrees for this project
</p>
</div>
{isLoadingWorktrees ? (
<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>
</p>
) : (
<div className="space-y-1">
{availableWorktrees.map((worktree) => (
<div
key={worktree.path}
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>
<p className="typography-micro text-muted-foreground/60 truncate">
{worktree.relativePath || worktree.path}
</p>
</div>
<button
type="button"
onClick={() => handleDeleteWorktree(worktree)}
className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
>
<RiDeleteBinLine className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -17,13 +17,13 @@ import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { getModifierLabel } from '@/lib/utils';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
export const CommandPalette: React.FC = () => {
const {
isCommandPaletteOpen,
setCommandPaletteOpen,
setHelpDialogOpen,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setSessionSwitcherOpen,
@@ -66,9 +66,9 @@ export const CommandPalette: React.FC = () => {
handleClose();
};
const handleOpenAdvancedSession = () => {
setSessionCreateDialogOpen(true);
const handleCreateWorktreeSession = () => {
handleClose();
createWorktreeSession();
};
const { isMobile } = useDeviceInfo();
@@ -135,7 +135,7 @@ export const CommandPalette: React.FC = () => {
<span>New Session</span>
<CommandShortcut>{getModifierLabel()} + N</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenAdvancedSession}>
<CommandItem onSelect={handleCreateWorktreeSession}>
<RiGitBranchLine className="mr-2 h-4 w-4" />
<span>New Session with Worktree</span>
<CommandShortcut>Shift + {getModifierLabel()} + N</CommandShortcut>
@@ -5,6 +5,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { hasModifier } from '@/lib/utils';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
export const useKeyboardShortcuts = () => {
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
@@ -13,7 +14,6 @@ export const useKeyboardShortcuts = () => {
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setModelSelectorOpen,
@@ -82,10 +82,13 @@ export const useKeyboardShortcuts = () => {
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
e.preventDefault();
if (e.shiftKey) {
setSessionCreateDialogOpen(true);
// Shift+Cmd/Ctrl+N creates a new session with auto-generated worktree
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
createWorktreeSession();
return;
}
// Cmd/Ctrl+N opens a new session without worktree
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
openNewSessionDraft();
@@ -138,7 +141,6 @@ export const useKeyboardShortcuts = () => {
isCommandPaletteOpen,
isHelpDialogOpen,
isSessionSwitcherOpen,
isSessionCreateDialogOpen,
isAboutDialogOpen,
activeMainTab,
isModelSelectorOpen,
@@ -150,7 +152,7 @@ export const useKeyboardShortcuts = () => {
}
// Skip if any overlay open or not on chat tab
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen;
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
if (hasOverlay || !isChatActive) {
@@ -168,7 +170,6 @@ export const useKeyboardShortcuts = () => {
isCommandPaletteOpen,
isHelpDialogOpen,
isSessionSwitcherOpen,
isSessionCreateDialogOpen,
isAboutDialogOpen,
activeMainTab,
} = useUIStore.getState();
@@ -182,7 +183,7 @@ export const useKeyboardShortcuts = () => {
}
// Check if any overlay is open or not on chat tab - don't process abort
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen;
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
if (hasOverlay || !isChatActive) {
@@ -238,7 +239,6 @@ export const useKeyboardShortcuts = () => {
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setModelSelectorOpen,
+6 -5
View File
@@ -8,6 +8,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sessionEvents } from '@/lib/sessionEvents';
import { isDesktopRuntime } from '@/lib/desktop';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
const MENU_ACTION_EVENT = 'openchamber:menu-action';
@@ -16,7 +17,7 @@ type MenuAction =
| 'settings'
| 'command-palette'
| 'new-session'
| 'worktree-creator'
| 'new-worktree-session'
| 'change-workspace'
| 'open-git-tab'
| 'open-diff-tab'
@@ -38,7 +39,6 @@ export const useMenuActions = (
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setAboutDialogOpen,
@@ -108,8 +108,10 @@ export const useMenuActions = (
openNewSessionDraft();
break;
case 'worktree-creator':
setSessionCreateDialogOpen(true);
case 'new-worktree-session':
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
createWorktreeSession();
break;
case 'change-workspace':
@@ -202,7 +204,6 @@ export const useMenuActions = (
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setAboutDialogOpen,
+7
View File
@@ -334,12 +334,19 @@ export interface FilesAPI {
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
}
export interface WorktreeDefaults {
branchPrefix?: string; // e.g. "feature", "bugfix" (no trailing slash)
baseBranch?: string; // e.g. "main", "develop", or "HEAD"
autoCreateWorktree?: boolean; // future: skip dialog, create worktree automatically
}
export interface ProjectEntry {
id: string;
path: string;
label?: string;
addedAt?: number;
lastOpenedAt?: number;
worktreeDefaults?: WorktreeDefaults;
}
export interface SettingsPayload {
@@ -0,0 +1,76 @@
/**
* Branch name generator utility for auto-generating friendly branch names.
* Uses Ubuntu-style adjective-noun word pairs for memorable, collision-resistant naming.
*/
import { getGitBranches } from '@/lib/gitApi';
const ADJECTIVES = [
'artful', 'bionic', 'cosmic', 'disco', 'focal', 'groovy', 'jammy', 'kinetic',
'lunar', 'noble', 'bold', 'brave', 'calm', 'eager', 'gentle', 'happy', 'keen',
'lively', 'merry', 'swift', 'warm', 'wise', 'bright', 'clever', 'daring',
'agile', 'crisp', 'fresh', 'lucid', 'quick', 'sharp', 'vivid', 'zealous',
];
const NOUNS = [
'aardvark', 'beaver', 'chipmunk', 'dolphin', 'falcon', 'gopher', 'hedgehog',
'jackal', 'koala', 'lemur', 'mongoose', 'narwhal', 'otter', 'pangolin',
'quokka', 'raccoon', 'salamander', 'toucan', 'walrus', 'yak', 'zebra',
'badger', 'condor', 'dingo', 'egret', 'ferret', 'gecko', 'heron', 'iguana',
];
/**
* Generate a random branch slug (e.g., "cosmic-dolphin", "noble-raccoon").
*/
export function generateBranchSlug(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
return `${adjective}-${noun}`;
}
/**
* Generate a branch name with optional prefix.
* @param prefix - Optional prefix like "feature", "bugfix" (no trailing slash)
* @returns Full branch name like "feature/cosmic-dolphin" or just "cosmic-dolphin"
*/
export function generateBranchName(prefix?: string): string {
const slug = generateBranchSlug();
if (prefix && prefix.trim()) {
const cleanPrefix = prefix.trim().replace(/\/+$/, '');
return `${cleanPrefix}/${slug}`;
}
return slug;
}
/**
* Generate a unique branch name that doesn't conflict with existing branches.
* @param projectDirectory - Project directory to check for existing branches
* @param prefix - Optional branch prefix
* @param maxAttempts - Maximum attempts to generate a unique name (default: 10)
* @returns Unique branch name, or null if all attempts failed
*/
export async function generateUniqueBranchName(
projectDirectory: string,
prefix?: string,
maxAttempts: number = 10
): Promise<string | null> {
let existingBranches: Set<string>;
try {
const branches = await getGitBranches(projectDirectory);
existingBranches = new Set(branches?.all ?? []);
} catch {
// If we can't get branches, just generate without checking
return generateBranchName(prefix);
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const candidate = generateBranchName(prefix);
if (!existingBranches.has(candidate)) {
return candidate;
}
}
// All attempts exhausted, return null
return null;
}
+17
View File
@@ -130,6 +130,23 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
// Preserve worktreeDefaults
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
const defaults: Record<string, unknown> = {};
if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) {
defaults.branchPrefix = wt.branchPrefix.trim();
}
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
defaults.baseBranch = wt.baseBranch.trim();
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
(project as unknown as Record<string, unknown>).worktreeDefaults = defaults;
}
}
result.push(project);
}
@@ -0,0 +1,183 @@
/**
* Utility for creating a new session with an auto-generated worktree.
* This is a standalone function that can be called from keyboard shortcuts,
* menu actions, or other non-hook contexts.
*/
import { toast } from 'sonner';
import { useSessionStore } from '@/stores/useSessionStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { checkIsGitRepository } from '@/lib/gitApi';
import { generateUniqueBranchName } from '@/lib/git/branchNameGenerator';
import {
createWorktree,
getWorktreeStatus,
removeWorktree,
runWorktreeSetupCommands,
} from '@/lib/git/worktreeService';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
const sanitizeWorktreeSlug = (value: string): string => {
return value
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 120);
};
// Track if we're currently creating a worktree session
let isCreatingWorktreeSession = false;
/**
* Create a new session with an auto-generated worktree.
* Uses project's worktree defaults (branch prefix, base branch) from settings.
*
* @returns The created session, or null if creation failed
*/
export async function createWorktreeSession(): Promise<{ id: string } | null> {
if (isCreatingWorktreeSession) {
return null;
}
const activeProject = useProjectsStore.getState().getActiveProject();
if (!activeProject?.path) {
toast.error('No active project', {
description: 'Please select a project first.',
});
return null;
}
const projectDirectory = activeProject.path;
// Check if it's a git repo
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectDirectory);
} catch {
// Ignore errors, treat as not a git repo
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
isCreatingWorktreeSession = true;
try {
// Get worktree defaults from project settings
const worktreeDefaults = activeProject.worktreeDefaults;
const branchPrefix = worktreeDefaults?.branchPrefix;
const baseBranch = worktreeDefaults?.baseBranch;
// Generate a unique branch name
const branchName = await generateUniqueBranchName(projectDirectory, branchPrefix);
if (!branchName) {
toast.error('Failed to generate branch name', {
description: 'Could not generate a unique branch name. Please try again.',
});
return null;
}
const worktreeSlug = sanitizeWorktreeSlug(branchName);
// Determine start point (base branch)
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
// Create the worktree
const metadata = await createWorktree({
projectDirectory,
worktreeSlug,
branch: branchName,
createBranch: true,
startPoint,
});
// Get worktree status
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
const createdMetadata = status ? { ...metadata, status } : metadata;
// Create the session
const sessionStore = useSessionStore.getState();
const session = await sessionStore.createSession(undefined, metadata.path);
if (!session) {
// Clean up the worktree if session creation failed
await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined);
toast.error('Failed to create session', {
description: 'Could not create a session for the worktree.',
});
return null;
}
// Initialize the session
const agents = useConfigStore.getState().agents;
sessionStore.initializeNewOpenChamberSession(session.id, agents);
sessionStore.setSessionDirectory(session.id, metadata.path);
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
// Update directory
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
// Refresh sessions list
try {
await sessionStore.loadSessions();
} catch {
// Ignore
}
// Get and run setup commands
const setupCommands = await getWorktreeSetupCommands(projectDirectory);
const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0);
if (commandsToRun.length > 0) {
toast.success('Worktree created', {
description: `Branch: ${branchName}. Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`,
});
// Run setup commands in background
runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun).then((result) => {
if (result.success) {
toast.success('Setup commands completed', {
description: `All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`,
});
} else {
const failed = result.results.filter(r => !r.success);
const succeeded = result.results.filter(r => r.success);
toast.error('Setup commands failed', {
description: `${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` +
(succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : ''),
});
}
}).catch(() => {
toast.error('Setup commands failed', {
description: 'Could not execute setup commands.',
});
});
} else {
toast.success('Worktree created', {
description: `Branch: ${branchName}`,
});
}
return session;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create worktree session';
toast.error('Failed to create worktree', {
description: message,
});
return null;
} finally {
isCreatingWorktreeSession = false;
}
}
/**
* Check if a worktree session is currently being created.
*/
export function isCreatingWorktree(): boolean {
return isCreatingWorktreeSession;
}
+57 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import type { ProjectEntry } from '@/lib/api/types';
import type { ProjectEntry, WorktreeDefaults } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { getSafeStorage } from './utils/safeStorage';
@@ -27,6 +27,7 @@ interface ProjectsStore {
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
getActiveProject: () => ProjectEntry | null;
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => void;
}
const safeStorage = getSafeStorage();
@@ -120,6 +121,22 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
const defaults: WorktreeDefaults = {};
if (typeof wt.branchPrefix === 'string') {
defaults.branchPrefix = wt.branchPrefix;
}
if (typeof wt.baseBranch === 'string') {
defaults.baseBranch = wt.baseBranch;
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
project.worktreeDefaults = defaults;
}
}
result.push(project);
}
@@ -434,6 +451,45 @@ export const useProjectsStore = create<ProjectsStore>()(
}
return projects.find((project) => project.id === activeProjectId) ?? null;
},
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
const target = projects.find((project) => project.id === projectId);
if (!target) {
return;
}
const merged: WorktreeDefaults = { ...target.worktreeDefaults };
if (defaults.branchPrefix !== undefined) {
if (defaults.branchPrefix.trim()) {
merged.branchPrefix = defaults.branchPrefix.trim();
} else {
delete merged.branchPrefix;
}
}
if (defaults.baseBranch !== undefined) {
if (defaults.baseBranch.trim()) {
merged.baseBranch = defaults.baseBranch.trim();
} else {
delete merged.baseBranch;
}
}
if (defaults.autoCreateWorktree !== undefined) {
merged.autoCreateWorktree = defaults.autoCreateWorktree;
}
const nextProjects = projects.map((project) =>
project.id === projectId
? { ...project, worktreeDefaults: Object.keys(merged).length > 0 ? merged : undefined }
: project
);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
}), { name: 'projects-store' })
);
+22 -2
View File
@@ -484,13 +484,33 @@ const sanitizeProjects = (input) => {
seenIds.add(id);
seenPaths.add(normalizedPath);
result.push({
const project = {
id,
path: normalizedPath,
...(label ? { label } : {}),
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
});
};
// Preserve worktreeDefaults
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults;
const defaults = {};
if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) {
defaults.branchPrefix = wt.branchPrefix.trim();
}
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
defaults.baseBranch = wt.baseBranch.trim();
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
project.worktreeDefaults = defaults;
}
}
result.push(project);
}
return result;