feat: Display default model in dropdowns and consolidate Git settings menu (#135)
* types: add commitMessageModel to DesktopSettings * store: add commitMessageModel state and fetch logic * lib: implement persistence for commitMessageModel setting * ui: create GitSettings component for model selection * ui: add Git section to OpenChamber settings sidebar * ui: register GitSectionContent in OpenChamberPage * server: use commitMessageModel setting for AI generation * tauri: sanitize commitMessageModel in settings commands * tauri: use commitMessageModel for desktop message generation * build: update lockfile and package configuration * refactor(openchamber): improve default model parsing with fallback logic * feat(openchamber): improve model selection fallback logic * refactor: move worktree settings into git section * refactor(openchamber): merge worktree into git section * refactor[worktree]: simplify empty state layout * revert: rollback bun.lock and package.json to main * feat(git): restrict model selection to OpenCode provider
This commit is contained in:
@@ -2089,13 +2089,30 @@ Diff summary:
|
|||||||
{}"#,
|
{}"#,
|
||||||
diff_summaries
|
diff_summaries
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let settings = state.settings().load().await.unwrap_or(serde_json::Value::Null);
|
||||||
|
let raw_model = settings.get("commitMessageModel")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let model_candidate = raw_model
|
||||||
|
.split('/')
|
||||||
|
.last()
|
||||||
|
.unwrap_or(raw_model)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
let model = if model_candidate.is_empty() {
|
||||||
|
"big-pickle"
|
||||||
|
} else {
|
||||||
|
model_candidate
|
||||||
|
};
|
||||||
|
|
||||||
// 3. Call API
|
// 3. Call API
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let res = client
|
let res = client
|
||||||
.post("https://opencode.ai/zen/v1/chat/completions")
|
.post("https://opencode.ai/zen/v1/chat/completions")
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"model": "big-pickle",
|
"model": model,
|
||||||
"messages": [{ "role": "user", "content": prompt }],
|
"messages": [{ "role": "user", "content": prompt }],
|
||||||
"max_tokens": 3000,
|
"max_tokens": 3000,
|
||||||
"stream": false,
|
"stream": false,
|
||||||
|
|||||||
@@ -257,6 +257,14 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
result_obj.insert("defaultAgent".to_string(), json!(trimmed));
|
result_obj.insert("defaultAgent".to_string(), json!(trimmed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("commitMessageModel") {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
result_obj.insert("commitMessageModel".to_string(), Value::Null);
|
||||||
|
} else {
|
||||||
|
result_obj.insert("commitMessageModel".to_string(), json!(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Boolean fields
|
// Boolean fields
|
||||||
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
|
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface ModelSelectorProps {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
onChange: (providerId: string, modelId: string) => void;
|
onChange: (providerId: string, modelId: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
allowedProviderIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||||
@@ -49,7 +50,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
providerId,
|
providerId,
|
||||||
modelId,
|
modelId,
|
||||||
onChange,
|
onChange,
|
||||||
className
|
className,
|
||||||
|
allowedProviderIds
|
||||||
}) => {
|
}) => {
|
||||||
const { providers, modelsMetadata } = useConfigStore();
|
const { providers, modelsMetadata } = useConfigStore();
|
||||||
const isMobile = useUIStore(state => state.isMobile);
|
const isMobile = useUIStore(state => state.isMobile);
|
||||||
@@ -65,6 +67,20 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
const allowedProviderSet = React.useMemo(() => {
|
||||||
|
if (!Array.isArray(allowedProviderIds) || allowedProviderIds.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Set(allowedProviderIds);
|
||||||
|
}, [allowedProviderIds]);
|
||||||
|
|
||||||
|
const visibleProviders = React.useMemo(() => {
|
||||||
|
if (!allowedProviderSet) {
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
return providers.filter((provider) => allowedProviderSet.has(String(provider.id)));
|
||||||
|
}, [providers, allowedProviderSet]);
|
||||||
|
|
||||||
const closeMobilePanel = () => setIsMobilePanelOpen(false);
|
const closeMobilePanel = () => setIsMobilePanelOpen(false);
|
||||||
const toggleMobileProviderExpansion = (provId: string) => {
|
const toggleMobileProviderExpansion = (provId: string) => {
|
||||||
setExpandedMobileProviders(prev => {
|
setExpandedMobileProviders(prev => {
|
||||||
@@ -188,6 +204,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
|
|
||||||
// Filter data for desktop dropdown
|
// Filter data for desktop dropdown
|
||||||
const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
|
const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
|
||||||
|
if (allowedProviderSet && !allowedProviderSet.has(providerID)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const provider = providers.find(p => p.id === providerID);
|
const provider = providers.find(p => p.id === providerID);
|
||||||
const providerName = provider?.name || providerID;
|
const providerName = provider?.name || providerID;
|
||||||
const modelName = getModelDisplayName(model);
|
const modelName = getModelDisplayName(model);
|
||||||
@@ -195,13 +214,16 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredRecents = recentModelsList.filter(({ model, providerID }) => {
|
const filteredRecents = recentModelsList.filter(({ model, providerID }) => {
|
||||||
|
if (allowedProviderSet && !allowedProviderSet.has(providerID)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const provider = providers.find(p => p.id === providerID);
|
const provider = providers.find(p => p.id === providerID);
|
||||||
const providerName = provider?.name || providerID;
|
const providerName = provider?.name || providerID;
|
||||||
const modelName = getModelDisplayName(model);
|
const modelName = getModelDisplayName(model);
|
||||||
return filterByQuery(modelName, providerName);
|
return filterByQuery(modelName, providerName);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredProviders = providers
|
const filteredProviders = visibleProviders
|
||||||
.map((provider) => {
|
.map((provider) => {
|
||||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||||
const filteredModels = providerModels.filter((model: ProviderModel) => {
|
const filteredModels = providerModels.filter((model: ProviderModel) => {
|
||||||
@@ -332,7 +354,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{providers.map((provider) => {
|
{visibleProviders.map((provider) => {
|
||||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||||
if (providerModels.length === 0) return null;
|
if (providerModels.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,33 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
|||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { getModifierLabel } from '@/lib/utils';
|
import { getModifierLabel } from '@/lib/utils';
|
||||||
|
|
||||||
|
const FALLBACK_PROVIDER_ID = 'opencode';
|
||||||
|
const FALLBACK_MODEL_ID = 'big-pickle';
|
||||||
|
|
||||||
|
const getDisplayModel = (
|
||||||
|
storedModel: string | undefined,
|
||||||
|
providers: Array<{ id: string; models: Array<{ id: string }> }>
|
||||||
|
): { providerId: string; modelId: string } => {
|
||||||
|
if (storedModel) {
|
||||||
|
const parts = storedModel.split('/');
|
||||||
|
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||||
|
return { providerId: parts[0], modelId: parts[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackProvider = providers.find(p => p.id === FALLBACK_PROVIDER_ID);
|
||||||
|
if (fallbackProvider?.models.some(m => m.id === FALLBACK_MODEL_ID)) {
|
||||||
|
return { providerId: FALLBACK_PROVIDER_ID, modelId: FALLBACK_MODEL_ID };
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstProvider = providers[0];
|
||||||
|
if (firstProvider?.models[0]) {
|
||||||
|
return { providerId: firstProvider.id, modelId: firstProvider.models[0].id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { providerId: '', modelId: '' };
|
||||||
|
};
|
||||||
|
|
||||||
export const DefaultsSettings: React.FC = () => {
|
export const DefaultsSettings: React.FC = () => {
|
||||||
const setProvider = useConfigStore((state) => state.setProvider);
|
const setProvider = useConfigStore((state) => state.setProvider);
|
||||||
const setModel = useConfigStore((state) => state.setModel);
|
const setModel = useConfigStore((state) => state.setModel);
|
||||||
@@ -27,13 +54,9 @@ export const DefaultsSettings: React.FC = () => {
|
|||||||
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
|
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
// Parse "provider/model" string into separate parts
|
|
||||||
const parsedModel = React.useMemo(() => {
|
const parsedModel = React.useMemo(() => {
|
||||||
if (!defaultModel) return { providerId: '', modelId: '' };
|
return getDisplayModel(defaultModel, providers);
|
||||||
const parts = defaultModel.split('/');
|
}, [defaultModel, providers]);
|
||||||
if (parts.length !== 2) return { providerId: '', modelId: '' };
|
|
||||||
return { providerId: parts[0] || '', modelId: parts[1] || '' };
|
|
||||||
}, [defaultModel]);
|
|
||||||
|
|
||||||
// Load current settings
|
// Load current settings
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -180,6 +203,7 @@ export const DefaultsSettings: React.FC = () => {
|
|||||||
}, [setAgent, setSettingsDefaultAgent]);
|
}, [setAgent, setSettingsDefaultAgent]);
|
||||||
|
|
||||||
const availableVariants = React.useMemo(() => {
|
const availableVariants = React.useMemo(() => {
|
||||||
|
if (!parsedModel.providerId || !parsedModel.modelId) return [];
|
||||||
const provider = providers.find((p) => p.id === parsedModel.providerId);
|
const provider = providers.find((p) => p.id === parsedModel.providerId);
|
||||||
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === parsedModel.modelId) as
|
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === parsedModel.modelId) as
|
||||||
| { variants?: Record<string, unknown> }
|
| { variants?: Record<string, unknown> }
|
||||||
@@ -274,16 +298,16 @@ export const DefaultsSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(defaultModel || defaultAgent) && (
|
{(parsedModel.providerId || defaultAgent) && (
|
||||||
<div className="typography-meta text-muted-foreground">
|
<div className="typography-meta text-muted-foreground">
|
||||||
New sessions will start with:{' '}
|
New sessions will start with:{' '}
|
||||||
{defaultModel && (
|
{parsedModel.providerId && (
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
{defaultModel}
|
{parsedModel.providerId}/{parsedModel.modelId}
|
||||||
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
|
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{defaultModel && defaultAgent && ' / '}
|
{parsedModel.providerId && defaultAgent && ' / '}
|
||||||
{defaultAgent && <span className="text-foreground">{defaultAgent}</span>}
|
{defaultAgent && <span className="text-foreground">{defaultAgent}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiInformationLine } from '@remixicon/react';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||||
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
|
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
|
|
||||||
|
const FALLBACK_PROVIDER_ID = 'opencode';
|
||||||
|
const FALLBACK_MODEL_ID = 'big-pickle';
|
||||||
|
|
||||||
|
const getDisplayModel = (
|
||||||
|
storedModel: string | undefined,
|
||||||
|
providers: Array<{ id: string; models: Array<{ id: string }> }>
|
||||||
|
): { providerId: string; modelId: string } => {
|
||||||
|
if (storedModel) {
|
||||||
|
const parts = storedModel.split('/');
|
||||||
|
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||||
|
return { providerId: parts[0], modelId: parts[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackProvider = providers.find(p => p.id === FALLBACK_PROVIDER_ID);
|
||||||
|
if (fallbackProvider?.models.some(m => m.id === FALLBACK_MODEL_ID)) {
|
||||||
|
return { providerId: FALLBACK_PROVIDER_ID, modelId: FALLBACK_MODEL_ID };
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstProvider = providers[0];
|
||||||
|
if (firstProvider?.models[0]) {
|
||||||
|
return { providerId: firstProvider.id, modelId: firstProvider.models[0].id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { providerId: '', modelId: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GitSettings: React.FC = () => {
|
||||||
|
const settingsCommitMessageModel = useConfigStore((state) => state.settingsCommitMessageModel);
|
||||||
|
const setSettingsCommitMessageModel = useConfigStore((state) => state.setSettingsCommitMessageModel);
|
||||||
|
const providers = useConfigStore((state) => state.providers);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
|
const opencodeProviders = React.useMemo(() => {
|
||||||
|
return providers.filter((provider) => provider.id === FALLBACK_PROVIDER_ID);
|
||||||
|
}, [providers]);
|
||||||
|
|
||||||
|
const parsedModel = React.useMemo(() => {
|
||||||
|
const effectiveStoredModel = settingsCommitMessageModel?.startsWith(`${FALLBACK_PROVIDER_ID}/`)
|
||||||
|
? settingsCommitMessageModel
|
||||||
|
: undefined;
|
||||||
|
return getDisplayModel(effectiveStoredModel, opencodeProviders);
|
||||||
|
}, [settingsCommitMessageModel, opencodeProviders]);
|
||||||
|
|
||||||
|
// Load current settings
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
let data: { commitMessageModel?: string } | null = null;
|
||||||
|
|
||||||
|
// 1. Desktop runtime (Tauri)
|
||||||
|
if (isDesktopRuntime()) {
|
||||||
|
data = await getDesktopSettings();
|
||||||
|
} else {
|
||||||
|
// 2. Runtime settings API (VSCode)
|
||||||
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
|
if (runtimeSettings) {
|
||||||
|
try {
|
||||||
|
const result = await runtimeSettings.load();
|
||||||
|
const settings = result?.settings;
|
||||||
|
if (settings) {
|
||||||
|
data = {
|
||||||
|
commitMessageModel: typeof settings.commitMessageModel === 'string' ? settings.commitMessageModel : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to fetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch API (Web)
|
||||||
|
if (!data) {
|
||||||
|
const response = await fetch('/api/config/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
data = await response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
const model = typeof data.commitMessageModel === 'string' && data.commitMessageModel.trim().length > 0
|
||||||
|
? data.commitMessageModel.trim()
|
||||||
|
: undefined;
|
||||||
|
setSettingsCommitMessageModel(model);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load git settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSettings();
|
||||||
|
}, [setSettingsCommitMessageModel]);
|
||||||
|
|
||||||
|
const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => {
|
||||||
|
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
|
||||||
|
setSettingsCommitMessageModel(newValue);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateDesktopSettings({
|
||||||
|
commitMessageModel: newValue ?? '',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save commit message model:', error);
|
||||||
|
}
|
||||||
|
}, [setSettingsCommitMessageModel]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">Commit Messages</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">
|
||||||
|
Configure how commit messages are generated.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="typography-ui-label text-muted-foreground">Model for generation</label>
|
||||||
|
<ModelSelector
|
||||||
|
providerId={parsedModel.providerId}
|
||||||
|
modelId={parsedModel.modelId}
|
||||||
|
onChange={handleModelChange}
|
||||||
|
allowedProviderIds={[FALLBACK_PROVIDER_ID]}
|
||||||
|
/>
|
||||||
|
<p className="typography-meta text-muted-foreground mt-1">
|
||||||
|
This model will be used to analyze diffs and suggest commit messages.
|
||||||
|
{!settingsCommitMessageModel && <> Default: <span className="text-foreground">opencode/big-pickle</span></>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
|||||||
import { AboutSettings } from './AboutSettings';
|
import { AboutSettings } from './AboutSettings';
|
||||||
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||||
import { DefaultsSettings } from './DefaultsSettings';
|
import { DefaultsSettings } from './DefaultsSettings';
|
||||||
|
import { GitSettings } from './GitSettings';
|
||||||
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
||||||
import { NotificationSettings } from './NotificationSettings';
|
import { NotificationSettings } from './NotificationSettings';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
@@ -52,8 +53,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
|||||||
return <ChatSectionContent />;
|
return <ChatSectionContent />;
|
||||||
case 'sessions':
|
case 'sessions':
|
||||||
return <SessionsSectionContent />;
|
return <SessionsSectionContent />;
|
||||||
case 'worktree':
|
case 'git':
|
||||||
return <WorktreeSectionContent />;
|
return <GitSectionContent />;
|
||||||
case 'notifications':
|
case 'notifications':
|
||||||
return <NotificationSectionContent />;
|
return <NotificationSectionContent />;
|
||||||
default:
|
default:
|
||||||
@@ -94,6 +95,24 @@ const SessionsSectionContent: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Git section: Commit message model, Worktree settings
|
||||||
|
const GitSectionContent: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<GitSettings />
|
||||||
|
<div className="border-t border-border/40 pt-6">
|
||||||
|
<div className="space-y-1 mb-4">
|
||||||
|
<h3 className="typography-ui-header font-semibold text-foreground">Worktree</h3>
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
Configure worktree branch defaults and manage existing worktrees.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<WorktreeSectionContent />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Notifications section: Native browser notifications
|
// Notifications section: Native browser notifications
|
||||||
const NotificationSectionContent: React.FC = () => {
|
const NotificationSectionContent: React.FC = () => {
|
||||||
return <NotificationSettings />;
|
return <NotificationSettings />;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
|||||||
import { AboutSettings } from './AboutSettings';
|
import { AboutSettings } from './AboutSettings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree' | 'notifications';
|
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'notifications';
|
||||||
|
|
||||||
interface OpenChamberSidebarProps {
|
interface OpenChamberSidebarProps {
|
||||||
selectedSection: OpenChamberSection;
|
selectedSection: OpenChamberSection;
|
||||||
@@ -35,9 +35,9 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
|||||||
items: ['Defaults', 'Retention'],
|
items: ['Defaults', 'Retention'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'worktree',
|
id: 'git',
|
||||||
label: 'Worktree',
|
label: 'Git',
|
||||||
items: ['Branch', 'Setup'],
|
items: ['Commit Messages', 'Worktree'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'notifications',
|
id: 'notifications',
|
||||||
|
|||||||
@@ -326,40 +326,25 @@ export const WorktreeSectionContent: React.FC = () => {
|
|||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<p className="typography-meta text-muted-foreground">
|
||||||
<div className="space-y-1">
|
Select a project to configure worktree defaults.
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
|
</p>
|
||||||
<p className="typography-meta text-muted-foreground">
|
|
||||||
Select a project to configure worktree defaults.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoadingGit) {
|
if (isLoadingGit) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<p className="typography-meta text-muted-foreground">
|
||||||
<div className="space-y-1">
|
Loading...
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
|
</p>
|
||||||
<p className="typography-meta text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGitRepo === false) {
|
if (isGitRepo === false) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<p className="typography-meta text-muted-foreground">
|
||||||
<div className="space-y-1">
|
Worktree settings are only available for Git repositories.
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
|
</p>
|
||||||
<p className="typography-meta text-muted-foreground">
|
|
||||||
Worktree settings are only available for Git repositories.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export type DesktopSettings = {
|
|||||||
defaultAgent?: string;
|
defaultAgent?: string;
|
||||||
autoCreateWorktree?: boolean;
|
autoCreateWorktree?: boolean;
|
||||||
queueModeEnabled?: boolean;
|
queueModeEnabled?: boolean;
|
||||||
|
commitMessageModel?: string; // format: "provider/model"
|
||||||
|
|
||||||
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
|
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
|
||||||
skillCatalogs?: SkillCatalogConfig[];
|
skillCatalogs?: SkillCatalogConfig[];
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
|||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('pinnedDirectories');
|
localStorage.removeItem('pinnedDirectories');
|
||||||
}
|
}
|
||||||
|
if (settings.commitMessageModel) {
|
||||||
|
localStorage.setItem('commitMessageModel', settings.commitMessageModel);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('commitMessageModel');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type PersistApi = {
|
type PersistApi = {
|
||||||
@@ -261,6 +266,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
||||||
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.commitMessageModel === 'string' && candidate.commitMessageModel.length > 0) {
|
||||||
|
result.commitMessageModel = candidate.commitMessageModel;
|
||||||
|
}
|
||||||
if (typeof candidate.queueModeEnabled === 'boolean') {
|
if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface OpenChamberDefaults {
|
|||||||
defaultVariant?: string;
|
defaultVariant?: string;
|
||||||
defaultAgent?: string;
|
defaultAgent?: string;
|
||||||
autoCreateWorktree?: boolean;
|
autoCreateWorktree?: boolean;
|
||||||
|
commitMessageModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||||
@@ -37,6 +38,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
|||||||
defaultVariant: settings?.defaultVariant,
|
defaultVariant: settings?.defaultVariant,
|
||||||
defaultAgent: settings?.defaultAgent,
|
defaultAgent: settings?.defaultAgent,
|
||||||
autoCreateWorktree: settings?.autoCreateWorktree,
|
autoCreateWorktree: settings?.autoCreateWorktree,
|
||||||
|
commitMessageModel: settings?.commitMessageModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +52,14 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
|||||||
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
|
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
|
||||||
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
||||||
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
||||||
|
const commitMessageModel = typeof data?.commitMessageModel === 'string' ? data.commitMessageModel.trim() : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||||
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
|
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
|
||||||
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
||||||
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
||||||
|
commitMessageModel: commitMessageModel.length > 0 ? commitMessageModel : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,12 +79,14 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
|||||||
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
|
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
|
||||||
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
||||||
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
||||||
|
const commitMessageModel = typeof data?.commitMessageModel === 'string' ? data.commitMessageModel.trim() : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||||
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
|
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
|
||||||
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
||||||
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
||||||
|
commitMessageModel: commitMessageModel.length > 0 ? commitMessageModel : undefined,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
@@ -372,6 +378,7 @@ interface ConfigStore {
|
|||||||
settingsDefaultVariant: string | undefined;
|
settingsDefaultVariant: string | undefined;
|
||||||
settingsDefaultAgent: string | undefined;
|
settingsDefaultAgent: string | undefined;
|
||||||
settingsAutoCreateWorktree: boolean;
|
settingsAutoCreateWorktree: boolean;
|
||||||
|
settingsCommitMessageModel: string | undefined; // format: "provider/model"
|
||||||
|
|
||||||
activateDirectory: (directory: string | null | undefined) => Promise<void>;
|
activateDirectory: (directory: string | null | undefined) => Promise<void>;
|
||||||
|
|
||||||
@@ -388,6 +395,7 @@ interface ConfigStore {
|
|||||||
setSettingsDefaultVariant: (variant: string | undefined) => void;
|
setSettingsDefaultVariant: (variant: string | undefined) => void;
|
||||||
setSettingsDefaultAgent: (agent: string | undefined) => void;
|
setSettingsDefaultAgent: (agent: string | undefined) => void;
|
||||||
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
|
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
|
||||||
|
setSettingsCommitMessageModel: (model: string | undefined) => void;
|
||||||
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
||||||
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
|
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
|
||||||
checkConnection: () => Promise<boolean>;
|
checkConnection: () => Promise<boolean>;
|
||||||
@@ -431,6 +439,7 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
settingsDefaultVariant: undefined,
|
settingsDefaultVariant: undefined,
|
||||||
settingsDefaultAgent: undefined,
|
settingsDefaultAgent: undefined,
|
||||||
settingsAutoCreateWorktree: false,
|
settingsAutoCreateWorktree: false,
|
||||||
|
settingsCommitMessageModel: undefined,
|
||||||
|
|
||||||
activateDirectory: async (directory) => {
|
activateDirectory: async (directory) => {
|
||||||
const directoryKey = toDirectoryKey(directory);
|
const directoryKey = toDirectoryKey(directory);
|
||||||
@@ -871,10 +880,12 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
|
|
||||||
const nextState: Partial<ConfigStore> = {
|
const nextState: Partial<ConfigStore> = {
|
||||||
settingsDefaultModel: openChamberDefaults.defaultModel,
|
settingsDefaultModel: openChamberDefaults.defaultModel,
|
||||||
settingsDefaultVariant: openChamberDefaults.defaultVariant,
|
settingsDefaultVariant: openChamberDefaults.defaultVariant,
|
||||||
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
||||||
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
|
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
|
||||||
directoryScoped: {
|
settingsCommitMessageModel: openChamberDefaults.commitMessageModel,
|
||||||
|
directoryScoped: {
|
||||||
|
|
||||||
...state.directoryScoped,
|
...state.directoryScoped,
|
||||||
[directoryKey]: nextSnapshot,
|
[directoryKey]: nextSnapshot,
|
||||||
},
|
},
|
||||||
@@ -1292,6 +1303,10 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
set({ settingsAutoCreateWorktree: enabled });
|
set({ settingsAutoCreateWorktree: enabled });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSettingsCommitMessageModel: (model) => {
|
||||||
|
set({ settingsCommitMessageModel: model });
|
||||||
|
},
|
||||||
|
|
||||||
checkConnection: async () => {
|
checkConnection: async () => {
|
||||||
const maxAttempts = 5;
|
const maxAttempts = 5;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|||||||
@@ -611,6 +611,10 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
||||||
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.commitMessageModel === 'string') {
|
||||||
|
const trimmed = candidate.commitMessageModel.trim();
|
||||||
|
result.commitMessageModel = trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||||
if (skillCatalogs) {
|
if (skillCatalogs) {
|
||||||
@@ -3501,6 +3505,15 @@ async function main(options = {}) {
|
|||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`;
|
const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`;
|
||||||
|
|
||||||
|
const settings = await readSettingsFromDiskMigrated();
|
||||||
|
const rawModel = typeof settings.commitMessageModel === 'string' ? settings.commitMessageModel.trim() : '';
|
||||||
|
const model = (() => {
|
||||||
|
if (!rawModel) return 'big-pickle';
|
||||||
|
const parts = rawModel.split('/').filter(Boolean);
|
||||||
|
const candidate = parts.length > 1 ? parts[parts.length - 1] : parts[0];
|
||||||
|
return candidate || 'big-pickle';
|
||||||
|
})();
|
||||||
|
|
||||||
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
|
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
|
||||||
let response;
|
let response;
|
||||||
@@ -3509,7 +3522,7 @@ async function main(options = {}) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'big-pickle',
|
model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
max_tokens: 3000,
|
max_tokens: 3000,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user