feat: add auto-create worktree setting and related UI components
This commit is contained in:
@@ -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'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" />
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user