[eric] settings: left-rail nav with focused panes (ChatGPT style), General scroll split into Account/General/Appearance/Privacy/Advanced

This commit is contained in:
ciregenz
2026-07-27 18:36:22 -07:00
parent ff38bed50e
commit dd8ca444f2
8 changed files with 156 additions and 143 deletions
+54 -21
View File
@@ -11,12 +11,19 @@ import { onboardingBus } from '@/app/components/Onboarding/eventBus';
import { fetchModels } from '@/shared/state/modelsSlice';
import { fetchModes } from '@/shared/state/modesSlice';
import { useThemeMode, useThemeAccent, useClaudeTokens } from '@/shared/styles/ThemeContext';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { X } from 'lucide-react';
import DirectoryBrowser from '@/app/components/editor/DirectoryBrowser';
import { CommandsContent } from '@/app/pages/Commands/Commands';
import GeneralTab from './sections/general/GeneralTab';
import AccountCard from './sections/subscription/AccountCard';
import GeneralAgentDefaults from './sections/general/GeneralAgentDefaults';
import GeneralInterface from './sections/general/GeneralInterface';
import GeneralAdvanced from './sections/general/GeneralAdvanced';
import DataPrivacySection from './sections/general/DataPrivacySection';
import ModelsTab from './sections/models/ModelsTab';
import UsageStats from './sections/usage/UsageStats';
import SettingsHeader from './sections/SettingsHeader';
import SettingsRail, { railLabelFor } from './sections/SettingsRail';
import { makeSettingsStyles } from './sections/settingsStyles';
// Skills/Tools moved here from the old sidebar Customization section; lazy since both pull heavy deps and Settings opens nearly every session.
@@ -92,7 +99,7 @@ const Settings: React.FC = () => {
}, [modelsByProvider, modelsLoaded, settings.connection_mode, settings.default_model]);
const initialTab = useAppSelector((s) => s.settings.initialTab);
const TAB_VALUES = ['general', 'models', 'skills', 'tools', 'commands', 'usage'] as const;
const TAB_VALUES = ['account', 'general', 'appearance', 'privacy', 'advanced', 'models', 'skills', 'tools', 'commands', 'usage'] as const;
type SettingsTab = typeof TAB_VALUES[number];
const isValidTab = (t: string | null | undefined): t is SettingsTab =>
!!t && (TAB_VALUES as readonly string[]).includes(t);
@@ -242,23 +249,30 @@ const Settings: React.FC = () => {
maxWidth={false}
PaperProps={{
sx: {
width: 780,
width: 880,
height: '85vh',
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
bgcolor: c.bg.page,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.md,
transition: 'none',
overflow: 'hidden',
},
}}
>
<SettingsHeader
activeTab={activeTab}
onTabChange={(v) => setActiveTab(v)}
onClose={handleRequestClose}
/>
<SettingsRail activeTab={activeTab} onTabChange={(v) => setActiveTab(v as SettingsTab)} />
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 1.75, pb: 0.75, flexShrink: 0 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
{railLabelFor(activeTab)}
</Typography>
<IconButton onClick={handleRequestClose} size="small" data-onboarding="settings-close-button" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<X size={18} />
</IconButton>
</Box>
<DialogContent sx={{
px: 3,
@@ -271,17 +285,35 @@ const Settings: React.FC = () => {
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}>
{activeTab === 'general' ? (
<GeneralTab
form={form}
setForm={setForm}
styles={styles}
setBrowseOpen={setBrowseOpen}
modelOptions={modelOptions}
modesList={modesList}
providerColors={PROVIDER_COLORS}
openswarmGradient={OPENSWARM_GRADIENT}
/>
{activeTab === 'account' ? (
<Box sx={{ pt: 1.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<AccountCard />
</Box>
) : activeTab === 'general' ? (
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<GeneralAgentDefaults
form={form}
setForm={setForm}
styles={styles}
setBrowseOpen={setBrowseOpen}
modelOptions={modelOptions}
modesList={modesList}
providerColors={PROVIDER_COLORS}
openswarmGradient={OPENSWARM_GRADIENT}
/>
</Box>
) : activeTab === 'appearance' ? (
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<GeneralInterface form={form} setForm={setForm} styles={styles} />
</Box>
) : activeTab === 'privacy' ? (
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<DataPrivacySection styles={styles} />
</Box>
) : activeTab === 'advanced' ? (
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<GeneralAdvanced form={form} setForm={setForm} styles={styles} />
</Box>
) : activeTab === 'models' ? (
<ModelsTab
form={form}
@@ -312,6 +344,7 @@ const Settings: React.FC = () => {
</Box>
)}
</DialogContent>
</Box>
<DirectoryBrowser
open={browseOpen}
@@ -1,66 +0,0 @@
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 { X } from 'lucide-react';
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,
}}
>
<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 } }}>
<X size={18} />
</IconButton>
</Box>
<Tabs
value={activeTab}
onChange={(_, v) => onTabChange(v)}
sx={{
minHeight: 34,
pb: 1,
// Pill segmented control: filled active pill, no underline indicator.
'& .MuiTabs-indicator': { display: 'none' },
'& .MuiTab-root': {
minHeight: 30,
textTransform: 'none',
fontSize: '0.875rem',
fontWeight: 500,
color: c.text.muted,
px: 1.75,
mr: 0.5,
borderRadius: '999px',
transition: 'background-color 0.12s, color 0.12s',
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}0A` },
'&.Mui-selected': { color: c.text.primary, fontWeight: 600, bgcolor: `${c.accent.primary}26` },
},
}}
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple data-onboarding="settings-models-tab" />
<Tab label="Skills" value="skills" disableRipple />
<Tab label="Tools" value="tools" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
<Tab label="Usage" value="usage" disableRipple />
</Tabs>
</DialogTitle>
);
};
export default SettingsHeader;
@@ -0,0 +1,102 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { User, Settings2, Palette, ShieldCheck, Wrench, Boxes, Sparkles, Hammer, SquareSlash, BarChart3 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// ChatGPT/Apple-style settings nav: a left rail of short, focused sections instead of one giant
// General scroll. Grouped so the eye lands (account, then app-wide, then capabilities).
export interface RailSection {
value: string;
label: string;
Icon: LucideIcon;
}
interface RailGroup {
header: string | null;
sections: RailSection[];
}
export const RAIL_GROUPS: RailGroup[] = [
{ header: null, sections: [
{ value: 'account', label: 'Account', Icon: User },
] },
{ header: 'App', sections: [
{ value: 'general', label: 'General', Icon: Settings2 },
{ value: 'appearance', label: 'Appearance', Icon: Palette },
{ value: 'privacy', label: 'Privacy', Icon: ShieldCheck },
{ value: 'advanced', label: 'Advanced', Icon: Wrench },
] },
{ header: 'Capabilities', sections: [
{ value: 'models', label: 'Models', Icon: Boxes },
{ value: 'skills', label: 'Skills', Icon: Sparkles },
{ value: 'tools', label: 'Tools', Icon: Hammer },
{ value: 'commands', label: 'Commands', Icon: SquareSlash },
{ value: 'usage', label: 'Usage', Icon: BarChart3 },
] },
];
export function railLabelFor(value: string): string {
for (const g of RAIL_GROUPS) {
const hit = g.sections.find((s) => s.value === value);
if (hit) return hit.label;
}
return 'Settings';
}
const SettingsRail: React.FC<{
activeTab: string;
onTabChange: (v: string) => void;
}> = ({ activeTab, onTabChange }) => {
const c = useClaudeTokens();
return (
<Box sx={{
width: 192, flexShrink: 0, display: 'flex', flexDirection: 'column',
borderRight: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface,
px: 1.25, py: 1.75, overflowY: 'auto',
}}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: c.font.size.md, px: 1, pb: 1 }}>
Settings
</Typography>
{RAIL_GROUPS.map((group) => (
<Box key={group.header ?? 'top'} sx={{ mb: 0.5 }}>
{group.header && (
<Typography sx={{
color: c.text.ghost, fontSize: '0.6875rem', fontWeight: 700,
letterSpacing: '0.07em', textTransform: 'uppercase', px: 1, pt: 1.5, pb: 0.5,
}}>
{group.header}
</Typography>
)}
{group.sections.map((s) => {
const selected = activeTab === s.value;
return (
<Box
key={s.value}
component="button"
onClick={() => onTabChange(s.value)}
data-onboarding={s.value === 'models' ? 'settings-models-tab' : undefined}
sx={{
display: 'flex', alignItems: 'center', gap: 1.25, width: '100%',
px: 1, py: 0.75, mb: '2px', border: 'none', borderRadius: '8px',
bgcolor: selected ? `${c.accent.primary}1F` : 'transparent',
color: selected ? c.text.primary : c.text.secondary,
fontFamily: 'inherit', fontSize: c.font.size.base, fontWeight: selected ? 600 : 500,
cursor: 'pointer', textAlign: 'left',
transition: 'background-color 0.12s, color 0.12s',
'&:hover': selected ? {} : { bgcolor: `${c.text.tertiary}0F`, color: c.text.primary },
}}
>
<s.Icon size={15} style={{ flexShrink: 0, opacity: selected ? 1 : 0.75 }} />
{s.label}
</Box>
);
})}
</Box>
))}
</Box>
);
};
export default SettingsRail;
@@ -112,7 +112,6 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) =>
return (
<Box>
<Typography sx={{ ...sectionSx, mt: 3 }}>Data &amp; Privacy</Typography>
<Box sx={{ ...rowSx, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box>
@@ -35,7 +35,6 @@ const GeneralAdvanced: React.FC<{
return (
<>
<Typography sx={{ ...sectionSx, mt: 3 }}>Advanced</Typography>
<Box sx={inlineRowSx} {...settingSelectAttrs('dev_mode', 'Developer mode', 'Advanced', 'Show transport details, env vars, and technical metadata throughout the app.')}>
<Box sx={{ mr: 3 }}>
@@ -33,7 +33,6 @@ const GeneralAgentDefaults: React.FC<{
return (
<>
<Typography sx={sectionSx}>Agent Defaults</Typography>
<Box sx={rowSx} {...settingSelectAttrs('default_system_prompt', 'System prompt', 'Agent Defaults', 'Prepended to every agent session before mode-specific instructions.')}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
@@ -28,7 +28,6 @@ const GeneralInterface: React.FC<{
return (
<>
<Typography sx={{ ...sectionSx, mt: 3 }}>Interface</Typography>
<Box sx={inlineRowSx} {...settingSelectAttrs('theme', 'Theme', 'Interface', 'Application color scheme.')}>
<Box sx={{ mr: 3 }}>
@@ -1,52 +0,0 @@
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 '../subscription/AccountCard';
import GeneralAgentDefaults from './GeneralAgentDefaults';
import GeneralInterface from './GeneralInterface';
import GeneralAdvanced from './GeneralAdvanced';
import DataPrivacySection from './DataPrivacySection';
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} />
<DataPrivacySection styles={styles} />
</Box>
);
};
export default GeneralTab;