From e60b9f882dddec87de63f7d56bec5f410acd11ad Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:51:00 +0530 Subject: [PATCH] refactor(settings): restructure settings page for better UX (#494) * refactor(settings): restructure settings page for better UX Reorganize settings into 4 clean sections (Account & Billing, Features, AI & Models, Developer Options) and extract developer-oriented options from user-facing panels into dedicated debug panels. - Merge Account & Security + Billing into Account & Billing section - Rename Automation & Channels to Features; add Tools, Voice here - Simplify AI & Skills to AI & Models (just Local AI Model) - Expand Developer Options from 4 to 10 items - Create 4 debug panels: ScreenAwarenessDebug, AutocompleteDebug, VoiceDebug, LocalModelDebug - Simplify 4 user panels by stripping dev knobs (FPS, debounce timers, test harnesses, diagnostics, etc.) - Delete AccessibilityPanel (merged into Screen Awareness) - Add "Advanced settings" link in each simplified panel - Update navigation breadcrumbs for new hierarchy * fix(settings): address PR review feedback on debug panels - Guard null result from openhumanAutocompleteDebugFocus() - Block save/start until full config loaded in AutocompletePanel - Separate poll errors from action errors in LocalModelDebugPanel - Replace useEffect setState with render-time one-shot init in ScreenAwarenessDebugPanel to avoid set-state-in-effect lint rule - Remove unused openhumanLocalAiAssetsStatus call from VoiceDebugPanel * fix(settings): update tests for simplified panel structure - Update ScreenIntelligencePanel test for renamed title, button text, and platform message - Rewrite AutocompletePanel test for simplified panel (dev options moved to AutocompleteDebugPanel) --- app/src/components/settings/SettingsHome.tsx | 48 +- .../settings/hooks/useSettingsNavigation.ts | 75 +- .../settings/panels/AccessibilityPanel.tsx | 272 ------- .../panels/AutocompleteDebugPanel.tsx | 695 ++++++++++++++++++ .../settings/panels/AutocompletePanel.tsx | 448 ++++------- .../settings/panels/DeveloperOptionsPanel.tsx | 98 ++- .../settings/panels/LocalModelDebugPanel.tsx | 451 ++++++++++++ .../settings/panels/LocalModelPanel.tsx | 439 ++--------- .../panels/ScreenAwarenessDebugPanel.tsx | 267 +++++++ .../panels/ScreenIntelligencePanel.tsx | 216 +----- .../settings/panels/VoiceDebugPanel.tsx | 249 +++++++ .../components/settings/panels/VoicePanel.tsx | 120 +-- .../__tests__/AccessibilityPanel.test.tsx | 97 --- .../__tests__/AutocompletePanel.test.tsx | 244 +++--- .../ScreenIntelligencePanel.test.tsx | 29 +- app/src/pages/Settings.tsx | 203 +++-- 16 files changed, 2269 insertions(+), 1682 deletions(-) delete mode 100644 app/src/components/settings/panels/AccessibilityPanel.tsx create mode 100644 app/src/components/settings/panels/AutocompleteDebugPanel.tsx create mode 100644 app/src/components/settings/panels/LocalModelDebugPanel.tsx create mode 100644 app/src/components/settings/panels/ScreenAwarenessDebugPanel.tsx create mode 100644 app/src/components/settings/panels/VoiceDebugPanel.tsx delete mode 100644 app/src/components/settings/panels/__tests__/AccessibilityPanel.test.tsx diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 9e2d7bad2..c7a1208c4 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -78,54 +78,42 @@ const SettingsHome = () => { const groupedMenuItems = [ { id: 'account', - title: 'Account & Security', - description: 'Recovery phrase, team management, and linked account access', + title: 'Account & Billing', + description: 'Recovery phrase, team, connections, billing, and privacy', icon: ( - + ), onClick: () => navigateToSettings('account'), dangerous: false, }, { - id: 'billing', - title: 'Billing & Usage', - description: 'Subscription plan, pay-as-you-go credits, and payment methods', + id: 'features', + title: 'Features', + description: 'Screen awareness, autocomplete, voice, messaging, and tools', icon: ( ), - onClick: () => navigateToSettings('billing'), + onClick: () => navigateToSettings('features'), dangerous: false, }, { - id: 'automation', - title: 'Automation & Channels', - description: 'Accessibility, screen intelligence, messaging, autocomplete, and cron jobs', - icon: ( - - - - ), - onClick: () => navigateToSettings('automation'), - dangerous: false, - }, - { - id: 'ai-tools', - title: 'AI & Skills', - description: 'Local model runtime, AI configuration, and skills behavior', + id: 'ai-models', + title: 'AI & Models', + description: 'Local AI model setup and downloads', icon: ( { /> ), - onClick: () => navigateToSettings('ai-tools'), + onClick: () => navigateToSettings('ai-models'), dangerous: false, }, { id: 'developer-options', title: 'Developer Options', - description: 'Diagnostic tools, console access, webhooks, and memory inspection', + description: 'Diagnostics, debug panels, webhooks, and memory inspection', icon: ( { if (path.includes('/settings/team/invites')) return 'team-invites'; if (path.includes('/settings/team')) return 'team'; if (path.includes('/settings/account')) return 'account'; - if (path.includes('/settings/automation')) return 'automation'; - if (path.includes('/settings/ai-tools')) return 'ai-tools'; + if (path.includes('/settings/features')) return 'features'; + if (path.includes('/settings/ai-models')) return 'ai-models'; if (path.includes('/settings/connections')) return 'connections'; if (path.includes('/settings/messaging')) return 'messaging'; if (path.includes('/settings/cron-jobs')) return 'cron-jobs'; + if (path.includes('/settings/screen-awareness-debug')) return 'screen-awareness-debug'; if (path.includes('/settings/screen-intelligence')) return 'screen-intelligence'; + if (path.includes('/settings/autocomplete-debug')) return 'autocomplete-debug'; if (path.includes('/settings/autocomplete')) return 'autocomplete'; if (path.includes('/settings/privacy')) return 'privacy'; if (path.includes('/settings/billing')) return 'billing'; if (path.includes('/settings/developer-options')) return 'developer-options'; - if (path.includes('/settings/accessibility')) return 'accessibility'; if (path.includes('/settings/ai')) return 'ai'; + if (path.includes('/settings/local-model-debug')) return 'local-model-debug'; if (path.includes('/settings/local-model')) return 'local-model'; + if (path.includes('/settings/voice-debug')) return 'voice-debug'; if (path.includes('/settings/voice')) return 'voice'; + if (path.includes('/settings/tools')) return 'tools'; if (path.includes('/settings/memory-data')) return 'memory-data'; if (path.includes('/settings/memory-debug')) return 'memory-debug'; if (path.includes('/settings/webhooks-debug')) return 'webhooks-debug'; @@ -126,18 +134,18 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { const settingsCrumb: BreadcrumbItem = { label: 'Settings', onClick: () => navigate('/settings') }; const accountCrumb: BreadcrumbItem = { - label: 'Account & Security', + label: 'Account & Billing', onClick: () => navigate('/settings/account'), }; - const automationCrumb: BreadcrumbItem = { - label: 'Automation & Channels', - onClick: () => navigate('/settings/automation'), + const featuresCrumb: BreadcrumbItem = { + label: 'Features', + onClick: () => navigate('/settings/features'), }; - const aiToolsCrumb: BreadcrumbItem = { - label: 'AI & Skills', - onClick: () => navigate('/settings/ai-tools'), + const aiModelsCrumb: BreadcrumbItem = { + label: 'AI & Models', + onClick: () => navigate('/settings/ai-models'), }; const teamCrumb: BreadcrumbItem = { label: 'Team', onClick: () => navigate('/settings/team') }; @@ -151,33 +159,29 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { switch (currentRoute) { // Section pages case 'account': - case 'automation': - case 'ai-tools': + case 'features': + case 'ai-models': return [settingsCrumb]; - // Top-level billing leaf (promoted out of Account & Security) - case 'billing': - return [settingsCrumb]; - - // Leaf panels under account + // Leaf panels under account & billing case 'recovery-phrase': case 'team': case 'connections': + case 'billing': + case 'privacy': return [settingsCrumb, accountCrumb]; - // Leaf panels under automation - case 'accessibility': + // Leaf panels under features case 'screen-intelligence': case 'autocomplete': - case 'messaging': - case 'cron-jobs': - return [settingsCrumb, automationCrumb]; - - // Leaf panels under ai-tools case 'voice': + case 'messaging': + case 'tools': + return [settingsCrumb, featuresCrumb]; + + // Leaf panels under AI & Models case 'local-model': - case 'ai': - return [settingsCrumb, aiToolsCrumb]; + return [settingsCrumb, aiModelsCrumb]; // Team sub-pages case 'team-members': @@ -185,14 +189,19 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { return [settingsCrumb, accountCrumb, teamCrumb]; // Developer sub-pages + case 'ai': + case 'agent-chat': + case 'cron-jobs': + case 'screen-awareness-debug': + case 'autocomplete-debug': + case 'voice-debug': + case 'local-model-debug': case 'webhooks-debug': case 'memory-data': case 'memory-debug': return [settingsCrumb, developerCrumb]; - // Other leaf pages - case 'privacy': - case 'agent-chat': + // Developer options section page case 'developer-options': return [settingsCrumb]; diff --git a/app/src/components/settings/panels/AccessibilityPanel.tsx b/app/src/components/settings/panels/AccessibilityPanel.tsx deleted file mode 100644 index 172147d31..000000000 --- a/app/src/components/settings/panels/AccessibilityPanel.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState'; -import SettingsHeader from '../components/SettingsHeader'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; - -const formatRemaining = (remainingMs: number | null): string => { - if (remainingMs === null || remainingMs <= 0) { - return '00:00'; - } - - const totalSeconds = Math.floor(remainingMs / 1000); - const mins = Math.floor(totalSeconds / 60) - .toString() - .padStart(2, '0'); - const secs = (totalSeconds % 60).toString().padStart(2, '0'); - return `${mins}:${secs}`; -}; - -const PermissionBadge = ({ label, value }: { label: string; value: string }) => { - const colorClass = - value === 'granted' - ? 'bg-green-50 text-green-700 border-green-200' - : value === 'denied' - ? 'bg-red-50 text-red-600 border-red-200' - : 'bg-stone-100 text-stone-600 border-stone-200'; - - return ( -
- {label} - - {value} - -
- ); -}; - -const AccessibilityPanel = () => { - const { navigateBack, breadcrumbs } = useSettingsNavigation(); - const { - status, - isLoading, - isRequestingPermissions, - isRestartingCore, - isStartingSession, - isStoppingSession, - isLoadingVision, - isFlushingVision, - recentVisionSummaries, - lastError, - requestPermission, - refreshPermissionsWithRestart, - refreshStatus, - refreshVision, - startSession, - stopSession, - flushVision, - } = useScreenIntelligenceState({ loadVision: true, visionLimit: 10, pollMs: 2000 }); - const [featureOverrides, setFeatureOverrides] = useState<{ screen_monitoring?: boolean }>({}); - - const anyPermissionDenied = - status?.permissions.accessibility === 'denied' || - status?.permissions.input_monitoring === 'denied'; - - const screenMonitoring = - featureOverrides.screen_monitoring ?? status?.features.screen_monitoring ?? true; - - const remaining = useMemo( - () => formatRemaining(status?.session.remaining_ms ?? null), - [status?.session.remaining_ms] - ); - - const startDisabled = - isStartingSession || - isLoading || - !status?.platform_supported || - status.session.active || - status.permissions.accessibility !== 'granted'; - const stopDisabled = isStoppingSession || !status?.session.active; - - return ( -
- - -
-
-

Permissions

- - - - {anyPermissionDenied && ( -
-

- After granting in System Settings, click “Restart & Refresh” below. -

- {status?.permission_check_process_path ? ( -

- Enable the same app macOS lists for this path (TCC is per executable).{' '} - - {status.permission_check_process_path} - -

- ) : null} -

- Still stuck? Remove the old entry for this app in System Settings → Privacy, then - click “Request” again. For dev, run{' '} - yarn core:stage so the sidecar matches - the staged binary name. -

-
- )} - - - - - {anyPermissionDenied ? ( - - ) : ( - - )} -
- -
-

Features

- - -
- -
-

Session

-
-
Status: {status?.session.active ? 'Active' : 'Stopped'}
-
Remaining: {remaining}
-
Frames (ephemeral): {status?.session.frames_in_memory ?? 0}
-
Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}
-
Vision: {status?.session.vision_state ?? 'idle'}
-
Vision queue: {status?.session.vision_queue_depth ?? 0}
-
- Last vision:{' '} - {status?.session.last_vision_at_ms - ? new Date(status.session.last_vision_at_ms).toLocaleTimeString() - : 'n/a'} -
-
- -
- - - -
-
- -
-
-

Vision Summaries

- -
- - {recentVisionSummaries.length === 0 ? ( -
No summaries yet.
- ) : ( -
- {recentVisionSummaries.map(summary => ( -
-
- {new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '} - {summary.app_name ?? 'Unknown App'} - {summary.window_title ? ` · ${summary.window_title}` : ''} -
-
{summary.actionable_notes}
-
- ))} -
- )} -
- - {!status?.platform_supported && ( -
- Accessibility Automation V1 is currently supported on macOS only. -
- )} - - {lastError && ( -
- {lastError} -
- )} -
-
- ); -}; - -export default AccessibilityPanel; diff --git a/app/src/components/settings/panels/AutocompleteDebugPanel.tsx b/app/src/components/settings/panels/AutocompleteDebugPanel.tsx new file mode 100644 index 000000000..209dbe449 --- /dev/null +++ b/app/src/components/settings/panels/AutocompleteDebugPanel.tsx @@ -0,0 +1,695 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + type AcceptedCompletion, + type AutocompleteConfig, + type AutocompleteStatus, + isTauri, + openhumanAutocompleteAccept, + openhumanAutocompleteClearHistory, + openhumanAutocompleteCurrent, + openhumanAutocompleteDebugFocus, + openhumanAutocompleteHistory, + openhumanAutocompleteSetStyle, + openhumanAutocompleteStart, + openhumanAutocompleteStatus, + openhumanAutocompleteStop, + openhumanGetConfig, +} from '../../../utils/tauriCommands'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +const DEFAULT_CONFIG: AutocompleteConfig = { + enabled: false, + debounce_ms: 120, + max_chars: 384, + style_preset: 'balanced', + style_instructions: null, + style_examples: [], + disabled_apps: [], + accept_with_tab: true, + overlay_ttl_ms: 1100, +}; + +const MAX_LOG_ENTRIES = 200; + +const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => { + if (!raw || typeof raw !== 'object') { + return DEFAULT_CONFIG; + } + const value = raw as Record; + return { + enabled: typeof value.enabled === 'boolean' ? value.enabled : DEFAULT_CONFIG.enabled, + debounce_ms: + typeof value.debounce_ms === 'number' ? value.debounce_ms : DEFAULT_CONFIG.debounce_ms, + max_chars: typeof value.max_chars === 'number' ? value.max_chars : DEFAULT_CONFIG.max_chars, + style_preset: + typeof value.style_preset === 'string' ? value.style_preset : DEFAULT_CONFIG.style_preset, + style_instructions: + typeof value.style_instructions === 'string' ? value.style_instructions : null, + style_examples: Array.isArray(value.style_examples) + ? value.style_examples.filter((entry): entry is string => typeof entry === 'string') + : DEFAULT_CONFIG.style_examples, + disabled_apps: Array.isArray(value.disabled_apps) + ? value.disabled_apps.filter((entry): entry is string => typeof entry === 'string') + : DEFAULT_CONFIG.disabled_apps, + accept_with_tab: + typeof value.accept_with_tab === 'boolean' + ? value.accept_with_tab + : DEFAULT_CONFIG.accept_with_tab, + overlay_ttl_ms: + typeof value.overlay_ttl_ms === 'number' + ? value.overlay_ttl_ms + : DEFAULT_CONFIG.overlay_ttl_ms, + }; +}; + +const AutocompleteDebugPanel = () => { + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + + // Status & loading + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + // Advanced settings form state (dev-facing fields only) + const [debounceMs, setDebounceMs] = useState(String(DEFAULT_CONFIG.debounce_ms)); + const [maxChars, setMaxChars] = useState(String(DEFAULT_CONFIG.max_chars)); + const [overlayTtlMs, setOverlayTtlMs] = useState(String(DEFAULT_CONFIG.overlay_ttl_ms)); + const [styleInstructions, setStyleInstructions] = useState(''); + const [styleExamplesText, setStyleExamplesText] = useState(''); + + // Test section + const [contextOverride, setContextOverride] = useState(''); + const [focusDebug, setFocusDebug] = useState(''); + + // Live logs + const [logs, setLogs] = useState([]); + const previousStatusRef = useRef(null); + + // Personalization history + const [historyEntries, setHistoryEntries] = useState([]); + const [isHistoryLoading, setIsHistoryLoading] = useState(false); + const [isClearingHistory, setIsClearingHistory] = useState(false); + + // ------------------------------------------------------------------------- + // Logging helpers + // ------------------------------------------------------------------------- + + const appendLogs = (entries: string[]) => { + if (entries.length === 0) return; + const now = new Date(); + const stamp = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, '0')}`; + setLogs(current => + [...current, ...entries.map(entry => `${stamp} ${entry}`)].slice(-MAX_LOG_ENTRIES) + ); + }; + + const appendUiLog = (entry: string) => { + appendLogs([`[ui-flow] ${entry}`]); + }; + + const trackStatusChanges = (next: AutocompleteStatus) => { + const previous = previousStatusRef.current; + if (!previous) { + previousStatusRef.current = next; + appendLogs([ + `[runtime] phase=${next.phase} running=${next.running ? 'yes' : 'no'} enabled=${next.enabled ? 'yes' : 'no'}`, + ]); + return; + } + + const nextEntries: string[] = []; + if (next.phase !== previous.phase) { + nextEntries.push(`phase ${previous.phase} -> ${next.phase}`); + } + if ((next.last_error ?? '') !== (previous.last_error ?? '') && next.last_error) { + nextEntries.push(`error: ${next.last_error}`); + } + if ( + (next.suggestion?.value ?? '') !== (previous.suggestion?.value ?? '') && + next.suggestion?.value + ) { + nextEntries.push(`suggestion ready: "${next.suggestion.value}"`); + } + + if (nextEntries.length > 0) { + appendLogs(nextEntries); + } + previousStatusRef.current = next; + }; + + // ------------------------------------------------------------------------- + // Data loading + // ------------------------------------------------------------------------- + + const load = async () => { + if (!isTauri()) return; + setIsLoading(true); + setError(null); + try { + const [statusResponse, configResponse] = await Promise.all([ + openhumanAutocompleteStatus(), + openhumanGetConfig(), + ]); + setStatus(statusResponse.result); + trackStatusChanges(statusResponse.result); + appendLogs(statusResponse.logs); + const config = parseAutocompleteConfig( + (configResponse.result.config as Record | undefined)?.autocomplete + ); + setDebounceMs(String(config.debounce_ms)); + setMaxChars(String(config.max_chars)); + setOverlayTtlMs(String(config.overlay_ttl_ms)); + setStyleInstructions(config.style_instructions ?? ''); + setStyleExamplesText(config.style_examples.join('\n')); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load autocomplete settings'); + } finally { + setIsLoading(false); + } + }; + + const loadHistory = async (): Promise => { + if (!isTauri()) return []; + setIsHistoryLoading(true); + try { + const response = await openhumanAutocompleteHistory({ limit: 20 }); + setHistoryEntries(response.result.entries); + return response.result.entries; + } catch { + // Non-critical — silently ignore + return []; + } finally { + setIsHistoryLoading(false); + } + }; + + useEffect(() => { + void load(); + void loadHistory(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ------------------------------------------------------------------------- + // Status polling + // ------------------------------------------------------------------------- + + const refreshStatus = async (showSpinner = false) => { + if (!isTauri()) return null; + if (showSpinner) { + setIsLoading(true); + setError(null); + } + try { + const response = await openhumanAutocompleteStatus(); + setStatus(response.result); + trackStatusChanges(response.result); + if (showSpinner) { + appendLogs(response.logs); + } + return response.result; + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to refresh autocomplete status'; + appendUiLog(`refresh status failed: ${msg}`); + setError(msg); + return null; + } finally { + if (showSpinner) { + setIsLoading(false); + } + } + }; + + useEffect(() => { + if (!isTauri()) return; + const intervalId = window.setInterval(() => { + void refreshStatus(); + }, 1200); + return () => window.clearInterval(intervalId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ------------------------------------------------------------------------- + // Runtime controls + // ------------------------------------------------------------------------- + + const start = async () => { + if (!isTauri()) return; + setError(null); + setMessage(null); + try { + const debounce = Number(debounceMs); + appendUiLog(`start requested (debounce=${String(debounce)}ms)`); + const response = await openhumanAutocompleteStart({ + debounce_ms: Number.isFinite(debounce) ? Math.min(Math.max(debounce, 50), 2000) : 120, + }); + appendLogs(response.logs); + const latestStatus = await refreshStatus(); + if (response.result.started) { + setMessage('Autocomplete started.'); + } else if (latestStatus?.enabled === false) { + setMessage('Autocomplete is disabled in settings. Enable it and save first.'); + } else if (latestStatus?.running) { + setMessage('Autocomplete is already running.'); + } else { + setMessage('Autocomplete did not start.'); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to start autocomplete'; + appendUiLog(`start failed: ${msg}`); + setError(msg); + } + }; + + const stop = async () => { + if (!isTauri()) return; + setError(null); + setMessage(null); + try { + appendUiLog('stop requested'); + const response = await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' }); + appendLogs(response.logs); + const latestStatus = await refreshStatus(); + setMessage('Autocomplete stopped.'); + if (latestStatus?.running) { + appendUiLog('runtime still reports running after stop'); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to stop autocomplete'; + appendUiLog(`stop failed: ${msg}`); + setError(msg); + } + }; + + // ------------------------------------------------------------------------- + // Test actions + // ------------------------------------------------------------------------- + + const testCurrent = async () => { + if (!isTauri()) return; + setError(null); + setMessage(null); + try { + appendUiLog( + contextOverride.trim() + ? `get suggestion requested (override chars=${String(contextOverride.trim().length)})` + : 'get suggestion requested (focused app context)' + ); + const response = await openhumanAutocompleteCurrent({ + context: contextOverride.trim() || undefined, + }); + appendLogs(response.logs); + setMessage( + response.result.suggestion?.value + ? `Suggestion: ${response.result.suggestion.value}` + : 'No suggestion returned.' + ); + await refreshStatus(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to fetch current suggestion'; + appendUiLog(`get suggestion failed: ${msg}`); + setError(msg); + } + }; + + const waitForAcceptedHistoryEntry = async (acceptedValue?: string | null) => { + if (!acceptedValue) { + await loadHistory(); + return; + } + const normalized = acceptedValue.trim(); + if (!normalized) { + await loadHistory(); + return; + } + + const maxAttempts = 6; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const entries = await loadHistory(); + const found = entries.some(entry => entry.suggestion.trim() === normalized); + if (found) { + return; + } + if (attempt < maxAttempts - 1) { + await new Promise(resolve => window.setTimeout(resolve, 180)); + } + } + }; + + const acceptSuggestion = async () => { + if (!isTauri()) return; + setError(null); + setMessage(null); + try { + appendUiLog('accept suggestion requested'); + const response = await openhumanAutocompleteAccept({ + suggestion: status?.suggestion?.value ?? undefined, + skip_apply: true, + }); + appendLogs(response.logs); + if (response.result.accepted && response.result.value) { + setMessage(`Accepted: ${response.result.value}`); + } else { + setMessage(response.result.reason ?? 'No suggestion was applied.'); + } + await refreshStatus(); + await waitForAcceptedHistoryEntry(response.result.value); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to accept suggestion'; + appendUiLog(`accept failed: ${msg}`); + setError(msg); + } + }; + + const debugFocus = async () => { + if (!isTauri()) return; + setError(null); + try { + appendUiLog('debug focus requested'); + const response = await openhumanAutocompleteDebugFocus(); + appendLogs(response.logs); + setFocusDebug(JSON.stringify(response.result, null, 2)); + if (response.result) { + appendUiLog( + `focus app=${response.result.app_name ?? 'n/a'} role=${response.result.role ?? 'n/a'} chars=${String(response.result.context.length)}` + ); + } else { + appendUiLog('focus debug returned no focused element'); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to inspect focused element'; + appendUiLog(`debug focus failed: ${msg}`); + setError(msg); + } + }; + + // ------------------------------------------------------------------------- + // Advanced settings save + // ------------------------------------------------------------------------- + + const saveAdvancedConfig = async () => { + if (!isTauri()) return; + setIsSaving(true); + setError(null); + setMessage(null); + try { + appendUiLog('saving advanced autocomplete settings'); + const debounce = Number(debounceMs); + const max = Number(maxChars); + const ttl = Number(overlayTtlMs); + const response = await openhumanAutocompleteSetStyle({ + debounce_ms: Number.isFinite(debounce) ? Math.min(Math.max(debounce, 50), 2000) : 120, + max_chars: Number.isFinite(max) ? Math.min(Math.max(max, 32), 1200) : 384, + overlay_ttl_ms: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 300), 10000) : 1100, + style_instructions: styleInstructions.trim() || undefined, + style_examples: styleExamplesText + .split('\n') + .map(entry => entry.trim()) + .filter(Boolean), + }); + setDebounceMs(String(response.result.config.debounce_ms)); + setMaxChars(String(response.result.config.max_chars)); + setOverlayTtlMs(String(response.result.config.overlay_ttl_ms)); + setStyleInstructions(response.result.config.style_instructions ?? ''); + setStyleExamplesText(response.result.config.style_examples.join('\n')); + appendLogs(response.logs); + setMessage('Advanced settings saved.'); + await refreshStatus(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to save advanced settings'; + appendUiLog(`save advanced settings failed: ${msg}`); + setError(msg); + } finally { + setIsSaving(false); + } + }; + + // ------------------------------------------------------------------------- + // History controls + // ------------------------------------------------------------------------- + + const clearHistory = async () => { + if (!isTauri()) return; + setIsClearingHistory(true); + try { + await openhumanAutocompleteClearHistory(); + setHistoryEntries([]); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to clear history'); + } finally { + setIsClearingHistory(false); + } + }; + + const clearLogs = () => { + setLogs([]); + previousStatusRef.current = status; + }; + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + + return ( +
+ + +
+ {/* ------------------------------------------------------------------ */} + {/* Runtime section */} + {/* ------------------------------------------------------------------ */} +
+

Runtime

+
+
Platform supported: {status?.platform_supported ? 'yes' : 'no'}
+
Enabled: {status?.enabled ? 'yes' : 'no'}
+
Running: {status?.running ? 'yes' : 'no'}
+
Phase: {status?.phase ?? 'unknown'}
+
Debounce: {status?.debounce_ms ?? 0}ms
+
Model: {status?.model_id ?? 'n/a'}
+
App: {status?.app_name ?? 'n/a'}
+
Last error: {status?.last_error ?? 'none'}
+
Current suggestion: {status?.suggestion?.value ?? 'none'}
+
+
+ + + +
+
+ + {/* ------------------------------------------------------------------ */} + {/* Test section */} + {/* ------------------------------------------------------------------ */} +
+

Test

+
+
Context Override (optional)
+