feat(settings): add SEC EDGAR configuration controls

This commit is contained in:
2026-04-05 21:11:23 -04:00
parent cfc5a615e3
commit d89a1ec84b
5 changed files with 423 additions and 142 deletions

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Settings,
Eye, Eye,
EyeOff, EyeOff,
Loader2, Loader2,
@@ -10,8 +9,6 @@ import {
Save, Save,
KeyRound, KeyRound,
Globe, Globe,
ShieldCheck,
ShieldAlert,
AlertCircle, AlertCircle,
} from 'lucide-react'; } from 'lucide-react';
import { agentSettingsBridge } from '../../lib/agentSettingsBridge'; import { agentSettingsBridge } from '../../lib/agentSettingsBridge';
@@ -37,6 +34,7 @@ interface FormState {
remoteBaseUrl: string; remoteBaseUrl: string;
defaultRemoteModel: string; defaultRemoteModel: string;
taskDefaults: Record<TaskProfile, AgentTaskRoute>; taskDefaults: Record<TaskProfile, AgentTaskRoute>;
secEdgarUserAgent: string;
remoteApiKey: string; remoteApiKey: string;
} }
@@ -44,6 +42,8 @@ interface ValidationState {
baseUrl: ValidationStatus; baseUrl: ValidationStatus;
baseUrlError?: string; baseUrlError?: string;
defaultModel: ValidationStatus; defaultModel: ValidationStatus;
secEdgarUserAgent: ValidationStatus;
secEdgarUserAgentError?: string;
apiKey: ValidationStatus; 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<AgentSettingsFormProps> = ({ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
status, status,
onStatusChange, onStatusChange,
@@ -77,12 +95,14 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
remoteBaseUrl: '', remoteBaseUrl: '',
defaultRemoteModel: '', defaultRemoteModel: '',
taskDefaults: mergeTaskDefaults({}, ''), taskDefaults: mergeTaskDefaults({}, ''),
secEdgarUserAgent: '',
remoteApiKey: '', remoteApiKey: '',
}); });
const [initialState, setInitialState] = useState<FormState | null>(null); const [initialState, setInitialState] = useState<FormState | null>(null);
const [validation, setValidation] = useState<ValidationState>({ const [validation, setValidation] = useState<ValidationState>({
baseUrl: 'idle', baseUrl: 'idle',
defaultModel: 'idle', defaultModel: 'idle',
secEdgarUserAgent: 'idle',
apiKey: 'idle', apiKey: 'idle',
}); });
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
@@ -103,6 +123,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
remoteBaseUrl: status.remoteBaseUrl, remoteBaseUrl: status.remoteBaseUrl,
defaultRemoteModel: status.defaultRemoteModel, defaultRemoteModel: status.defaultRemoteModel,
taskDefaults: mergeTaskDefaults(status.taskDefaults, status.defaultRemoteModel), taskDefaults: mergeTaskDefaults(status.taskDefaults, status.defaultRemoteModel),
secEdgarUserAgent: status.secEdgarUserAgent,
remoteApiKey: '', remoteApiKey: '',
}; };
@@ -114,6 +135,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
setValidation({ setValidation({
baseUrl: 'idle', baseUrl: 'idle',
defaultModel: 'idle', defaultModel: 'idle',
secEdgarUserAgent: 'idle',
apiKey: 'idle', apiKey: 'idle',
}); });
}, [status]); }, [status]);
@@ -127,7 +149,8 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
formState.remoteBaseUrl !== initialState.remoteBaseUrl || formState.remoteBaseUrl !== initialState.remoteBaseUrl ||
formState.defaultRemoteModel !== initialState.defaultRemoteModel || formState.defaultRemoteModel !== initialState.defaultRemoteModel ||
JSON.stringify(formState.taskDefaults) !== JSON.stringify(initialState.taskDefaults) || 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); setHasUnsavedChanges(hasChanges);
}, [formState, initialState]); }, [formState, initialState]);
@@ -155,6 +178,23 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
} }
}, [formState.defaultRemoteModel]); }, [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 // Validate API key
useEffect(() => { useEffect(() => {
if (formState.remoteApiKey) { if (formState.remoteApiKey) {
@@ -180,6 +220,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
remoteBaseUrl: formState.remoteBaseUrl, remoteBaseUrl: formState.remoteBaseUrl,
defaultRemoteModel: formState.defaultRemoteModel, defaultRemoteModel: formState.defaultRemoteModel,
taskDefaults: formState.taskDefaults, taskDefaults: formState.taskDefaults,
secEdgarUserAgent: formState.secEdgarUserAgent,
}; };
const setTaskRoute = useCallback( const setTaskRoute = useCallback(
@@ -279,19 +320,24 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
const handleDefaultRemoteModelChange = (nextValue: string) => { const handleDefaultRemoteModelChange = (nextValue: string) => {
const previousValue = formState.defaultRemoteModel; const previousValue = formState.defaultRemoteModel;
setFormState((prev) => ({ ...prev, defaultRemoteModel: nextValue })); setFormState((current) => {
setTaskDefaults((current) => { const nextTaskDefaults = { ...current.taskDefaults };
const next = { ...current };
for (const profile of TASK_PROFILES) { for (const profile of TASK_PROFILES) {
if (next[profile].model.trim() === previousValue.trim()) { if (nextTaskDefaults[profile].model.trim() === previousValue.trim()) {
next[profile] = { model: nextValue }; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -501,7 +547,7 @@ export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
validationStatus={validation.apiKey} validationStatus={validation.apiKey}
helperText={ helperText={
status.hasRemoteApiKey && !formState.remoteApiKey status.hasRemoteApiKey && !formState.remoteApiKey
? `Current key ending in ••••${status.remoteApiKey?.slice(-4) || '****'}` ? 'A remote API key is currently stored.'
: 'Required for API requests' : 'Required for API requests'
} }
disabled={isBusy} disabled={isBusy}

View File

@@ -8,6 +8,7 @@ export interface ModelOption {
} }
export interface ModelSelectorProps { export interface ModelSelectorProps {
id?: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder?: string; placeholder?: string;
@@ -29,6 +30,7 @@ const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
]; ];
export const ModelSelector: React.FC<ModelSelectorProps> = ({ export const ModelSelector: React.FC<ModelSelectorProps> = ({
id,
value, value,
onChange, onChange,
placeholder = 'Select or enter a model', placeholder = 'Select or enter a model',
@@ -97,15 +99,16 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
> >
{!isCustomMode ? ( {!isCustomMode ? (
<button <button
id={id}
type="button" type="button"
onClick={() => !disabled && setIsOpen(!isOpen)} onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled} disabled={disabled}
className="w-full rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-left text-sm font-mono text-[#e0e0e0] outline-none transition-colors hover:border-[#3a3a3a] focus:border-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50" className="w-full border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-left text-sm font-mono text-[#e0e0e0] outline-none transition-all hover:border-[#3a3a3a] focus:border-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50 border-l-2 focus:border-l-[#58a6ff] hover:border-l-[#3a3a3a]"
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<span> <span className="flex-1 min-w-0 truncate">
{selectedOption ? ( {selectedOption ? (
<span> <span>
{selectedOption.label} {selectedOption.label}
@@ -117,13 +120,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<span className="text-[#666666]">{placeholder}</span> <span className="text-[#666666]">{placeholder}</span>
)} )}
</span> </span>
<span className="text-[#666666]" aria-hidden="true"> <span className="text-[#666666] flex-shrink-0" aria-hidden="true">
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} {isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</span> </span>
</div> </div>
</button> </button>
) : ( ) : (
<input <input
id={id}
ref={inputRef} ref={inputRef}
type="text" type="text"
value={value} value={value}
@@ -135,29 +139,33 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Enter custom model name" 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 && ( {isOpen && !isCustomMode && (
<div className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[#2a2a2a] bg-[#111111] shadow-lg"> <div className="absolute z-10 mt-1 max-h-60 w-full overflow-auto border border-[#2a2a2a] bg-[#111111] shadow-lg">
<div className="border-b border-[#2a2a2a] p-2"> <div className="border-b border-[#1a1a1a] p-3">
<div className="relative"> <div className="relative group">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#666666]" /> <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#666666] group-focus-within:text-[#58a6ff] transition-colors" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search models..." 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 autoFocus
/> />
</div> </div>
</div> </div>
<ul role="listbox" className="py-1"> <ul role="listbox" className="py-1">
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<li className="px-3 py-2 text-sm font-mono text-[#888888]"> <li className="px-3 py-4 text-center">
No models found <div className="flex items-center justify-center gap-2 text-xs font-mono text-[#666666]">
<div className="h-px w-8 bg-[#2a2a2a]" />
<span>No models found</span>
<div className="h-px w-8 bg-[#2a2a2a]" />
</div>
</li> </li>
) : ( ) : (
filteredOptions.map((option) => ( filteredOptions.map((option) => (
@@ -166,16 +174,16 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
role="option" role="option"
aria-selected={value === option.value} aria-selected={value === option.value}
onClick={() => handleSelect(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 value === option.value
? 'bg-[#1d4c7d] text-[#8dc3ff]' ? 'border-l-[#58a6ff] bg-[#1d4c7d]/30 text-[#8dc3ff]'
: 'text-[#e0e0e0] hover:bg-[#1d1d1d]' : 'border-l-transparent text-[#e0e0e0] hover:border-l-[#2a2a2a] hover:bg-[#1a1a1a]'
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{option.label}</span> <span className="truncate">{option.label}</span>
{option.provider && ( {option.provider && (
<span className="text-xs text-[#666666]">{option.provider}</span> <span className="text-xs text-[#666666] ml-2">{option.provider}</span>
)} )}
</div> </div>
</li> </li>
@@ -183,13 +191,13 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
)} )}
{allowCustom && ( {allowCustom && (
<> <>
<li className="my-1 border-t border-[#2a2a2a]" role="separator" /> <li className="my-1 border-t border-[#1a1a1a]" role="separator" />
<li <li
role="option" role="option"
onClick={handleCustomMode} onClick={handleCustomMode}
className="cursor-pointer px-3 py-2 text-sm font-mono text-[#888888] transition-colors hover:bg-[#1d1d1d] hover:text-[#e0e0e0]" className="cursor-pointer px-3 py-2 text-sm font-mono text-[#888888] transition-colors hover:text-[#e0e0e0] hover:bg-[#1a1a1a]"
> >
Custom model... + Custom model...
</li> </li>
</> </>
)} )}

View File

@@ -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<SecEdgarSettingsCardProps> = ({
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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<section className="rounded-lg bg-[#0f0f0f] p-6 shadow-sm">
<div className="mb-6 flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<h2 className="text-base font-mono font-semibold text-[#e0e0e0]">
SEC EDGAR
</h2>
<HelpIcon tooltip="Identity string sent with SEC EDGAR requests. The SEC asks automated clients to provide a descriptive product name and contact email." />
</div>
<p className="mt-1.5 text-xs font-mono leading-6 text-[#888888]">
Configure how MosaicIQ identifies itself when calling SEC EDGAR.
</p>
</div>
<div className="rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-right">
<div className="flex items-center gap-2 text-[11px] font-mono text-[#888888]">
<Globe className="h-3.5 w-3.5" />
<span>{status?.hasSecEdgarUserAgent ? 'Stored in app' : 'Env fallback / unset'}</span>
</div>
</div>
</div>
<ValidatedInput
label="SEC EDGAR User Agent"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="MosaicIQ admin@example.com"
validationStatus={validation.status}
errorMessage={validation.error}
helperText={helperText}
disabled={isBusy}
/>
<div className="mt-5 flex justify-end gap-3">
{hasChanges && (
<button
type="button"
onClick={() => setValue(initialValue)}
className="rounded border border-[#2a2a2a] bg-[#151515] px-4 py-2 text-xs font-mono text-[#888888] transition-colors hover:border-[#3a3a3a] hover:text-[#e0e0e0]"
disabled={isBusy}
>
Discard Changes
</button>
)}
<button
type="button"
onClick={handleSave}
disabled={isBusy || !hasChanges || !isValid || !status}
className="flex items-center gap-2 rounded border border-[#58a6ff] bg-[#0f1f31] px-4 py-2 text-xs font-mono text-[#58a6ff] transition-colors hover:bg-[#1a2d3d] disabled:cursor-not-allowed disabled:opacity-50"
>
{isBusy ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save SEC Setting
</>
)}
</button>
</div>
{success && (
<div className="mt-4 flex items-center gap-3 rounded-lg border border-[#214f31] bg-[#102417] px-4 py-3 text-sm font-mono text-[#9ee6b3]">
<CheckCircle className="h-5 w-5" />
<div className="flex-1">{success}</div>
<button
type="button"
onClick={() => setSuccess(null)}
className="text-[#9ee6b3] opacity-60 transition-opacity hover:opacity-100"
aria-label="Dismiss success message"
>
<XCircle className="h-4 w-4" />
</button>
</div>
)}
{error && (
<div className="mt-4 flex items-center gap-3 rounded-lg border border-[#5c2b2b] bg-[#211313] px-4 py-3 text-sm font-mono text-[#ffb4b4]">
<XCircle className="h-5 w-5" />
<div className="flex-1">{error}</div>
<button
type="button"
onClick={() => setError(null)}
className="text-[#ffb4b4] opacity-60 transition-opacity hover:opacity-100"
aria-label="Dismiss error message"
>
<XCircle className="h-4 w-4" />
</button>
</div>
)}
</section>
);
};

View File

@@ -9,7 +9,6 @@ import {
X, X,
XCircle, XCircle,
Loader2, Loader2,
Globe,
Wifi, Wifi,
WifiOff, WifiOff,
KeyRound, KeyRound,
@@ -26,6 +25,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { AgentConfigStatus } from '../../types/agentSettings'; import { AgentConfigStatus } from '../../types/agentSettings';
import { AgentSettingsForm } from './AgentSettingsForm'; import { AgentSettingsForm } from './AgentSettingsForm';
import { SecEdgarSettingsCard } from './SecEdgarSettingsCard';
import { ToastContainer, type Toast, type ToastType } from './Toast'; import { ToastContainer, type Toast, type ToastType } from './Toast';
type SettingsSectionId = 'general' | 'ai' | 'workspace' | 'about'; type SettingsSectionId = 'general' | 'ai' | 'workspace' | 'about';
@@ -154,6 +154,12 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { 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) // Cmd/Ctrl + S to save (prevent default)
if ((e.metaKey || e.ctrlKey) && e.key === 's') { if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault(); e.preventDefault();
@@ -170,13 +176,14 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
document.getElementById('settings-search')?.focus(); document.getElementById('settings-search')?.focus();
} }
if (isEditingField) {
return;
}
// ? to show shortcuts // ? to show shortcuts
if (e.key === '?' && !e.shiftKey && !e.metaKey && !e.ctrlKey) { 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 // Escape to close shortcuts modal
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -199,66 +206,70 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
const renderContent = () => { const renderContent = () => {
if (activeSection === 'general') { if (activeSection === 'general') {
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<section className="rounded-lg bg-[#0f0f0f] p-6 shadow-sm"> <section>
<h2 className="text-base font-mono font-semibold text-[#e0e0e0]"> <h2 className="text-sm font-mono font-semibold text-[#e0e0e0] mb-3">
Settings are centralized here Settings are centralized here
</h2> </h2>
<p className="mt-3 max-w-2xl text-sm font-mono leading-7 text-[#888888]"> <p className="max-w-2xl text-sm font-mono leading-7 text-[#888888]">
This page is the dedicated control surface for MosaicIQ configuration. New settings 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 categories should be added as submenu entries here instead of modal dialogs or one-off
controls elsewhere in the shell. controls elsewhere in the shell.
</p> </p>
</section> </section>
<section className="grid gap-4 lg:grid-cols-3"> <section>
<div className="flex items-center gap-3 border-b border-[#1a1a1a] pb-3 mb-4">
<div className="h-px flex-1 bg-[#1a1a1a]" />
<span className="text-xs font-mono text-[#666666] uppercase tracking-wider">Status Overview</span>
<div className="h-px flex-1 bg-[#1a1a1a]" />
</div>
<div className="grid gap-6 sm:grid-cols-3">
{statusSummary.map((item) => ( {statusSummary.map((item) => (
<div <div key={item.label} className="space-y-1">
key={item.label} <div className="flex items-center gap-2 text-[#666666]">
className="rounded-lg border border-[#1a1a1a] bg-[#0f0f0f] p-5 transition-shadow hover:shadow-md" <span className="text-sm">{item.icon}</span>
> <span className="text-xs font-mono uppercase tracking-wide">{item.label}</span>
<div className="mb-2 flex items-center gap-2">
<span className="text-[#888888]" aria-hidden="true">
{item.icon}
</span>
<span className="text-xs font-mono text-[#888888]">{item.label}</span>
</div> </div>
<div className="text-sm font-mono text-[#e0e0e0]">{item.value}</div> <div className="text-sm font-mono text-[#e0e0e0]">{item.value}</div>
</div> </div>
))} ))}
</div>
</section> </section>
<section className="rounded-lg bg-[#0f0f0f] p-6 shadow-sm"> <section>
<h2 className="text-base font-mono font-semibold text-[#e0e0e0]"> <h2 className="text-sm font-mono font-semibold text-[#e0e0e0] mb-4">
Settings roadmap Settings roadmap
</h2> </h2>
<div className="mt-5 grid gap-4 md:grid-cols-2"> <div className="space-y-4">
<div className="rounded-lg border border-[#1a1a1a] bg-[#0a0a0a] p-5"> <div className="flex gap-4 p-4 border-l-2 border-[#1a1a1a] hover:border-[#58a6ff] transition-colors">
<div className="mb-2 flex items-center gap-2"> <span className="text-[#666666] mt-0.5">
<span className="text-[#888888]" aria-hidden="true">
<Bot className="h-5 w-5" /> <Bot className="h-5 w-5" />
</span> </span>
<span className="text-sm font-mono text-[#e0e0e0]">AI & Models</span> <div className="flex-1">
</div> <h3 className="text-sm font-mono text-[#e0e0e0] mb-1">AI & Models</h3>
<p className="text-sm font-mono leading-7 text-[#888888]"> <p className="text-sm font-mono leading-6 text-[#888888]">
Active now. Provider routing, default models, and credential storage are managed Active now. Provider routing, default models, and credential storage are managed
in this section. in this section.
</p> </p>
</div> </div>
<div className="rounded-lg border border-[#1a1a1a] bg-[#0a0a0a] p-5"> </div>
<div className="mb-2 flex items-center gap-2"> <div className="flex gap-4 p-4 border-l-2 border-[#1a1a1a] hover:border-[#58a6ff] transition-colors">
<span className="text-[#888888]" aria-hidden="true"> <span className="text-[#666666] mt-0.5">
<FolderOpen className="h-5 w-5" /> <FolderOpen className="h-5 w-5" />
</span> </span>
<span className="text-sm font-mono text-[#e0e0e0]">Workspace</span> <div className="flex-1">
</div> <h3 className="text-sm font-mono text-[#e0e0e0] mb-1">Workspace</h3>
<p className="text-sm font-mono leading-7 text-[#888888]"> <p className="text-sm font-mono leading-6 text-[#888888]">
Reserved for terminal defaults, tab naming conventions, and shell preferences as Reserved for terminal defaults, tab naming conventions, and shell preferences as
those controls are added. those controls are added.
</p> </p>
</div> </div>
</div> </div>
</div>
</section> </section>
<SecEdgarSettingsCard status={status} onStatusChange={onStatusChange} />
</div> </div>
); );
} }
@@ -269,41 +280,43 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
if (activeSection === 'workspace') { if (activeSection === 'workspace') {
return ( return (
<section className="rounded-lg bg-[#0f0f0f] p-6 shadow-sm"> <section>
<h2 className="text-base font-mono font-semibold text-[#e0e0e0]">Workspace settings</h2> <h2 className="text-sm font-mono font-semibold text-[#e0e0e0] mb-3">Workspace settings</h2>
<p className="mt-3 max-w-2xl text-sm font-mono leading-7 text-[#888888]"> <p className="max-w-2xl text-sm font-mono leading-7 text-[#888888] mb-6">
This submenu is in place for shell-wide controls such as sidebar defaults, startup 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 behavior, and terminal preferences. Future workspace configuration should be added here
rather than inside the terminal view. rather than inside the terminal view.
</p> </p>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-[#1a1a1a] bg-[#0a0a0a] p-5"> <div className="space-y-4">
<div className="mb-2 flex items-center gap-2"> <div className="flex gap-4 p-4 border-l-2 border-[#1a1a1a] hover:border-[#58a6ff] transition-colors">
<span className="text-[#888888]" aria-hidden="true"> <span className="text-[#666666] mt-0.5">
<Zap className="h-5 w-5" /> <Zap className="h-5 w-5" />
</span> </span>
<span className="text-sm font-mono text-[#e0e0e0]">Planned controls</span> <div className="flex-1">
</div> <h3 className="text-sm font-mono text-[#e0e0e0] mb-1">Planned controls</h3>
<p className="text-sm font-mono leading-7 text-[#888888]"> <p className="text-sm font-mono leading-6 text-[#888888]">
Default workspace names, sidebar visibility at launch, terminal input behavior, and Default workspace names, sidebar visibility at launch, terminal input behavior, and
session retention rules. session retention rules.
</p> </p>
</div> </div>
<div className="rounded-lg border border-[#1a1a1a] bg-[#0a0a0a] p-5"> </div>
<div className="mb-2 flex items-center gap-2">
<span className="text-[#888888]" aria-hidden="true"> <div className="flex gap-4 p-4 border-l-2 border-[#1a1a1a] hover:border-[#58a6ff] transition-colors">
<span className="text-[#666666] mt-0.5">
<ClipboardList className="h-5 w-5" /> <ClipboardList className="h-5 w-5" />
</span> </span>
<span className="text-sm font-mono text-[#e0e0e0]">Implementation note</span> <div className="flex-1">
</div> <h3 className="text-sm font-mono text-[#e0e0e0] mb-1">Implementation note</h3>
<p className="text-sm font-mono leading-7 text-[#888888]"> <p className="text-sm font-mono leading-6 text-[#888888]">
Keep future settings grouped by submenu and avoid reintroducing feature-specific Keep future settings grouped by submenu and avoid reintroducing feature-specific
controls in headers, toolbars, or modal overlays. controls in headers, toolbars, or modal overlays.
</p> </p>
</div> </div>
</div> </div>
</div>
<div className="mt-6 rounded-lg border border-[#3d3420] bg-[#1d170c] px-4 py-3"> <div className="mt-8 p-4 border-l-2 border-[#3d3420] bg-[#1d170c]/30">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-[#e7bb62]" aria-hidden="true" /> <Clock className="h-5 w-5 text-[#e7bb62]" aria-hidden="true" />
<div> <div>
@@ -319,9 +332,9 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
} }
return ( return (
<section className="rounded-lg bg-[#0f0f0f] p-6 shadow-sm"> <section>
<h2 className="text-base font-mono font-semibold text-[#e0e0e0]">About this settings page</h2> <h2 className="text-sm font-mono font-semibold text-[#e0e0e0] mb-3">About this settings page</h2>
<div className="mt-4 space-y-4 text-sm font-mono leading-7 text-[#888888]"> <div className="space-y-4 text-sm font-mono leading-7 text-[#888888]">
<p> <p>
The settings page is designed as a durable home for configuration instead of scattering The settings page is designed as a durable home for configuration instead of scattering
controls across the product shell. controls across the product shell.
@@ -332,7 +345,7 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
</p> </p>
<p> <p>
Current shell shortcut: use the bottom-left cog or press{' '} Current shell shortcut: use the bottom-left cog or press{' '}
<kbd className="rounded border border-[#2a2a2a] bg-[#0d0d0d] px-2 py-1 text-xs text-[#e0e0e0]"> <kbd className="mx-1 px-1.5 py-0.5 text-xs text-[#e0e0e0] bg-[#1a1a1a] rounded">
<Command className="inline h-3 w-3" /> + , <Command className="inline h-3 w-3" /> + ,
</kbd>{' '} </kbd>{' '}
to open settings. to open settings.
@@ -340,39 +353,39 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
</div> </div>
{/* Keyboard shortcuts help */} {/* Keyboard shortcuts help */}
<div className="mt-6 rounded-lg border border-[#1a1a1a] bg-[#0a0a0a] p-5"> <div className="mt-6 pt-6 border-t border-[#1a1a1a]">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-mono font-semibold text-[#e0e0e0]">Keyboard shortcuts</h3> <h3 className="text-sm font-mono font-semibold text-[#e0e0e0]">Keyboard shortcuts</h3>
<button <button
type="button" type="button"
onClick={() => setShowKeyboardShortcuts(true)} onClick={() => setShowKeyboardShortcuts(true)}
className="rounded border border-[#2a2a2a] bg-[#111111] px-3 py-1.5 text-xs font-mono text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff]" className="px-3 py-1.5 text-xs font-mono text-[#888888] border border-[#2a2a2a] hover:border-[#58a6ff] hover:text-[#58a6ff] transition-colors rounded"
> >
View all View all
</button> </button>
</div> </div>
<div className="grid gap-2 md:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between text-xs font-mono"> <div className="flex items-center justify-between text-xs font-mono">
<span className="text-[#888888]">Save settings</span> <span className="text-[#888888]">Save settings</span>
<kbd className="flex items-center gap-1 rounded border border-[#2a2a2a] bg-[#0d0d0d] px-2 py-0.5 text-[#e0e0e0]"> <kbd className="flex items-center gap-1 px-2 py-0.5 text-[#e0e0e0] bg-[#1a1a1a] rounded">
<Command className="h-3 w-3" />S <Command className="h-3 w-3" />S
</kbd> </kbd>
</div> </div>
<div className="flex items-center justify-between text-xs font-mono"> <div className="flex items-center justify-between text-xs font-mono">
<span className="text-[#888888]">Search settings</span> <span className="text-[#888888]">Search settings</span>
<kbd className="flex items-center gap-1 rounded border border-[#2a2a2a] bg-[#0d0d0d] px-2 py-0.5 text-[#e0e0e0]"> <kbd className="flex items-center gap-1 px-2 py-0.5 text-[#e0e0e0] bg-[#1a1a1a] rounded">
<Command className="h-3 w-3" />K <Command className="h-3 w-3" />K
</kbd> </kbd>
</div> </div>
<div className="flex items-center justify-between text-xs font-mono"> <div className="flex items-center justify-between text-xs font-mono">
<span className="text-[#888888]">Show shortcuts</span> <span className="text-[#888888]">Show shortcuts</span>
<kbd className="rounded border border-[#2a2a2a] bg-[#0d0d0d] px-2 py-0.5 text-[#e0e0e0]"> <kbd className="px-2 py-0.5 text-[#e0e0e0] bg-[#1a1a1a] rounded">
? ?
</kbd> </kbd>
</div> </div>
<div className="flex items-center justify-between text-xs font-mono"> <div className="flex items-center justify-between text-xs font-mono">
<span className="text-[#888888]">Navigate sections</span> <span className="text-[#888888]">Navigate sections</span>
<kbd className="rounded border border-[#2a2a2a] bg-[#0d0d0d] px-2 py-0.5 text-[#e0e0e0]"> <kbd className="px-2 py-0.5 text-[#e0e0e0] bg-[#1a1a1a] rounded">
1-4 1-4
</kbd> </kbd>
</div> </div>
@@ -436,26 +449,30 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
{/* Sidebar */} {/* Sidebar */}
<aside className="border-b border-[#1a1a1a] bg-[#0d0d0d] lg:w-[280px] lg:border-b-0 lg:border-r"> <aside className="border-b border-[#1a1a1a] bg-[#0d0d0d] lg:w-[280px] lg:border-b-0 lg:border-r">
{/* Search */} {/* Search */}
<div className="border-b border-[#1a1a1a] p-4"> <div className="border-b border-[#1a1a1a] px-4 py-3">
<div className="relative"> <div className="relative group">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#666666]" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#666666] group-focus-within:text-[#58a6ff] transition-colors" />
<input <input
id="settings-search" id="settings-search"
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search settings... (⌘K)" placeholder="Search settings... (⌘K)"
className="w-full rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 pl-9 text-sm font-mono text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]" className="w-full border-l-2 border-[#1a1a1a] bg-transparent px-3 py-2 pl-9 text-sm font-mono text-[#e0e0e0] outline-none transition-all focus:border-l-[#58a6ff] focus:bg-[#0a0a0a]"
/> />
</div> </div>
{searchQuery && filteredSections.length === 0 && ( {searchQuery && filteredSections.length === 0 && (
<p className="mt-2 text-xs font-mono text-[#888888]">No settings found</p> <div className="mt-3 flex items-center gap-2 px-1">
<div className="h-px flex-1 bg-[#2a2a2a]" />
<span className="text-xs font-mono text-[#666666]">No results</span>
<div className="h-px flex-1 bg-[#2a2a2a]" />
</div>
)} )}
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav <nav
className="flex gap-2 overflow-x-auto px-4 py-4 lg:flex-col lg:gap-1 lg:overflow-visible" className="flex gap-1 overflow-x-auto px-4 py-3 lg:flex-col lg:gap-0.5 lg:overflow-visible"
role="navigation" role="navigation"
aria-label="Settings sections" aria-label="Settings sections"
> >
@@ -466,27 +483,25 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
key={section.id} key={section.id}
type="button" type="button"
onClick={() => setActiveSection(section.id)} onClick={() => setActiveSection(section.id)}
className={`min-w-[180px] rounded border px-4 py-3 text-left transition-all lg:min-w-0 ${ className={`min-w-[180px] flex items-center gap-3 px-3 py-2.5 text-left transition-all lg:min-w-0 ${
isActive isActive
? 'border-[#3a3a3a] bg-[#111111] text-[#e0e0e0] shadow-sm' ? 'bg-[#1a1a1a] text-[#e0e0e0]'
: 'border-transparent bg-transparent text-[#888888] hover:border-[#2a2a2a] hover:bg-[#111111] hover:text-[#e0e0e0]' : 'text-[#888888] hover:bg-[#111111] hover:text-[#e0e0e0]'
}`} }`}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
> >
<div className="flex items-center gap-3"> <span className={`transition-colors ${isActive ? 'text-[#e0e0e0]' : 'text-[#666666]'}`}>
<span className={isActive ? 'text-[#e0e0e0]' : 'text-[#888888]'}>
{section.icon} {section.icon}
</span> </span>
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono">{section.label}</span> <span className="text-xs font-mono truncate">{section.label}</span>
<span className="text-[10px] text-[#666666]">{index + 1}</span> <kbd className="text-[10px] text-[#444444] font-mono">{index + 1}</kbd>
</div> </div>
<div className="mt-1 text-[11px] font-mono leading-tight text-[#666666]"> <div className="mt-0.5 text-[11px] font-mono leading-tight truncate opacity-70">
{section.description} {section.description}
</div> </div>
</div> </div>
</div>
</button> </button>
); );
})} })}
@@ -509,9 +524,9 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
</nav> </nav>
{/* Status badges */} {/* Status badges */}
<div className="mb-6 flex flex-wrap items-center gap-3"> <div className="mb-6 flex flex-wrap items-center gap-4">
<div <div
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-[11px] font-mono ${statusBadgeClassName( className={`flex items-center gap-2 px-3 py-1.5 text-[11px] font-mono border-l-2 ${statusBadgeClassName(
Boolean(status?.configured), Boolean(status?.configured),
)}`} )}`}
> >
@@ -527,19 +542,20 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
</> </>
)} )}
</div> </div>
<div className="rounded-md border border-[#2a2a2a] bg-[#111111] px-3 py-1.5 text-[11px] font-mono text-[#888888]"> <div className="flex items-center gap-2 px-3 py-1.5 text-[11px] font-mono text-[#888888] border-l-2 border-[#2a2a2a]">
Active section: {currentSectionLabel} <span className="w-1.5 h-1.5 rounded-full bg-[#666666]" />
<span>Active section: {currentSectionLabel}</span>
</div> </div>
</div> </div>
{/* Refresh error */} {/* Refresh error */}
{refreshError ? ( {refreshError ? (
<div <div
className="mb-6 rounded-lg border border-[#5c2b2b] bg-[#211313] px-4 py-3 text-sm font-mono text-[#ffb4b4]" className="mb-6 p-4 border-l-2 border-[#5c2b2b] bg-[#211313]/30 text-sm font-mono text-[#ffb4b4]"
role="alert" role="alert"
> >
<div className="flex items-center gap-3"> <div className="flex items-start gap-3">
<XCircle className="h-5 w-5" aria-hidden="true" /> <XCircle className="h-5 w-5 mt-0.5" aria-hidden="true" />
<div className="flex-1">{refreshError}</div> <div className="flex-1">{refreshError}</div>
<button <button
type="button" type="button"

View File

@@ -13,9 +13,11 @@ export interface AgentConfigStatus {
remoteConfigured: boolean; remoteConfigured: boolean;
remoteEnabled: boolean; remoteEnabled: boolean;
hasRemoteApiKey: boolean; hasRemoteApiKey: boolean;
hasSecEdgarUserAgent: boolean;
remoteBaseUrl: string; remoteBaseUrl: string;
defaultRemoteModel: string; defaultRemoteModel: string;
taskDefaults: Record<TaskProfile, AgentTaskRoute>; taskDefaults: Record<TaskProfile, AgentTaskRoute>;
secEdgarUserAgent: string;
} }
export interface SaveAgentRuntimeConfigRequest { export interface SaveAgentRuntimeConfigRequest {
@@ -23,6 +25,7 @@ export interface SaveAgentRuntimeConfigRequest {
remoteBaseUrl: string; remoteBaseUrl: string;
defaultRemoteModel: string; defaultRemoteModel: string;
taskDefaults: Record<TaskProfile, AgentTaskRoute>; taskDefaults: Record<TaskProfile, AgentTaskRoute>;
secEdgarUserAgent: string;
} }
export interface UpdateRemoteApiKeyRequest { export interface UpdateRemoteApiKeyRequest {