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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user