import { useEffect, useState } from 'react'; import { browser } from 'wxt/browser'; import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, DEFAULT_THRESHOLD_SETTINGS, type ConnectionStatus, type LockState, type ThresholdSettings, } from '@/shared/types'; import type { OsiasMessage, SettingsResult } from '@/shared/messaging'; const CONNECTION_LABELS: Record = { connecting: 'Connecting…', connected: 'Connected', disconnected: 'Disconnected — retrying', 'auth-error': 'Sign-in failed — retrying', }; export function App() { const [lockState, setLockState] = useState(DEFAULT_LOCK_STATE); const [connectionStatus, setConnectionStatus] = useState(DEFAULT_CONNECTION_STATUS); const [settings, setSettings] = useState(undefined); const [settingsStatus, setSettingsStatus] = useState<'loading' | 'idle' | 'saving'>('loading'); const [settingsError, setSettingsError] = useState(undefined); useEffect(() => { browser.runtime .sendMessage({ type: 'osias:get-lock-state' } satisfies OsiasMessage) .then((state: LockState | undefined) => setLockState(state ?? DEFAULT_LOCK_STATE)); browser.runtime .sendMessage({ type: 'osias:get-connection-status' } satisfies OsiasMessage) .then((status: ConnectionStatus | undefined) => setConnectionStatus(status ?? DEFAULT_CONNECTION_STATUS)); browser.runtime .sendMessage({ type: 'osias:get-settings' } satisfies OsiasMessage) .then((result: SettingsResult | undefined) => { setSettingsStatus('idle'); if (result && 'settings' in result) { setSettings(result.settings); } else { setSettingsError(result?.error ?? 'Failed to load settings.'); } }); const listener = (message: OsiasMessage) => { if (message?.type === 'osias:lock-state-changed') { setLockState(message.state); } else if (message?.type === 'osias:connection-status-changed') { setConnectionStatus(message.status); } }; browser.runtime.onMessage.addListener(listener); return () => browser.runtime.onMessage.removeListener(listener); }, []); function signOut(): void { void browser.runtime.sendMessage({ type: 'osias:sign-out' } satisfies OsiasMessage); } function switchAccount(): void { void browser.runtime.sendMessage({ type: 'osias:switch-account' } satisfies OsiasMessage); } function saveSettings(form: ThresholdForm): void { setSettingsStatus('saving'); setSettingsError(undefined); browser.runtime .sendMessage({ type: 'osias:update-settings', settings: fromForm(form) } satisfies OsiasMessage) .then((result: SettingsResult | undefined) => { setSettingsStatus('idle'); if (result && 'settings' in result) { setSettings(result.settings); } else { setSettingsError(result?.error ?? 'Failed to save settings.'); } }); } return (

Osias

Trading companion

{CONNECTION_LABELS[connectionStatus]}
{settingsError && (
{settingsError}
)}
{lockState.locked ? ( <>
🔒
Locked

{lockState.reason ?? 'No reason provided.'}

Scope: {lockState.scope}

) : ( <>
✓
Unlocked

No active restrictions.

)}
{connectionStatus === 'connected' && (
)}
); } /** * Percent-shaped fields (`near_full_exit_fraction`, both loss thresholds) are * edited here as whole numbers (e.g. "30") and converted to/from the * backend's fractional wire representation only at this boundary — same * "convert only at the seam" rule already applied to LockScope's kebab/snake * case split (see ../../CLAUDE.md). */ type ThresholdForm = { exitPercent: string; windowHours: string; streakLength: string; singleLossPercent: string; streakLossPercent: string; cooldownMinutes: string; }; function toForm(settings: ThresholdSettings): ThresholdForm { return { exitPercent: String(Math.round(settings.near_full_exit_fraction * 100)), windowHours: String(settings.loss_window_hours), streakLength: String(settings.streak_length), singleLossPercent: String(Math.round(-settings.single_trade_loss_threshold * 100)), streakLossPercent: String(Math.round(-settings.streak_sum_loss_threshold * 100)), cooldownMinutes: String(settings.cooldown_minutes), }; } function fromForm(form: ThresholdForm): Partial { return { near_full_exit_fraction: Number(form.exitPercent) / 100, loss_window_hours: Number(form.windowHours), streak_length: Number(form.streakLength), single_trade_loss_threshold: -Number(form.singleLossPercent) / 100, streak_sum_loss_threshold: -Number(form.streakLossPercent) / 100, cooldown_minutes: Number(form.cooldownMinutes), }; } function SettingsPanel({ settings, status, onSave, }: { settings: ThresholdSettings | undefined; status: 'loading' | 'idle' | 'saving'; onSave: (form: ThresholdForm) => void; }) { const [expanded, setExpanded] = useState(false); const [form, setForm] = useState(() => toForm(settings ?? DEFAULT_THRESHOLD_SETTINGS)); useEffect(() => { if (settings) setForm(toForm(settings)); }, [settings]); function field(key: keyof ThresholdForm, value: string): void { setForm((prev) => ({ ...prev, [key]: value })); } if (!expanded) { return ( ); } return (

Settings

Loss thresholds

); }