[eric] split: extract Settings general + models tabs, thin parent shell

This commit is contained in:
ciregenz
2026-05-23 06:26:42 -07:00
parent 693f9e4fad
commit 7d5481f64a
10 changed files with 1106 additions and 2397 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import React from 'react';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const ConfirmDiscardDialog: React.FC<{ open: boolean; onCancel: () => void; onConfirm: () => void }> = ({ open, onCancel, onConfirm }) => {
const c = useClaudeTokens();
return (
<Dialog
open={open}
onClose={onCancel}
PaperProps={{
sx: {
bgcolor: c.bg.page,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.md,
maxWidth: 380,
},
}}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', pb: 0.5, px: 3, pt: 2.5 }}>
Discard unsaved changes?
</DialogTitle>
<DialogContent sx={{ px: 3 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem' }}>
Your in-progress edits will be cleared and the form will revert to your saved settings. This can&apos;t be undone.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button
onClick={onCancel}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Keep editing
</Button>
<Button
variant="contained"
onClick={onConfirm}
sx={{
bgcolor: c.status.error,
color: '#fff',
'&:hover': { bgcolor: c.status.error, filter: 'brightness(0.9)' },
textTransform: 'none',
borderRadius: 1.5,
fontSize: '0.85rem',
}}
>
Discard
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmDiscardDialog;
@@ -0,0 +1,113 @@
import React from 'react';
import { report } from '@/shared/serviceClient';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Switch from '@mui/material/Switch';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
import { resetTour } from '@/app/components/Onboarding/OnboardingProgressSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import TrustedFilePatterns from '@/app/components/TrustedFilePatterns';
import SoftwareUpdateRow from './SoftwareUpdateRow';
import type { SettingsStyles } from './settingsStyles';
const GeneralAdvanced: React.FC<{
form: AppSettings;
setForm: React.Dispatch<React.SetStateAction<AppSettings>>;
styles: SettingsStyles;
}> = ({ form, setForm, styles }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const appVersion = useAppSelector((s) => s.update.appVersion);
const { sectionSx, rowSx, inlineRowSx, inlineRowLastSx, labelSx, descSx } = styles;
return (
<>
<Typography sx={{ ...sectionSx, mt: 3 }}>Advanced</Typography>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Developer mode</Typography>
<Typography sx={descSx}>Show transport details, environment variables, raw configs, and other technical metadata throughout the app.</Typography>
</Box>
<Switch
checked={form.dev_mode}
onChange={(e) => setForm({ ...form, dev_mode: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Experimental updates</Typography>
<Typography sx={descSx}>Receive pre-release builds with new features earlier. These versions may be less stable than normal releases.</Typography>
</Box>
<Switch
checked={form.allow_experimental_updates}
onChange={(e) => setForm({ ...form, allow_experimental_updates: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Typography sx={{ ...sectionSx, mt: 3 }}>About</Typography>
<Box sx={rowSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={labelSx}>Version</Typography>
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>
{appVersion ?? '-'}
</Typography>
</Box>
</Box>
</Box>
<SoftwareUpdateRow styles={styles} />
<TrustedFilePatterns />
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ ...labelSx, mb: 0.25 }}>Onboarding tour</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Re-run the Show me walkthrough at any time.
</Typography>
</Box>
<Button
variant="outlined"
size="small"
data-onboarding="settings-restart-tour"
onClick={() => {
report('onboarding_v2', 'tour_restarted');
try {
window.localStorage.removeItem('openswarm.onboarding.v2');
} catch { /* ignore */ }
dispatch(resetTour());
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}}
sx={{
color: c.text.secondary,
borderColor: c.border.medium,
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
}}
>
Restart tour
</Button>
</Box>
</>
);
};
export default GeneralAdvanced;
@@ -0,0 +1,261 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
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 FolderOpenIcon from '@mui/icons-material/FolderOpen';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import { useAppDispatch } from '@/shared/hooks';
import { resetSystemPrompt, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { SettingsStyles } from './settingsStyles';
type ModelOption = { value: string; label: string };
const GeneralAgentDefaults: React.FC<{
form: AppSettings;
setForm: React.Dispatch<React.SetStateAction<AppSettings>>;
styles: SettingsStyles;
setBrowseOpen: (v: boolean) => void;
modelOptions: { grouped: Record<string, ModelOption[]>; flat: Array<ModelOption & { provider: string }> };
modesList: Array<{ id: string; name: string }>;
providerColors: Record<string, string>;
openswarmGradient: string;
}> = ({ form, setForm, styles, setBrowseOpen, modelOptions, modesList, providerColors, openswarmGradient }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { fieldSx, sectionSx, rowSx, inlineRowSx, inlineRowLastSx, labelSx, descSx } = styles;
return (
<>
<Typography sx={sectionSx}>Agent Defaults</Typography>
<Box sx={rowSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={labelSx}>System prompt</Typography>
{form.default_system_prompt !== DEFAULT_SYSTEM_PROMPT && (
<Button
size="small"
startIcon={<RestartAltIcon sx={{ fontSize: 14 }} />}
onClick={async () => {
await dispatch(resetSystemPrompt());
setForm((prev) => ({ ...prev, default_system_prompt: DEFAULT_SYSTEM_PROMPT }));
}}
sx={{
color: c.accent.primary,
textTransform: 'none',
fontSize: '0.75rem',
py: 0.25,
'&:hover': { bgcolor: `${c.accent.primary}10` },
}}
>
Reset to default
</Button>
)}
</Box>
<Typography sx={{ ...descSx, mb: 1.5 }}>
Prepended to every agent session before mode-specific instructions. Modes can override with their own.
</Typography>
<TextField
value={form.default_system_prompt ?? DEFAULT_SYSTEM_PROMPT}
onChange={(e) => setForm({ ...form, default_system_prompt: e.target.value || null })}
multiline
minRows={3}
maxRows={8}
fullWidth
size="small"
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: c.font.mono,
fontSize: '0.8rem',
lineHeight: 1.6,
color: c.text.secondary,
},
}}
/>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Working directory</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
Default folder agents start in. Modes can override per-mode.
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
value={form.default_folder ?? ''}
onChange={(e) => setForm({ ...form, default_folder: e.target.value || null })}
size="small"
fullWidth
placeholder="Not set (uses project root)"
sx={{
...fieldSx,
'& .MuiOutlinedInput-root': {
...fieldSx['& .MuiOutlinedInput-root'],
fontFamily: c.font.mono,
},
}}
/>
<Button
variant="outlined"
onClick={() => setBrowseOpen(true)}
startIcon={<FolderOpenIcon sx={{ fontSize: 16 }} />}
sx={{
color: c.text.tertiary,
borderColor: c.border.medium,
textTransform: 'none',
whiteSpace: 'nowrap',
minWidth: 'auto',
fontSize: '0.8rem',
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
}}
>
Browse
</Button>
</Box>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Model</Typography>
<Typography sx={descSx}>Default model for new sessions.</Typography>
</Box>
<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>
);
}}
>
{Object.entries(modelOptions.grouped).flatMap(([prov, models]) => {
const isOpenSwarmPro = prov === 'OpenSwarm Pro';
const brandColor = providerColors[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 ? openswarmGradient : 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: openswarmGradient,
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>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Mode</Typography>
<Typography sx={descSx}>Default interaction mode for new sessions.</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 170 }}>
<Select
value={form.default_mode}
onChange={(e) => setForm({ ...form, default_mode: e.target.value })}
sx={{ fontSize: '0.85rem' }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
{modesList.map((m) => (
<MenuItem key={m.id} value={m.id}>{m.name}</MenuItem>
))}
</Select>
</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>
<Typography sx={descSx}>Auto-stop after this many turns. Empty = unlimited.</Typography>
</Box>
<TextField
type="number"
value={form.default_max_turns ?? ''}
onChange={(e) => setForm({ ...form, default_max_turns: e.target.value ? parseInt(e.target.value) : null })}
size="small"
placeholder="∞"
inputProps={{ min: 1 }}
sx={{ ...fieldSx, width: 100 }}
/>
</Box>
</>
);
};
export default GeneralAgentDefaults;
@@ -0,0 +1,226 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Slider from '@mui/material/Slider';
import Switch from '@mui/material/Switch';
import LightModeIcon from '@mui/icons-material/LightMode';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import LanguageIcon from '@mui/icons-material/Language';
import { AppSettings } from '@/shared/state/settingsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { SettingsStyles } from './settingsStyles';
const GeneralInterface: React.FC<{
form: AppSettings;
setForm: React.Dispatch<React.SetStateAction<AppSettings>>;
styles: SettingsStyles;
}> = ({ form, setForm, styles }) => {
const c = useClaudeTokens();
const [recordingShortcut, setRecordingShortcut] = useState(false);
const { fieldSx, sectionSx, rowSx, rowLastSx, inlineRowSx, inlineRowLastSx, labelSx, descSx } = styles;
return (
<>
<Typography sx={{ ...sectionSx, mt: 3 }}>Interface</Typography>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Theme</Typography>
<Typography sx={descSx}>Application color scheme.</Typography>
</Box>
<ToggleButtonGroup
value={form.theme}
exclusive
onChange={(_, v) => { if (v) setForm({ ...form, theme: v }); }}
size="small"
sx={{
'& .MuiToggleButton-root': {
color: c.text.muted,
borderColor: c.border.medium,
textTransform: 'none',
px: 2,
py: 0.5,
gap: 0.5,
fontSize: '0.8rem',
'&.Mui-selected': {
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
borderColor: c.accent.primary,
'&:hover': { bgcolor: `${c.accent.primary}20` },
},
},
}}
>
<ToggleButton value="light">
<LightModeIcon sx={{ fontSize: 16 }} /> Light
</ToggleButton>
<ToggleButton value="dark">
<DarkModeIcon sx={{ fontSize: 16 }} /> Dark
</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Zoom sensitivity</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>
Scroll-to-zoom responsiveness. Lower for trackpads, higher for mouse wheels.
</Typography>
<Box sx={{ px: 1 }}>
<Slider
value={form.zoom_sensitivity}
onChange={(_, v) => setForm({ ...form, zoom_sensitivity: v as number })}
min={1}
max={100}
step={1}
valueLabelDisplay="auto"
marks={[
{ value: 1, label: 'Low' },
{ value: 50, label: 'Default' },
{ value: 100, label: 'High' },
]}
sx={{
color: c.accent.primary,
'& .MuiSlider-markLabel': { color: c.text.tertiary, fontSize: '0.7rem' },
'& .MuiSlider-valueLabel': { bgcolor: c.accent.primary },
}}
/>
</Box>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>New agent shortcut</Typography>
<Typography sx={descSx}>Keyboard shortcut to create an agent.</Typography>
</Box>
<Box
tabIndex={0}
onKeyDown={(e) => {
if (!recordingShortcut) return;
if (['Meta', 'Control', 'Shift', 'Alt'].includes(e.key)) return;
e.preventDefault();
const parts: string[] = [];
if (e.metaKey) parts.push('Meta');
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
parts.push(e.key.length === 1 ? e.key.toLowerCase() : e.key);
setForm({ ...form, new_agent_shortcut: parts.join('+') });
setRecordingShortcut(false);
}}
onBlur={() => setRecordingShortcut(false)}
onClick={() => setRecordingShortcut(true)}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.75,
borderRadius: `${c.radius.sm}px`,
border: `1px solid ${recordingShortcut ? c.accent.primary : c.border.medium}`,
cursor: 'pointer',
outline: 'none',
transition: 'border-color 0.15s',
'&:hover': { borderColor: c.accent.primary },
}}
>
<KeyboardIcon sx={{ fontSize: 16, color: recordingShortcut ? c.accent.primary : c.text.tertiary }} />
{recordingShortcut ? (
<Typography sx={{ fontSize: '0.8rem', color: c.accent.primary, fontWeight: 500 }}>
Press shortcut
</Typography>
) : (
<Typography sx={{ fontSize: '0.8rem', color: c.text.primary, fontFamily: c.font.mono, fontWeight: 500 }}>
{form.new_agent_shortcut
.split('+')
.map((p) => {
if (p === 'Meta') return '⌘';
if (p === 'Ctrl') return 'Ctrl';
if (p === 'Alt') return '⌥';
if (p === 'Shift') return '⇧';
return p.toUpperCase();
})
.join(' + ')}
</Typography>
)}
</Box>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Auto-enable element selection</Typography>
<Typography sx={descSx}>Automatically enter element selection mode when creating a new agent.</Typography>
</Box>
<Switch
checked={form.auto_select_mode_on_new_agent}
onChange={(e) => setForm({ ...form, auto_select_mode_on_new_agent: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Default agent spawn state in dashboard</Typography>
<Typography sx={descSx}>When enabled, new agents spawn expanded instead of collapsed.</Typography>
</Box>
<Switch
checked={form.expand_new_chats_in_dashboard}
onChange={(e) => setForm({ ...form, expand_new_chats_in_dashboard: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Auto-reveal sub-agents on dashboard</Typography>
<Typography sx={descSx}>Automatically show sub-agent cards (from CreateAgent / InvokeAgent) tethered to their parent on the dashboard.</Typography>
</Box>
<Switch
checked={form.auto_reveal_sub_agents}
onChange={(e) => setForm({ ...form, auto_reveal_sub_agents: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Typography sx={{ ...sectionSx, mt: 3 }}>Browser</Typography>
<Box sx={rowLastSx}>
<Typography sx={labelSx}>Default homepage</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
URL loaded when opening a new browser card on the dashboard.
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<LanguageIcon sx={{ fontSize: 18, color: c.text.tertiary, flexShrink: 0 }} />
<TextField
value={form.browser_homepage}
onChange={(e) => setForm({ ...form, browser_homepage: e.target.value })}
size="small"
fullWidth
placeholder="https://www.google.com"
sx={{
...fieldSx,
'& .MuiOutlinedInput-root': {
...fieldSx['& .MuiOutlinedInput-root'],
fontFamily: c.font.mono,
},
}}
/>
</Box>
</Box>
</>
);
};
export default GeneralInterface;
@@ -0,0 +1,49 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { AppSettings } from '@/shared/state/settingsSlice';
import AccountCard from './AccountCard';
import GeneralAgentDefaults from './GeneralAgentDefaults';
import GeneralInterface from './GeneralInterface';
import GeneralAdvanced from './GeneralAdvanced';
import type { SettingsStyles } from './settingsStyles';
type ModelOption = { value: string; label: string };
const GeneralTab: React.FC<{
form: AppSettings;
setForm: React.Dispatch<React.SetStateAction<AppSettings>>;
styles: SettingsStyles;
setBrowseOpen: (v: boolean) => void;
modelOptions: { grouped: Record<string, ModelOption[]>; flat: Array<ModelOption & { provider: string }> };
modesList: Array<{ id: string; name: string }>;
providerColors: Record<string, string>;
openswarmGradient: string;
}> = ({ form, setForm, styles, setBrowseOpen, modelOptions, modesList, providerColors, openswarmGradient }) => {
const { sectionSx } = styles;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<Typography sx={sectionSx}>Account</Typography>
<AccountCard />
<GeneralAgentDefaults
form={form}
setForm={setForm}
styles={styles}
setBrowseOpen={setBrowseOpen}
modelOptions={modelOptions}
modesList={modesList}
providerColors={providerColors}
openswarmGradient={openswarmGradient}
/>
<GeneralInterface form={form} setForm={setForm} styles={styles} />
<GeneralAdvanced form={form} setForm={setForm} styles={styles} />
</Box>
);
};
export default GeneralTab;
@@ -0,0 +1,82 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { AppSettings } from '@/shared/state/settingsSlice';
import OpenSwarmProCard from './OpenSwarmProCard';
import SubscriptionCards from './SubscriptionCards';
import ApiKeyCard, { API_KEY_CARDS } from './ApiKeyCard';
import CustomProvidersEditor from './CustomProvidersEditor';
import type { SettingsStyles } from './settingsStyles';
const ModelsTab: React.FC<{
form: AppSettings;
setForm: React.Dispatch<React.SetStateAction<AppSettings>>;
showApiKey: boolean;
setShowApiKey: (v: boolean) => void;
styles: SettingsStyles;
}> = ({ form, setForm, showApiKey, setShowApiKey, styles }) => {
const c = useClaudeTokens();
const { descSx } = styles;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<Box data-onboarding="settings-pro-section" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
One Subscription, No Setup
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Don't have a Claude account? We'll handle it for you. One simple subscription covers Claude Sonnet, Opus, and Haiku.
</Typography>
<OpenSwarmProCard />
</Box>
<Box data-onboarding="settings-external-subs" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Use Your Existing Subscriptions
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription, no API key needed, no extra cost.
</Typography>
<SubscriptionCards />
</Box>
<Box data-onboarding="settings-api-keys" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Connect With API Keys
</Typography>
<Typography sx={{ ...descSx, mb: -1 }}>
Pay per use. Each key is stored locally on your device.
</Typography>
{API_KEY_CARDS.map((config) => (
<ApiKeyCard
key={config.field}
config={config}
form={form}
setForm={setForm}
showApiKey={showApiKey}
setShowApiKey={setShowApiKey}
styles={styles}
/>
))}
<CustomProvidersEditor
form={form}
setForm={setForm}
showApiKey={showApiKey}
setShowApiKey={setShowApiKey}
styles={styles}
/>
</Box>
</Box>
);
};
export default ModelsTab;
@@ -0,0 +1,57 @@
import React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import SaveIcon from '@mui/icons-material/Save';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const SettingsFooter: React.FC<{
hasChanges: boolean;
onDiscard: () => void;
onClose: () => void;
onSave: () => void;
}> = ({ hasChanges, onDiscard, onClose, onSave }) => {
const c = useClaudeTokens();
return (
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'space-between' }}>
{/* Left: explicit Discard; only path to wipe the persisted draft. */}
<Box>
{hasChanges && (
<Button
onClick={onDiscard}
sx={{ color: c.status.error, textTransform: 'none', fontSize: '0.85rem' }}
>
Discard changes
</Button>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
onClick={onClose}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Close
</Button>
<Button
variant="contained"
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
onClick={onSave}
disabled={!hasChanges}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
textTransform: 'none',
borderRadius: 1.5,
px: 2.5,
fontSize: '0.85rem',
}}
>
Save
</Button>
</Box>
</DialogActions>
);
};
export default SettingsFooter;
@@ -0,0 +1,59 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import DialogTitle from '@mui/material/DialogTitle';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const SettingsHeader: React.FC<{
activeTab: string;
onTabChange: (v: any) => void;
onClose: () => void;
}> = ({ activeTab, onTabChange, onClose }) => {
const c = useClaudeTokens();
return (
<DialogTitle
sx={{
px: 3,
py: 0,
borderBottom: `1px solid ${c.border.subtle}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5, pb: 0.5 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
Settings
</Typography>
<IconButton onClick={onClose} size="small" data-onboarding="settings-close-button" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<Tabs
value={activeTab}
onChange={(_, v) => onTabChange(v)}
sx={{
minHeight: 36,
'& .MuiTab-root': {
minHeight: 36,
textTransform: 'none',
fontSize: '0.85rem',
fontWeight: 500,
color: c.text.muted,
px: 1.5,
'&.Mui-selected': { color: c.accent.primary, fontWeight: 600 },
},
'& .MuiTabs-indicator': { backgroundColor: c.accent.primary, height: 2 },
}}
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple data-onboarding="settings-models-tab" />
<Tab label="Usage" value="usage" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
</Tabs>
</DialogTitle>
);
};
export default SettingsHeader;
@@ -0,0 +1,156 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import LinearProgress from '@mui/material/LinearProgress';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import DownloadIcon from '@mui/icons-material/Download';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updateSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { SettingsStyles } from './settingsStyles';
const SoftwareUpdateRow: React.FC<{ styles: SettingsStyles }> = ({ styles }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { rowLastSx, labelSx, descSx } = styles;
const updateStatus = useAppSelector((s) => s.update.status);
const availableVersion = useAppSelector((s) => s.update.availableVersion);
const downloadPercent = useAppSelector((s) => s.update.downloadPercent);
const updateError = useAppSelector((s) => s.update.error);
const installing = useAppSelector((s) => s.update.installing);
const handleCheckForUpdates = async () => {
dispatch(setChecking());
const timeout = setTimeout(() => {
dispatch(setUpdateError('Update check timed out. Please try again.'));
}, 15000);
try {
await (window as any).openswarm?.checkForUpdates();
} catch {
/* error handled via IPC event listener */
} finally {
clearTimeout(timeout);
}
};
const handleDownloadUpdate = async () => {
try {
await (window as any).openswarm?.downloadUpdate();
} catch {
/* error handled via IPC event listener */
}
};
const handleInstallUpdate = () => {
if (installing) return;
dispatch(setInstalling());
(window as any).openswarm?.installUpdate();
};
return (
<Box sx={rowLastSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: updateStatus === 'downloading' ? 1 : 0 }}>
<Box>
<Typography sx={labelSx}>Software update</Typography>
<Typography sx={descSx}>
{updateStatus === 'checking' && 'Checking for updates…'}
{updateStatus === 'not-available' && 'You\'re on the latest version.'}
{updateStatus === 'available' && `Version ${availableVersion} is available.`}
{updateStatus === 'downloading' && `Downloading update… ${Math.round(downloadPercent)}%`}
{updateStatus === 'downloaded' && `Version ${availableVersion} is ready to install.`}
{updateStatus === 'error' && (updateError || 'Update check failed.')}
{updateStatus === 'idle' && 'Check for new versions of OpenSwarm.'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, ml: 2 }}>
{updateStatus === 'checking' && (
<CircularProgress size={18} sx={{ color: c.text.tertiary }} />
)}
{updateStatus === 'not-available' && (
<CheckCircleOutlineIcon sx={{ fontSize: 18, color: c.status.success }} />
)}
{updateStatus === 'error' && (
<ErrorOutlineIcon sx={{ fontSize: 18, color: c.status.error }} />
)}
{(updateStatus === 'idle' || updateStatus === 'not-available' || updateStatus === 'error') && (
<Button
variant="outlined"
size="small"
onClick={handleCheckForUpdates}
startIcon={<SystemUpdateAltIcon sx={{ fontSize: 15 }} />}
sx={{
color: c.text.secondary,
borderColor: c.border.medium,
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
}}
>
Check for Updates
</Button>
)}
{updateStatus === 'available' && (
<Button
variant="outlined"
size="small"
onClick={handleDownloadUpdate}
startIcon={<DownloadIcon sx={{ fontSize: 15 }} />}
sx={{
color: c.accent.primary,
borderColor: c.accent.primary,
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
'&:hover': { bgcolor: `${c.accent.primary}10` },
}}
>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button
variant="contained"
size="small"
onClick={handleInstallUpdate}
disabled={installing}
startIcon={installing
? <CircularProgress size={14} sx={{ color: '#fff' }} />
: <RestartAltIcon sx={{ fontSize: 15 }} />}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.accent.primary, color: '#fff', opacity: 0.7 },
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
borderRadius: 1.5,
}}
>
{installing ? 'Restarting…' : 'Restart & Update'}
</Button>
)}
</Box>
</Box>
{updateStatus === 'downloading' && (
<LinearProgress
variant="determinate"
value={downloadPercent}
sx={{
height: 3,
borderRadius: 2,
bgcolor: `${c.accent.primary}20`,
'& .MuiLinearProgress-bar': { bgcolor: c.accent.primary, borderRadius: 2 },
}}
/>
)}
</Box>
);
};
export default SoftwareUpdateRow;