From 7d5481f64aecc9146adcd98bf450e39e585c5139 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 23 May 2026 06:26:42 -0700 Subject: [PATCH] [eric] split: extract Settings general + models tabs, thin parent shell --- frontend/src/app/pages/Settings/Settings.tsx | 2440 +---------------- .../sections/ConfirmDiscardDialog.tsx | 60 + .../Settings/sections/GeneralAdvanced.tsx | 113 + .../sections/GeneralAgentDefaults.tsx | 261 ++ .../Settings/sections/GeneralInterface.tsx | 226 ++ .../pages/Settings/sections/GeneralTab.tsx | 49 + .../app/pages/Settings/sections/ModelsTab.tsx | 82 + .../Settings/sections/SettingsFooter.tsx | 57 + .../Settings/sections/SettingsHeader.tsx | 59 + .../Settings/sections/SoftwareUpdateRow.tsx | 156 ++ 10 files changed, 1106 insertions(+), 2397 deletions(-) create mode 100644 frontend/src/app/pages/Settings/sections/ConfirmDiscardDialog.tsx create mode 100644 frontend/src/app/pages/Settings/sections/GeneralAdvanced.tsx create mode 100644 frontend/src/app/pages/Settings/sections/GeneralAgentDefaults.tsx create mode 100644 frontend/src/app/pages/Settings/sections/GeneralInterface.tsx create mode 100644 frontend/src/app/pages/Settings/sections/GeneralTab.tsx create mode 100644 frontend/src/app/pages/Settings/sections/ModelsTab.tsx create mode 100644 frontend/src/app/pages/Settings/sections/SettingsFooter.tsx create mode 100644 frontend/src/app/pages/Settings/sections/SettingsHeader.tsx create mode 100644 frontend/src/app/pages/Settings/sections/SoftwareUpdateRow.tsx diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 8f03c04b..a9a844fa 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -1,65 +1,24 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { report } from '@/shared/serviceClient'; +import React, { useState, useEffect, useMemo, useCallback } 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 IconButton from '@mui/material/IconButton'; -import InputAdornment from '@mui/material/InputAdornment'; -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 Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; -import Tab from '@mui/material/Tab'; -import Tabs from '@mui/material/Tabs'; 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 FolderOpenIcon from '@mui/icons-material/FolderOpen'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; -import LightModeIcon from '@mui/icons-material/LightMode'; -import DarkModeIcon from '@mui/icons-material/DarkMode'; -import SaveIcon from '@mui/icons-material/Save'; -import CloseIcon from '@mui/icons-material/Close'; -import KeyboardIcon from '@mui/icons-material/Keyboard'; -import LanguageIcon from '@mui/icons-material/Language'; -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 CircularProgress from '@mui/material/CircularProgress'; -import LinearProgress from '@mui/material/LinearProgress'; -import Collapse from '@mui/material/Collapse'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, signOut, setDraft, clearDraft, AppSettings, CustomProvider, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice'; +import { updateSettings, closeSettingsModal, setDraft, clearDraft, AppSettings } from '@/shared/state/settingsSlice'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; -import { resetTour } from '@/app/components/Onboarding/OnboardingProgressSlice'; -import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { fetchModels } from '@/shared/state/modelsSlice'; -import { - fetchSubscriptionStatus, - setSubscriptionStatus, - selectSubscriptionConnections, -} from '@/shared/state/subscriptionsSlice'; -import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updateSlice'; import { fetchModes } from '@/shared/state/modesSlice'; -import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; +import { useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext'; import DirectoryBrowser from '@/app/components/DirectoryBrowser'; -import TrustedFilePatterns from '@/app/components/TrustedFilePatterns'; import { CommandsContent } from '@/app/pages/Commands/Commands'; -import { API_BASE } from '@/shared/config'; -import PlanPicker from '@/app/components/PlanPicker'; -import type { OpenSwarmPlan } from '@/shared/subscription/checkout'; +import GeneralTab from './sections/GeneralTab'; +import ModelsTab from './sections/ModelsTab'; +import UsageStats from './sections/UsageStats'; +import SettingsHeader from './sections/SettingsHeader'; +import SettingsFooter from './sections/SettingsFooter'; +import ConfirmDiscardDialog from './sections/ConfirmDiscardDialog'; +import { makeSettingsStyles } from './sections/settingsStyles'; // Brand colors for provider group headers; mirrors ChatInput picker. const PROVIDER_COLORS: Record = { @@ -83,1106 +42,6 @@ const DEFAULT_MODEL_FALLBACK = [ { value: 'haiku', label: 'Claude Haiku 4.5' }, ]; -const SUBSCRIPTION_PROVIDERS = [ - { id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet 4.6, Opus 4.6, Haiku 4.5', color: '#E8927A', preview: false }, - // "Gemini" routes through Antigravity OAuth (same Google sign-in, higher quota than Gemini CLI's free tier). - { id: 'antigravity', name: 'Gemini Advanced', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro, 2.5 Flash', color: '#4285F4', preview: false }, - { id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false }, -]; - -const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => { - const c = useClaudeTokens(); - const isPreview = (provider as any).preview; - return ( - - - - - - {provider.name} - - {connecting ? 'Waiting for authorization...' : provider.desc} - - - - {isPreview ? ( - - Coming soon - - ) : connected ? ( - disconnecting ? ( - - ) : ( - - Disconnect - - ) - ) : connecting && userCode ? ( - - Enter code: - {userCode} - - ) : connecting ? ( - - - Connecting... - - ) : ( - - )} - - - ); -}; - -/** Pro managed-subscription card: Subscribe CTA when disconnected, live usage + Manage/Disconnect when active. */ -interface OpenSwarmProStatus { - connected: boolean; - connection_mode?: string; - plan?: string | null; - status?: string | null; - expires?: string | null; - // Backend returns reason + last_plan on 401/402 so UI distinguishes "subscription ended" from "never subscribed". - reason?: 'revoked' | 'expired' | null; - last_plan?: string | null; - usage?: { - // Live utilization (0-100%) of the shared pool subscription's 5h window; polled ~30s. - utilization?: number; - window_hours?: number; - window_ends_at?: number; - pool_active_accounts?: number; - } | null; -} - -/** Clamp arbitrary cloud plan name to one of the three picker tiers; defaults to pro_plus. */ -const clampPickerPlan = (plan: string | null | undefined): OpenSwarmPlan => { - if (plan === 'pro' || plan === 'pro_plus' || plan === 'ultra') return plan; - return 'pro_plus'; -}; - -/** Account card at top of General tab; three states: signed in, paid-but-unlinked, or not signed in. */ -const AccountCard: React.FC = () => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - // Narrow primitive selectors so unrelated settings edits (theme, etc.) don't re-render this card. - const userEmail = useAppSelector((s) => s.settings.data.user_email ?? null); - const userId = useAppSelector((s) => s.settings.data.user_id ?? null); - const signinMethod = useAppSelector((s) => s.settings.data.signin_method ?? null); - const hasBearer = useAppSelector((s) => Boolean(s.settings.data.openswarm_bearer_token)); - const installId = useAppSelector((s) => s.settings.data.installation_id ?? ''); - const proxyUrl = useAppSelector((s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL); - const [signingOut, setSigningOut] = useState(false); - - const methodLabel = (() => { - switch (signinMethod) { - case 'google': return 'Signed in with Google'; - case 'stripe': return 'Signed in via Stripe checkout'; - default: return null; - } - })(); - - const onSignOut = async () => { - setSigningOut(true); - try { - await dispatch(signOut()).unwrap(); - } catch (e) { - console.error('Sign out failed:', e); - } finally { - setSigningOut(false); - } - }; - - const onSignIn = () => { - // Pass local_port so the bearer-handoff page POSTs to the right backend (Electron binds in 8324..8424). - const localPort = (window as any).__OPENSWARM_PORT__ || 8324; - const params = new URLSearchParams({ - install_id: installId, - local_port: String(localPort), - }); - const startUrl = proxyUrl.replace(/\/$/, '') + '/api/auth/google/start?' + params.toString(); - const api = (window as any).openswarm; - if (api?.openExternal) api.openExternal(startUrl); - else window.open(startUrl, '_blank'); - }; - - // Not signed in at all (no bearer, no user_id); inline CTA. - if (!userId && !hasBearer) { - return ( - - Not signed in - - Sign in to sync settings across devices and back up your data. - - - - ); - } - - return ( - - - - - {userEmail || 'Signed in'} - - {methodLabel && ( - {methodLabel} - )} - {!userId && hasBearer && ( - - Subscription connected. Sign in to also link this device to your account. - - )} - - - {!userId && hasBearer && ( - - )} - - - - - ); -}; - -const OpenSwarmProCard: React.FC = () => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const [status, setStatus] = useState(null); - const [busy, setBusy] = useState<'manage' | 'disconnect' | null>(null); - // Track fired usage thresholds so the event doesn't spam every 30s while counter hovers past the line. - const firedUsageThresholds = useRef>(new Set()); - - const refresh = useCallback(async () => { - try { - const r = await fetch(`${API_BASE}/subscription/status`); - if (r.ok) setStatus(await r.json()); - } catch { - // silently ignore; cloud might be offline - } - }, []); - - useEffect(() => { - refresh(); - const id = setInterval(refresh, 30_000); - return () => clearInterval(id); - }, [refresh]); - - const handleManage = async () => { - report('subscription', 'manage_clicked', { - plan: status?.plan ?? null, - status: status?.status ?? null, - }); - setBusy('manage'); - try { - const r = await fetch(`${API_BASE}/subscription/portal`, { method: 'POST' }); - if (r.ok) { - const { url } = await r.json(); - const api = (window as any).openswarm; - if (url && api?.openExternal) api.openExternal(url); - else if (url) window.open(url, '_blank'); - } - } finally { - setBusy(null); - } - }; - - const handleDisconnect = async () => { - setBusy('disconnect'); - try { - await dispatch(disconnectSubscription()).unwrap(); - await refresh(); - } finally { - setBusy(null); - } - }; - - // Fire usage_warning once per threshold (80%, 90%); placed before the early return so hook chain stays stable. - useEffect(() => { - if (!status?.connected) return; - const rawPct = status.usage?.utilization ?? 0; - const current = Math.max(0, Math.min(100, Math.round(rawPct))); - for (const threshold of [80, 90] as const) { - if (current >= threshold && !firedUsageThresholds.current.has(threshold)) { - firedUsageThresholds.current.add(threshold); - report('subscription', 'usage_warning', { - plan: status.plan ?? null, - utilization: current, - threshold, - }); - } - } - }, [status]); - - // Don't flash a CTA that disappears on first fetch. - if (!status) return null; - - const isConnected = !!status.connected; - const usage = status.usage; - // Pool utilization (0-100%) for the current 5h window of the routed subscription. - const pct = Math.max(0, Math.min(100, Math.round(usage?.utilization ?? 0))); - const windowEndsAt = usage?.window_ends_at; - - const expiresLabel = (() => { - if (!status.expires) return null; - try { - const d = new Date(status.expires); - return d.toLocaleDateString(undefined, { - month: 'short', day: 'numeric', year: 'numeric', - }); - } catch { - return null; - } - })(); - - const planLabel = (() => { - if (!status.plan) return 'Pro'; - return status.plan - .replace(/_/g, '+') - .replace(/\b\w/g, (s) => s.toUpperCase()); - })(); - - return ( - - - - - OpenSwarm Pro - - {isConnected && ( - - )} - {!isConnected && ( - - - RECOMMENDED - - - )} - - - - {isConnected ? ( - <> - {/* Canceled-in-grace banner: canceled in Stripe but still inside paid period. */} - {status.status === 'canceled' && ( - - - Subscription canceled. You still have access until {expiresLabel || 'the end of the period'}. - - - )} - - {/* Usage bar; percentage only, no raw counts. */} - - - - Current usage - - - {pct}% used - - - = 90 ? c.status.warning : pct >= 70 ? c.status.info : c.accent.primary, - borderRadius: 999, - }, - }} - /> - {windowEndsAt && ( - - Resets {(() => { - const diff = windowEndsAt - Date.now(); - if (diff <= 0) return 'soon'; - const hrs = Math.floor(diff / 3600000); - const mins = Math.floor((diff % 3600000) / 60000); - if (hrs > 0) return `in ${hrs} hr ${mins} min`; - return `in ${mins} min`; - })()} - - )} - - {expiresLabel && status.status !== 'canceled' && ( - - Renews on {expiresLabel} - - )} - - - - - {/* Canceled-in-grace: 3-tier picker inline for resubscribe; active subs use Stripe's portal instead. */} - {status.status === 'canceled' && ( - <> - - - Resubscribe to keep access past {expiresLabel || 'your end date'} - - - Pick any plan below; you can keep your current tier or switch. - - - - - )} - - ) : status.reason === 'expired' && status.last_plan ? ( - // Expired: bearer's sub ended past grace; show picker with prior plan preselected. - <> - - Your OpenSwarm Pro subscription has ended. Pick a plan to keep using Claude Sonnet, Opus, and Haiku without a Claude account. - - - - ) : status.reason === 'revoked' && status.last_plan ? ( - // Token revoked but sub existed; CTA language differs so user knows this isn't billing. - <> - - Your OpenSwarm Pro access token was revoked. Pick a plan to reconnect. - - - - ) : ( - // Genuine new user; never had a subscription on this machine. - <> - - One subscription, no Claude account needed. We handle everything behind the scenes. - - - - )} - - ); -}; - -const SubscriptionCards: React.FC = () => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - // status + connections live in subscriptionsSlice so the onboarding gate (hasModelConnected) sees OAuth connections immediately. - const status = useAppSelector((s) => s.subscriptions.status); - const connections = useAppSelector(selectSubscriptionConnections); - const [connecting, setConnecting] = useState(null); - const [disconnecting, setDisconnecting] = useState(null); - const [userCode, setUserCode] = useState(''); - const [pollTimer, setPollTimer] = useState(null); - - // Thin wrapper that returns the resolved status so call sites inspecting the payload keep working. - const fetchStatus = useCallback( - async (opts?: { preserveTransient?: boolean }) => { - return dispatch(fetchSubscriptionStatus(opts)).unwrap(); - }, - [dispatch], - ); - - // Refetch model picker after sub changes so newly-connected providers surface in the dropdown immediately. - const refreshPickerModels = () => { dispatch(fetchModels()); }; - - useEffect(() => { - let cancelled = false; - (async () => { - // Retry initial load; a single transient probe miss would otherwise wedge the spinner until reopen. - for (const delay of [0, 800, 2000]) { - if (cancelled) return; - if (delay) await new Promise(r => setTimeout(r, delay)); - const data = await fetchStatus(); - if (data?.running) break; - } - })(); - const interval = setInterval(() => fetchStatus({ preserveTransient: true }), 30000); - return () => { cancelled = true; clearInterval(interval); }; - }, [fetchStatus]); - - const isConnected = (providerId: string) => - connections.some( - (p: any) => - p.provider === providerId && (p.isActive || p.testStatus === 'active'), - ); - - const handleConnect = async (providerId: string) => { - if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); } - setConnecting(providerId); - setUserCode(''); - - // Small delay on retry to avoid Claude's rate limit. - await new Promise(r => setTimeout(r, 500)); - - try { - const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider: providerId }), - }); - if (!r.ok) { setConnecting(null); return; } - const data = await r.json(); - - if (data.flow === 'device_code') { - const code = data.user_code || ''; - setUserCode(code); - // Named window + features so Electron's setWindowOpenHandler spawns a BrowserWindow popup, not a webview tab. - let devicePopup: Window | null = null; - if (data.verification_uri) { - devicePopup = window.open(data.verification_uri, 'oauth_connect', 'width=600,height=720'); - } - - // Shared cleanup; whichever detection path fires first calls this. - let stopped = false; - const onDeviceSuccess = () => { - if (stopped) return; - stopped = true; - clearInterval(devicePollTimer); - clearInterval(statusPollTimer); - setPollTimer(null); - setConnecting(null); - setUserCode(''); - fetchStatus(); - refreshPickerModels(); - // Auto-close popup 2s after success so the "Congratulations" page is briefly visible then closes. - setTimeout(() => { - if (devicePopup && !devicePopup.closed) { - try { devicePopup.close(); } catch {} - } - }, 2000); - }; - - // Path 1: device-code poll via backend/9Router; primary path. - const pollOnce = async () => { - if (stopped) return; - try { - const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }), - }); - if (!pr.ok) { - console.warn(`[subscription-poll] ${providerId}: HTTP ${pr.status}`); - return; - } - const pd = await pr.json(); - if (pd.success) { - onDeviceSuccess(); - } else if (!pd.pending) { - console.warn(`[subscription-poll] ${providerId}: not success, not pending:`, pd); - } - } catch (e) { - console.warn(`[subscription-poll] ${providerId}: error:`, e); - } - }; - pollOnce(); - const devicePollTimer = setInterval(pollOnce, 5000); - - // Path 2: status poller every 2s; catches connection even when device-code poll silently errors. - const statusPollTimer = setInterval(async () => { - if (stopped) return; - try { - const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); - const sd = await sr.json(); - const connections = sd.providers?.connections || []; - if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) { - onDeviceSuccess(); - } - } catch {} - }, 2000); - - setPollTimer(devicePollTimer); - - // Listen for main-window focus; Electron's popup.closed is unreliable when child BrowserWindow is destroyed. - let focusCheckDone = false; - const onFocus = async () => { - if (stopped || focusCheckDone) return; - focusCheckDone = true; - window.removeEventListener('focus', onFocus); - // Give 9Router 3s to process the token exchange before the final status check. - await new Promise(r => setTimeout(r, 3000)); - if (stopped) return; - try { - const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); - const sd = await sr.json(); - const connections = sd.providers?.connections || []; - if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) { - onDeviceSuccess(); - return; - } - } catch {} - // Connection not found; reset card. - stopped = true; - clearInterval(devicePollTimer); - clearInterval(statusPollTimer); - setPollTimer(null); - setConnecting(null); - setUserCode(''); - fetchStatus(); - }; - // Delay focus listener; popup open can blur/refocus the parent and falsely trigger it. - setTimeout(() => { - if (!stopped) window.addEventListener('focus', onFocus); - }, 2000); - - // 5-minute hard timeout; cleans up everything. - setTimeout(() => { - if (stopped) return; - stopped = true; - window.removeEventListener('focus', onFocus); - clearInterval(devicePollTimer); - clearInterval(statusPollTimer); - setPollTimer(null); - setConnecting(null); - setUserCode(''); - if (devicePopup && !devicePopup.closed) { - try { devicePopup.close(); } catch {} - } - }, 300000); - - } else if (data.flow === 'authorization_code') { - // Gemini/Google block embedded browsers; backend sets use_external_browser and exchange happens server-side via /api/subscriptions/callback. Detect via status poller (no postMessage possible). - const useExternal = !!data.use_external_browser; - let popup: Window | null = null; - if (useExternal && (window as any).openswarm?.openExternal) { - (window as any).openswarm.openExternal(data.auth_url); - } else { - popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); - } - - // Status polling: primary for external-browser flow, secondary for popup flow (postMessage is faster). - const statusPoller = setInterval(async () => { - try { - const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); - const sd = await sr.json(); - const connections = sd.providers?.connections || []; - if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) { - clearInterval(statusPoller); - setPollTimer(null); - if (!useExternal) window.removeEventListener('message', msgHandler); - setConnecting(null); - fetchStatus(); - refreshPickerModels(); - } - } catch {} - }, 2000); - setPollTimer(statusPoller); - - // Shared exchange helper invoked by whichever relay path delivers the code first. - let exchanged = false; - const runExchange = async (code: string, state?: string) => { - if (exchanged) return; - exchanged = true; - window.removeEventListener('message', msgHandler); - if (ipcUnsub) ipcUnsub(); - clearInterval(statusPoller); - setPollTimer(null); - if (popup && !popup.closed) popup.close(); - try { - await fetch(`${API_BASE}/agents/subscriptions/exchange`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider: providerId, code, - redirect_uri: data.redirect_uri, code_verifier: data.code_verifier, - state: state || data.state, - }), - }); - } catch {} - setConnecting(null); - fetchStatus(); - refreshPickerModels(); - }; - - // postMessage listener; no-ops when cross-origin redirects sever window.opener. - const msgHandler = async (event: MessageEvent) => { - const d = event.data; - const callbackData = d?.type === 'oauth_callback' ? d.data : d; - if (callbackData?.code) await runExchange(callbackData.code, callbackData.state); - }; - if (!useExternal) window.addEventListener('message', msgHandler); - - // Electron IPC fallback; main.js forwards callback params so exchange works when opener postMessage fails. - let ipcUnsub: (() => void) | null = null; - const ow = (window as any).openswarm; - if (ow && typeof ow.onOauthCallback === 'function') { - ipcUnsub = ow.onOauthCallback(async (cb: { code?: string; state?: string; error?: string }) => { - if (cb?.code) await runExchange(cb.code, cb.state); - }); - } - - // 3min popup / 5min external-browser; bounds the Connecting indicator, safety-net poller is the real exit. - const timeoutMs = useExternal ? 300_000 : 180_000; - setTimeout(() => { - clearInterval(statusPoller); - setPollTimer(null); - if (!useExternal) window.removeEventListener('message', msgHandler); - if (ipcUnsub) ipcUnsub(); - setConnecting(null); - }, timeoutMs); - - } else { - setConnecting(null); - } - } catch { setConnecting(null); } - }; - - const handleDisconnect = async (providerId: string) => { - setDisconnecting(providerId); - try { - await fetch(`${API_BASE}/agents/subscriptions/disconnect`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider: providerId }), - }); - } catch {} - // Wait briefly for 9Router to process, then refresh subscription status + model picker. - setTimeout(() => { - fetchStatus(); - refreshPickerModels(); - setDisconnecting(null); - }, 500); - }; - - // 4s safety-net poller while connecting; clears Connecting state whenever 9Router reports the provider isActive (handles Windows postMessage failures). - useEffect(() => { - if (!connecting) return; - let cancelled = false; - const tick = async () => { - try { - const r = await fetch(`${API_BASE}/agents/subscriptions/status`); - const d = await r.json(); - if (cancelled) return; - const conns = d?.providers?.connections || []; - if (conns.some((p: any) => p.provider === connecting && (p.isActive || p.testStatus === 'active'))) { - dispatch(setSubscriptionStatus(d)); - setConnecting(null); - setUserCode(''); - refreshPickerModels(); - } - } catch {} - }; - const id = setInterval(tick, 4000); - return () => { cancelled = true; clearInterval(id); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connecting]); - - if (!status) { - return ( - - {SUBSCRIPTION_PROVIDERS.map(p => ( - - - - - - - - ))} - - ); - } - - if (!status?.running) { - return ( - - - - Starting subscription service... - - - This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed. - - - ); - } - - return ( - - {SUBSCRIPTION_PROVIDERS.map(p => ( - handleConnect(p.id)} - onDisconnect={() => handleDisconnect(p.id)} - connecting={connecting === p.id} - disconnecting={disconnecting === p.id} - userCode={connecting === p.id ? userCode : undefined} - /> - ))} - - ); -}; - -const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E']; -const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD']; - -const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; palette?: string[]; tokens: any }> = ({ value, max, width = 16, palette = PIXEL_SALMON, tokens: c }) => { - const filled = max > 0 ? Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width)) : 0; - return ( - - {Array.from({ length: width }, (_, i) => ( - - ))} - - ); -}; - -const UsageStats: React.FC = () => { - const c = useClaudeTokens(); - const [stats, setStats] = useState(null); - - useEffect(() => { - fetch(`${API_BASE}/service/usage-summary`) - .then(r => r.json()) - .then(setStats) - .catch(() => {}); - }, []); - - if (!stats) { - const skeletonPulse = { - animation: 'skeleton-pulse 1.5s ease-in-out infinite', - '@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } }, - }; - const skeletonCard = { - p: 1.5, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated, - border: `1px solid ${c.border.subtle}`, ...skeletonPulse, - }; - return ( - - - {Array.from({ length: 4 }, (_, i) => ( - - - - - - ))} - - - {Array.from({ length: 4 }, (_, i) => ( - - - - - - ))} - - - {Array.from({ length: 2 }, (_, i) => ( - - - {Array.from({ length: 3 }, (_, j) => ( - - - - - - - {Array.from({ length: 16 }, (_, k) => ( - - ))} - - - ))} - - ))} - - - ); - } - - const formatCost = (v: number) => { - if (v === 0) return '$0.00'; - if (v < 0.001) return `$${v.toFixed(6)}`; - if (v < 0.01) return `$${v.toFixed(5)}`; - if (v < 1) return `$${v.toFixed(4)}`; - return `$${v.toFixed(2)}`; - }; - const formatDuration = (s: number) => { - if (s === 0) return '0s'; - if (s < 60) return `${s.toFixed(1)}s`; - if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`; - return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; - }; - const formatTotalTime = (s: number) => { - if (s < 60) return `${s.toFixed(1)}s`; - if (s < 3600) return `${(s / 60).toFixed(1)} min`; - return `${(s / 3600).toFixed(1)} hrs`; - }; - - const cardSx = { - p: 1.5, - borderRadius: `${c.radius.md}px`, - bgcolor: c.bg.elevated, - border: `1px solid ${c.border.subtle}`, - }; - const labelSx = { fontSize: '0.58rem', fontWeight: 700, color: c.text.ghost, textTransform: 'uppercase' as const, letterSpacing: '0.06em', mb: 0.25 }; - const valueSx = { fontSize: '1.05rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 }; - const subSx = { fontSize: '0.62rem', color: c.text.tertiary, mt: 0.25 }; - - const modelEntries = Object.entries(stats.models_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][]; - const providerEntries = Object.entries(stats.providers_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][]; - const toolEntries = Object.entries(stats.top_tools || {}).slice(0, 10) as [string, number][]; - const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1; - const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][]; - - const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => ( - - ); - - const totalTime = stats.avg_duration_seconds * stats.total_sessions; - const msgsPerSession = stats.total_sessions > 0 ? (stats.total_messages / stats.total_sessions).toFixed(1) : '0'; - const toolsPerSession = stats.total_sessions > 0 ? (stats.total_tool_calls / stats.total_sessions).toFixed(1) : '0'; - const formatTokens = (n: number) => { - if (n === 0) return '0'; - if (n < 1000) return String(n); - if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`; - return `${(n / 1_000_000).toFixed(2)}M`; - }; - const isSubscription = stats.cost_source === '9router'; - const costSourceLabel = isSubscription ? 'saved with your subscription' : stats.cost_source === 'sdk' ? 'via API' : ''; - - return ( - - - - Total Sessions - {stats.total_sessions.toLocaleString()} - - {statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'} - - - - {isSubscription ? 'You Saved' : 'Total Cost'} - {formatCost(stats.total_cost_usd)} - - {isSubscription - ? `${formatCost(stats.avg_cost_per_session)} avg, saved with your subscription` - : costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg, ${costSourceLabel}` : 'no cost data'} - - - - Total Messages - {stats.total_messages.toLocaleString()} - - {msgsPerSession} avg per session - - - - Total Tool Calls - {stats.total_tool_calls.toLocaleString()} - - {toolsPerSession} avg per session - - - - - - - Total Run Time - {formatTotalTime(totalTime)} - across all sessions - - - Avg Session - {formatDuration(stats.avg_duration_seconds)} - per session duration - - - Completion Rate - {(stats.completion_rate * 100).toFixed(1)}% - - sessions finished successfully - - - - Tokens Used - - {stats.total_prompt_tokens || stats.total_completion_tokens - ? formatTokens((stats.total_prompt_tokens || 0) + (stats.total_completion_tokens || 0)) - : Object.keys(stats.providers_used || {}).length} - - - {stats.total_prompt_tokens || stats.total_completion_tokens - ? `${formatTokens(stats.total_prompt_tokens || 0)} in, ${formatTokens(stats.total_completion_tokens || 0)} out` - : providerEntries.map(([p]) => p).join(', ') || 'none'} - - - - - - - Models Used - {modelEntries.length > 0 ? modelEntries.map(([model, count]) => { - const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0'; - return ( - - - {model} - - {count} ({pct}%) - - - - - ); - }) : No sessions yet} - - - - Top Tools - {toolEntries.length > 0 ? toolEntries.map(([tool, count]) => { - const shortName = tool.includes('__') ? tool.split('__').pop() : tool; - const pct = stats.total_tool_calls > 0 ? ((count / stats.total_tool_calls) * 100).toFixed(0) : '0'; - return ( - - - {shortName} - - {count} call{count !== 1 ? 's' : ''} ({pct}%) - - - - - ); - }) : No tool calls yet} - - - - ); -}; - -const API_KEY_STEPS = [ - { - title: 'Open the Anthropic Console', - detail: 'Visit console.anthropic.com; create a free account if you don\'t have one yet.', - link: 'https://console.anthropic.com', - }, - { - title: 'Navigate to API Keys', - detail: 'In the dashboard, click "Settings" in the left sidebar, then select "API Keys".', - }, - { - title: 'Create a new key', - detail: 'Click the "Create Key" button. Name it anything you like (e.g. "OpenSwarm").', - }, - { - title: 'Copy your key', - detail: 'Click the copy icon next to your new key. It will start with sk-ant-api03-…', - }, - { - title: 'Paste it above & save', - detail: 'Paste the key into the field above, then hit Save. You\'re all set!', - }, -]; - const Settings: React.FC = () => { const open = useAppSelector((s) => s.settings.modalOpen); const c = useClaudeTokens(); @@ -1215,13 +74,6 @@ const Settings: React.FC = () => { 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); - const downloadPercent = useAppSelector((s) => s.update.downloadPercent); - const updateError = useAppSelector((s) => s.update.error); - const installing = useAppSelector((s) => s.update.installing); - const initialTab = useAppSelector((s) => s.settings.initialTab); // In-flight edits persisted to Redux so they survive modal close; cleared on save or explicit Discard. const draft = useAppSelector((s) => s.settings.draft); @@ -1250,9 +102,7 @@ const Settings: React.FC = () => { const [showApiKey, setShowApiKey] = useState(false); const [browseOpen, setBrowseOpen] = useState(false); const [saved, setSaved] = useState(false); - const [recordingShortcut, setRecordingShortcut] = useState(false); const [confirmDiscard, setConfirmDiscard] = useState(false); - const [showApiHelp, setShowApiHelp] = useState(false); useEffect(() => { dispatch(fetchModes()); @@ -1289,34 +139,6 @@ const Settings: React.FC = () => { } }, [form, activeTab, open, loaded, settings, draft, dispatch]); - 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(); - }; - const hasChanges = JSON.stringify(form) !== JSON.stringify(settings); const handleSave = async () => { @@ -1341,57 +163,7 @@ const Settings: React.FC = () => { dispatch(clearDraft()); }, [settings, dispatch]); - const fieldSx = { - '& .MuiOutlinedInput-root': { - fontSize: '0.85rem', - }, - }; - - const sectionSx = { - fontSize: '0.7rem', - fontWeight: 600, - letterSpacing: '0.06em', - textTransform: 'uppercase' as const, - color: c.text.tertiary, - mb: 0.5, - mt: 0.5, - }; - - const rowSx = { - py: 2, - borderBottom: `1px solid ${c.border.subtle}`, - }; - - const rowLastSx = { - py: 2, - }; - - const inlineRowSx = { - ...rowSx, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - }; - - const inlineRowLastSx = { - ...rowLastSx, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - }; - - const labelSx = { - color: c.text.primary, - fontWeight: 500, - fontSize: '0.875rem', - lineHeight: 1.4, - }; - - const descSx = { - color: c.text.tertiary, - fontSize: '0.75rem', - lineHeight: 1.4, - }; + const styles = makeSettingsStyles(c); return ( <> @@ -1411,44 +183,11 @@ const Settings: React.FC = () => { }, }} > - - - - Settings - - - - - - setActiveTab(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 }, - }} - > - - - - - - + setActiveTab(v)} + onClose={handleRequestClose} + /> { scrollbarColor: `${c.border.medium} transparent`, }}> {activeTab === 'general' ? ( - - - Account - - - Agent Defaults - - - - System prompt - {form.default_system_prompt !== DEFAULT_SYSTEM_PROMPT && ( - - )} - - - Prepended to every agent session before mode-specific instructions. Modes can override with their own. - - 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, - }, - }} - /> - - - - Working directory - - Default folder agents start in. Modes can override per-mode. - - - 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, - }, - }} - /> - - - - - - - Model - Default model for new sessions. - - - - - - - - - Mode - Default interaction mode for new sessions. - - - - - - - - - Thinking - Default thinking level for reasoning-capable models. - - - - - - - - - Max turns - Auto-stop after this many turns. Empty = unlimited. - - setForm({ ...form, default_max_turns: e.target.value ? parseInt(e.target.value) : null })} - size="small" - placeholder="∞" - inputProps={{ min: 1 }} - sx={{ ...fieldSx, width: 100 }} - /> - - - Interface - - - - Theme - Application color scheme. - - { 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` }, - }, - }, - }} - > - - Light - - - Dark - - - - - - Zoom sensitivity - - Scroll-to-zoom responsiveness. Lower for trackpads, higher for mouse wheels. - - - 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 }, - }} - /> - - - - - - New agent shortcut - Keyboard shortcut to create an agent. - - { - 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 }, - }} - > - - {recordingShortcut ? ( - - Press shortcut… - - ) : ( - - {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(' + ')} - - )} - - - - - - Auto-enable element selection - Automatically enter element selection mode when creating a new agent. - - 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 }, - }} - /> - - - - - Default agent spawn state in dashboard - When enabled, new agents spawn expanded instead of collapsed. - - 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 }, - }} - /> - - - - - Auto-reveal sub-agents on dashboard - Automatically show sub-agent cards (from CreateAgent / InvokeAgent) tethered to their parent on the dashboard. - - 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 }, - }} - /> - - - Browser - - - Default homepage - - URL loaded when opening a new browser card on the dashboard. - - - - 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, - }, - }} - /> - - - - Advanced - - - - Developer mode - Show transport details, environment variables, raw configs, and other technical metadata throughout the app. - - 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 }, - }} - /> - - - - - Experimental updates - Receive pre-release builds with new features earlier. These versions may be less stable than normal releases. - - 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 }, - }} - /> - - - About - - - - - Version - - {appVersion ?? '-'} - - - - - - - - - Software update - - {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.'} - - - - {updateStatus === 'checking' && ( - - )} - {updateStatus === 'not-available' && ( - - )} - {updateStatus === 'error' && ( - - )} - {(updateStatus === 'idle' || updateStatus === 'not-available' || updateStatus === 'error') && ( - - )} - {updateStatus === 'available' && ( - - )} - {updateStatus === 'downloaded' && ( - - )} - - - {updateStatus === 'downloading' && ( - - )} - - - - - - - Onboarding tour - - Re-run the Show me walkthrough at any time. - - - - - - + ) : activeTab === 'models' ? ( - - - - - One Subscription, No Setup - - - - Don't have a Claude account? We'll handle it for you. One simple subscription covers Claude Sonnet, Opus, and Haiku. - - - - - - - - Or Use Your Existing Subscriptions - - - - Already paying for Claude, ChatGPT, or Gemini? Connect your subscription, no API key needed, no extra cost. - - - - - - - - Or Connect With API Keys - - - - Pay per use. Each key is stored locally on your device. - - - - - Anthropic - {form.anthropic_api_key ? ( - CONNECTED - ) : null} - - Claude Sonnet, Opus, Haiku. - - setForm({ ...form, anthropic_api_key: e.target.value || null })} - size="small" - fullWidth - placeholder="sk-ant-..." - sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }} - InputProps={{ - endAdornment: ( - - setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> - {showApiKey ? : } - - - ), - }} - /> - - Get key - - - - - - - OpenAI - {form.openai_api_key ? ( - CONNECTED - ) : null} - - GPT-5.4, GPT-5.4 Mini, o-series reasoning models. - - setForm({ ...form, openai_api_key: e.target.value || null })} - size="small" - fullWidth - placeholder="sk-..." - sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }} - InputProps={{ - endAdornment: ( - - setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> - {showApiKey ? : } - - - ), - }} - /> - - Get key - - - - - - - Google - {form.google_api_key ? ( - CONNECTED - ) : null} - - Gemini 3 Pro, Gemini 3 Flash, Gemini 2.5 Pro. - - setForm({ ...form, google_api_key: e.target.value || null })} - size="small" - fullWidth - placeholder="AIza..." - sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }} - InputProps={{ - endAdornment: ( - - setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> - {showApiKey ? : } - - - ), - }} - /> - - Get key - - - - - - - OpenRouter - {form.openrouter_api_key ? ( - CONNECTED - ) : null} - - 300+ models from xAI, Meta, DeepSeek, Mistral, Qwen, and more. - - setForm({ ...form, openrouter_api_key: e.target.value || null })} - size="small" - fullWidth - placeholder="sk-or-..." - sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }} - InputProps={{ - endAdornment: ( - - setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> - {showApiKey ? : } - - - ), - }} - /> - - Get key - - - - - - - Custom Providers - {(() => { - const list = form.custom_providers || []; - if (list.length === 0) return null; - const readyCount = list.filter(cp => { - const filled = (cp.models || []).filter(m => (m.value || '').trim()).length; - return !!cp.name?.trim() && !!cp.base_url?.trim() && !!cp.api_key?.trim() && filled > 0; - }).length; - const allReady = readyCount === list.length; - return ( - - {readyCount} OF {list.length} READY - - ); - })()} - - - Add OpenAI-compatible endpoints (Ollama Cloud, Together, Groq, local Ollama, anything that speaks /v1/chat/completions). - - - - {(form.custom_providers || []).map((cp, idx) => { - const list = form.custom_providers || []; - const updateProvider = (patch: Partial) => { - const next = list.map((x, i) => (i === idx ? { ...x, ...patch } : x)); - setForm({ ...form, custom_providers: next }); - }; - const removeProvider = () => { - const next = list.filter((_, i) => i !== idx); - setForm({ ...form, custom_providers: next }); - }; - const addModel = () => { - const nextModels = [...(cp.models || []), { value: '', label: '' }]; - updateProvider({ models: nextModels }); - }; - const updateModel = (mIdx: number, value: string) => { - const nextModels = (cp.models || []).map((m, i) => - i === mIdx ? { ...m, value, label: value } : m - ); - updateProvider({ models: nextModels }); - }; - const removeModel = (mIdx: number) => { - const nextModels = (cp.models || []).filter((_, i) => i !== mIdx); - updateProvider({ models: nextModels }); - }; - const filledModelCount = (cp.models || []).filter(m => (m.value || '').trim()).length; - const nameMissing = !cp.name?.trim(); - const urlMissing = !cp.base_url?.trim(); - const modelsMissing = filledModelCount === 0; - // api_key is optional (local LM Studio/Ollama/llama.cpp/vLLM run without auth); hosted providers need a real key. - const isReady = !nameMissing && !urlMissing && !modelsMissing; - const dupeNameWithEarlier = list.findIndex((other, i) => - i < idx && (other.name || '').trim().toLowerCase() === (cp.name || '').trim().toLowerCase() && (cp.name || '').trim() !== '' - ) !== -1; - const missingLabels: string[] = []; - if (nameMissing) missingLabels.push('name'); - if (urlMissing) missingLabels.push('base URL'); - if (modelsMissing) missingLabels.push('a model'); - - return ( - - - - - {isReady ? 'Ready' : `Incomplete, add ${missingLabels.join(', ')}`} - - - - - - - updateProvider({ name: e.target.value })} - size="small" - fullWidth - placeholder="e.g. Ollama Cloud" - label="Name" - required - error={dupeNameWithEarlier} - helperText={dupeNameWithEarlier ? 'Name must be unique' : undefined} - InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }} - sx={fieldSx} - /> - updateProvider({ base_url: e.target.value })} - size="small" - fullWidth - placeholder="https://ollama.com/v1" - label="Base URL" - required - InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }} - sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }} - /> - updateProvider({ api_key: e.target.value })} - size="small" - fullWidth - placeholder="Leave blank for local servers (LM Studio, Ollama, ...)" - label="API Key (optional)" - InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }} - sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }} - InputProps={{ - endAdornment: ( - - setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> - {showApiKey ? : } - - - ), - }} - /> - - - - - Models - - {((cp.models || []).length === 0) ? ( - - No models yet, add the model IDs this endpoint serves. - - ) : ( - (cp.models || []).map((m, mIdx) => ( - - updateModel(mIdx, e.target.value)} - size="small" - fullWidth - placeholder="e.g. gpt-oss:120b" - sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono, fontSize: '0.78rem' } }} - /> - removeModel(mIdx)} - size="small" - title="Remove model" - sx={{ - color: c.text.tertiary, - '&:hover': { color: c.status.error, bgcolor: `${c.status.error}10` }, - }} - > - - - - )) - )} - - - - ); - })} - - - - - - - + ) : activeTab === 'usage' ? ( @@ -2512,44 +229,12 @@ const Settings: React.FC = () => { {(activeTab === 'general' || activeTab === 'models') && ( - - {/* Left: explicit Discard; only path to wipe the persisted draft. */} - - {hasChanges && ( - - )} - - - - - - + setConfirmDiscard(true)} + onClose={handleRequestClose} + onSave={handleSave} + /> )} { - setConfirmDiscard(false)} - PaperProps={{ - sx: { - bgcolor: c.bg.page, - borderRadius: 2, - border: `1px solid ${c.border.subtle}`, - boxShadow: c.shadow.md, - maxWidth: 380, - }, - }} - > - - Discard unsaved changes? - - - - Your in-progress edits will be cleared and the form will revert to your saved settings. This can't be undone. - - - - - - - + onCancel={() => setConfirmDiscard(false)} + onConfirm={handleConfirmDiscard} + /> ); }; diff --git a/frontend/src/app/pages/Settings/sections/ConfirmDiscardDialog.tsx b/frontend/src/app/pages/Settings/sections/ConfirmDiscardDialog.tsx new file mode 100644 index 00000000..d744e511 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/ConfirmDiscardDialog.tsx @@ -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 ( + + + Discard unsaved changes? + + + + Your in-progress edits will be cleared and the form will revert to your saved settings. This can't be undone. + + + + + + + + ); +}; + +export default ConfirmDiscardDialog; diff --git a/frontend/src/app/pages/Settings/sections/GeneralAdvanced.tsx b/frontend/src/app/pages/Settings/sections/GeneralAdvanced.tsx new file mode 100644 index 00000000..0c8acf6c --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/GeneralAdvanced.tsx @@ -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>; + 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 ( + <> + Advanced + + + + Developer mode + Show transport details, environment variables, raw configs, and other technical metadata throughout the app. + + 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 }, + }} + /> + + + + + Experimental updates + Receive pre-release builds with new features earlier. These versions may be less stable than normal releases. + + 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 }, + }} + /> + + + About + + + + + Version + + {appVersion ?? '-'} + + + + + + + + + + + + Onboarding tour + + Re-run the Show me walkthrough at any time. + + + + + + ); +}; + +export default GeneralAdvanced; diff --git a/frontend/src/app/pages/Settings/sections/GeneralAgentDefaults.tsx b/frontend/src/app/pages/Settings/sections/GeneralAgentDefaults.tsx new file mode 100644 index 00000000..f26de4b5 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/GeneralAgentDefaults.tsx @@ -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>; + styles: SettingsStyles; + setBrowseOpen: (v: boolean) => void; + modelOptions: { grouped: Record; flat: Array }; + modesList: Array<{ id: string; name: string }>; + providerColors: Record; + 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 ( + <> + Agent Defaults + + + + System prompt + {form.default_system_prompt !== DEFAULT_SYSTEM_PROMPT && ( + + )} + + + Prepended to every agent session before mode-specific instructions. Modes can override with their own. + + 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, + }, + }} + /> + + + + Working directory + + Default folder agents start in. Modes can override per-mode. + + + 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, + }, + }} + /> + + + + + + + Model + Default model for new sessions. + + + + + + + + + Mode + Default interaction mode for new sessions. + + + + + + + + + Thinking + Default thinking level for reasoning-capable models. + + + + + + + + + Max turns + Auto-stop after this many turns. Empty = unlimited. + + setForm({ ...form, default_max_turns: e.target.value ? parseInt(e.target.value) : null })} + size="small" + placeholder="∞" + inputProps={{ min: 1 }} + sx={{ ...fieldSx, width: 100 }} + /> + + + ); +}; + +export default GeneralAgentDefaults; diff --git a/frontend/src/app/pages/Settings/sections/GeneralInterface.tsx b/frontend/src/app/pages/Settings/sections/GeneralInterface.tsx new file mode 100644 index 00000000..a8d2db5b --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/GeneralInterface.tsx @@ -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>; + styles: SettingsStyles; +}> = ({ form, setForm, styles }) => { + const c = useClaudeTokens(); + const [recordingShortcut, setRecordingShortcut] = useState(false); + const { fieldSx, sectionSx, rowSx, rowLastSx, inlineRowSx, inlineRowLastSx, labelSx, descSx } = styles; + + return ( + <> + Interface + + + + Theme + Application color scheme. + + { 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` }, + }, + }, + }} + > + + Light + + + Dark + + + + + + Zoom sensitivity + + Scroll-to-zoom responsiveness. Lower for trackpads, higher for mouse wheels. + + + 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 }, + }} + /> + + + + + + New agent shortcut + Keyboard shortcut to create an agent. + + { + 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 }, + }} + > + + {recordingShortcut ? ( + + Press shortcut… + + ) : ( + + {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(' + ')} + + )} + + + + + + Auto-enable element selection + Automatically enter element selection mode when creating a new agent. + + 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 }, + }} + /> + + + + + Default agent spawn state in dashboard + When enabled, new agents spawn expanded instead of collapsed. + + 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 }, + }} + /> + + + + + Auto-reveal sub-agents on dashboard + Automatically show sub-agent cards (from CreateAgent / InvokeAgent) tethered to their parent on the dashboard. + + 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 }, + }} + /> + + + Browser + + + Default homepage + + URL loaded when opening a new browser card on the dashboard. + + + + 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, + }, + }} + /> + + + + ); +}; + +export default GeneralInterface; diff --git a/frontend/src/app/pages/Settings/sections/GeneralTab.tsx b/frontend/src/app/pages/Settings/sections/GeneralTab.tsx new file mode 100644 index 00000000..aab7f711 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/GeneralTab.tsx @@ -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>; + styles: SettingsStyles; + setBrowseOpen: (v: boolean) => void; + modelOptions: { grouped: Record; flat: Array }; + modesList: Array<{ id: string; name: string }>; + providerColors: Record; + openswarmGradient: string; +}> = ({ form, setForm, styles, setBrowseOpen, modelOptions, modesList, providerColors, openswarmGradient }) => { + const { sectionSx } = styles; + return ( + + + Account + + + + + + + + + + ); +}; + +export default GeneralTab; diff --git a/frontend/src/app/pages/Settings/sections/ModelsTab.tsx b/frontend/src/app/pages/Settings/sections/ModelsTab.tsx new file mode 100644 index 00000000..87e220aa --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/ModelsTab.tsx @@ -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>; + showApiKey: boolean; + setShowApiKey: (v: boolean) => void; + styles: SettingsStyles; +}> = ({ form, setForm, showApiKey, setShowApiKey, styles }) => { + const c = useClaudeTokens(); + const { descSx } = styles; + return ( + + + + + One Subscription, No Setup + + + + Don't have a Claude account? We'll handle it for you. One simple subscription covers Claude Sonnet, Opus, and Haiku. + + + + + + + + Or Use Your Existing Subscriptions + + + + Already paying for Claude, ChatGPT, or Gemini? Connect your subscription, no API key needed, no extra cost. + + + + + + + + Or Connect With API Keys + + + + Pay per use. Each key is stored locally on your device. + + + {API_KEY_CARDS.map((config) => ( + + ))} + + + + + + ); +}; + +export default ModelsTab; diff --git a/frontend/src/app/pages/Settings/sections/SettingsFooter.tsx b/frontend/src/app/pages/Settings/sections/SettingsFooter.tsx new file mode 100644 index 00000000..b047dda5 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/SettingsFooter.tsx @@ -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 ( + + {/* Left: explicit Discard; only path to wipe the persisted draft. */} + + {hasChanges && ( + + )} + + + + + + + ); +}; + +export default SettingsFooter; diff --git a/frontend/src/app/pages/Settings/sections/SettingsHeader.tsx b/frontend/src/app/pages/Settings/sections/SettingsHeader.tsx new file mode 100644 index 00000000..9acf9318 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/SettingsHeader.tsx @@ -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 ( + + + + Settings + + + + + + 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 }, + }} + > + + + + + + + ); +}; + +export default SettingsHeader; diff --git a/frontend/src/app/pages/Settings/sections/SoftwareUpdateRow.tsx b/frontend/src/app/pages/Settings/sections/SoftwareUpdateRow.tsx new file mode 100644 index 00000000..ad1ac8f9 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/SoftwareUpdateRow.tsx @@ -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 ( + + + + Software update + + {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.'} + + + + {updateStatus === 'checking' && ( + + )} + {updateStatus === 'not-available' && ( + + )} + {updateStatus === 'error' && ( + + )} + {(updateStatus === 'idle' || updateStatus === 'not-available' || updateStatus === 'error') && ( + + )} + {updateStatus === 'available' && ( + + )} + {updateStatus === 'downloaded' && ( + + )} + + + {updateStatus === 'downloading' && ( + + )} + + ); +}; + +export default SoftwareUpdateRow;