[eric] default-model + thinking-mode settings overhaul: Settings dropdown mirrors the in-session picker (provider-grouped from

state.models.byProvider, disambiguates same-named models by · Provider), toolbar now honors the stored default_model instead of snapping to
  stale Redux initial 'sonnet' and re-syncs on every reopen so last-used picks don't leak, smart fallback per priority Anthropic > OpenAI >
  Gemini > OpenSwarm Pro > OpenSwarm with Snackbar warning when the user's default becomes unreachable, new Default thinking mode setting
  (Auto/Off/Low/Medium/High) added to AppSettings and threaded into AgentSession on launch, onboarding flow for user intuitive-ness slight tweaks
This commit is contained in:
ciregenz
2026-04-20 14:56:09 -07:00
parent 301b0f7707
commit b9f7698b0c
10 changed files with 305 additions and 17 deletions
@@ -31,7 +31,7 @@ export function claudeToOpenAIRequest(model, body, stream) {
: body.system;
systemContent = systemContent.replace(
/You are Claude Code, Anthropic's official CLI for Claude\.\s*/,
/^You are [^.]*?(?:Claude Code|Claude agent)[^.]*?\.\s*/,
""
);
+1
View File
@@ -387,6 +387,7 @@ class AgentManager:
max_turns=config.max_turns,
cwd=effective_cwd,
dashboard_id=config.dashboard_id,
thinking_level=getattr(global_settings, "default_thinking_level", "auto"),
)
self.sessions[session_id] = session
+2 -1
View File
@@ -1,5 +1,5 @@
from pydantic import BaseModel, Field
from typing import Optional, Any
from typing import Optional, Any, Literal
DEFAULT_SYSTEM_PROMPT = (
"You are a personal AI assistant running inside OpenSwarm.\n\n"
@@ -37,6 +37,7 @@ class AppSettings(BaseModel):
default_model: str = "sonnet"
default_mode: str = "agent"
default_max_turns: Optional[int] = None
default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
zoom_sensitivity: float = 50.0
theme: str = "dark"
new_agent_shortcut: str = "Meta+l"
+106 -2
View File
@@ -1,10 +1,12 @@
import React, { useMemo, useEffect } from 'react';
import React, { useMemo, useEffect, useState, useRef } from 'react';
import { Provider } from 'react-redux';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import { store } from '../shared/state/store';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
import { fetchSettings, updateSettings } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { API_BASE } from '@/shared/config';
import {
@@ -186,6 +188,106 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
return <>{children}</>;
};
// Priority order for picking a default model when the user's stored
// default_model is unreachable (no matching provider connected). The user's
// preferred fallback ordering: direct provider keys first, then OpenSwarm
// Pro, then Copilot-powered OpenSwarm free tier.
const DEFAULT_MODEL_PRIORITY: string[] = [
'Anthropic',
'OpenAI',
'Google',
'OpenSwarm Pro',
'OpenSwarm',
];
// Preferred model pick inside each provider group. Ordered by the user's
// stated preference: Sonnet mid-tier for Claude, GPT-5.4 Mini for OpenAI,
// Flash for Gemini, and conservative picks for the shared tiers.
const DEFAULT_MODEL_PICKS: Record<string, string[]> = {
Anthropic: ['sonnet-cc', 'sonnet'],
OpenAI: ['gpt-5.4-mini', 'gpt-5.4'],
Google: ['gemini-2.5-flash', 'gemini-3-flash', 'gemini-2.5-pro'],
'OpenSwarm Pro': ['sonnet', 'opus'],
OpenSwarm: ['gpt-5-mini', 'claude-haiku-4.5', 'gpt-4.1'],
};
function pickFallbackModel(
byProvider: Record<string, Array<{ value: string; label: string }>>,
): { value: string; label: string; provider: string } | null {
for (const prov of DEFAULT_MODEL_PRIORITY) {
const models = byProvider[prov];
if (!models || models.length === 0) continue;
const available = new Map(models.map((m) => [m.value, m]));
const picks = DEFAULT_MODEL_PICKS[prov] || [];
for (const candidate of picks) {
const m = available.get(candidate);
if (m) return { value: m.value, label: m.label, provider: prov };
}
const first = models[0];
return { value: first.value, label: first.label, provider: prov };
}
return null;
}
// Reconciles the stored default_model against the set of models actually
// reachable given the user's current connections. When the stored value is
// unavailable, falls back per DEFAULT_MODEL_PRIORITY and shows a one-time
// warning so the user knows why their default changed.
const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const byProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const [warning, setWarning] = useState<{ from: string; to: string; provider: string } | null>(null);
const pendingRef = useRef(false);
useEffect(() => {
if (!settingsLoaded || !modelsLoaded) return;
if (pendingRef.current) return;
if (Object.keys(byProvider).length === 0) return;
const flat = Object.values(byProvider).flat();
const currentExists = flat.some((m) => m.value === settings.default_model);
if (currentExists) return;
const fallback = pickFallbackModel(byProvider);
if (!fallback || fallback.value === settings.default_model) return;
const fromLabel = flat.find((m) => m.value === settings.default_model)?.label ?? settings.default_model;
pendingRef.current = true;
dispatch(updateSettings({ ...settings, default_model: fallback.value }))
.finally(() => {
pendingRef.current = false;
});
setWarning({ from: fromLabel, to: fallback.label, provider: fallback.provider });
}, [settingsLoaded, modelsLoaded, byProvider, settings, dispatch]);
return (
<>
{children}
<Snackbar
open={!!warning}
autoHideDuration={8000}
onClose={() => setWarning(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
severity="warning"
variant="filled"
onClose={() => setWarning(null)}
sx={{ fontSize: '0.8rem' }}
>
{warning && (
<>Default model <b>{warning.from}</b> is no longer available switched to <b>{warning.to}</b> ({warning.provider}).</>
)}
</Alert>
</Snackbar>
</>
);
};
const UpdateListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
@@ -259,6 +361,7 @@ const ThemedApp: React.FC = () => {
<HashRouter>
<ShortcutsProvider>
<SettingsLoader>
<DefaultModelGuard>
<UpdateListener>
<DeepLinkListener>
<Routes>
@@ -280,6 +383,7 @@ const ThemedApp: React.FC = () => {
<OnboardingModal />
</DeepLinkListener>
</UpdateListener>
</DefaultModelGuard>
</SettingsLoader>
</ShortcutsProvider>
</HashRouter>
@@ -37,8 +37,8 @@ const streamingCursorKeyframes = `
// wave traveling through the letters.
const thinkingShimmerKeyframes = `
@keyframes thinking-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`;
+18 -2
View File
@@ -154,11 +154,13 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}
return false;
});
const [newAgentBounce, setNewAgentBounce] = useState(false);
const handleWalkthroughComplete = useCallback(() => {
setShowWalkthrough(false);
localStorage.removeItem('openswarm_walkthrough_pending');
localStorage.setItem('openswarm_walkthrough_seen', 'true');
setNewAgentBounce(true);
}, []);
const handleHighlightCard = useCallback((cardId: string) => {
@@ -1657,11 +1659,23 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
pointerEvents: 'none',
}}
>
<style>{`@keyframes empty-state-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`}</style>
<Typography sx={{ color: c.text.tertiary, fontSize: '1.1rem', mb: 1 }}>
No agents running
</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.9rem' }}>
Click &quot;New Agent&quot; to launch your first Claude Code instance
<Typography
sx={{
fontSize: '0.9rem',
background: `linear-gradient(90deg, ${c.text.ghost} 0%, ${c.text.ghost} 40%, ${c.text.primary} 50%, ${c.text.ghost} 60%, ${c.text.ghost} 100%)`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
color: 'transparent',
animation: 'empty-state-shimmer 6s linear infinite',
}}
>
Click the &quot;+&quot; button below to launch your first agent
</Typography>
</Box>
) : (
@@ -1956,6 +1970,8 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
onHistoryResume={handleHistoryResume}
onAddBrowser={handleAddBrowser}
dashboardId={dashboardId}
newAgentBounce={newAgentBounce}
onNewAgentBounceEnd={() => setNewAgentBounce(false)}
/>
</Box>
@@ -40,6 +40,8 @@ interface Props {
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
dashboardId?: string;
newAgentBounce?: boolean;
onNewAgentBounceEnd?: () => void;
}
const TOOLBAR_OWNER_ID = '__toolbar__';
@@ -83,7 +85,7 @@ function formatRelativeTime(dateStr: string | null): string {
}
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId }, ref) => {
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId, newAgentBounce, onNewAgentBounceEnd }, ref) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
@@ -93,17 +95,37 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const historyListRef = useRef<HTMLDivElement>(null);
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultThinkingLevel = useAppSelector((s) => s.settings.data.default_thinking_level);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const [mode, setMode] = useState(defaultMode || 'agent');
const [model, setModel] = useState(defaultModel || 'sonnet');
const [thinkingLevel, setThinkingLevel] = useState<'off' | 'low' | 'medium' | 'high' | 'auto'>('auto');
const [thinkingLevel, setThinkingLevel] = useState<'off' | 'low' | 'medium' | 'high' | 'auto'>(defaultThinkingLevel || 'auto');
// Snap to the persisted Settings defaults as soon as they arrive from the
// backend. Without the settingsLoaded guard, the effect fires against the
// Redux initialState ('sonnet') before the real default has loaded, and
// the settingsApplied flag then locks out the real default for the rest
// of the session — so new chats spawn under the stale value.
const settingsApplied = useRef(false);
useEffect(() => {
if (!settingsApplied.current) {
if (settingsLoaded && !settingsApplied.current) {
setMode(defaultMode || 'agent');
setModel(defaultModel || 'sonnet');
setThinkingLevel(defaultThinkingLevel || 'auto');
settingsApplied.current = true;
}
}, [defaultMode, defaultModel]);
}, [settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
// Reset to the current Settings defaults each time the toolbar reopens
// for a new compose session, so the user's in-session model/mode picks
// don't leak into the next new-chat draft.
const prevInputOpen = useRef(false);
useEffect(() => {
if (settingsLoaded && inputOpen && !prevInputOpen.current) {
setMode(defaultMode || 'agent');
setModel(defaultModel || 'sonnet');
setThinkingLevel(defaultThinkingLevel || 'auto');
}
prevInputOpen.current = inputOpen;
}, [inputOpen, settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
const [viewPickerOpen, setViewPickerOpen] = useState(false);
const [viewSearch, setViewSearch] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
@@ -597,6 +619,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
data-onboarding="new-agent-button"
tabIndex={0}
onClick={onNewAgent}
onAnimationEnd={newAgentBounce ? onNewAgentBounceEnd : undefined}
sx={{
display: 'flex',
alignItems: 'center',
@@ -610,6 +633,13 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
transition: 'background-color 0.15s',
'&:hover': { bgcolor: c.accent.hover },
'&:active': { bgcolor: c.accent.pressed },
...(newAgentBounce && {
animation: 'new-agent-bounce 0.7s ease-in-out 4',
'@keyframes new-agent-bounce': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-8px)' },
},
}),
}}
>
<AddIcon sx={{ fontSize: 20 }} />
+138 -4
View File
@@ -7,6 +7,7 @@ import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import ListSubheader from '@mui/material/ListSubheader';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import ToggleButton from '@mui/material/ToggleButton';
@@ -57,6 +58,29 @@ import type { OpenSwarmPlan } from '@/shared/subscription/checkout';
// through 9Router's `github` OAuth under the generic SubscriptionCard path
// below, so the dead component was removed.
// Brand colors for provider group headers in the default-model picker.
// Mirrors the set used by the in-session ChatInput picker.
const PROVIDER_COLORS: Record<string, string> = {
anthropic: '#E8927A',
openai: '#74AA9C',
google: '#4285F4',
gemini: '#4285F4',
xai: '#8B949E',
meta: '#0866FF',
deepseek: '#4D6BFE',
mistral: '#FF7000',
qwen: '#A974FF',
cohere: '#FF7759',
};
const OPENSWARM_GRADIENT =
'linear-gradient(135deg, #8FB3FF 0%, #E56BC4 45%, #FFA85C 100%)';
const DEFAULT_MODEL_FALLBACK = [
{ value: 'sonnet', label: 'Claude Sonnet 4.6' },
{ value: 'opus', label: 'Claude Opus 4.6' },
{ value: 'haiku', label: 'Claude Haiku 4.5' },
];
// ── Subscription Provider Card ──
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet 4.6, Opus 4.6, Haiku 4.5', color: '#E8927A', preview: false },
@@ -1093,6 +1117,29 @@ const Settings: React.FC = () => {
const modesList = useMemo(() => Object.values(modes), [modes]);
// Model picker source — same state as the in-session ChatInput picker, so
// Settings shows exactly the models gated-in by the user's connected
// providers / subscriptions (OpenSwarm Pro, Anthropic, OpenAI, Google, ...).
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const modelOptions = useMemo(() => {
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
const key = settings.connection_mode === 'openswarm-pro' ? 'OpenSwarm Pro' : 'Anthropic';
return {
grouped: { [key]: DEFAULT_MODEL_FALLBACK },
flat: DEFAULT_MODEL_FALLBACK.map((m) => ({ ...m, provider: key })),
};
}
const grouped: Record<string, Array<{ value: string; label: string }>> = {};
const flat: Array<{ value: string; label: string; provider: string }> = [];
for (const [prov, models] of Object.entries(modelsByProvider)) {
grouped[prov] = models.map((m) => ({ value: m.value, label: m.label }));
for (const m of models) flat.push({ value: m.value, label: m.label, provider: prov });
}
return { grouped, flat };
}, [modelsByProvider, modelsLoaded, settings.connection_mode]);
const updateStatus = useAppSelector((s) => s.update.status);
const appVersion = useAppSelector((s) => s.update.appVersion);
const availableVersion = useAppSelector((s) => s.update.availableVersion);
@@ -1122,6 +1169,10 @@ const Settings: React.FC = () => {
dispatch(fetchModes());
}, [dispatch]);
useEffect(() => {
if (open) dispatch(fetchModels());
}, [open, dispatch]);
useEffect(() => {
// Reset to the General tab on open, but NOT when the caller has
// explicitly requested a tab via openSettingsModal(<tab>) — e.g. the
@@ -1416,16 +1467,78 @@ const Settings: React.FC = () => {
<Typography sx={labelSx}>Model</Typography>
<Typography sx={descSx}>Default model for new sessions.</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 170 }}>
<FormControl size="small" sx={{ minWidth: 220 }}>
<Select
value={form.default_model}
onChange={(e) => setForm({ ...form, default_model: e.target.value })}
sx={{ fontSize: '0.85rem' }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
renderValue={(val) => {
const m = modelOptions.flat.find((x) => x.value === val);
if (!m) return String(val);
return (
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
<span>{m.label}</span>
<Typography component="span" sx={{ fontSize: '0.65rem', color: c.text.ghost }}>
· {m.provider}
</Typography>
</Box>
);
}}
>
<MenuItem value="sonnet">Sonnet 4.6</MenuItem>
<MenuItem value="opus">Opus 4.6</MenuItem>
<MenuItem value="haiku">Haiku 3.5</MenuItem>
{Object.entries(modelOptions.grouped).flatMap(([prov, models]) => {
const isOpenSwarmPro = prov === 'OpenSwarm Pro';
const brandColor = PROVIDER_COLORS[prov.toLowerCase()] ?? c.text.tertiary;
return [
<ListSubheader
key={`header-${prov}`}
sx={{
bgcolor: c.bg.surface,
lineHeight: '1.8em',
px: 1.5,
py: 0.4,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
flexShrink: 0,
background: isOpenSwarmPro ? OPENSWARM_GRADIENT : brandColor,
boxShadow: isOpenSwarmPro
? '0 0 8px rgba(229, 107, 196, 0.6)'
: `0 0 6px ${brandColor}80`,
}}
/>
<Typography
sx={{
fontSize: '0.68rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
...(isOpenSwarmPro
? {
background: OPENSWARM_GRADIENT,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}
: { color: brandColor }),
}}
>
{prov}
</Typography>
</Box>
</ListSubheader>,
...models.map((m) => (
<MenuItem key={m.value} value={m.value} sx={{ fontSize: '0.85rem', pl: 3 }}>
{m.label}
</MenuItem>
)),
];
})}
</Select>
</FormControl>
</Box>
@@ -1449,6 +1562,27 @@ const Settings: React.FC = () => {
</FormControl>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Thinking</Typography>
<Typography sx={descSx}>Default thinking level for reasoning-capable models.</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 170 }}>
<Select
value={form.default_thinking_level}
onChange={(e) => setForm({ ...form, default_thinking_level: e.target.value as AppSettings['default_thinking_level'] })}
sx={{ fontSize: '0.85rem' }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
<MenuItem value="auto">Auto</MenuItem>
<MenuItem value="off">Off</MenuItem>
<MenuItem value="low">Low</MenuItem>
<MenuItem value="medium">Medium</MenuItem>
<MenuItem value="high">High</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Max turns</Typography>
@@ -40,6 +40,7 @@ export interface AppSettings {
default_model: string;
default_mode: string;
default_max_turns: number | null;
default_thinking_level: 'off' | 'low' | 'medium' | 'high' | 'auto';
zoom_sensitivity: number;
theme: 'light' | 'dark';
new_agent_shortcut: string;
@@ -92,6 +93,7 @@ const initialState: SettingsState = {
default_model: 'sonnet',
default_mode: 'agent',
default_max_turns: null,
default_thinking_level: 'auto',
zoom_sensitivity: 50,
theme: 'dark',
new_agent_shortcut: 'Meta+l',
File diff suppressed because one or more lines are too long