feat: add auto-create worktree setting and related UI components

This commit is contained in:
Bohdan Triapitsyn
2026-01-07 23:37:32 +02:00
parent b6646b6b3e
commit c7700d5384
11 changed files with 144 additions and 14 deletions
@@ -257,6 +257,9 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Bool(b)) = obj.get("queueModeEnabled") {
result_obj.insert("queueModeEnabled".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("autoCreateWorktree") {
result_obj.insert("autoCreateWorktree".to_string(), json!(b));
}
// Number fields
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
@@ -7,6 +7,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getModifierLabel } from '@/lib/utils';
export const DefaultsSettings: React.FC = () => {
const setProvider = useConfigStore((state) => state.setProvider);
@@ -14,6 +15,8 @@ export const DefaultsSettings: React.FC = () => {
const setAgent = useConfigStore((state) => state.setAgent);
const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel);
const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent);
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree);
const providers = useConfigStore((state) => state.providers);
const [defaultModel, setDefaultModel] = React.useState<string | undefined>();
@@ -126,6 +129,18 @@ export const DefaultsSettings: React.FC = () => {
}
}, [setAgent, setSettingsDefaultAgent]);
const handleAutoWorktreeChange = React.useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const enabled = e.target.checked;
setSettingsAutoCreateWorktree(enabled);
try {
await updateDesktopSettings({
autoCreateWorktree: enabled,
});
} catch (error) {
console.warn('Failed to save auto create worktree setting:', error);
}
}, [setSettingsAutoCreateWorktree]);
if (isLoading) {
return null;
}
@@ -134,14 +149,13 @@ export const DefaultsSettings: React.FC = () => {
<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">Default model & agent</h3>
<h3 className="typography-ui-header font-semibold text-foreground">Session Defaults</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">
Set the default model and agent for new sessions.<br />
When not set, uses agent&apos;s preferred model or opencode/big-pickle as fallback.
Configure default behaviors for new sessions.
</TooltipContent>
</Tooltip>
</div>
@@ -174,6 +188,25 @@ export const DefaultsSettings: React.FC = () => {
{defaultAgent && <span className="text-foreground">{defaultAgent}</span>}
</div>
)}
<div className="pt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary"
checked={settingsAutoCreateWorktree}
onChange={handleAutoWorktreeChange}
/>
<span className="typography-ui-label text-foreground">
Always create worktree for new sessions
</span>
</label>
<p className="typography-meta text-muted-foreground pl-5.5 mt-1">
{settingsAutoCreateWorktree
? `New session (Worktree): ${getModifierLabel()} + N • New session (Standard): Shift + ${getModifierLabel()} + N`
: `New session (Standard): ${getModifierLabel()} + N • New session (Worktree): Shift + ${getModifierLabel()} + N`}
</p>
</div>
</div>
);
};
@@ -698,6 +698,18 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const handleDeleteSession = React.useCallback(
async (session: Session) => {
const descendants = collectDescendants(session.id);
// Check if this is a worktree session - if so, show confirmation dialog
const worktree = worktreeMetadata.get(session.id);
if (worktree) {
sessionEvents.requestDelete({
sessions: [session, ...descendants],
mode: 'worktree',
worktree,
});
return;
}
if (descendants.length === 0) {
const success = await deleteSession(session.id);
@@ -718,7 +730,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
}
},
[collectDescendants, deleteSession, deleteSessions],
[collectDescendants, deleteSession, deleteSessions, worktreeMetadata],
);
const handleCreateSessionInGroup = React.useCallback(
@@ -12,6 +12,7 @@ import {
import { useUIStore } from '@/stores/useUIStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
@@ -37,6 +38,8 @@ export const CommandPalette: React.FC = () => {
getSessionsByDirectory,
} = useSessionStore();
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
const { currentDirectory } = useDirectoryStore();
const { themeMode, setThemeMode } = useThemeSystem();
@@ -133,12 +136,16 @@ export const CommandPalette: React.FC = () => {
<CommandItem onSelect={handleCreateSession}>
<RiAddLine className="mr-2 h-4 w-4" />
<span>New Session</span>
<CommandShortcut>{getModifierLabel()} + N</CommandShortcut>
<CommandShortcut>
{settingsAutoCreateWorktree ? `Shift + ${getModifierLabel()} + N` : `${getModifierLabel()} + N`}
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleCreateWorktreeSession}>
<RiGitBranchLine className="mr-2 h-4 w-4" />
<span>New Session with Worktree</span>
<CommandShortcut>Shift + {getModifierLabel()} + N</CommandShortcut>
<CommandShortcut>
{settingsAutoCreateWorktree ? `${getModifierLabel()} + N` : `Shift + ${getModifierLabel()} + N`}
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleShowHelp}>
<RiQuestionLine className="mr-2 h-4 w-4" />
+6 -4
View File
@@ -7,6 +7,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useUIStore } from "@/stores/useUIStore";
import { useConfigStore } from "@/stores/useConfigStore";
import {
RiAddLine,
RiArrowUpSLine,
@@ -82,6 +83,7 @@ type ShortcutSection = {
export const HelpDialog: React.FC = () => {
const { isHelpDialogOpen, setHelpDialogOpen } = useUIStore();
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
const mod = getModifierLabel();
@@ -116,13 +118,13 @@ export const HelpDialog: React.FC = () => {
items: [
{
keys: [`${mod} + N`],
description: "Create New Session",
icon: RiAddLine,
description: settingsAutoCreateWorktree ? "Create new session in worktree" : "Create New Session",
icon: settingsAutoCreateWorktree ? RiGitBranchLine : RiAddLine,
},
{
keys: [`Shift + ${mod} + N`],
description: "Open Worktree Creator",
icon: RiGitBranchLine,
description: settingsAutoCreateWorktree ? "Create New Session" : "Create new session in worktree",
icon: settingsAutoCreateWorktree ? RiAddLine : RiGitBranchLine,
},
{ keys: [`${mod} + I`], description: "Focus Chat Input", icon: RiText },
{
+10 -3
View File
@@ -6,6 +6,7 @@ import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { hasModifier } from '@/lib/utils';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { useConfigStore } from '@/stores/useConfigStore';
export const useKeyboardShortcuts = () => {
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
@@ -81,14 +82,20 @@ export const useKeyboardShortcuts = () => {
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
e.preventDefault();
if (e.shiftKey) {
// Shift+Cmd/Ctrl+N creates a new session with auto-generated worktree
const autoWorktree = useConfigStore.getState().settingsAutoCreateWorktree;
// If autoWorktree is true: Cmd+N -> Worktree, Cmd+Shift+N -> Standard
// If autoWorktree is false: Cmd+N -> Standard, Cmd+Shift+N -> Worktree
const shouldCreateWorktree = autoWorktree ? !e.shiftKey : e.shiftKey;
if (shouldCreateWorktree) {
// Create new session with auto-generated worktree
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
createWorktreeSession();
return;
}
// Cmd/Ctrl+N opens a new session without worktree
// Open a new session without worktree
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
openNewSessionDraft();
+1
View File
@@ -55,6 +55,7 @@ export type DesktopSettings = {
autoDeleteAfterDays?: number;
defaultModel?: string; // format: "provider/model"
defaultAgent?: string;
autoCreateWorktree?: boolean;
queueModeEnabled?: boolean;
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
+3
View File
@@ -255,6 +255,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
result.defaultAgent = candidate.defaultAgent;
}
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
}
+48 -1
View File
@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { useSessionStore } from '@/stores/useSessionStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { checkIsGitRepository } from '@/lib/gitApi';
import { generateUniqueBranchName } from '@/lib/git/branchNameGenerator';
@@ -116,11 +117,57 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
}
// Initialize the session
const agents = useConfigStore.getState().agents;
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);
}
}
}
}
} catch {
// Ignore errors setting default agent
}
// Update directory
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
+12
View File
@@ -23,6 +23,7 @@ const FALLBACK_MODEL_ID = "big-pickle";
interface OpenChamberDefaults {
defaultModel?: string;
defaultAgent?: string;
autoCreateWorktree?: boolean;
}
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
@@ -33,6 +34,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
return {
defaultModel: settings?.defaultModel,
defaultAgent: settings?.defaultAgent,
autoCreateWorktree: settings?.autoCreateWorktree,
};
}
@@ -46,6 +48,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
return {
defaultModel: typeof data?.defaultModel === 'string' ? data.defaultModel : undefined,
defaultAgent: typeof data?.defaultAgent === 'string' ? data.defaultAgent : undefined,
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
};
}
} catch {
@@ -65,6 +68,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
return {
defaultModel: typeof data?.defaultModel === 'string' ? data.defaultModel : undefined,
defaultAgent: typeof data?.defaultAgent === 'string' ? data.defaultAgent : undefined,
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
};
} catch {
return {};
@@ -351,6 +355,7 @@ interface ConfigStore {
// OpenChamber settings-based defaults (take precedence over agent preferences)
settingsDefaultModel: string | undefined; // format: "provider/model"
settingsDefaultAgent: string | undefined;
settingsAutoCreateWorktree: boolean;
activateDirectory: (directory: string | null | undefined) => Promise<void>;
@@ -362,6 +367,7 @@ interface ConfigStore {
setSelectedProvider: (providerId: string) => void;
setSettingsDefaultModel: (model: string | undefined) => void;
setSettingsDefaultAgent: (agent: string | undefined) => void;
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
checkConnection: () => Promise<boolean>;
@@ -402,6 +408,7 @@ export const useConfigStore = create<ConfigStore>()(
modelsMetadata: new Map<string, ModelMetadata>(),
settingsDefaultModel: undefined,
settingsDefaultAgent: undefined,
settingsAutoCreateWorktree: false,
activateDirectory: async (directory) => {
const directoryKey = toDirectoryKey(directory);
@@ -730,6 +737,7 @@ export const useConfigStore = create<ConfigStore>()(
const nextState: Partial<ConfigStore> = {
settingsDefaultModel: openChamberDefaults.defaultModel,
settingsDefaultAgent: openChamberDefaults.defaultAgent,
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
@@ -1111,6 +1119,10 @@ export const useConfigStore = create<ConfigStore>()(
set({ settingsDefaultAgent: agent });
},
setSettingsAutoCreateWorktree: (enabled: boolean) => {
set({ settingsAutoCreateWorktree: enabled });
},
checkConnection: async () => {
const maxAttempts = 5;
let attempt = 0;
+3
View File
@@ -599,6 +599,9 @@ const sanitizeSettingsUpdate = (payload) => {
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
}
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
if (skillCatalogs) {