import { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; import { useConfig } from '../../hooks/useConfig'; import { useUnsavedGuard } from '../../lib/unsavedGuard'; import { updateConfig } from '../../api'; import { LlmWorkersForm } from './LlmWorkersForm'; import { WorkspaceForm } from './WorkspaceForm'; import { PathsStorageForm } from './PathsStorageForm'; import { ExecutionForm } from './ExecutionForm'; import { ToolsForm } from './ToolsForm'; import { ToolsWebForm } from './ToolsWebForm'; import { ToolsMediaForm } from './ToolsMediaForm'; import { ToolsExternalForm } from './ToolsExternalForm'; import { AskSubtasksForm } from './AskSubtasksForm'; import { SearchFilterForm } from './SearchFilterForm'; import { BrowserSettingsForm } from './BrowserSettingsForm'; import { ContextForm } from './ContextForm'; import { SafetyForm } from './SafetyForm'; import { PreferencesForm } from './PreferencesForm'; import { NotificationsForm } from './NotificationsForm'; import { BrandingForm } from './BrandingForm'; import { MemoryLearningForm } from './MemoryLearningForm'; import { MetricsForm } from './MetricsForm'; import { ServerTlsForm } from './ServerTlsForm'; import { ReflectionForm } from './ReflectionForm'; import { SkillsQuotaForm } from './SkillsQuotaForm'; import { McpForm } from './McpForm'; import { SshForm } from './SshForm'; import { GatewayServerForm } from './GatewayServerForm'; import { PushNotificationsForm } from './PushNotificationsForm'; import { AuthForm } from './AuthForm'; import { OrgsForm } from './OrgsForm'; import { PetsForm } from './PetsForm'; import { A2aDelegationsForm } from './A2aDelegationsForm'; import { ChatConnectorsForm } from './ChatConnectorsForm'; import { useToast } from '../../hooks/useToast'; import { settingsFieldId, type ConfigDraftStatus } from './types'; import { useAuthState } from '../../App'; interface ConfigFormProps { section: string; isAdmin: boolean; focusFieldKey?: string; focusFieldRequestId?: number; onDraftStatusChange?: (status: ConfigDraftStatus) => void; } function PreferencesFormWrapper() { const auth = useAuthState(); if (auth.mode !== 'authenticated') { return
Log in to manage preferences.
; } return ( ); } /** * Pets is a per-user concept (chat mascot + worker/backend assignment), so it * uses its own /api/users/me/pets endpoints — not the admin config draft. It * renders stand-alone (no global save bar) with a local toast for import/save * feedback, mirroring the other user-scoped sections. */ function PetsFormWrapper() { const auth = useAuthState(); const { toast, showToast } = useToast(); // Pets work for the logged-in user OR the synthetic local user when auth is // disabled (mode 'disabled'). Only block when auth is ON but nobody is logged // in. ('loading' falls through to the panel, which shows its own spinner.) if (auth.mode === 'unauthenticated') { return
Log in to manage pets.
; } return (
{toast && (
{toast.message}
)}
); } /** Set a value at a dot-separated path in an object (immutable). */ function setNestedValue(obj: any, path: string, value: any): any { const keys = path.split('.'); if (keys.length === 1) { return { ...obj, [keys[0]]: value }; } const [first, ...rest] = keys; return { ...obj, [first]: setNestedValue(obj[first] ?? {}, rest.join('.'), value) }; } /** Count leaf-level differences between two values. Arrays are compared as a single leaf. */ function countDiff(a: any, b: any): number { if (a === b) return 0; if (Array.isArray(a) || Array.isArray(b)) { return JSON.stringify(a) === JSON.stringify(b) ? 0 : 1; } if (a && b && typeof a === 'object' && typeof b === 'object') { const keys = new Set([...Object.keys(a), ...Object.keys(b)]); let total = 0; for (const k of keys) total += countDiff(a[k], b[k]); return total; } // Treat undefined/null/empty-string as equivalent to reduce noise from optional fields. const norm = (v: any) => (v === undefined || v === null || v === '' ? null : v); return norm(a) === norm(b) ? 0 : 1; } export function ConfigForm({ section, isAdmin, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) { const { t } = useTranslation('settings'); const usesAdminDraft = isAdmin && !['preferences', 'notifications', 'memory-learning', 'pets', 'a2a-delegations', 'chat-connectors', 'organizations'].includes(section); useEffect(() => { if (!usesAdminDraft) onDraftStatusChange?.({ dirtySectionIds: new Set() }); }, [onDraftStatusChange, usesAdminDraft]); // User-scoped sections use their own per-user APIs and should not load the // admin /api/config draft. Render them stand-alone without the global save bar. if (section === 'preferences') { return
; } if (section === 'notifications') { return
; } if (section === 'memory-learning') { return
; } if (section === 'pets') { return ; } if (section === 'a2a-delegations') { return
; } if (!isAdmin) { return
{t('configForm.adminOnly')}
; } // Local organizations: admin-managed via /api/admin/orgs (not config.yaml), // so render stand-alone without the global save bar. if (section === 'organizations') { return ; } // Chat connectors: admin-managed via /api/admin/chat/bindings (not // config.yaml), so render stand-alone without the global save bar — // mirrors a2a-delegations/organizations above. if (section === 'chat-connectors') { return
; } // Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server' // by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a // dedicated branch here. The keys UI lives inside GatewayServerForm // as the Virtual Keys section. return ; } function ConfigFormInner({ section, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) { const { t } = useTranslation('settings'); const { data, isLoading, error, refetch } = useConfig(); const queryClient = useQueryClient(); const [draft, setDraft] = useState(null); const [etag, setEtag] = useState(''); const [overriddenByEnv, setOverriddenByEnv] = useState>({}); const [isDirty, setIsDirty] = useState(false); const [saving, setSaving] = useState(false); const [toast, setToast] = useState(null); const [toastIsError, setToastIsError] = useState(false); // Field keys currently flagged invalid via SectionFormProps.onValidityChange. // While non-empty, Save & Apply is disabled: an invalid draft never reaches // the config draft (the field withholds onChange), so allowing a save from // another dirty field would silently discard the invalid field's edit while // looking saved. Reporting components clear their key on unmount (see the // contract on SectionFormProps), so section switches / row removals cannot // leave Save bricked by a stale key. const [invalidKeys, setInvalidKeys] = useState>(new Set()); const [dirtySectionIds, setDirtySectionIds] = useState>(new Set()); const completedFocusRequestRef = useRef(); // Bumped whenever the draft is replaced wholesale out from under any // in-progress local field state (Discard Changes, or a fresh `data` // load/refetch below). Section forms with local "in-progress draft" // state that doesn't purely derive from the `value` prop (see // ExtraBodyField in LlmWorkersForm) fold this into their row keys to // force a remount — see SectionFormProps.resetToken for the contract. const [resetToken, setResetToken] = useState(0); // Sync fetched config into draft. This effect only re-runs when the // `data` object identity changes — i.e. the initial load and any // subsequent refetch (e.g. after a save-conflict reload) — never on // every render, so bumping resetToken here only fires on a genuine // external replacement of the draft, matching the Discard Changes case // below. Also clears invalidKeys: any field-local invalid draft that // hasn't reached this fresh `data` (and therefore never reached the // server) is being discarded along with the rest of the draft, so a // phantom invalid key must not survive to permanently block Save. useEffect(() => { if (data) { setDraft(data.config); setEtag(data.etag); setOverriddenByEnv(data.overriddenByEnv); setIsDirty(false); setInvalidKeys(new Set()); setDirtySectionIds(new Set()); setResetToken(t => t + 1); } }, [data]); const handleChange = useCallback((path: string, value: any) => { setDraft((prev: any) => setNestedValue(prev, path, value)); setIsDirty(true); setDirtySectionIds(prev => prev.has(section) ? prev : new Set(prev).add(section)); }, [section]); const handleValidityChange = useCallback((fieldKey: string, valid: boolean) => { setInvalidKeys(prev => { if (valid ? !prev.has(fieldKey) : prev.has(fieldKey)) return prev; // no-op → keep identity const next = new Set(prev); if (valid) next.delete(fieldKey); else next.add(fieldKey); return next; }); }, []); const handleNavigateToInvalid = () => { const fieldKey = invalidKeys.values().next().value; if (!fieldKey) return; const field = document.getElementById(settingsFieldId(fieldKey)); if (!(field instanceof HTMLElement)) return; const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' }); field.focus({ preventScroll: true }); }; useEffect(() => { if (!focusFieldKey) return; if (completedFocusRequestRef.current === focusFieldRequestId) return; const field = document.getElementById(settingsFieldId(focusFieldKey)); if (!(field instanceof HTMLElement)) return; completedFocusRequestRef.current = focusFieldRequestId; const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' }); field.focus({ preventScroll: true }); field.classList.add('ring-2', 'ring-amber-400'); const timer = window.setTimeout(() => field.classList.remove('ring-2', 'ring-amber-400'), 1600); return () => window.clearTimeout(timer); }, [focusFieldKey, focusFieldRequestId, draft, section]); const handleDiscard = () => { if (data) { setDraft(data.config); setIsDirty(false); // Discard doesn't change `data`, so the [data] sync effect above // won't fire — clear any phantom invalid key and bump resetToken // here directly so field-local draft state (e.g. ExtraBodyField's // in-progress textarea + JSON error) remounts from the reverted // value instead of getting stuck showing a stale error forever. setInvalidKeys(new Set()); setDirtySectionIds(new Set()); setResetToken(t => t + 1); } }; const handleSave = async () => { if (!draft) return; setSaving(true); try { const result = await updateConfig(draft, etag); if (result.conflict) { if (confirm(t('configForm.conflict'))) { await refetch(); } return; } await queryClient.invalidateQueries({ queryKey: ['config'] }); setIsDirty(false); setDirtySectionIds(new Set()); setToastIsError(false); setToast(t('configForm.saved')); setTimeout(() => setToast(null), 2000); } catch (e: any) { setToastIsError(true); setToast(t('configForm.error', { msg: e.message })); setTimeout(() => setToast(null), 3000); } finally { setSaving(false); } }; const dirtyCount = isDirty && data ? countDiff(data.config, draft) : 0; useEffect(() => { if (dirtyCount === 0 && dirtySectionIds.size > 0) setDirtySectionIds(new Set()); }, [dirtyCount, dirtySectionIds]); useEffect(() => { onDraftStatusChange?.({ dirtySectionIds: dirtyCount > 0 ? dirtySectionIds : new Set() }); }, [dirtyCount, dirtySectionIds, onDraftStatusChange]); // Arm beforeunload + register an in-app guard so navigating elsewhere // (e.g. clicking a TopBar tab) prompts when there are unsaved fields. useUnsavedGuard(dirtyCount > 0); if (isLoading) return
Loading...
; if (error) return
{t('configForm.loadError')}
; if (!draft) return null; const formProps = { config: draft, onChange: handleChange, overriddenByEnv, onValidityChange: handleValidityChange, resetToken }; const sectionForm = (() => { switch (section) { // ── System case 'branding': return ; case 'paths-storage': return ; case 'execution': return ; // ── LLM (Step 7: LlmWorkersForm replaces ProviderForm; reads llm.workers, // not provider.workers. 'provider' alias kept for URL backwards compat.) case 'provider': case 'llm-workers': return ; case 'gateway-server': return ; case 'llm-metrics': return ; // ── Server / Network case 'server-tls': return ; // ── Agent Runtime case 'ask-subtasks': return ; case 'context': return ; case 'safety': return ; case 'reflection': return ; case 'skills-quota': return ; case 'push-notifications': return ; case 'auth': return ; // ── Tools sub-sections — Step 9 split the legacy grab-bag // ToolsForm into focused per-category forms. Each binds to the // same `tools.*` config keys as before (functionally equivalent), // just without the in-form sub-tab nav. case 'tools-web': // Folds SearchFilterForm in as a sub-section (Step 3 // INVESTIGATE #3 follow-up). return ; case 'tools-browser': // Browser runtime (page/action timeouts, channel, etc.) is its // own form — kept verbatim, just relocated. return ; case 'tools-media': return ; case 'tools-external': return ; // ── MCP & Connections case 'mcp': return ; // ── SSH (admin) case 'ssh': return { setToast(msg); setTimeout(() => setToast(null), 3000); }} />; // ── Legacy ids — kept here only so a direct URL hit still renders // something during the transition window. The Settings page also // rewrites the URL to the new id via `LEGACY_SECTION_REDIRECT`, so // these branches are mostly defensive. ('provider' moved to the // LLM-Workers case above — Step 7 — so it now lands on the new // form. 'tools' bookmark still resolves to the legacy ToolsForm // with all sub-tabs visible per Step 9 fallback design.) case 'workspace': return ; case 'tools': return ; case 'search-filter': return ; case 'browser-settings': return ; default: return
Unknown section: {section}
; } })(); const dirty = dirtyCount > 0; const blockedByInvalid = invalidKeys.size > 0; return (
{sectionForm} {/* Sticky save bar: stays visible while scrolling, gets a strong amber accent when dirty so it cannot be missed. The pb-20 on the parent reserves space so the bar never overlaps the last form field. */}
{toast ? ( {toast} ) : blockedByInvalid ? (
{t('configForm.invalidBlocked')}
) : dirty ? ( {t('configForm.unsavedAdmin', { count: dirtyCount })} {t('configForm.unsavedShort', { count: dirtyCount })} ) : null}
); }