import { AI_AGENT_DEFINITIONS, createMissingAiAgentsStatus, getAiAgentDefinition, resolveDefaultAiAgent, type AiAgentId, type AiAgentsStatus, } from '../lib/aiAgents' import { useState, useRef, useCallback, useEffect, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, } from 'react' import { X } from '@phosphor-icons/react' import type { Settings } from '../types' import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel' import { trackEvent } from '../lib/telemetry' import { Button } from './ui/button' import { Checkbox, type CheckedState } from './ui/checkbox' import { Input } from './ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './ui/select' import { Switch } from './ui/switch' interface SettingsPanelProps { open: boolean settings: Settings aiAgentsStatus?: AiAgentsStatus onSave: (settings: Settings) => void isGitVault?: boolean explicitOrganizationEnabled?: boolean onSaveExplicitOrganization?: (enabled: boolean) => void onClose: () => void } interface SettingsDraft { pullInterval: number autoGitEnabled: boolean autoGitIdleThresholdSeconds: number autoGitInactiveThresholdSeconds: number defaultAiAgent: AiAgentId releaseChannel: ReleaseChannel initialH1AutoRename: boolean crashReporting: boolean analytics: boolean explicitOrganization: boolean } interface SettingsBodyProps { pullInterval: number setPullInterval: (value: number) => void isGitVault: boolean autoGitEnabled: boolean setAutoGitEnabled: (value: boolean) => void autoGitIdleThresholdSeconds: number setAutoGitIdleThresholdSeconds: (value: number) => void autoGitInactiveThresholdSeconds: number setAutoGitInactiveThresholdSeconds: (value: number) => void aiAgentsStatus: AiAgentsStatus defaultAiAgent: AiAgentId setDefaultAiAgent: (value: AiAgentId) => void releaseChannel: ReleaseChannel setReleaseChannel: (value: ReleaseChannel) => void initialH1AutoRename: boolean setInitialH1AutoRename: (value: boolean) => void explicitOrganization: boolean setExplicitOrganization: (value: boolean) => void crashReporting: boolean setCrashReporting: (value: boolean) => void analytics: boolean setAnalytics: (value: boolean) => void } const PULL_INTERVAL_OPTIONS = [1, 2, 5, 10, 15, 30] as const const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90 const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30 function isSaveShortcut(event: ReactKeyboardEvent): boolean { return event.key === 'Enter' && (event.metaKey || event.ctrlKey) } function createSettingsDraft( settings: Settings, explicitOrganizationEnabled: boolean, ): SettingsDraft { return { pullInterval: settings.auto_pull_interval_minutes ?? 5, autoGitEnabled: settings.autogit_enabled ?? false, autoGitIdleThresholdSeconds: sanitizePositiveInteger( settings.autogit_idle_threshold_seconds, DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS, ), autoGitInactiveThresholdSeconds: sanitizePositiveInteger( settings.autogit_inactive_threshold_seconds, DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS, ), defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent), releaseChannel: normalizeReleaseChannel(settings.release_channel), initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true, crashReporting: settings.crash_reporting_enabled ?? false, analytics: settings.analytics_enabled ?? false, explicitOrganization: explicitOrganizationEnabled, } } function resolveTelemetryConsent(settings: Settings, draft: SettingsDraft): boolean | null { if (draft.crashReporting || draft.analytics) return true return settings.telemetry_consent === null ? null : false } function resolveAnonymousId(settings: Settings, draft: SettingsDraft): string | null { if (draft.crashReporting || draft.analytics) { return settings.anonymous_id ?? crypto.randomUUID() } return settings.anonymous_id } function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Settings { return { auto_pull_interval_minutes: draft.pullInterval, autogit_enabled: draft.autoGitEnabled, autogit_idle_threshold_seconds: draft.autoGitIdleThresholdSeconds, autogit_inactive_threshold_seconds: draft.autoGitInactiveThresholdSeconds, telemetry_consent: resolveTelemetryConsent(settings, draft), crash_reporting_enabled: draft.crashReporting, analytics_enabled: draft.analytics, anonymous_id: resolveAnonymousId(settings, draft), release_channel: serializeReleaseChannel(draft.releaseChannel), initial_h1_auto_rename_enabled: draft.initialH1AutoRename, default_ai_agent: draft.defaultAiAgent, } } function trackTelemetryConsentChange(previousAnalytics: boolean, nextAnalytics: boolean): void { if (!previousAnalytics && nextAnalytics) trackEvent('telemetry_opted_in') if (previousAnalytics && !nextAnalytics) trackEvent('telemetry_opted_out') } function isChecked(checked: CheckedState): boolean { return checked === true } function sanitizePositiveInteger(value: number | null | undefined, fallback: number): number { if (value === null || value === undefined || !Number.isFinite(value) || value < 1) return fallback return Math.round(value) } export function SettingsPanel({ open, settings, aiAgentsStatus = createMissingAiAgentsStatus(), onSave, isGitVault = true, explicitOrganizationEnabled = true, onSaveExplicitOrganization, onClose, }: SettingsPanelProps) { if (!open) return null return ( ) } type SettingsPanelInnerProps = Omit & { aiAgentsStatus: AiAgentsStatus isGitVault: boolean explicitOrganizationEnabled: boolean } function SettingsPanelInner({ settings, aiAgentsStatus, onSave, isGitVault, explicitOrganizationEnabled, onSaveExplicitOrganization, onClose, }: SettingsPanelInnerProps) { const [draft, setDraft] = useState(() => createSettingsDraft(settings, explicitOrganizationEnabled)) const panelRef = useRef(null) useEffect(() => { const timer = setTimeout(() => { const focusTarget = panelRef.current?.querySelector('[data-settings-autofocus="true"]') focusTarget?.focus() }, 50) return () => clearTimeout(timer) }, []) const updateDraft = useCallback( (key: Key, value: SettingsDraft[Key]) => { setDraft((current) => ({ ...current, [key]: value })) }, [], ) const handleSave = useCallback(() => { trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics) onSave(buildSettingsFromDraft(settings, draft)) onSaveExplicitOrganization?.(draft.explicitOrganization) onClose() }, [draft, onClose, onSave, onSaveExplicitOrganization, settings]) const handleBackdropClick = useCallback( (event: ReactMouseEvent) => { if (event.target === event.currentTarget) onClose() }, [onClose], ) const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation() onClose() return } if (isSaveShortcut(event)) { event.preventDefault() handleSave() } }, [handleSave, onClose], ) return (
updateDraft('pullInterval', value)} isGitVault={isGitVault} autoGitEnabled={draft.autoGitEnabled} setAutoGitEnabled={(value) => updateDraft('autoGitEnabled', value)} autoGitIdleThresholdSeconds={draft.autoGitIdleThresholdSeconds} setAutoGitIdleThresholdSeconds={(value) => updateDraft('autoGitIdleThresholdSeconds', value)} autoGitInactiveThresholdSeconds={draft.autoGitInactiveThresholdSeconds} setAutoGitInactiveThresholdSeconds={(value) => updateDraft('autoGitInactiveThresholdSeconds', value)} aiAgentsStatus={aiAgentsStatus} defaultAiAgent={draft.defaultAiAgent} setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)} releaseChannel={draft.releaseChannel} setReleaseChannel={(value) => updateDraft('releaseChannel', value)} initialH1AutoRename={draft.initialH1AutoRename} setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)} explicitOrganization={draft.explicitOrganization} setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)} crashReporting={draft.crashReporting} setCrashReporting={(value) => updateDraft('crashReporting', value)} analytics={draft.analytics} setAnalytics={(value) => updateDraft('analytics', value)} />
) } function SettingsHeader({ onClose }: { onClose: () => void }) { return (
Settings
) } function SettingsBody({ pullInterval, setPullInterval, isGitVault, autoGitEnabled, setAutoGitEnabled, autoGitIdleThresholdSeconds, setAutoGitIdleThresholdSeconds, autoGitInactiveThresholdSeconds, setAutoGitInactiveThresholdSeconds, aiAgentsStatus, defaultAiAgent, setDefaultAiAgent, releaseChannel, setReleaseChannel, initialH1AutoRename, setInitialH1AutoRename, explicitOrganization, setExplicitOrganization, crashReporting, setCrashReporting, analytics, setAnalytics, }: SettingsBodyProps) { return (
) } function SyncAndUpdatesSection({ pullInterval, setPullInterval, releaseChannel, setReleaseChannel, }: Pick) { return ( <> setPullInterval(Number(value))} options={PULL_INTERVAL_OPTIONS.map((value) => ({ value: `${value}`, label: `${value}`, }))} testId="settings-pull-interval" autoFocus={true} /> setReleaseChannel(value as ReleaseChannel)} options={[ { value: 'stable', label: 'Stable' }, { value: 'alpha', label: 'Alpha' }, ]} testId="settings-release-channel" /> ) } function autoGitSectionDescription(isGitVault: boolean): string { return isGitVault ? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.' : 'AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.' } function AutoGitSettingsSection({ isGitVault, autoGitEnabled, setAutoGitEnabled, autoGitIdleThresholdSeconds, setAutoGitIdleThresholdSeconds, autoGitInactiveThresholdSeconds, setAutoGitInactiveThresholdSeconds, }: Pick< SettingsBodyProps, | 'isGitVault' | 'autoGitEnabled' | 'setAutoGitEnabled' | 'autoGitIdleThresholdSeconds' | 'setAutoGitIdleThresholdSeconds' | 'autoGitInactiveThresholdSeconds' | 'setAutoGitInactiveThresholdSeconds' >) { return ( <> ) } function TitleSettingsSection({ initialH1AutoRename, setInitialH1AutoRename, }: Pick) { return ( <> ) } function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus): Array<{ value: string; label: string }> { return AI_AGENT_DEFINITIONS.map((definition) => { const status = aiAgentsStatus[definition.id] const suffix = status.status === 'installed' ? ` (installed${status.version ? ` ${status.version}` : ''})` : ' (missing)' return { value: definition.id, label: `${definition.label}${suffix}`, } }) } function AiAgentSettingsSection({ aiAgentsStatus, defaultAiAgent, setDefaultAiAgent, }: Pick) { return ( <> setDefaultAiAgent(value as AiAgentId)} options={buildDefaultAiAgentOptions(aiAgentsStatus)} testId="settings-default-ai-agent" />
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)}
) } function PrivacySettingsSection({ crashReporting, setCrashReporting, analytics, setAnalytics, }: Pick) { return ( <> ) } function SettingsSection({ children, showDivider = true, }: { children: ReactNode showDivider?: boolean }) { return (
{showDivider ? : null} {children}
) } function SectionHeading({ title, description, }: { title: string description: string }) { return (
{title}
{description}
) } function Divider() { return
} function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus): string { const definition = getAiAgentDefinition(defaultAiAgent) const status = aiAgentsStatus[defaultAiAgent] if (status.status === 'installed') { return `${definition.label}${status.version ? ` ${status.version}` : ''} is ready to use.` } return `${definition.label} is not installed yet. You can still select it now and install it later.` } function LabeledSelect({ label, value, onValueChange, options, testId, autoFocus = false, }: { label: string value: string onValueChange: (value: string) => void options: Array<{ value: string; label: string }> testId: string autoFocus?: boolean }) { return (
) } function LabeledNumberInput({ label, value, onValueChange, testId, disabled = false, }: { label: string value: number onValueChange: (value: number) => void testId: string disabled?: boolean }) { return (
onValueChange(sanitizePositiveInteger(Number(event.target.value), value))} data-testid={testId} className="w-full bg-transparent" />
) } function OrganizationWorkflowSection({ checked, onChange, }: { checked: boolean onChange: (value: boolean) => void }) { return ( <> ) } function SettingsSwitchRow({ label, description, checked, onChange, disabled = false, testId, }: { label: string description: string checked: boolean onChange: (value: boolean) => void disabled?: boolean testId?: string }) { return ( ) } function TelemetryToggle({ label, description, checked, onChange, testId, }: { label: string description: string checked: boolean onChange: (value: boolean) => void testId: string }) { return ( ) } function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) { return (
{'\u2318'}, to open settings
) }