From d89a1ec84bf414ef3ae297b8047cc952f8a7d9dc Mon Sep 17 00:00:00 2001 From: francy51 Date: Sun, 5 Apr 2026 21:11:23 -0400 Subject: [PATCH] feat(settings): add SEC EDGAR configuration controls --- .../components/Settings/AgentSettingsForm.tsx | 70 +++++- .../src/components/Settings/ModelSelector.tsx | 48 ++-- .../Settings/SecEdgarSettingsCard.tsx | 208 +++++++++++++++ .../src/components/Settings/SettingsPage.tsx | 236 ++++++++++-------- MosaicIQ/src/types/agentSettings.ts | 3 + 5 files changed, 423 insertions(+), 142 deletions(-) create mode 100644 MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx diff --git a/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx b/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx index d991984..c9b2070 100644 --- a/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx +++ b/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - Settings, Eye, EyeOff, Loader2, @@ -10,8 +9,6 @@ import { Save, KeyRound, Globe, - ShieldCheck, - ShieldAlert, AlertCircle, } from 'lucide-react'; import { agentSettingsBridge } from '../../lib/agentSettingsBridge'; @@ -37,6 +34,7 @@ interface FormState { remoteBaseUrl: string; defaultRemoteModel: string; taskDefaults: Record; + secEdgarUserAgent: string; remoteApiKey: string; } @@ -44,6 +42,8 @@ interface ValidationState { baseUrl: ValidationStatus; baseUrlError?: string; defaultModel: ValidationStatus; + secEdgarUserAgent: ValidationStatus; + secEdgarUserAgentError?: string; apiKey: ValidationStatus; } @@ -68,6 +68,24 @@ const validateUrl = (url: string): { valid: boolean; error?: string } => { } }; +const validateSecEdgarUserAgent = ( + value: string, +): { valid: boolean; error?: string } => { + const trimmed = value.trim(); + if (!trimmed) { + return { valid: true }; + } + + if (!trimmed.includes(' ') || !trimmed.includes('@')) { + return { + valid: false, + error: 'Use a format like `MosaicIQ admin@example.com`', + }; + } + + return { valid: true }; +}; + export const AgentSettingsForm: React.FC = ({ status, onStatusChange, @@ -77,12 +95,14 @@ export const AgentSettingsForm: React.FC = ({ remoteBaseUrl: '', defaultRemoteModel: '', taskDefaults: mergeTaskDefaults({}, ''), + secEdgarUserAgent: '', remoteApiKey: '', }); const [initialState, setInitialState] = useState(null); const [validation, setValidation] = useState({ baseUrl: 'idle', defaultModel: 'idle', + secEdgarUserAgent: 'idle', apiKey: 'idle', }); const [showApiKey, setShowApiKey] = useState(false); @@ -103,6 +123,7 @@ export const AgentSettingsForm: React.FC = ({ remoteBaseUrl: status.remoteBaseUrl, defaultRemoteModel: status.defaultRemoteModel, taskDefaults: mergeTaskDefaults(status.taskDefaults, status.defaultRemoteModel), + secEdgarUserAgent: status.secEdgarUserAgent, remoteApiKey: '', }; @@ -114,6 +135,7 @@ export const AgentSettingsForm: React.FC = ({ setValidation({ baseUrl: 'idle', defaultModel: 'idle', + secEdgarUserAgent: 'idle', apiKey: 'idle', }); }, [status]); @@ -127,7 +149,8 @@ export const AgentSettingsForm: React.FC = ({ formState.remoteBaseUrl !== initialState.remoteBaseUrl || formState.defaultRemoteModel !== initialState.defaultRemoteModel || JSON.stringify(formState.taskDefaults) !== JSON.stringify(initialState.taskDefaults) || - (formState.remoteApiKey && formState.remoteApiKey.length > 0); + formState.secEdgarUserAgent !== initialState.secEdgarUserAgent || + Boolean(formState.remoteApiKey && formState.remoteApiKey.length > 0); setHasUnsavedChanges(hasChanges); }, [formState, initialState]); @@ -155,6 +178,23 @@ export const AgentSettingsForm: React.FC = ({ } }, [formState.defaultRemoteModel]); + useEffect(() => { + if (formState.secEdgarUserAgent.trim()) { + const result = validateSecEdgarUserAgent(formState.secEdgarUserAgent); + setValidation((prev) => ({ + ...prev, + secEdgarUserAgent: result.valid ? 'valid' : 'invalid', + secEdgarUserAgentError: result.error, + })); + } else { + setValidation((prev) => ({ + ...prev, + secEdgarUserAgent: 'idle', + secEdgarUserAgentError: undefined, + })); + } + }, [formState.secEdgarUserAgent]); + // Validate API key useEffect(() => { if (formState.remoteApiKey) { @@ -180,6 +220,7 @@ export const AgentSettingsForm: React.FC = ({ remoteBaseUrl: formState.remoteBaseUrl, defaultRemoteModel: formState.defaultRemoteModel, taskDefaults: formState.taskDefaults, + secEdgarUserAgent: formState.secEdgarUserAgent, }; const setTaskRoute = useCallback( @@ -279,19 +320,24 @@ export const AgentSettingsForm: React.FC = ({ const handleDefaultRemoteModelChange = (nextValue: string) => { const previousValue = formState.defaultRemoteModel; - setFormState((prev) => ({ ...prev, defaultRemoteModel: nextValue })); - setTaskDefaults((current) => { - const next = { ...current }; + setFormState((current) => { + const nextTaskDefaults = { ...current.taskDefaults }; for (const profile of TASK_PROFILES) { - if (next[profile].model.trim() === previousValue.trim()) { - next[profile] = { model: nextValue }; + if (nextTaskDefaults[profile].model.trim() === previousValue.trim()) { + nextTaskDefaults[profile] = { model: nextValue }; } } - return next; + return { + ...current, + defaultRemoteModel: nextValue, + taskDefaults: nextTaskDefaults, + }; }); }; - const isFormValid = validation.baseUrl === 'valid' || validation.baseUrl === 'idle'; + const isFormValid = + (validation.baseUrl === 'valid' || validation.baseUrl === 'idle') && + (validation.secEdgarUserAgent === 'valid' || validation.secEdgarUserAgent === 'idle'); return (
@@ -501,7 +547,7 @@ export const AgentSettingsForm: React.FC = ({ validationStatus={validation.apiKey} helperText={ status.hasRemoteApiKey && !formState.remoteApiKey - ? `Current key ending in ••••${status.remoteApiKey?.slice(-4) || '****'}` + ? 'A remote API key is currently stored.' : 'Required for API requests' } disabled={isBusy} diff --git a/MosaicIQ/src/components/Settings/ModelSelector.tsx b/MosaicIQ/src/components/Settings/ModelSelector.tsx index c8cb216..55062d2 100644 --- a/MosaicIQ/src/components/Settings/ModelSelector.tsx +++ b/MosaicIQ/src/components/Settings/ModelSelector.tsx @@ -8,6 +8,7 @@ export interface ModelOption { } export interface ModelSelectorProps { + id?: string; value: string; onChange: (value: string) => void; placeholder?: string; @@ -29,6 +30,7 @@ const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ ]; export const ModelSelector: React.FC = ({ + id, value, onChange, placeholder = 'Select or enter a model', @@ -97,15 +99,16 @@ export const ModelSelector: React.FC = ({ > {!isCustomMode ? ( ) : ( = ({ }} onKeyDown={handleKeyDown} placeholder="Enter custom model name" - className="w-full rounded border border-[#58a6ff] bg-[#111111] px-3 py-2 text-sm font-mono text-[#e0e0e0] outline-none focus:ring-2 focus:ring-[#58a6ff] focus:ring-offset-2 focus:ring-offset-[#111111]" + className="w-full border border-[#58a6ff] bg-[#111111] px-3 py-2 text-sm font-mono text-[#e0e0e0] outline-none focus:border-l-[#58a6ff] border-l-2 transition-all" /> )} {isOpen && !isCustomMode && ( -
-
-
- +
+
+
+ setSearchQuery(e.target.value)} placeholder="Search models..." - className="w-full rounded border border-[#2a2a2a] bg-[#0d0d0d] py-2 pl-9 pr-3 text-sm font-mono text-[#e0e0e0] outline-none focus:border-[#58a6ff]" + className="w-full bg-transparent py-1.5 pl-8 pr-2 text-sm font-mono text-[#e0e0e0] outline-none border-l-2 border-[#1a1a1a] focus:border-l-[#58a6ff] transition-colors" autoFocus />
    {filteredOptions.length === 0 ? ( -
  • - No models found +
  • +
    +
    + No models found +
    +
  • ) : ( filteredOptions.map((option) => ( @@ -166,16 +174,16 @@ export const ModelSelector: React.FC = ({ role="option" aria-selected={value === option.value} onClick={() => handleSelect(option.value)} - className={`cursor-pointer px-3 py-2 text-sm font-mono transition-colors ${ + className={`cursor-pointer px-3 py-2 text-sm font-mono transition-colors border-l-2 ${ value === option.value - ? 'bg-[#1d4c7d] text-[#8dc3ff]' - : 'text-[#e0e0e0] hover:bg-[#1d1d1d]' + ? 'border-l-[#58a6ff] bg-[#1d4c7d]/30 text-[#8dc3ff]' + : 'border-l-transparent text-[#e0e0e0] hover:border-l-[#2a2a2a] hover:bg-[#1a1a1a]' }`} >
    - {option.label} + {option.label} {option.provider && ( - {option.provider} + {option.provider} )}
    @@ -183,13 +191,13 @@ export const ModelSelector: React.FC = ({ )} {allowCustom && ( <> -
  • +
  • - Custom model... + + Custom model...
  • )} diff --git a/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx b/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx new file mode 100644 index 0000000..969b74f --- /dev/null +++ b/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx @@ -0,0 +1,208 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { CheckCircle, Globe, Loader2, Save, XCircle } from 'lucide-react'; +import { agentSettingsBridge } from '../../lib/agentSettingsBridge'; +import { AgentConfigStatus } from '../../types/agentSettings'; +import { ValidatedInput, ValidationStatus } from './ValidatedInput'; +import { HelpIcon } from './Tooltip'; + +interface SecEdgarSettingsCardProps { + status: AgentConfigStatus | null; + onStatusChange: (status: AgentConfigStatus) => void; +} + +const validateSecEdgarUserAgent = ( + value: string, +): { valid: boolean; error?: string } => { + const trimmed = value.trim(); + if (!trimmed) { + return { valid: true }; + } + + if (!trimmed.includes(' ') || !trimmed.includes('@')) { + return { + valid: false, + error: 'Use a format like `MosaicIQ admin@example.com`', + }; + } + + return { valid: true }; +}; + +export const SecEdgarSettingsCard: React.FC = ({ + status, + onStatusChange, +}) => { + const [value, setValue] = useState(''); + const [initialValue, setInitialValue] = useState(''); + const [validation, setValidation] = useState<{ + status: ValidationStatus; + error?: string; + }>({ + status: 'idle', + }); + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + useEffect(() => { + if (!status) { + return; + } + + setValue(status.secEdgarUserAgent); + setInitialValue(status.secEdgarUserAgent); + setValidation({ status: 'idle' }); + setError(null); + setSuccess(null); + }, [status]); + + useEffect(() => { + if (!value.trim()) { + setValidation({ status: 'idle' }); + return; + } + + const result = validateSecEdgarUserAgent(value); + setValidation({ + status: result.valid ? 'valid' : 'invalid', + error: result.error, + }); + }, [value]); + + const hasChanges = value !== initialValue; + const isValid = validation.status === 'valid' || validation.status === 'idle'; + + const helperText = useMemo(() => { + if (status?.hasSecEdgarUserAgent) { + return 'Stored in app settings and used before env fallback.'; + } + return 'Leave blank to fall back to the `SEC_EDGAR_USER_AGENT` environment variable.'; + }, [status]); + + const handleSave = async () => { + if (!status || !isValid) { + return; + } + + setIsBusy(true); + setError(null); + setSuccess(null); + + try { + const nextStatus = await agentSettingsBridge.saveRuntimeConfig({ + remoteEnabled: status.remoteEnabled, + remoteBaseUrl: status.remoteBaseUrl, + defaultRemoteModel: status.defaultRemoteModel, + taskDefaults: status.taskDefaults, + secEdgarUserAgent: value, + }); + onStatusChange(nextStatus); + setInitialValue(value); + setSuccess('SEC EDGAR setting saved successfully'); + } catch (saveError) { + setError( + saveError instanceof Error + ? saveError.message + : 'Failed to save SEC EDGAR setting.', + ); + } finally { + setIsBusy(false); + } + }; + + return ( +
    +
    +
    +
    +

    + SEC EDGAR +

    + +
    +

    + Configure how MosaicIQ identifies itself when calling SEC EDGAR. +

    +
    +
    +
    + + {status?.hasSecEdgarUserAgent ? 'Stored in app' : 'Env fallback / unset'} +
    +
    +
    + + setValue(event.target.value)} + placeholder="MosaicIQ admin@example.com" + validationStatus={validation.status} + errorMessage={validation.error} + helperText={helperText} + disabled={isBusy} + /> + +
    + {hasChanges && ( + + )} + +
    + + {success && ( +
    + +
    {success}
    + +
    + )} + + {error && ( +
    + +
    {error}
    + +
    + )} +
    + ); +}; diff --git a/MosaicIQ/src/components/Settings/SettingsPage.tsx b/MosaicIQ/src/components/Settings/SettingsPage.tsx index b62c637..b29c6c7 100644 --- a/MosaicIQ/src/components/Settings/SettingsPage.tsx +++ b/MosaicIQ/src/components/Settings/SettingsPage.tsx @@ -9,7 +9,6 @@ import { X, XCircle, Loader2, - Globe, Wifi, WifiOff, KeyRound, @@ -26,6 +25,7 @@ import { } from 'lucide-react'; import { AgentConfigStatus } from '../../types/agentSettings'; import { AgentSettingsForm } from './AgentSettingsForm'; +import { SecEdgarSettingsCard } from './SecEdgarSettingsCard'; import { ToastContainer, type Toast, type ToastType } from './Toast'; type SettingsSectionId = 'general' | 'ai' | 'workspace' | 'about'; @@ -154,6 +154,12 @@ export const SettingsPage: React.FC = ({ // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + const activeElement = document.activeElement as HTMLElement | null; + const isEditingField = + activeElement?.tagName === 'INPUT' || + activeElement?.tagName === 'TEXTAREA' || + activeElement?.isContentEditable === true; + // Cmd/Ctrl + S to save (prevent default) if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); @@ -170,12 +176,13 @@ export const SettingsPage: React.FC = ({ document.getElementById('settings-search')?.focus(); } + if (isEditingField) { + return; + } + // ? to show shortcuts if (e.key === '?' && !e.shiftKey && !e.metaKey && !e.ctrlKey) { - const activeElement = document.activeElement; - if (activeElement?.tagName !== 'INPUT' && activeElement?.tagName !== 'TEXTAREA') { - setShowKeyboardShortcuts((prev) => !prev); - } + setShowKeyboardShortcuts((prev) => !prev); } // Escape to close shortcuts modal @@ -199,66 +206,70 @@ export const SettingsPage: React.FC = ({ const renderContent = () => { if (activeSection === 'general') { return ( -
    -
    -

    +
    +
    +

    Settings are centralized here

    -

    +

    This page is the dedicated control surface for MosaicIQ configuration. New settings categories should be added as submenu entries here instead of modal dialogs or one-off controls elsewhere in the shell.

    -
    - {statusSummary.map((item) => ( -
    -
    - - {item.label} +
    +
    +
    + Status Overview +
    +
    +
    + {statusSummary.map((item) => ( +
    +
    + {item.icon} + {item.label} +
    +
    {item.value}
    -
    {item.value}
    -
    - ))} + ))} +
    -
    -

    +
    +

    Settings roadmap

    -
    -
    -
    - - AI & Models +
    +
    + + + +
    +

    AI & Models

    +

    + Active now. Provider routing, default models, and credential storage are managed + in this section. +

    -

    - Active now. Provider routing, default models, and credential storage are managed - in this section. -

    -
    -
    - - Workspace +
    + + + +
    +

    Workspace

    +

    + Reserved for terminal defaults, tab naming conventions, and shell preferences as + those controls are added. +

    -

    - Reserved for terminal defaults, tab naming conventions, and shell preferences as - those controls are added. -

    + +

    ); } @@ -269,41 +280,43 @@ export const SettingsPage: React.FC = ({ if (activeSection === 'workspace') { return ( -
    -

    Workspace settings

    -

    +

    +

    Workspace settings

    +

    This submenu is in place for shell-wide controls such as sidebar defaults, startup behavior, and terminal preferences. Future workspace configuration should be added here rather than inside the terminal view.

    -
    -
    -
    - - Planned controls + +
    +
    + + + +
    +

    Planned controls

    +

    + Default workspace names, sidebar visibility at launch, terminal input behavior, and + session retention rules. +

    -

    - Default workspace names, sidebar visibility at launch, terminal input behavior, and - session retention rules. -

    -
    -
    - - Implementation note + +
    + + + +
    +

    Implementation note

    +

    + Keep future settings grouped by submenu and avoid reintroducing feature-specific + controls in headers, toolbars, or modal overlays. +

    -

    - Keep future settings grouped by submenu and avoid reintroducing feature-specific - controls in headers, toolbars, or modal overlays. -

    -
    +