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:
Nelson Pires
2026-01-14 00:21:16 +02:00
committed by GitHub
parent fbb058ed97
commit 279efb28b3
12 changed files with 319 additions and 49 deletions
@@ -25,6 +25,7 @@ interface ModelSelectorProps {
modelId: string;
onChange: (providerId: string, modelId: string) => void;
className?: string;
allowedProviderIds?: string[];
}
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
@@ -49,7 +50,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
providerId,
modelId,
onChange,
className
className,
allowedProviderIds
}) => {
const { providers, modelsMetadata } = useConfigStore();
const isMobile = useUIStore(state => state.isMobile);
@@ -65,6 +67,20 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const [selectedIndex, setSelectedIndex] = React.useState(0);
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 toggleMobileProviderExpansion = (provId: string) => {
setExpandedMobileProviders(prev => {
@@ -188,6 +204,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
// Filter data for desktop dropdown
const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
if (allowedProviderSet && !allowedProviderSet.has(providerID)) {
return false;
}
const provider = providers.find(p => p.id === providerID);
const providerName = provider?.name || providerID;
const modelName = getModelDisplayName(model);
@@ -195,13 +214,16 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
});
const filteredRecents = recentModelsList.filter(({ model, providerID }) => {
if (allowedProviderSet && !allowedProviderSet.has(providerID)) {
return false;
}
const provider = providers.find(p => p.id === providerID);
const providerName = provider?.name || providerID;
const modelName = getModelDisplayName(model);
return filterByQuery(modelName, providerName);
});
const filteredProviders = providers
const filteredProviders = visibleProviders
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const filteredModels = providerModels.filter((model: ProviderModel) => {
@@ -332,7 +354,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</div>
)}
{providers.map((provider) => {
{visibleProviders.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
if (providerModels.length === 0) return null;
@@ -10,6 +10,33 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
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 = () => {
const setProvider = useConfigStore((state) => state.setProvider);
const setModel = useConfigStore((state) => state.setModel);
@@ -27,13 +54,9 @@ export const DefaultsSettings: React.FC = () => {
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);
// Parse "provider/model" string into separate parts
const parsedModel = React.useMemo(() => {
if (!defaultModel) return { providerId: '', modelId: '' };
const parts = defaultModel.split('/');
if (parts.length !== 2) return { providerId: '', modelId: '' };
return { providerId: parts[0] || '', modelId: parts[1] || '' };
}, [defaultModel]);
return getDisplayModel(defaultModel, providers);
}, [defaultModel, providers]);
// Load current settings
React.useEffect(() => {
@@ -180,6 +203,7 @@ export const DefaultsSettings: React.FC = () => {
}, [setAgent, setSettingsDefaultAgent]);
const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return [];
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
| { variants?: Record<string, unknown> }
@@ -274,16 +298,16 @@ export const DefaultsSettings: React.FC = () => {
</div>
</div>
{(defaultModel || defaultAgent) && (
{(parsedModel.providerId || defaultAgent) && (
<div className="typography-meta text-muted-foreground">
New sessions will start with:{' '}
{defaultModel && (
{parsedModel.providerId && (
<span className="text-foreground">
{defaultModel}
{parsedModel.providerId}/{parsedModel.modelId}
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
</span>
)}
{defaultModel && defaultAgent && ' / '}
{parsedModel.providerId && defaultAgent && ' / '}
{defaultAgent && <span className="text-foreground">{defaultAgent}</span>}
</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 { SessionRetentionSettings } from './SessionRetentionSettings';
import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
import { WorktreeSectionContent } from './WorktreeSectionContent';
import { NotificationSettings } from './NotificationSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -52,8 +53,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
return <ChatSectionContent />;
case 'sessions':
return <SessionsSectionContent />;
case 'worktree':
return <WorktreeSectionContent />;
case 'git':
return <GitSectionContent />;
case 'notifications':
return <NotificationSectionContent />;
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
const NotificationSectionContent: React.FC = () => {
return <NotificationSettings />;
@@ -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' | 'worktree' | 'notifications';
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'notifications';
interface OpenChamberSidebarProps {
selectedSection: OpenChamberSection;
@@ -35,9 +35,9 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
items: ['Defaults', 'Retention'],
},
{
id: 'worktree',
label: 'Worktree',
items: ['Branch', 'Setup'],
id: 'git',
label: 'Git',
items: ['Commit Messages', 'Worktree'],
},
{
id: 'notifications',
@@ -326,40 +326,25 @@ export const WorktreeSectionContent: React.FC = () => {
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>
<p className="typography-meta text-muted-foreground">
Select a project to configure worktree defaults.
</p>
);
}
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>
<p className="typography-meta text-muted-foreground">
Loading...
</p>
);
}
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>
<p className="typography-meta text-muted-foreground">
Worktree settings are only available for Git repositories.
</p>
);
}
+1
View File
@@ -58,6 +58,7 @@ export type DesktopSettings = {
defaultAgent?: string;
autoCreateWorktree?: boolean;
queueModeEnabled?: boolean;
commitMessageModel?: string; // format: "provider/model"
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
skillCatalogs?: SkillCatalogConfig[];
+8
View File
@@ -47,6 +47,11 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else {
localStorage.removeItem('pinnedDirectories');
}
if (settings.commitMessageModel) {
localStorage.setItem('commitMessageModel', settings.commitMessageModel);
} else {
localStorage.removeItem('commitMessageModel');
}
};
type PersistApi = {
@@ -261,6 +266,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
if (typeof candidate.commitMessageModel === 'string' && candidate.commitMessageModel.length > 0) {
result.commitMessageModel = candidate.commitMessageModel;
}
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
}
+19 -4
View File
@@ -25,6 +25,7 @@ interface OpenChamberDefaults {
defaultVariant?: string;
defaultAgent?: string;
autoCreateWorktree?: boolean;
commitMessageModel?: string;
}
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
@@ -37,6 +38,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
defaultVariant: settings?.defaultVariant,
defaultAgent: settings?.defaultAgent,
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 defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const commitMessageModel = typeof data?.commitMessageModel === 'string' ? data.commitMessageModel.trim() : '';
return {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
commitMessageModel: commitMessageModel.length > 0 ? commitMessageModel : undefined,
};
}
} catch {
@@ -75,12 +79,14 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const commitMessageModel = typeof data?.commitMessageModel === 'string' ? data.commitMessageModel.trim() : '';
return {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
commitMessageModel: commitMessageModel.length > 0 ? commitMessageModel : undefined,
};
} catch {
return {};
@@ -372,6 +378,7 @@ interface ConfigStore {
settingsDefaultVariant: string | undefined;
settingsDefaultAgent: string | undefined;
settingsAutoCreateWorktree: boolean;
settingsCommitMessageModel: string | undefined; // format: "provider/model"
activateDirectory: (directory: string | null | undefined) => Promise<void>;
@@ -388,6 +395,7 @@ interface ConfigStore {
setSettingsDefaultVariant: (variant: string | undefined) => void;
setSettingsDefaultAgent: (agent: string | undefined) => void;
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
setSettingsCommitMessageModel: (model: string | undefined) => void;
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
checkConnection: () => Promise<boolean>;
@@ -431,6 +439,7 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultVariant: undefined,
settingsDefaultAgent: undefined,
settingsAutoCreateWorktree: false,
settingsCommitMessageModel: undefined,
activateDirectory: async (directory) => {
const directoryKey = toDirectoryKey(directory);
@@ -871,10 +880,12 @@ export const useConfigStore = create<ConfigStore>()(
const nextState: Partial<ConfigStore> = {
settingsDefaultModel: openChamberDefaults.defaultModel,
settingsDefaultVariant: openChamberDefaults.defaultVariant,
settingsDefaultAgent: openChamberDefaults.defaultAgent,
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
directoryScoped: {
settingsDefaultVariant: openChamberDefaults.defaultVariant,
settingsDefaultAgent: openChamberDefaults.defaultAgent,
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
settingsCommitMessageModel: openChamberDefaults.commitMessageModel,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
@@ -1292,6 +1303,10 @@ export const useConfigStore = create<ConfigStore>()(
set({ settingsAutoCreateWorktree: enabled });
},
setSettingsCommitMessageModel: (model) => {
set({ settingsCommitMessageModel: model });
},
checkConnection: async () => {
const maxAttempts = 5;
let attempt = 0;