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)
This commit is contained in:
Cyrus Gray
2026-04-10 21:51:00 +05:30
committed by GitHub
parent 8e8da17ad9
commit e60b9f882d
16 changed files with 2269 additions and 1682 deletions
+18 -30
View File
@@ -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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5v14" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
),
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3 3v8a3 3 0 003 3z"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
),
onClick: () => navigateToSettings('billing'),
onClick: () => navigateToSettings('features'),
dangerous: false,
},
{
id: 'automation',
title: 'Automation & Channels',
description: 'Accessibility, screen intelligence, messaging, autocomplete, and cron jobs',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h18v12H3zM8 21h8m-4-4v4"
/>
</svg>
),
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -136,13 +124,13 @@ const SettingsHome = () => {
/>
</svg>
),
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -4,8 +4,8 @@ import { useLocation, useNavigate } from 'react-router-dom';
export type SettingsRoute =
| 'home'
| 'account'
| 'automation'
| 'ai-tools'
| 'features'
| 'ai-models'
| 'connections'
| 'messaging'
| 'cron-jobs'
@@ -17,15 +17,19 @@ export type SettingsRoute =
| 'team-members'
| 'team-invites'
| 'developer-options'
| 'accessibility'
| 'ai'
| 'local-model'
| 'voice'
| 'tools'
| 'memory-data'
| 'memory-debug'
| 'recovery-phrase'
| 'webhooks-debug'
| 'agent-chat';
| 'agent-chat'
| 'screen-awareness-debug'
| 'autocomplete-debug'
| 'voice-debug'
| 'local-model-debug';
export interface BreadcrumbItem {
label: string;
@@ -69,20 +73,24 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
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];
@@ -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 (
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 p-3">
<span className="text-sm text-stone-700">{label}</span>
<span className={`rounded-md border px-2 py-1 text-xs uppercase tracking-wide ${colorClass}`}>
{value}
</span>
</div>
);
};
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 (
<div className="z-10 relative">
<SettingsHeader
title="Accessibility Automation"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Permissions</h3>
<PermissionBadge
label="Accessibility"
value={status?.permissions.accessibility ?? 'unknown'}
/>
<PermissionBadge
label="Input Monitoring"
value={status?.permissions.input_monitoring ?? 'unknown'}
/>
{anyPermissionDenied && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700 space-y-1">
<p>
After granting in System Settings, click &ldquo;Restart &amp; Refresh&rdquo; below.
</p>
{status?.permission_check_process_path ? (
<p className="opacity-75 text-xs">
Enable the same app macOS lists for this path (TCC is per executable).{' '}
<span className="font-mono break-all text-stone-600">
{status.permission_check_process_path}
</span>
</p>
) : null}
<p className="opacity-75">
Still stuck? Remove the old entry for this app in System Settings Privacy, then
click &ldquo;Request&rdquo; again. For dev, run{' '}
<span className="font-mono text-xs">yarn core:stage</span> so the sidecar matches
the staged binary name.
</p>
</div>
)}
<button
type="button"
onClick={() => void requestPermission('accessibility')}
disabled={isRequestingPermissions || isRestartingCore}
className="mt-1 rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Request Accessibility'}
</button>
<button
type="button"
onClick={() => void requestPermission('input_monitoring')}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Open Input Monitoring'}
</button>
{anyPermissionDenied ? (
<button
type="button"
onClick={() => void refreshPermissionsWithRestart()}
disabled={isRestartingCore || isLoading}
className="rounded-lg border border-amber-500/60 bg-amber-50 px-3 py-2 text-sm text-amber-700 disabled:opacity-50">
{isRestartingCore ? 'Restarting core…' : 'Restart & Refresh Permissions'}
</button>
) : (
<button
type="button"
onClick={() => void refreshStatus()}
disabled={isLoading || isRestartingCore}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700 disabled:opacity-50">
{isLoading ? 'Refreshing…' : 'Refresh Status'}
</button>
)}
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Features</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Screen Monitoring</span>
<input
type="checkbox"
checked={screenMonitoring}
onChange={event =>
setFeatureOverrides(current => ({
...current,
screen_monitoring: event.target.checked,
}))
}
/>
</label>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Session</h3>
<div className="text-sm text-stone-700 space-y-1">
<div>Status: {status?.session.active ? 'Active' : 'Stopped'}</div>
<div>Remaining: {remaining}</div>
<div>Frames (ephemeral): {status?.session.frames_in_memory ?? 0}</div>
<div>Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</div>
<div>Vision: {status?.session.vision_state ?? 'idle'}</div>
<div>Vision queue: {status?.session.vision_queue_depth ?? 0}</div>
<div>
Last vision:{' '}
{status?.session.last_vision_at_ms
? new Date(status.session.last_vision_at_ms).toLocaleTimeString()
: 'n/a'}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
void startSession({
consent: true,
ttl_secs: status?.config.session_ttl_secs ?? 300,
screen_monitoring: screenMonitoring,
})
}
disabled={startDisabled}
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
{isStartingSession ? 'Starting…' : 'Start Session'}
</button>
<button
type="button"
onClick={() => void stopSession('manual_stop')}
disabled={stopDisabled}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
{isStoppingSession ? 'Stopping…' : 'Stop Session'}
</button>
<button
type="button"
onClick={() => void flushVision()}
disabled={isFlushingVision || !status?.session.active}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
{isFlushingVision ? 'Analyzing…' : 'Analyze Now'}
</button>
</div>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Vision Summaries</h3>
<button
type="button"
onClick={() => void refreshVision(10)}
disabled={isLoadingVision}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-1.5 text-xs text-stone-700 disabled:opacity-50">
{isLoadingVision ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{recentVisionSummaries.length === 0 ? (
<div className="text-xs text-stone-500">No summaries yet.</div>
) : (
<div className="space-y-2">
{recentVisionSummaries.map(summary => (
<div
key={summary.id}
className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
<div className="text-stone-500">
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
{summary.app_name ?? 'Unknown App'}
{summary.window_title ? ` · ${summary.window_title}` : ''}
</div>
<div className="mt-1 text-stone-800">{summary.actionable_notes}</div>
</div>
))}
</div>
)}
</section>
{!status?.platform_supported && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
Accessibility Automation V1 is currently supported on macOS only.
</div>
)}
{lastError && (
<div className="rounded-xl border border-red-300 bg-red-50 p-3 text-sm text-red-600">
{lastError}
</div>
)}
</div>
</div>
);
};
export default AccessibilityPanel;
@@ -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<string, unknown>;
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<AutocompleteStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
// Advanced settings form state (dev-facing fields only)
const [debounceMs, setDebounceMs] = useState<string>(String(DEFAULT_CONFIG.debounce_ms));
const [maxChars, setMaxChars] = useState<string>(String(DEFAULT_CONFIG.max_chars));
const [overlayTtlMs, setOverlayTtlMs] = useState<string>(String(DEFAULT_CONFIG.overlay_ttl_ms));
const [styleInstructions, setStyleInstructions] = useState<string>('');
const [styleExamplesText, setStyleExamplesText] = useState<string>('');
// Test section
const [contextOverride, setContextOverride] = useState<string>('');
const [focusDebug, setFocusDebug] = useState<string>('');
// Live logs
const [logs, setLogs] = useState<string[]>([]);
const previousStatusRef = useRef<AutocompleteStatus | null>(null);
// Personalization history
const [historyEntries, setHistoryEntries] = useState<AcceptedCompletion[]>([]);
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<string, unknown> | 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<AcceptedCompletion[]> => {
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 (
<div className="z-10 relative">
<SettingsHeader
title="Autocomplete Debug"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
{/* ------------------------------------------------------------------ */}
{/* Runtime section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Runtime</h3>
<div className="text-sm text-stone-700 space-y-1">
<div>Platform supported: {status?.platform_supported ? 'yes' : 'no'}</div>
<div>Enabled: {status?.enabled ? 'yes' : 'no'}</div>
<div>Running: {status?.running ? 'yes' : 'no'}</div>
<div>Phase: {status?.phase ?? 'unknown'}</div>
<div>Debounce: {status?.debounce_ms ?? 0}ms</div>
<div>Model: {status?.model_id ?? 'n/a'}</div>
<div>App: {status?.app_name ?? 'n/a'}</div>
<div>Last error: {status?.last_error ?? 'none'}</div>
<div>Current suggestion: {status?.suggestion?.value ?? 'none'}</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void refreshStatus(true)}
disabled={isLoading}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700 disabled:opacity-50">
{isLoading ? 'Refreshing…' : 'Refresh Status'}
</button>
<button
type="button"
onClick={() => void start()}
disabled={!status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
Start
</button>
<button
type="button"
onClick={() => void stop()}
disabled={!status?.running}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
Stop
</button>
</div>
</section>
{/* ------------------------------------------------------------------ */}
{/* Test section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Test</h3>
<div className="space-y-1">
<div className="text-xs text-stone-600">Context Override (optional)</div>
<textarea
value={contextOverride}
onChange={event => setContextOverride(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void testCurrent()}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600">
Get Suggestion
</button>
<button
type="button"
onClick={() => void acceptSuggestion()}
className="rounded-lg border border-emerald-500/60 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
Accept Suggestion
</button>
<button
type="button"
onClick={() => void debugFocus()}
className="rounded-lg border border-amber-500/60 bg-amber-50 px-3 py-2 text-sm text-amber-700">
Debug Focus
</button>
</div>
{focusDebug && (
<pre className="max-h-48 overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700">
{focusDebug}
</pre>
)}
</section>
{/* ------------------------------------------------------------------ */}
{/* Live Logs section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Live Logs</h3>
<button
type="button"
onClick={clearLogs}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-1.5 text-xs text-stone-700">
Clear
</button>
</div>
<pre className="max-h-56 overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700">
{logs.length > 0 ? logs.join('\n') : 'No logs yet.'}
</pre>
</section>
{/* ------------------------------------------------------------------ */}
{/* Advanced settings */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Advanced Settings</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Debounce Ms</span>
<input
type="number"
min={50}
max={2000}
step={10}
value={debounceMs}
onChange={event => setDebounceMs(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Max Characters</span>
<input
type="number"
min={32}
max={1200}
step={8}
value={maxChars}
onChange={event => setMaxChars(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Overlay TTL Ms</span>
<input
type="number"
min={300}
max={10000}
step={100}
value={overlayTtlMs}
onChange={event => setOverlayTtlMs(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
/>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">Style Instructions</div>
<textarea
value={styleInstructions}
onChange={event => setStyleInstructions(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600">Style Examples (one per line)</div>
<textarea
value={styleExamplesText}
onChange={event => setStyleExamplesText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
/>
</div>
<button
type="button"
onClick={() => void saveAdvancedConfig()}
disabled={isSaving}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
{isSaving ? 'Saving…' : 'Save'}
</button>
</section>
{/* ------------------------------------------------------------------ */}
{/* Personalization History */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Personalization History</h3>
<button
type="button"
onClick={() => void clearHistory()}
disabled={isClearingHistory || historyEntries.length === 0}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-1.5 text-xs text-red-600 disabled:opacity-40">
{isClearingHistory ? 'Clearing…' : 'Clear History'}
</button>
</div>
<p className="text-xs text-stone-500">
{isHistoryLoading
? 'Loading…'
: historyEntries.length === 0
? 'No accepted completions yet. Accept suggestions with Tab to start personalising.'
: `${String(historyEntries.length)} accepted completion${historyEntries.length === 1 ? '' : 's'} stored — used to personalise future suggestions.`}
</p>
{historyEntries.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-xl border border-stone-200 bg-stone-50 p-2 space-y-1">
{historyEntries.map((entry, idx) => (
<div
key={`${String(entry.timestamp_ms)}-${String(idx)}`}
className="flex flex-col gap-0.5 rounded-lg bg-white px-2 py-1.5 text-xs border border-stone-100">
<div className="flex items-center gap-2 text-stone-500">
<span className="shrink-0">
{new Date(entry.timestamp_ms).toLocaleString()}
</span>
{entry.app_name && (
<span className="rounded bg-stone-100 px-1 text-stone-600">
{entry.app_name}
</span>
)}
</div>
<div className="flex items-baseline gap-1 text-stone-700 truncate">
<span className="shrink-0 text-stone-400"></span>
<span className="truncate text-stone-500">{entry.context.slice(-40)}</span>
<span className="shrink-0 text-stone-400"></span>
<span className="font-medium text-primary-500 truncate">
{entry.suggestion}
</span>
</div>
</div>
))}
</div>
)}
</section>
{/* ------------------------------------------------------------------ */}
{/* Feedback messages */}
{/* ------------------------------------------------------------------ */}
{message && <div className="text-xs text-green-700">{message}</div>}
{error && <div className="text-xs text-red-600">{error}</div>}
</div>
</div>
);
};
export default AutocompleteDebugPanel;
@@ -1,15 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import {
type AcceptedCompletion,
type AutocompleteConfig,
type AutocompleteStatus,
isTauri,
openhumanAutocompleteAccept,
openhumanAutocompleteClearHistory,
openhumanAutocompleteCurrent,
openhumanAutocompleteDebugFocus,
openhumanAutocompleteHistory,
openhumanAutocompleteSetStyle,
openhumanAutocompleteStart,
openhumanAutocompleteStatus,
@@ -18,8 +12,6 @@ import {
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import AppFilterSection from './autocomplete/AppFilterSection';
import CompletionStyleSection from './autocomplete/CompletionStyleSection';
const DEFAULT_CONFIG: AutocompleteConfig = {
enabled: false,
@@ -33,8 +25,6 @@ const DEFAULT_CONFIG: AutocompleteConfig = {
overlay_ttl_ms: 1100,
};
const MAX_LOG_ENTRIES = 200;
const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
if (!raw || typeof raw !== 'object') {
return DEFAULT_CONFIG;
@@ -67,80 +57,26 @@ const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
};
const AutocompletePanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [enabled, setEnabled] = useState<boolean>(DEFAULT_CONFIG.enabled);
const [debounceMs, setDebounceMs] = useState<string>(String(DEFAULT_CONFIG.debounce_ms));
const [maxChars, setMaxChars] = useState<string>(String(DEFAULT_CONFIG.max_chars));
const [stylePreset, setStylePreset] = useState<string>(DEFAULT_CONFIG.style_preset);
const [styleInstructions, setStyleInstructions] = useState<string>('');
const [styleExamplesText, setStyleExamplesText] = useState<string>('');
const [disabledAppsText, setDisabledAppsText] = useState<string>(
DEFAULT_CONFIG.disabled_apps.join('\n')
);
const [acceptWithTab, setAcceptWithTab] = useState<boolean>(DEFAULT_CONFIG.accept_with_tab);
const [overlayTtlMs, setOverlayTtlMs] = useState<string>(String(DEFAULT_CONFIG.overlay_ttl_ms));
const [contextOverride, setContextOverride] = useState<string>('');
const [focusDebug, setFocusDebug] = useState<string>('');
const [logs, setLogs] = useState<string[]>([]);
const previousStatusRef = useRef<AutocompleteStatus | null>(null);
// Personalization history state
const [historyEntries, setHistoryEntries] = useState<AcceptedCompletion[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [isClearingHistory, setIsClearingHistory] = useState(false);
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;
};
// Hold full config so we can pass through unchanged advanced values on save.
// configLoaded tracks whether we've received real config from the backend.
const fullConfigRef = useRef<AutocompleteConfig>(DEFAULT_CONFIG);
const [configLoaded, setConfigLoaded] = useState(false);
const load = async () => {
if (!isTauri()) return;
setIsLoading(true);
setError(null);
try {
const [statusResponse, configResponse] = await Promise.all([
@@ -148,155 +84,73 @@ const AutocompletePanel = () => {
openhumanGetConfig(),
]);
setStatus(statusResponse.result);
trackStatusChanges(statusResponse.result);
appendLogs(statusResponse.logs);
const config = parseAutocompleteConfig(
(configResponse.result.config as Record<string, unknown> | undefined)?.autocomplete
);
fullConfigRef.current = config;
setConfigLoaded(true);
setEnabled(config.enabled);
setDebounceMs(String(config.debounce_ms));
setMaxChars(String(config.max_chars));
setStylePreset(config.style_preset);
setStyleInstructions(config.style_instructions ?? '');
setStyleExamplesText(config.style_examples.join('\n'));
setDisabledAppsText(config.disabled_apps.join('\n'));
setAcceptWithTab(config.accept_with_tab);
setOverlayTtlMs(String(config.overlay_ttl_ms));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load autocomplete settings');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void load();
void loadHistory();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadHistory = async (): Promise<AcceptedCompletion[]> => {
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);
}
};
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 clearHistory = async () => {
const refreshStatus = 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 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);
}
} catch {
// Non-critical
}
};
useEffect(() => {
if (!isTauri()) return;
const intervalId = window.setInterval(() => {
void refreshStatus();
}, 1200);
return () => window.clearInterval(intervalId);
}, []);
const saveConfig = async () => {
if (!isTauri()) return;
setIsSaving(true);
setError(null);
setMessage(null);
try {
appendUiLog('saving autocomplete settings');
const debounce = Number(debounceMs);
const max = Number(maxChars);
const ttl = Number(overlayTtlMs);
const prev = fullConfigRef.current;
const response = await openhumanAutocompleteSetStyle({
enabled,
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,
debounce_ms: prev.debounce_ms,
max_chars: prev.max_chars,
style_preset: stylePreset.trim() || 'balanced',
style_instructions: styleInstructions.trim() || undefined,
style_examples: styleExamplesText
.split('\n')
.map(entry => entry.trim())
.filter(Boolean),
style_instructions: prev.style_instructions ?? undefined,
style_examples: prev.style_examples,
disabled_apps: disabledAppsText
.split('\n')
.map(entry => entry.trim())
.filter(Boolean),
accept_with_tab: acceptWithTab,
overlay_ttl_ms: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 300), 10000) : 1100,
overlay_ttl_ms: prev.overlay_ttl_ms,
});
fullConfigRef.current = response.result.config;
setEnabled(response.result.config.enabled);
setDebounceMs(String(response.result.config.debounce_ms));
setMaxChars(String(response.result.config.max_chars));
setStylePreset(response.result.config.style_preset);
setStyleInstructions(response.result.config.style_instructions ?? '');
setStyleExamplesText(response.result.config.style_examples.join('\n'));
setDisabledAppsText(response.result.config.disabled_apps.join('\n'));
setAcceptWithTab(response.result.config.accept_with_tab);
setOverlayTtlMs(String(response.result.config.overlay_ttl_ms));
appendLogs(response.logs);
setMessage('Autocomplete settings saved.');
await refreshStatus();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save autocomplete settings';
appendUiLog(`save settings failed: ${msg}`);
setError(msg);
setError(err instanceof Error ? err.message : 'Failed to save autocomplete settings');
} finally {
setIsSaving(false);
}
@@ -307,26 +161,17 @@ const AutocompletePanel = () => {
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,
debounce_ms: fullConfigRef.current.debounce_ms,
});
appendLogs(response.logs);
const latestStatus = await refreshStatus();
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.');
setMessage('Autocomplete did not start. Check if it is enabled.');
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to start autocomplete';
appendUiLog(`start failed: ${msg}`);
setError(msg);
setError(err instanceof Error ? err.message : 'Failed to start autocomplete');
}
};
@@ -335,159 +180,116 @@ const AutocompletePanel = () => {
setError(null);
setMessage(null);
try {
appendUiLog('stop requested');
const response = await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
appendLogs(response.logs);
const latestStatus = await refreshStatus();
await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
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);
setError(err instanceof Error ? err.message : 'Failed to stop autocomplete');
}
};
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 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));
appendUiLog(
`focus app=${response.result.app_name ?? 'n/a'} role=${response.result.role ?? 'n/a'} chars=${String(response.result.context.length)}`
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to inspect focused element';
appendUiLog(`debug focus failed: ${msg}`);
setError(msg);
}
};
useEffect(() => {
if (!isTauri()) return;
const intervalId = window.setInterval(() => {
void refreshStatus();
}, 1200);
return () => window.clearInterval(intervalId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const clearLogs = () => {
setLogs([]);
previousStatusRef.current = status;
};
return (
<div className="z-10 relative">
<SettingsHeader
title="Inline Autocomplete"
title="Autocomplete"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
<AppFilterSection
status={status}
isLoading={isLoading}
contextOverride={contextOverride}
focusDebug={focusDebug}
logs={logs}
message={message}
error={error}
onSetContextOverride={setContextOverride}
onRefreshStatus={() => void refreshStatus(true)}
onStart={() => void start()}
onStop={() => void stop()}
onTestCurrent={() => void testCurrent()}
onAcceptSuggestion={() => void acceptSuggestion()}
onDebugFocus={() => void debugFocus()}
onClearLogs={clearLogs}
/>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Settings</h3>
<CompletionStyleSection
enabled={enabled}
debounceMs={debounceMs}
maxChars={maxChars}
stylePreset={stylePreset}
styleInstructions={styleInstructions}
styleExamplesText={styleExamplesText}
disabledAppsText={disabledAppsText}
acceptWithTab={acceptWithTab}
overlayTtlMs={overlayTtlMs}
isSaving={isSaving}
historyEntries={historyEntries}
isHistoryLoading={isHistoryLoading}
isClearingHistory={isClearingHistory}
onSetEnabled={setEnabled}
onSetDebounceMs={setDebounceMs}
onSetMaxChars={setMaxChars}
onSetStylePreset={setStylePreset}
onSetStyleInstructions={setStyleInstructions}
onSetStyleExamplesText={setStyleExamplesText}
onSetDisabledAppsText={setDisabledAppsText}
onSetAcceptWithTab={setAcceptWithTab}
onSetOverlayTtlMs={setOverlayTtlMs}
onSaveConfig={() => void saveConfig()}
onClearHistory={() => void clearHistory()}
/>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Enabled</span>
<input
type="checkbox"
checked={enabled}
onChange={event => setEnabled(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Accept With Tab</span>
<input
type="checkbox"
checked={acceptWithTab}
onChange={event => setAcceptWithTab(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Style Preset</span>
<select
value={stylePreset}
onChange={event => setStylePreset(event.target.value)}
className="rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700">
<option value="balanced">Balanced</option>
<option value="concise">Concise</option>
<option value="formal">Formal</option>
<option value="casual">Casual</option>
<option value="custom">Custom</option>
</select>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">
Disabled Apps (one bundle/app token per line)
</div>
<textarea
value={disabledAppsText}
onChange={event => setDisabledAppsText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
/>
</div>
<button
type="button"
onClick={() => void saveConfig()}
disabled={isSaving || !configLoaded}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
{isSaving ? 'Saving…' : 'Save Settings'}
</button>
</section>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Runtime</h3>
<div className="text-sm text-stone-600 space-y-1">
<div>Running: {status?.running ? 'yes' : 'no'}</div>
<div>Enabled: {status?.enabled ? 'yes' : 'no'}</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void start()}
disabled={!configLoaded || !status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
Start
</button>
<button
type="button"
onClick={() => void stop()}
disabled={!status?.running}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
Stop
</button>
</div>
</section>
{message && <div className="text-xs text-green-700">{message}</div>}
{error && <div className="text-xs text-red-600">{error}</div>}
<button
type="button"
onClick={() => navigateToSettings('autocomplete-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
Advanced settings
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
@@ -14,7 +14,103 @@ const developerItems = [
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
d="M12 3l1.9 3.85 4.25.62-3.08 3 .73 4.23L12 12.77 8.2 14.7l.73-4.23-3.08-3 4.25-.62L12 3z"
/>
</svg>
),
},
{
id: 'agent-chat',
title: 'Agent Chat',
description: 'Test agent conversation with model and temperature overrides',
route: 'agent-chat',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M21 11c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 19l1.395-3.72C3.512 14.042 3 12.574 3 11c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
),
},
{
id: 'cron-jobs',
title: 'Cron Jobs',
description: 'View and configure scheduled jobs for runtime skills',
route: 'cron-jobs',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
},
{
id: 'screen-awareness-debug',
title: 'Screen Awareness Debug',
description: 'FPS tuning, vision model config, capture tests, and session diagnostics',
route: 'screen-awareness-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h18v12H3zM8 21h8m-4-4v4"
/>
</svg>
),
},
{
id: 'autocomplete-debug',
title: 'Autocomplete Debug',
description: 'Timing, test harness, focus debug, activity logs, and history',
route: 'autocomplete-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 7h16M4 12h10m-10 5h7m10 0l3 3m0 0l3-3m-3 3v-8"
/>
</svg>
),
},
{
id: 'voice-debug',
title: 'Voice Debug',
description: 'Runtime status, silence threshold, and recording diagnostics',
route: 'voice-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3a3 3 0 00-3 3v6a3 3 0 006 0V6a3 3 0 00-3-3zm-7 9a7 7 0 0014 0m-7 7v2m-4 0h8"
/>
</svg>
),
},
{
id: 'local-model-debug',
title: 'Local Model Debug',
description: 'Ollama config, asset downloads, model tests, and diagnostics',
route: 'local-model-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
),
@@ -0,0 +1,451 @@
import { useEffect, useMemo, useState } from 'react';
import {
formatBytes,
formatEta,
progressFromDownloads,
progressFromStatus,
} from '../../../utils/localAiHelpers';
import {
type LocalAiAssetsStatus,
type LocalAiDiagnostics,
type LocalAiDownloadsProgress,
type LocalAiEmbeddingResult,
type LocalAiSpeechResult,
type LocalAiStatus,
type LocalAiSuggestion,
type LocalAiTtsResult,
openhumanLocalAiAssetsStatus,
openhumanLocalAiDiagnostics,
openhumanLocalAiDownload,
openhumanLocalAiDownloadAllAssets,
openhumanLocalAiDownloadAsset,
openhumanLocalAiDownloadsProgress,
openhumanLocalAiEmbed,
openhumanLocalAiPrompt,
openhumanLocalAiSetOllamaPath,
openhumanLocalAiStatus,
openhumanLocalAiSuggestQuestions,
openhumanLocalAiSummarize,
openhumanLocalAiTranscribe,
openhumanLocalAiTts,
openhumanLocalAiVisionPrompt,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import ModelDownloadSection from './local-model/ModelDownloadSection';
import ModelStatusSection from './local-model/ModelStatusSection';
const statusTone = (state: string): string => {
switch (state) {
case 'ready':
return 'text-green-600';
case 'downloading':
case 'installing':
case 'loading':
return 'text-primary-600';
case 'degraded':
return 'text-amber-700';
case 'disabled':
return 'text-stone-500';
default:
return 'text-stone-700';
}
};
const LocalModelDebugPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [status, setStatus] = useState<LocalAiStatus | null>(null);
const [assets, setAssets] = useState<LocalAiAssetsStatus | null>(null);
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
const [statusError, setStatusError] = useState<string>('');
const [isTriggeringDownload, setIsTriggeringDownload] = useState(false);
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
const [assetDownloadBusy, setAssetDownloadBusy] = useState<Record<string, boolean>>({});
const [summaryInput, setSummaryInput] = useState('');
const [summaryOutput, setSummaryOutput] = useState('');
const [isSummaryLoading, setIsSummaryLoading] = useState(false);
const [suggestInput, setSuggestInput] = useState('');
const [suggestions, setSuggestions] = useState<LocalAiSuggestion[]>([]);
const [isSuggestLoading, setIsSuggestLoading] = useState(false);
const [promptInput, setPromptInput] = useState('');
const [promptOutput, setPromptOutput] = useState('');
const [isPromptLoading, setIsPromptLoading] = useState(false);
const [promptNoThink, setPromptNoThink] = useState(true);
const [promptError, setPromptError] = useState('');
const [visionPromptInput, setVisionPromptInput] = useState('');
const [visionImageInput, setVisionImageInput] = useState('');
const [visionOutput, setVisionOutput] = useState('');
const [isVisionLoading, setIsVisionLoading] = useState(false);
const [embeddingInput, setEmbeddingInput] = useState('');
const [embeddingOutput, setEmbeddingOutput] = useState<LocalAiEmbeddingResult | null>(null);
const [isEmbeddingLoading, setIsEmbeddingLoading] = useState(false);
const [audioPathInput, setAudioPathInput] = useState('');
const [transcribeOutput, setTranscribeOutput] = useState<LocalAiSpeechResult | null>(null);
const [isTranscribeLoading, setIsTranscribeLoading] = useState(false);
const [ttsInput, setTtsInput] = useState('');
const [ttsOutputPath, setTtsOutputPath] = useState('');
const [ttsOutput, setTtsOutput] = useState<LocalAiTtsResult | null>(null);
const [isTtsLoading, setIsTtsLoading] = useState(false);
const [diagnostics, setDiagnostics] = useState<LocalAiDiagnostics | null>(null);
const [isDiagnosticsLoading, setIsDiagnosticsLoading] = useState(false);
const [diagnosticsError, setDiagnosticsError] = useState('');
const [showErrorDetail, setShowErrorDetail] = useState(false);
const [ollamaPathInput, setOllamaPathInput] = useState('');
const [isSettingPath, setIsSettingPath] = useState(false);
const progress = useMemo(() => {
const downloadProgress = progressFromDownloads(downloads);
if (downloadProgress != null) return downloadProgress;
return progressFromStatus(status);
}, [downloads, status]);
const currentState = downloads?.state ?? status?.state;
const isInstalling = currentState === 'installing';
const isIndeterminateDownload =
isInstalling ||
(currentState === 'downloading' &&
typeof downloads?.progress !== 'number' &&
typeof status?.download_progress !== 'number');
const isInstallError = status?.state === 'degraded' && status?.error_category === 'install';
const downloadedBytes = downloads?.downloaded_bytes ?? status?.downloaded_bytes;
const totalBytes = downloads?.total_bytes ?? status?.total_bytes;
const speedBps = downloads?.speed_bps ?? status?.download_speed_bps;
const etaSeconds = downloads?.eta_seconds ?? status?.eta_seconds;
const downloadedText =
typeof downloadedBytes === 'number'
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
: '';
const speedText =
typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : '';
const etaText = formatEta(etaSeconds);
const loadStatus = async () => {
try {
const [statusResponse, assetsResponse, downloadsResponse] = await Promise.all([
openhumanLocalAiStatus(),
openhumanLocalAiAssetsStatus(),
openhumanLocalAiDownloadsProgress(),
]);
setStatus(statusResponse.result);
setAssets(assetsResponse.result);
setDownloads(downloadsResponse.result);
} catch {
// Poll failures are non-critical — don't clear action errors.
// Status/assets/downloads retain their last known values.
}
};
useEffect(() => {
void loadStatus();
const timer = setInterval(() => {
void loadStatus();
}, 1500);
return () => clearInterval(timer);
}, []);
const triggerDownload = async (force: boolean) => {
setIsTriggeringDownload(true);
setStatusError('');
setBootstrapMessage('');
try {
await openhumanLocalAiDownload(force);
await openhumanLocalAiDownloadAllAssets(force);
const freshStatus = await openhumanLocalAiStatus();
setStatus(freshStatus.result);
if (freshStatus.result?.state === 'ready') {
setBootstrapMessage(force ? 'Re-bootstrap complete' : 'Models verified');
}
setTimeout(() => setBootstrapMessage(''), 3000);
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to trigger local model bootstrap';
setStatusError(message);
} finally {
setIsTriggeringDownload(false);
}
};
const runSummaryTest = async () => {
if (!summaryInput.trim()) return;
setIsSummaryLoading(true);
setSummaryOutput('');
setStatusError('');
try {
const result = await openhumanLocalAiSummarize(summaryInput.trim(), 220);
setSummaryOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Summarization test failed');
} finally {
setIsSummaryLoading(false);
}
};
const runSuggestTest = async () => {
if (!suggestInput.trim()) return;
setIsSuggestLoading(true);
setSuggestions([]);
setStatusError('');
try {
const result = await openhumanLocalAiSuggestQuestions(suggestInput.trim());
setSuggestions(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Suggestion test failed');
} finally {
setIsSuggestLoading(false);
}
};
const runPromptTest = async () => {
if (!promptInput.trim()) return;
setIsPromptLoading(true);
setPromptOutput('');
setPromptError('');
try {
const result = await openhumanLocalAiPrompt(promptInput.trim(), 180, promptNoThink);
setPromptOutput(result.result);
await loadStatus();
} catch (err) {
setPromptError(err instanceof Error ? err.message : 'Prompt test failed');
} finally {
setIsPromptLoading(false);
}
};
const runVisionTest = async () => {
if (!visionPromptInput.trim() || !visionImageInput.trim()) return;
setIsVisionLoading(true);
setVisionOutput('');
setStatusError('');
try {
const imageRefs = visionImageInput
.split('\n')
.map(v => v.trim())
.filter(Boolean);
const result = await openhumanLocalAiVisionPrompt(visionPromptInput.trim(), imageRefs, 220);
setVisionOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Vision test failed');
} finally {
setIsVisionLoading(false);
}
};
const runEmbeddingTest = async () => {
if (!embeddingInput.trim()) return;
setIsEmbeddingLoading(true);
setEmbeddingOutput(null);
setStatusError('');
try {
const inputs = embeddingInput
.split('\n')
.map(v => v.trim())
.filter(Boolean);
const result = await openhumanLocalAiEmbed(inputs);
setEmbeddingOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Embedding test failed');
} finally {
setIsEmbeddingLoading(false);
}
};
const runTranscribeTest = async () => {
if (!audioPathInput.trim()) return;
setIsTranscribeLoading(true);
setTranscribeOutput(null);
setStatusError('');
try {
const result = await openhumanLocalAiTranscribe(audioPathInput.trim());
setTranscribeOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Transcription test failed');
} finally {
setIsTranscribeLoading(false);
}
};
const runTtsTest = async () => {
if (!ttsInput.trim()) return;
setIsTtsLoading(true);
setTtsOutput(null);
setStatusError('');
try {
const result = await openhumanLocalAiTts(
ttsInput.trim(),
ttsOutputPath.trim() ? ttsOutputPath.trim() : undefined
);
setTtsOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'TTS test failed');
} finally {
setIsTtsLoading(false);
}
};
const triggerAssetDownload = async (
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
) => {
setAssetDownloadBusy(prev => ({ ...prev, [capability]: true }));
setStatusError('');
try {
const result = await openhumanLocalAiDownloadAsset(capability);
setAssets(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : `Failed to download ${capability} asset`);
} finally {
setAssetDownloadBusy(prev => ({ ...prev, [capability]: false }));
}
};
const handleSetOllamaPath = async () => {
setIsSettingPath(true);
setStatusError('');
try {
await openhumanLocalAiSetOllamaPath(ollamaPathInput);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Failed to set Ollama path');
} finally {
setIsSettingPath(false);
}
};
const handleClearOllamaPath = async () => {
setOllamaPathInput('');
setIsSettingPath(true);
try {
await openhumanLocalAiSetOllamaPath('');
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Failed to clear Ollama path');
} finally {
setIsSettingPath(false);
}
};
const handleRunDiagnostics = async () => {
setIsDiagnosticsLoading(true);
setDiagnosticsError('');
try {
const result = await openhumanLocalAiDiagnostics();
setDiagnostics(result);
} catch (err) {
setDiagnosticsError(err instanceof Error ? err.message : 'Diagnostics failed');
} finally {
setIsDiagnosticsLoading(false);
}
};
return (
<div>
<SettingsHeader
title="Local Model Debug"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<ModelStatusSection
status={status}
downloads={downloads}
diagnostics={diagnostics}
isDiagnosticsLoading={isDiagnosticsLoading}
diagnosticsError={diagnosticsError}
statusError={statusError}
isTriggeringDownload={isTriggeringDownload}
bootstrapMessage={bootstrapMessage}
progress={progress}
isIndeterminateDownload={isIndeterminateDownload}
isInstalling={isInstalling}
isInstallError={isInstallError}
showErrorDetail={showErrorDetail}
ollamaPathInput={ollamaPathInput}
isSettingPath={isSettingPath}
downloadedText={downloadedText}
speedText={speedText}
etaText={etaText}
statusTone={statusTone}
onRefreshStatus={() => void loadStatus()}
onTriggerDownload={force => void triggerDownload(force)}
onSetOllamaPath={() => void handleSetOllamaPath()}
onClearOllamaPath={() => void handleClearOllamaPath()}
onSetOllamaPathInput={setOllamaPathInput}
onToggleErrorDetail={() => setShowErrorDetail(v => !v)}
onRunDiagnostics={() => void handleRunDiagnostics()}
/>
<ModelDownloadSection
assets={assets}
assetDownloadBusy={assetDownloadBusy}
statusTone={statusTone}
onTriggerAssetDownload={capability => void triggerAssetDownload(capability)}
summaryInput={summaryInput}
summaryOutput={summaryOutput}
isSummaryLoading={isSummaryLoading}
onSetSummaryInput={setSummaryInput}
onRunSummaryTest={() => void runSummaryTest()}
suggestInput={suggestInput}
suggestions={suggestions}
isSuggestLoading={isSuggestLoading}
onSetSuggestInput={setSuggestInput}
onRunSuggestTest={() => void runSuggestTest()}
promptInput={promptInput}
promptOutput={promptOutput}
promptError={promptError}
isPromptLoading={isPromptLoading}
promptNoThink={promptNoThink}
onSetPromptInput={setPromptInput}
onSetPromptNoThink={setPromptNoThink}
onRunPromptTest={() => void runPromptTest()}
visionPromptInput={visionPromptInput}
visionImageInput={visionImageInput}
visionOutput={visionOutput}
isVisionLoading={isVisionLoading}
onSetVisionPromptInput={setVisionPromptInput}
onSetVisionImageInput={setVisionImageInput}
onRunVisionTest={() => void runVisionTest()}
embeddingInput={embeddingInput}
embeddingOutput={embeddingOutput}
isEmbeddingLoading={isEmbeddingLoading}
onSetEmbeddingInput={setEmbeddingInput}
onRunEmbeddingTest={() => void runEmbeddingTest()}
audioPathInput={audioPathInput}
transcribeOutput={transcribeOutput}
isTranscribeLoading={isTranscribeLoading}
onSetAudioPathInput={setAudioPathInput}
onRunTranscribeTest={() => void runTranscribeTest()}
ttsInput={ttsInput}
ttsOutputPath={ttsOutputPath}
ttsOutput={ttsOutput}
isTtsLoading={isTtsLoading}
onSetTtsInput={setTtsInput}
onSetTtsOutputPath={setTtsOutputPath}
onRunTtsTest={() => void runTtsTest()}
/>
</div>
</div>
);
};
export default LocalModelDebugPanel;
@@ -8,55 +8,19 @@ import {
} from '../../../utils/localAiHelpers';
import {
type ApplyPresetResult,
type LocalAiAssetsStatus,
type LocalAiDiagnostics,
type LocalAiDownloadsProgress,
type LocalAiEmbeddingResult,
type LocalAiSpeechResult,
type LocalAiStatus,
type LocalAiSuggestion,
type LocalAiTtsResult,
openhumanLocalAiApplyPreset,
openhumanLocalAiAssetsStatus,
openhumanLocalAiDiagnostics,
openhumanLocalAiDownload,
openhumanLocalAiDownloadAllAssets,
openhumanLocalAiDownloadAsset,
openhumanLocalAiDownloadsProgress,
openhumanLocalAiEmbed,
openhumanLocalAiPresets,
openhumanLocalAiPrompt,
openhumanLocalAiSetOllamaPath,
openhumanLocalAiStatus,
openhumanLocalAiSuggestQuestions,
openhumanLocalAiSummarize,
openhumanLocalAiTranscribe,
openhumanLocalAiTts,
openhumanLocalAiVisionPrompt,
type PresetsResponse,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import DeviceCapabilitySection from './local-model/DeviceCapabilitySection';
import ModelDownloadSection from './local-model/ModelDownloadSection';
import ModelStatusSection from './local-model/ModelStatusSection';
const statusTone = (state: string): string => {
switch (state) {
case 'ready':
return 'text-green-600';
case 'downloading':
case 'installing':
case 'loading':
return 'text-primary-600';
case 'degraded':
return 'text-amber-700';
case 'disabled':
return 'text-stone-500';
default:
return 'text-stone-700';
}
};
const formatRamGb = (bytes: number): string => {
const gb = bytes / (1024 * 1024 * 1024);
@@ -64,57 +28,18 @@ const formatRamGb = (bytes: number): string => {
};
const LocalModelPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const [status, setStatus] = useState<LocalAiStatus | null>(null);
const [assets, setAssets] = useState<LocalAiAssetsStatus | null>(null);
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
const [statusError, setStatusError] = useState<string>('');
const [isTriggeringDownload, setIsTriggeringDownload] = useState(false);
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
const [assetDownloadBusy, setAssetDownloadBusy] = useState<Record<string, boolean>>({});
const [summaryInput, setSummaryInput] = useState('');
const [summaryOutput, setSummaryOutput] = useState('');
const [isSummaryLoading, setIsSummaryLoading] = useState(false);
const [suggestInput, setSuggestInput] = useState('');
const [suggestions, setSuggestions] = useState<LocalAiSuggestion[]>([]);
const [isSuggestLoading, setIsSuggestLoading] = useState(false);
const [promptInput, setPromptInput] = useState('');
const [promptOutput, setPromptOutput] = useState('');
const [isPromptLoading, setIsPromptLoading] = useState(false);
const [promptNoThink, setPromptNoThink] = useState(true);
const [promptError, setPromptError] = useState('');
const [visionPromptInput, setVisionPromptInput] = useState('');
const [visionImageInput, setVisionImageInput] = useState('');
const [visionOutput, setVisionOutput] = useState('');
const [isVisionLoading, setIsVisionLoading] = useState(false);
const [embeddingInput, setEmbeddingInput] = useState('');
const [embeddingOutput, setEmbeddingOutput] = useState<LocalAiEmbeddingResult | null>(null);
const [isEmbeddingLoading, setIsEmbeddingLoading] = useState(false);
const [audioPathInput, setAudioPathInput] = useState('');
const [transcribeOutput, setTranscribeOutput] = useState<LocalAiSpeechResult | null>(null);
const [isTranscribeLoading, setIsTranscribeLoading] = useState(false);
const [ttsInput, setTtsInput] = useState('');
const [ttsOutputPath, setTtsOutputPath] = useState('');
const [ttsOutput, setTtsOutput] = useState<LocalAiTtsResult | null>(null);
const [isTtsLoading, setIsTtsLoading] = useState(false);
const [diagnostics, setDiagnostics] = useState<LocalAiDiagnostics | null>(null);
const [isDiagnosticsLoading, setIsDiagnosticsLoading] = useState(false);
const [diagnosticsError, setDiagnosticsError] = useState('');
const [presetsData, setPresetsData] = useState<PresetsResponse | null>(null);
const [presetsLoading, setPresetsLoading] = useState(true);
const [isApplyingPreset, setIsApplyingPreset] = useState(false);
const [presetError, setPresetError] = useState('');
const [presetSuccess, setPresetSuccess] = useState<ApplyPresetResult | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const progress = useMemo(() => {
const downloadProgress = progressFromDownloads(downloads);
@@ -128,38 +53,24 @@ const LocalModelPanel = () => {
(currentState === 'downloading' &&
typeof downloads?.progress !== 'number' &&
typeof status?.download_progress !== 'number');
const isInstallError = status?.state === 'degraded' && status?.error_category === 'install';
const [showErrorDetail, setShowErrorDetail] = useState(false);
const [ollamaPathInput, setOllamaPathInput] = useState('');
const [isSettingPath, setIsSettingPath] = useState(false);
const downloadedBytes = downloads?.downloaded_bytes ?? status?.downloaded_bytes;
const totalBytes = downloads?.total_bytes ?? status?.total_bytes;
const speedBps = downloads?.speed_bps ?? status?.download_speed_bps;
const etaSeconds = downloads?.eta_seconds ?? status?.eta_seconds;
const downloadedText =
typeof downloadedBytes === 'number'
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
: '';
const speedText =
typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : '';
const etaText = formatEta(etaSeconds);
const loadStatus = async () => {
try {
const [statusResponse, assetsResponse, downloadsResponse] = await Promise.all([
const [statusResponse, downloadsResponse] = await Promise.all([
openhumanLocalAiStatus(),
openhumanLocalAiAssetsStatus(),
openhumanLocalAiDownloadsProgress(),
]);
setStatus(statusResponse.result);
setAssets(assetsResponse.result);
setDownloads(downloadsResponse.result);
setStatusError('');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to read local model status';
setStatusError(message);
setStatus(null);
setAssets(null);
setDownloads(null);
}
};
@@ -172,7 +83,6 @@ const LocalModelPanel = () => {
setPresetError('');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load presets';
console.warn('[LocalModelPanel] failed to load presets:', msg);
setPresetError(msg);
} finally {
setPresetsLoading(false);
@@ -227,188 +137,10 @@ const LocalModelPanel = () => {
}
};
const runSummaryTest = async () => {
if (!summaryInput.trim()) return;
setIsSummaryLoading(true);
setSummaryOutput('');
setStatusError('');
try {
const result = await openhumanLocalAiSummarize(summaryInput.trim(), 220);
setSummaryOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Summarization test failed');
} finally {
setIsSummaryLoading(false);
}
};
const runSuggestTest = async () => {
if (!suggestInput.trim()) return;
setIsSuggestLoading(true);
setSuggestions([]);
setStatusError('');
try {
const result = await openhumanLocalAiSuggestQuestions(suggestInput.trim());
setSuggestions(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Suggestion test failed');
} finally {
setIsSuggestLoading(false);
}
};
const runPromptTest = async () => {
if (!promptInput.trim()) return;
setIsPromptLoading(true);
setPromptOutput('');
setPromptError('');
try {
const result = await openhumanLocalAiPrompt(promptInput.trim(), 180, promptNoThink);
setPromptOutput(result.result);
await loadStatus();
} catch (err) {
setPromptError(err instanceof Error ? err.message : 'Prompt test failed');
} finally {
setIsPromptLoading(false);
}
};
const runVisionTest = async () => {
if (!visionPromptInput.trim() || !visionImageInput.trim()) return;
setIsVisionLoading(true);
setVisionOutput('');
setStatusError('');
try {
const imageRefs = visionImageInput
.split('\n')
.map(v => v.trim())
.filter(Boolean);
const result = await openhumanLocalAiVisionPrompt(visionPromptInput.trim(), imageRefs, 220);
setVisionOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Vision test failed');
} finally {
setIsVisionLoading(false);
}
};
const runEmbeddingTest = async () => {
if (!embeddingInput.trim()) return;
setIsEmbeddingLoading(true);
setEmbeddingOutput(null);
setStatusError('');
try {
const inputs = embeddingInput
.split('\n')
.map(v => v.trim())
.filter(Boolean);
const result = await openhumanLocalAiEmbed(inputs);
setEmbeddingOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Embedding test failed');
} finally {
setIsEmbeddingLoading(false);
}
};
const runTranscribeTest = async () => {
if (!audioPathInput.trim()) return;
setIsTranscribeLoading(true);
setTranscribeOutput(null);
setStatusError('');
try {
const result = await openhumanLocalAiTranscribe(audioPathInput.trim());
setTranscribeOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Transcription test failed');
} finally {
setIsTranscribeLoading(false);
}
};
const runTtsTest = async () => {
if (!ttsInput.trim()) return;
setIsTtsLoading(true);
setTtsOutput(null);
setStatusError('');
try {
const result = await openhumanLocalAiTts(
ttsInput.trim(),
ttsOutputPath.trim() ? ttsOutputPath.trim() : undefined
);
setTtsOutput(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'TTS test failed');
} finally {
setIsTtsLoading(false);
}
};
const triggerAssetDownload = async (
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
) => {
setAssetDownloadBusy(prev => ({ ...prev, [capability]: true }));
setStatusError('');
try {
const result = await openhumanLocalAiDownloadAsset(capability);
setAssets(result.result);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : `Failed to download ${capability} asset`);
} finally {
setAssetDownloadBusy(prev => ({ ...prev, [capability]: false }));
}
};
const handleSetOllamaPath = async () => {
setIsSettingPath(true);
setStatusError('');
try {
await openhumanLocalAiSetOllamaPath(ollamaPathInput);
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Failed to set Ollama path');
} finally {
setIsSettingPath(false);
}
};
const handleClearOllamaPath = async () => {
setOllamaPathInput('');
setIsSettingPath(true);
try {
await openhumanLocalAiSetOllamaPath('');
await loadStatus();
} catch (err) {
setStatusError(err instanceof Error ? err.message : 'Failed to clear Ollama path');
} finally {
setIsSettingPath(false);
}
};
const handleRunDiagnostics = async () => {
setIsDiagnosticsLoading(true);
setDiagnosticsError('');
try {
const result = await openhumanLocalAiDiagnostics();
setDiagnostics(result);
} catch (err) {
setDiagnosticsError(err instanceof Error ? err.message : 'Diagnostics failed');
} finally {
setIsDiagnosticsLoading(false);
}
};
return (
<div>
<SettingsHeader
title="Local Model"
title="Local AI Model"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -425,101 +157,86 @@ const LocalModelPanel = () => {
formatRamGb={formatRamGb}
/>
{/* Simplified download status */}
<section className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Model Status</h3>
<div className="text-sm text-stone-600">
State:{' '}
<span
className={`font-medium ${
currentState === 'ready'
? 'text-green-600'
: currentState === 'downloading' || currentState === 'installing'
? 'text-primary-600'
: currentState === 'degraded'
? 'text-amber-700'
: 'text-stone-700'
}`}>
{currentState ?? 'unknown'}
</span>
</div>
{(currentState === 'downloading' || isInstalling) && (
<div className="space-y-2">
<div className="w-full h-2 rounded-full bg-stone-200 overflow-hidden">
{isIndeterminateDownload ? (
<div className="h-full bg-primary-500 animate-pulse rounded-full w-1/2" />
) : (
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{ width: `${String(Math.min(progress ?? 0, 100))}%` }}
/>
)}
</div>
<div className="flex justify-between text-xs text-stone-500">
<span>
{typeof downloadedBytes === 'number'
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
: ''}
</span>
<span>
{typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : ''}
{etaSeconds ? ` · ${formatEta(etaSeconds)}` : ''}
</span>
</div>
</div>
)}
{bootstrapMessage && <div className="text-xs text-green-700">{bootstrapMessage}</div>}
<div className="flex gap-2">
<button
type="button"
onClick={() => void triggerDownload(false)}
disabled={isTriggeringDownload}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isTriggeringDownload ? 'Downloading…' : 'Download Models'}
</button>
<button
type="button"
onClick={() => void loadStatus()}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700">
Refresh
</button>
</div>
{statusError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
{statusError}
</div>
)}
</section>
<button
type="button"
onClick={() => setShowAdvanced(prev => !prev)}
className="flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700 transition-colors">
<svg
className={`w-4 h-4 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
onClick={() => navigateToSettings('local-model-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
Advanced settings
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{showAdvanced ? 'Hide Advanced' : 'Show Advanced'}
</button>
{showAdvanced && (
<>
<ModelStatusSection
status={status}
downloads={downloads}
diagnostics={diagnostics}
isDiagnosticsLoading={isDiagnosticsLoading}
diagnosticsError={diagnosticsError}
statusError={statusError}
isTriggeringDownload={isTriggeringDownload}
bootstrapMessage={bootstrapMessage}
progress={progress}
isIndeterminateDownload={isIndeterminateDownload}
isInstalling={isInstalling}
isInstallError={isInstallError}
showErrorDetail={showErrorDetail}
ollamaPathInput={ollamaPathInput}
isSettingPath={isSettingPath}
downloadedText={downloadedText}
speedText={speedText}
etaText={etaText}
statusTone={statusTone}
onRefreshStatus={() => void loadStatus()}
onTriggerDownload={force => void triggerDownload(force)}
onSetOllamaPath={() => void handleSetOllamaPath()}
onClearOllamaPath={() => void handleClearOllamaPath()}
onSetOllamaPathInput={setOllamaPathInput}
onToggleErrorDetail={() => setShowErrorDetail(v => !v)}
onRunDiagnostics={() => void handleRunDiagnostics()}
/>
<ModelDownloadSection
assets={assets}
assetDownloadBusy={assetDownloadBusy}
statusTone={statusTone}
onTriggerAssetDownload={capability => void triggerAssetDownload(capability)}
summaryInput={summaryInput}
summaryOutput={summaryOutput}
isSummaryLoading={isSummaryLoading}
onSetSummaryInput={setSummaryInput}
onRunSummaryTest={() => void runSummaryTest()}
suggestInput={suggestInput}
suggestions={suggestions}
isSuggestLoading={isSuggestLoading}
onSetSuggestInput={setSuggestInput}
onRunSuggestTest={() => void runSuggestTest()}
promptInput={promptInput}
promptOutput={promptOutput}
promptError={promptError}
isPromptLoading={isPromptLoading}
promptNoThink={promptNoThink}
onSetPromptInput={setPromptInput}
onSetPromptNoThink={setPromptNoThink}
onRunPromptTest={() => void runPromptTest()}
visionPromptInput={visionPromptInput}
visionImageInput={visionImageInput}
visionOutput={visionOutput}
isVisionLoading={isVisionLoading}
onSetVisionPromptInput={setVisionPromptInput}
onSetVisionImageInput={setVisionImageInput}
onRunVisionTest={() => void runVisionTest()}
embeddingInput={embeddingInput}
embeddingOutput={embeddingOutput}
isEmbeddingLoading={isEmbeddingLoading}
onSetEmbeddingInput={setEmbeddingInput}
onRunEmbeddingTest={() => void runEmbeddingTest()}
audioPathInput={audioPathInput}
transcribeOutput={transcribeOutput}
isTranscribeLoading={isTranscribeLoading}
onSetAudioPathInput={setAudioPathInput}
onRunTranscribeTest={() => void runTranscribeTest()}
ttsInput={ttsInput}
ttsOutputPath={ttsOutputPath}
ttsOutput={ttsOutput}
isTtsLoading={isTtsLoading}
onSetTtsInput={setTtsInput}
onSetTtsOutputPath={setTtsOutputPath}
onRunTtsTest={() => void runTtsTest()}
/>
</>
)}
</div>
</div>
);
@@ -0,0 +1,267 @@
import { type ComponentProps, useRef, useState } from 'react';
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const DebugSection = ({
state,
}: {
state: ComponentProps<typeof ScreenIntelligenceDebugPanel>['state'];
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<section className="space-y-3">
<button
type="button"
onClick={() => setIsOpen(prev => !prev)}
className="flex w-full items-center justify-between text-sm font-semibold text-stone-900">
<span>Debug & Diagnostics</span>
<span className="text-xs text-stone-400">{isOpen ? 'Collapse' : 'Expand'}</span>
</button>
{isOpen && <ScreenIntelligenceDebugPanel state={state} />}
</section>
);
};
const ScreenAwarenessDebugPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const {
status,
lastError,
isLoadingVision,
recentVisionSummaries,
refreshStatus,
refreshVision,
runCaptureTest,
captureTestResult,
isCaptureTestRunning,
} = useScreenIntelligenceState({ loadVision: true, visionLimit: 10, pollMs: 2000 });
const [baselineFps, setBaselineFps] = useState<string>('1');
const [useVisionModel, setUseVisionModel] = useState<boolean>(true);
const [keepScreenshots, setKeepScreenshots] = useState<boolean>(false);
const [allowlistText, setAllowlistText] = useState('');
const [denylistText, setDenylistText] = useState('');
const [isSavingConfig, setIsSavingConfig] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
// Initialize form state from server config once on first render where config
// is available. After initialization, form state is user-controlled until save.
// This runs during render (not in useEffect) so it is synchronous and avoids
// the set-state-in-effect lint rule.
const initializedRef = useRef(false);
if (!initializedRef.current && status?.config) {
initializedRef.current = true;
// One-time assignment — React batches these with the current render.
setBaselineFps(String(status.config.baseline_fps ?? 1));
setUseVisionModel(status.config.use_vision_model ?? true);
setKeepScreenshots(status.config.keep_screenshots ?? false);
setAllowlistText((status.config.allowlist ?? []).join('\n'));
setDenylistText((status.config.denylist ?? []).join('\n'));
}
const saveConfig = async () => {
if (!isTauri()) return;
setConfigError(null);
setIsSavingConfig(true);
try {
const fps = Number(baselineFps);
await openhumanUpdateScreenIntelligenceSettings({
enabled: status?.config.enabled ?? false,
policy_mode:
status?.config.policy_mode === 'whitelist_only'
? 'whitelist_only'
: 'all_except_blacklist',
baseline_fps: Number.isFinite(fps) && fps > 0 ? fps : 1,
use_vision_model: useVisionModel,
keep_screenshots: keepScreenshots,
allowlist: allowlistText
.split('\n')
.map(v => v.trim())
.filter(Boolean),
denylist: denylistText
.split('\n')
.map(v => v.trim())
.filter(Boolean),
});
await refreshStatus();
} catch (error) {
setConfigError(error instanceof Error ? error.message : 'Failed to save screen intelligence');
} finally {
setIsSavingConfig(false);
}
};
return (
<div className="z-10 relative">
<SettingsHeader
title="Screen Awareness Debug"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
{/* Advanced policy settings */}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Screen Intelligence Policy</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Baseline FPS</span>
<input
type="number"
min={0.2}
max={30}
step={0.1}
value={baselineFps}
onChange={event => setBaselineFps(event.target.value)}
className="w-24 rounded border border-stone-200 bg-white px-2 py-1 text-xs text-stone-700"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<div>
<span className="text-sm text-stone-700">Use Vision Model</span>
<p className="text-xs text-stone-400">
Send screenshots to a vision LLM for richer context. When off, only OCR text is used
with a text LLM faster and no vision model required.
</p>
</div>
<input
type="checkbox"
checked={useVisionModel}
onChange={event => setUseVisionModel(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<div>
<span className="text-sm text-stone-700">Keep Screenshots</span>
<p className="text-xs text-stone-400">
Save captured screenshots to the workspace instead of deleting after processing
</p>
</div>
<input
type="checkbox"
checked={keepScreenshots}
onChange={event => setKeepScreenshots(event.target.checked)}
/>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">Allowlist (one rule per line)</div>
<textarea
value={allowlistText}
onChange={event => setAllowlistText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600">Denylist (one rule per line)</div>
<textarea
value={denylistText}
onChange={event => setDenylistText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
/>
</div>
<button
type="button"
onClick={() => void saveConfig()}
disabled={isSavingConfig}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isSavingConfig ? 'Saving…' : 'Save Screen Intelligence Settings'}
</button>
{configError && <div className="text-xs text-red-600">{configError}</div>}
</section>
{/* Session stats */}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Session Stats</h3>
<div className="text-sm text-stone-600 space-y-1">
<div>Frames (ephemeral): {status?.session.frames_in_memory ?? 0}</div>
<div>Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</div>
<div>Vision: {status?.session.vision_state ?? 'idle'}</div>
<div>Vision queue: {status?.session.vision_queue_depth ?? 0}</div>
<div>
Last vision:{' '}
{status?.session.last_vision_at_ms
? new Date(status.session.last_vision_at_ms).toLocaleTimeString()
: 'n/a'}
</div>
</div>
</section>
{/* Vision summaries */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Vision Summaries</h3>
<button
type="button"
onClick={() => void refreshVision(10)}
disabled={isLoadingVision}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-600 disabled:opacity-50">
{isLoadingVision ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{recentVisionSummaries.length === 0 ? (
<div className="text-xs text-stone-500">No summaries yet.</div>
) : (
<div className="space-y-2">
{recentVisionSummaries.map(summary => (
<div
key={summary.id}
className="rounded-xl border border-stone-200 bg-white p-3 text-xs text-stone-200">
<div className="text-stone-500">
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
{summary.app_name ?? 'Unknown App'}
{summary.window_title ? ` · ${summary.window_title}` : ''}
</div>
<div className="mt-1 text-stone-800">{summary.actionable_notes}</div>
</div>
))}
</div>
)}
</section>
{/* Debug & Diagnostics (collapsible) */}
<DebugSection
state={{
status,
recentVisionSummaries,
lastError,
captureTestResult,
isCaptureTestRunning,
refreshStatus,
refreshVision,
runCaptureTest,
}}
/>
{/* Platform unsupported notice */}
{status !== null && !status.platform_supported && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
Screen Intelligence V1 is currently supported on macOS only.
</div>
)}
{/* Error notice */}
{lastError && (
<div className="rounded-xl border border-red-300 bg-red-50 p-3 text-sm text-red-600">
{lastError}
</div>
)}
</div>
</div>
);
};
export default ScreenAwarenessDebugPanel;
@@ -1,6 +1,5 @@
import { type ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
@@ -20,29 +19,8 @@ const formatRemaining = (remainingMs: number | null): string => {
return `${mins}:${secs}`;
};
const DebugSection = ({
state,
}: {
state: ComponentProps<typeof ScreenIntelligenceDebugPanel>['state'];
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<section className="space-y-3">
<button
type="button"
onClick={() => setIsOpen(prev => !prev)}
className="flex w-full items-center justify-between text-sm font-semibold text-stone-900">
<span>Debug & Diagnostics</span>
<span className="text-xs text-stone-400">{isOpen ? 'Collapse' : 'Expand'}</span>
</button>
{isOpen && <ScreenIntelligenceDebugPanel state={state} />}
</section>
);
};
const ScreenIntelligencePanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const {
status,
lastRestartSummary,
@@ -51,39 +29,23 @@ const ScreenIntelligencePanel = () => {
isRestartingCore,
isStartingSession,
isStoppingSession,
isLoadingVision,
isFlushingVision,
recentVisionSummaries,
lastError,
refreshStatus,
refreshVision,
startSession,
stopSession,
flushVision,
requestPermission,
refreshPermissionsWithRestart,
runCaptureTest,
captureTestResult,
isCaptureTestRunning,
} = useScreenIntelligenceState({ loadVision: true, visionLimit: 10, pollMs: 2000 });
} = useScreenIntelligenceState({ loadVision: false, pollMs: 2000 });
const [featureOverrides, setFeatureOverrides] = useState<{ screen_monitoring?: boolean }>({});
const [enabled, setEnabled] = useState<boolean>(false);
const [policyMode, setPolicyMode] = useState<'all_except_blacklist' | 'whitelist_only'>(
'all_except_blacklist'
);
const [baselineFps, setBaselineFps] = useState<string>('1');
const [useVisionModel, setUseVisionModel] = useState<boolean>(true);
const [keepScreenshots, setKeepScreenshots] = useState<boolean>(false);
const [allowlistText, setAllowlistText] = useState('');
const [denylistText, setDenylistText] = useState('');
const [isSavingConfig, setIsSavingConfig] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
// CoreStateProvider polls every 2s (CoreStateProvider.tsx POLL_MS), producing a
// new `status` object reference on every tick even when the underlying config is
// unchanged. Keying this effect on `status?.config` identity would therefore
// clobber in-progress user edits every 2 seconds. Compare the serialized value
// instead, so we only re-sync when the server config has actually changed.
const lastSyncedConfigSigRef = useRef<string | null>(null);
useEffect(() => {
if (!status?.config) {
@@ -98,11 +60,6 @@ const ScreenIntelligencePanel = () => {
setPolicyMode(
status.config.policy_mode === 'whitelist_only' ? 'whitelist_only' : 'all_except_blacklist'
);
setBaselineFps(String(status.config.baseline_fps ?? 1));
setUseVisionModel(status.config.use_vision_model ?? true);
setKeepScreenshots(status.config.keep_screenshots ?? false);
setAllowlistText((status.config.allowlist ?? []).join('\n'));
setDenylistText((status.config.denylist ?? []).join('\n'));
}, [status?.config]);
const screenMonitoring =
@@ -132,21 +89,14 @@ const ScreenIntelligencePanel = () => {
setConfigError(null);
setIsSavingConfig(true);
try {
const fps = Number(baselineFps);
await openhumanUpdateScreenIntelligenceSettings({
enabled,
policy_mode: policyMode,
baseline_fps: Number.isFinite(fps) && fps > 0 ? fps : 1,
use_vision_model: useVisionModel,
keep_screenshots: keepScreenshots,
allowlist: allowlistText
.split('\n')
.map(v => v.trim())
.filter(Boolean),
denylist: denylistText
.split('\n')
.map(v => v.trim())
.filter(Boolean),
baseline_fps: status?.config.baseline_fps ?? 1,
use_vision_model: status?.config.use_vision_model ?? true,
keep_screenshots: status?.config.keep_screenshots ?? false,
allowlist: status?.config.allowlist ?? [],
denylist: status?.config.denylist ?? [],
});
await refreshStatus();
} catch (error) {
@@ -159,7 +109,7 @@ const ScreenIntelligencePanel = () => {
return (
<div className="z-10 relative">
<SettingsHeader
title="Screen Intelligence"
title="Screen Awareness"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -182,7 +132,7 @@ const ScreenIntelligencePanel = () => {
/>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Screen Intelligence Policy</h3>
<h3 className="text-sm font-semibold text-stone-900">Screen Awareness</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Enabled</span>
@@ -210,77 +160,6 @@ const ScreenIntelligencePanel = () => {
</select>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Baseline FPS</span>
<input
type="number"
min={0.2}
max={30}
step={0.1}
value={baselineFps}
onChange={event => setBaselineFps(event.target.value)}
className="w-24 rounded border border-stone-200 bg-white px-2 py-1 text-xs text-stone-700"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<div>
<span className="text-sm text-stone-700">Use Vision Model</span>
<p className="text-xs text-stone-400">
Send screenshots to a vision LLM for richer context. When off, only OCR text is used
with a text LLM faster and no vision model required.
</p>
</div>
<input
type="checkbox"
checked={useVisionModel}
onChange={event => setUseVisionModel(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<div>
<span className="text-sm text-stone-700">Keep Screenshots</span>
<p className="text-xs text-stone-400">
Save captured screenshots to the workspace instead of deleting after processing
</p>
</div>
<input
type="checkbox"
checked={keepScreenshots}
onChange={event => setKeepScreenshots(event.target.checked)}
/>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">Allowlist (one rule per line)</div>
<textarea
value={allowlistText}
onChange={event => setAllowlistText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600">Denylist (one rule per line)</div>
<textarea
value={denylistText}
onChange={event => setDenylistText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
/>
</div>
<button
type="button"
onClick={() => void saveConfig()}
disabled={isSavingConfig}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isSavingConfig ? 'Saving…' : 'Save Screen Intelligence Settings'}
</button>
{configError && <div className="text-xs text-red-600">{configError}</div>}
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Screen Monitoring</span>
<input
@@ -294,6 +173,15 @@ const ScreenIntelligencePanel = () => {
}
/>
</label>
<button
type="button"
onClick={() => void saveConfig()}
disabled={isSavingConfig}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isSavingConfig ? 'Saving…' : 'Save Settings'}
</button>
{configError && <div className="text-xs text-red-600">{configError}</div>}
</section>
<section className="space-y-3">
@@ -301,16 +189,6 @@ const ScreenIntelligencePanel = () => {
<div className="text-sm text-stone-600 space-y-1">
<div>Status: {status?.session.active ? 'Active' : 'Stopped'}</div>
<div>Remaining: {remaining}</div>
<div>Frames (ephemeral): {status?.session.frames_in_memory ?? 0}</div>
<div>Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</div>
<div>Vision: {status?.session.vision_state ?? 'idle'}</div>
<div>Vision queue: {status?.session.vision_queue_depth ?? 0}</div>
<div>
Last vision:{' '}
{status?.session.last_vision_at_ms
? new Date(status.session.last_vision_at_ms).toLocaleTimeString()
: 'n/a'}
</div>
</div>
<div className="flex gap-2">
@@ -344,53 +222,9 @@ const ScreenIntelligencePanel = () => {
</div>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Vision Summaries</h3>
<button
type="button"
onClick={() => void refreshVision(10)}
disabled={isLoadingVision}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-600 disabled:opacity-50">
{isLoadingVision ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{recentVisionSummaries.length === 0 ? (
<div className="text-xs text-stone-500">No summaries yet.</div>
) : (
<div className="space-y-2">
{recentVisionSummaries.map(summary => (
<div
key={summary.id}
className="rounded-xl border border-stone-200 bg-white p-3 text-xs text-stone-200">
<div className="text-stone-500">
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
{summary.app_name ?? 'Unknown App'}
{summary.window_title ? ` · ${summary.window_title}` : ''}
</div>
<div className="mt-1 text-stone-800">{summary.actionable_notes}</div>
</div>
))}
</div>
)}
</section>
<DebugSection
state={{
status,
recentVisionSummaries,
lastError,
captureTestResult,
isCaptureTestRunning,
refreshStatus,
refreshVision,
runCaptureTest,
}}
/>
{status !== null && !status.platform_supported && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
Screen Intelligence V1 is currently supported on macOS only.
Screen Awareness is currently supported on macOS only.
</div>
)}
@@ -399,6 +233,16 @@ const ScreenIntelligencePanel = () => {
{lastError}
</div>
)}
<button
type="button"
onClick={() => navigateToSettings('screen-awareness-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
Advanced settings
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
@@ -0,0 +1,249 @@
import { useEffect, useRef, useState } from 'react';
import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
openhumanVoiceServerStatus,
openhumanVoiceStatus,
type VoiceServerSettings,
type VoiceServerStatus,
type VoiceStatus,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const VoiceDebugPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
const [serverStatus, setServerStatus] = useState<VoiceServerStatus | null>(null);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const settingsRef = useRef<VoiceServerSettings | null>(null);
const savedSettingsRef = useRef<VoiceServerSettings | null>(null);
const hasUnsavedChanges =
settings != null &&
savedSettings != null &&
JSON.stringify(settings) !== JSON.stringify(savedSettings);
useEffect(() => {
settingsRef.current = settings;
}, [settings]);
useEffect(() => {
savedSettingsRef.current = savedSettings;
}, [savedSettings]);
const loadData = async (forceSettings = false) => {
try {
const [settingsResponse, serverResponse, voiceResponse] = await Promise.all([
openhumanGetVoiceServerSettings(),
openhumanVoiceServerStatus(),
openhumanVoiceStatus(),
]);
// Only overwrite local settings if there are no unsaved edits,
// or if explicitly forced (e.g. after save or initial load).
// This prevents the 2s polling timer from clobbering user input.
const currentSettings = settingsRef.current;
const currentSavedSettings = savedSettingsRef.current;
if (
forceSettings ||
!currentSettings ||
JSON.stringify(currentSettings) === JSON.stringify(currentSavedSettings)
) {
setSettings(settingsResponse.result);
}
setSavedSettings(settingsResponse.result);
setServerStatus(serverResponse);
setVoiceStatus(voiceResponse);
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load voice debug data';
setError(message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadData(true);
const timer = window.setInterval(() => {
void loadData(false);
}, 2000);
return () => window.clearInterval(timer);
}, []);
const updateSetting = <K extends keyof VoiceServerSettings>(
key: K,
value: VoiceServerSettings[K]
) => {
setSettings(current => (current ? { ...current, [key]: value } : current));
};
const saveSettings = async () => {
if (!settings) return;
setIsSaving(true);
setError(null);
setNotice(null);
try {
await openhumanUpdateVoiceServerSettings({
auto_start: settings.auto_start,
hotkey: settings.hotkey,
activation_mode: settings.activation_mode,
skip_cleanup: settings.skip_cleanup,
min_duration_secs: settings.min_duration_secs,
silence_threshold: settings.silence_threshold,
custom_dictionary: settings.custom_dictionary,
});
setNotice('Debug settings saved.');
await loadData(true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save voice settings';
setError(message);
} finally {
setIsSaving(false);
}
};
return (
<div>
<SettingsHeader
title="Voice Debug"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
{/* Runtime status section */}
<section className="space-y-3">
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-stone-900">Runtime Status</h3>
<p className="text-xs text-stone-500 mt-1">
Live diagnostics for the voice server and speech-to-text engine.
</p>
</div>
<button
type="button"
onClick={() => void loadData()}
className="text-xs text-primary-600 hover:text-primary-700">
Refresh
</button>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-stone-200 bg-white p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500">Server</div>
<div className="mt-1 font-medium text-stone-900">
{serverStatus ? serverStatus.state : isLoading ? 'Loading…' : 'Unavailable'}
</div>
</div>
<div className="rounded-md border border-stone-200 bg-white p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500">STT</div>
<div className="mt-1 font-medium text-stone-900">
{voiceStatus?.stt_available ? 'Ready' : 'Not ready'}
</div>
</div>
</div>
{serverStatus && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-stone-600">
<div>Hotkey: {serverStatus.hotkey || 'n/a'}</div>
<div>Mode: {serverStatus.activation_mode}</div>
<div>Transcriptions: {serverStatus.transcription_count}</div>
</div>
)}
{serverStatus?.last_error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
<div className="font-medium mb-1">Server Error</div>
{serverStatus.last_error}
</div>
)}
</div>
</section>
{/* Advanced settings section */}
<section className="space-y-3">
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">Advanced Settings</h3>
<p className="text-xs text-stone-500 mt-1">
Low-level tuning parameters for recording and silence detection.
</p>
</div>
{settings && (
<>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">
Minimum Recording Seconds
</span>
<input
type="number"
min="0"
step="0.1"
value={settings.min_duration_secs}
onChange={e => updateSetting('min_duration_secs', Number(e.target.value) || 0)}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">
Silence Threshold (RMS)
</span>
<p className="text-[11px] text-stone-400">
Recordings with energy below this are treated as silence and skipped. Lower =
more sensitive.
</p>
<input
type="number"
min="0"
max="1"
step="0.001"
value={settings.silence_threshold}
onChange={e =>
updateSetting('silence_threshold', Number(e.target.value) || 0.002)
}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
</>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
{error}
</div>
)}
{notice && (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-700">
{notice}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => void saveSettings()}
disabled={isSaving || !hasUnsavedChanges}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{isSaving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</section>
</div>
</div>
);
};
export default VoiceDebugPanel;
+20 -100
View File
@@ -20,9 +20,9 @@ const VoicePanel = () => {
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
const [serverStatus, setServerStatus] = useState<VoiceServerStatus | null>(null);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(null);
const [, setVoiceStatus] = useState<VoiceStatus | null>(null);
const [sttReady, setSttReady] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isStopping, setIsStopping] = useState(false);
@@ -53,9 +53,6 @@ const VoicePanel = () => {
openhumanVoiceStatus(),
openhumanLocalAiAssetsStatus(),
]);
// Only overwrite local settings if there are no unsaved edits,
// or if explicitly forced (e.g. after save or initial load).
// This prevents the 2s polling timer from clobbering user input.
const currentSettings = settingsRef.current;
const currentSavedSettings = savedSettingsRef.current;
if (
@@ -191,60 +188,12 @@ const VoicePanel = () => {
/>
<div className="p-4 space-y-4">
<section className="space-y-3">
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-stone-900">Runtime</h3>
<p className="text-xs text-stone-500 mt-1">
Hold the hotkey to dictate and insert text into the active field.
</p>
</div>
<button
type="button"
onClick={() => void loadData()}
className="text-xs text-primary-600 hover:text-primary-700">
Refresh
</button>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-stone-200 bg-white p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500">Server</div>
<div className="mt-1 font-medium text-stone-900">
{serverStatus ? serverStatus.state : isLoading ? 'Loading…' : 'Unavailable'}
</div>
</div>
<div className="rounded-md border border-stone-200 bg-white p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500">STT</div>
<div className="mt-1 font-medium text-stone-900">
{voiceStatus?.stt_available ? 'Ready' : 'Not ready'}
</div>
</div>
</div>
{serverStatus && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-stone-600">
<div>Hotkey: {serverStatus.hotkey || 'n/a'}</div>
<div>Mode: {serverStatus.activation_mode}</div>
<div>Transcriptions: {serverStatus.transcription_count}</div>
</div>
)}
{serverStatus?.last_error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
{serverStatus.last_error}
</div>
)}
</div>
</section>
<section className={`space-y-3 ${disabled ? 'opacity-60' : ''}`}>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">Voice Server Settings</h3>
<h3 className="text-sm font-semibold text-stone-900">Voice Settings</h3>
<p className="text-xs text-stone-500 mt-1">
Configure startup behavior, hotkey handling, and transcription cleanup.
Hold the hotkey to dictate and insert text into the active field.
</p>
</div>
@@ -286,53 +235,14 @@ const VoicePanel = () => {
</label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="flex items-center gap-2 text-sm text-stone-700">
<input
type="checkbox"
checked={settings.auto_start}
onChange={e => updateSetting('auto_start', e.target.checked)}
className="h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
/>
Start voice server automatically with the core
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">
Minimum Recording Seconds
</span>
<input
type="number"
min="0"
step="0.1"
value={settings.min_duration_secs}
onChange={e =>
updateSetting('min_duration_secs', Number(e.target.value) || 0)
}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">
Silence Threshold (RMS)
</span>
<p className="text-[11px] text-stone-400">
Recordings with energy below this are treated as silence and skipped. Lower =
more sensitive.
</p>
<label className="flex items-center gap-2 text-sm text-stone-700">
<input
type="number"
min="0"
max="1"
step="0.001"
value={settings.silence_threshold}
onChange={e =>
updateSetting('silence_threshold', Number(e.target.value) || 0.002)
}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
type="checkbox"
checked={settings.auto_start}
onChange={e => updateSetting('auto_start', e.target.checked)}
className="h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
/>
Start voice server automatically with the core
</label>
<div className="space-y-2">
@@ -452,6 +362,16 @@ const VoicePanel = () => {
</div>
</div>
</section>
<button
type="button"
onClick={() => navigateToSettings('voice-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
Advanced settings
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
@@ -1,97 +0,0 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import {
type ScreenIntelligenceState,
useScreenIntelligenceState,
} from '../../../../features/screen-intelligence/useScreenIntelligenceState';
import AccessibilityPanel from '../AccessibilityPanel';
vi.mock('../../../../features/screen-intelligence/useScreenIntelligenceState', () => ({
useScreenIntelligenceState: vi.fn(),
}));
const mockState: ScreenIntelligenceState = {
status: {
platform_supported: true,
permissions: {
screen_recording: 'unknown',
accessibility: 'granted',
input_monitoring: 'unknown',
},
features: { screen_monitoring: true },
session: {
active: false,
started_at_ms: null,
expires_at_ms: null,
remaining_ms: null,
ttl_secs: 300,
panic_hotkey: 'Cmd+Shift+.',
stop_reason: null,
frames_in_memory: 0,
last_capture_at_ms: null,
last_context: null,
vision_enabled: true,
vision_state: 'idle',
vision_queue_depth: 0,
last_vision_at_ms: null,
last_vision_summary: null,
},
config: {
enabled: true,
capture_policy: 'hybrid',
policy_mode: 'all_except_blacklist',
baseline_fps: 1,
vision_enabled: true,
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
use_vision_model: true,
keep_screenshots: false,
allowlist: [],
denylist: ['wallet'],
},
denylist: ['wallet'],
is_context_blocked: false,
},
lastRestartSummary: null,
recentVisionSummaries: [],
captureTestResult: null,
isCaptureTestRunning: false,
isLoading: false,
isRequestingPermissions: false,
isRestartingCore: false,
isStartingSession: false,
isStoppingSession: false,
isLoadingVision: false,
isFlushingVision: false,
lastError: null,
refreshStatus: vi.fn(),
requestPermission: vi.fn(),
refreshPermissionsWithRestart: vi.fn(),
startSession: vi.fn(),
stopSession: vi.fn(),
refreshVision: vi.fn(),
flushVision: vi.fn(),
runCaptureTest: vi.fn(),
clearError: vi.fn(),
};
describe('AccessibilityPanel', () => {
it('renders permission and session sections', () => {
vi.mocked(useScreenIntelligenceState).mockReturnValue(mockState);
render(
<MemoryRouter initialEntries={['/settings/accessibility']}>
<AccessibilityPanel />
</MemoryRouter>
);
expect(screen.getByText('Accessibility Automation')).toBeInTheDocument();
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByText('Session')).toBeInTheDocument();
expect(screen.queryByText('Screen Recording')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start Session' })).toBeInTheDocument();
});
});
@@ -1,19 +1,13 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import {
type AutocompleteConfig,
type AutocompleteCurrentParams,
type AutocompleteStatus,
type CommandResponse,
type ConfigSnapshot,
isTauri,
openhumanAutocompleteAccept,
openhumanAutocompleteClearHistory,
openhumanAutocompleteCurrent,
openhumanAutocompleteDebugFocus,
openhumanAutocompleteHistory,
openhumanAutocompleteSetStyle,
openhumanAutocompleteStart,
openhumanAutocompleteStatus,
@@ -52,7 +46,7 @@ const cloneStatus = (status: AutocompleteStatus): AutocompleteStatus => ({
suggestion: status.suggestion ? { ...status.suggestion } : null,
});
describe('AutocompletePanel', () => {
describe('AutocompletePanel (simplified)', () => {
let runtime: RuntimeHarness;
beforeEach(() => {
@@ -87,25 +81,13 @@ describe('AutocompletePanel', () => {
vi.mocked(openhumanAutocompleteStatus).mockImplementation(async () => ({
result: cloneStatus(runtime.status),
logs: [
`[autocomplete] status running=${runtime.status.running ? 'yes' : 'no'} phase=${runtime.status.phase}`,
],
logs: [],
}));
vi.mocked(openhumanGetConfig).mockImplementation(async () =>
makeConfigSnapshot(runtime.config)
);
vi.mocked(openhumanAutocompleteHistory).mockResolvedValue({
result: { entries: [] },
logs: ['[autocomplete] history entries=0'],
});
vi.mocked(openhumanAutocompleteClearHistory).mockResolvedValue({
result: { cleared: 0 },
logs: ['[autocomplete] history cleared=0'],
});
vi.mocked(openhumanAutocompleteSetStyle).mockImplementation(async params => {
runtime.config = {
...runtime.config,
@@ -115,181 +97,125 @@ describe('AutocompletePanel', () => {
disabled_apps: params.disabled_apps ?? runtime.config.disabled_apps,
};
runtime.status.enabled = runtime.config.enabled;
runtime.status.debounce_ms = runtime.config.debounce_ms;
if (!runtime.config.enabled) {
runtime.status.running = false;
runtime.status.phase = 'disabled';
runtime.status.suggestion = null;
}
return {
result: { config: { ...runtime.config } },
logs: [
`[autocomplete] set_style enabled=${String(runtime.config.enabled)} debounce=${String(runtime.config.debounce_ms)} max_chars=${String(runtime.config.max_chars)} accept_with_tab=${String(runtime.config.accept_with_tab)}`,
],
};
return { result: { config: { ...runtime.config } }, logs: [] };
});
vi.mocked(openhumanAutocompleteStart).mockImplementation(async params => {
vi.mocked(openhumanAutocompleteStart).mockImplementation(async () => {
if (!runtime.config.enabled) {
return { result: { started: false }, logs: ['[autocomplete] start blocked: disabled'] };
return { result: { started: false }, logs: [] };
}
runtime.status.running = true;
runtime.status.phase = 'idle';
runtime.status.debounce_ms = params?.debounce_ms ?? runtime.config.debounce_ms;
runtime.status.updated_at_ms = Date.now();
return {
result: { started: true },
logs: [`[autocomplete] start running=yes debounce=${String(runtime.status.debounce_ms)}`],
};
return { result: { started: true }, logs: [] };
});
vi.mocked(openhumanAutocompleteStop).mockImplementation(async () => {
runtime.status.running = false;
runtime.status.phase = 'idle';
runtime.status.suggestion = null;
runtime.status.updated_at_ms = Date.now();
return { result: { stopped: true }, logs: ['[autocomplete] stop running=no'] };
});
vi.mocked(openhumanAutocompleteCurrent).mockImplementation(
async (params?: AutocompleteCurrentParams) => {
const context = params?.context?.trim() ?? '';
const suggestion = context ? 'completion draft' : '';
runtime.status.app_name = 'OpenHuman';
runtime.status.phase = suggestion ? 'ready' : 'idle';
runtime.status.suggestion = suggestion ? { value: suggestion, confidence: 0.74 } : null;
runtime.status.updated_at_ms = Date.now();
return {
result: {
app_name: runtime.status.app_name,
context,
suggestion: runtime.status.suggestion,
},
logs: [
`[autocomplete] current context_chars=${String(context.length)} suggestion=${suggestion ? 'yes' : 'no'}`,
],
};
}
);
vi.mocked(openhumanAutocompleteAccept).mockImplementation(async params => {
const value = params?.suggestion ?? runtime.status.suggestion?.value ?? '';
if (!value) {
return {
result: {
accepted: false,
applied: false,
value: null,
reason: 'no suggestion available',
},
logs: ['[autocomplete] accept no-op'],
};
}
runtime.status.phase = 'idle';
runtime.status.suggestion = null;
runtime.status.updated_at_ms = Date.now();
return {
result: { accepted: true, applied: true, value, reason: null },
logs: [`[autocomplete] accept applied value_chars=${String(value.length)}`],
};
});
vi.mocked(openhumanAutocompleteDebugFocus).mockResolvedValue({
result: {
app_name: 'OpenHuman',
role: 'TextArea',
context: 'draft context',
selected_text: null,
raw_error: null,
},
logs: ['[autocomplete] debug focus'],
return { result: { stopped: true }, logs: [] };
});
});
it('runs start → suggest → accept flow and reflects status, settings, and logs', async () => {
it('shows user-facing settings and can save style preset changes', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Inline Autocomplete');
await screen.findByText('Autocomplete');
// Verify user-facing controls are present
expect(screen.getByText('Enabled')).toBeInTheDocument();
expect(screen.getByText('Accept With Tab')).toBeInTheDocument();
expect(screen.getByText('Style Preset')).toBeInTheDocument();
// Verify runtime status section shows
await waitFor(() => {
expect(screen.getByText('Phase: idle')).toBeInTheDocument();
expect(screen.getByText('Running: no')).toBeInTheDocument();
});
expect(screen.getByText('Platform supported: yes')).toBeInTheDocument();
expect(screen.getByText('Running: no')).toBeInTheDocument();
expect(screen.getByText('Debounce: 120ms')).toBeInTheDocument();
// Change style preset and save
const presetRow = screen.getByText('Style Preset').closest('label');
const presetSelect = presetRow?.querySelector('select') as HTMLSelectElement;
fireEvent.change(presetSelect, { target: { value: 'concise' } });
const debounceRow = screen.getByText('Debounce (ms)').closest('label');
const debounceInput = debounceRow?.querySelector('input') as HTMLInputElement;
fireEvent.change(debounceInput, { target: { value: '220' } });
const maxCharsRow = screen.getByText('Max Chars').closest('label');
const maxCharsInput = maxCharsRow?.querySelector('input') as HTMLInputElement;
fireEvent.change(maxCharsInput, { target: { value: '256' } });
const acceptWithTabRow = screen.getByText('Accept With Tab').closest('label');
const acceptWithTabInput = acceptWithTabRow?.querySelector('input') as HTMLInputElement;
fireEvent.click(acceptWithTabInput);
fireEvent.click(screen.getByRole('button', { name: 'Save Autocomplete Settings' }));
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ debounce_ms: 220, max_chars: 256, accept_with_tab: false })
expect.objectContaining({ style_preset: 'concise', accept_with_tab: true })
);
});
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
expect(await screen.findByText('Autocomplete settings saved.')).toBeInTheDocument();
});
it('can start and stop the autocomplete runtime', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
// Wait for status to load
await waitFor(() => {
expect(screen.getByText('Running: no')).toBeInTheDocument();
});
// Start
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
await waitFor(() => {
expect(openhumanAutocompleteStart).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText('Running: yes')).toBeInTheDocument();
expect(screen.getByText('Debounce: 220ms')).toBeInTheDocument();
expect(screen.getByText('Autocomplete started.')).toBeInTheDocument();
});
const contextLabel = screen.getByText('Context Override (optional)');
const contextInput = contextLabel.parentElement?.querySelector(
'textarea'
) as HTMLTextAreaElement;
fireEvent.change(contextInput, { target: { value: 'Please review this change' } });
fireEvent.click(screen.getByRole('button', { name: 'Get Suggestion' }));
// Stop
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
await waitFor(() => {
expect(openhumanAutocompleteCurrent).toHaveBeenCalledWith({
context: 'Please review this change',
});
expect(openhumanAutocompleteStop).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText('Current suggestion: completion draft')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Accept Suggestion' }));
await waitFor(() => {
expect(openhumanAutocompleteAccept).toHaveBeenCalledWith({
suggestion: 'completion draft',
skip_apply: true,
});
});
await waitFor(() => {
expect(screen.getByText('Current suggestion: none')).toBeInTheDocument();
expect(screen.getByText('Accepted: completion draft')).toBeInTheDocument();
});
const logsSection = screen.getByText('Live Logs').closest('section');
expect(logsSection).not.toBeNull();
const logsScope = within(logsSection as HTMLElement);
const logsOutput = (logsSection as HTMLElement).querySelector('pre') as HTMLElement;
expect(logsOutput.textContent).toContain('[autocomplete] start');
expect(logsOutput.textContent).toContain('[autocomplete] current');
expect(logsOutput.textContent).toContain('phase idle -> ready');
expect(logsOutput.textContent).toContain('phase ready -> idle');
fireEvent.click(logsScope.getByRole('button', { name: 'Clear' }));
await waitFor(() => {
expect(logsOutput.textContent).toContain('No logs yet.');
expect(screen.getByText('Autocomplete stopped.')).toBeInTheDocument();
});
});
it('preserves advanced settings when saving from the simplified panel', async () => {
runtime.config.debounce_ms = 500;
runtime.config.max_chars = 800;
runtime.config.overlay_ttl_ms = 2000;
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
// Wait for config to load
await waitFor(() => {
expect(screen.getByText('Running: no')).toBeInTheDocument();
});
// Toggle enabled off and save
const enabledLabel = screen.getByText('Enabled').closest('label');
const enabledCheckbox = enabledLabel?.querySelector(
'input[type="checkbox"]'
) as HTMLInputElement;
fireEvent.click(enabledCheckbox);
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
debounce_ms: 500,
max_chars: 800,
overlay_ttl_ms: 2000,
})
);
});
});
it('shows the Advanced settings link', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
expect(screen.getByText('Advanced settings')).toBeInTheDocument();
});
});
@@ -117,13 +117,18 @@ describe('ScreenIntelligencePanel', () => {
vi.mocked(isTauri).mockReturnValue(true);
});
it('saves screen intelligence settings and refreshes core-backed status', async () => {
it('saves screen awareness settings and refreshes core-backed status', async () => {
const deferred = createDeferred<{ result: ConfigSnapshot; logs: [] }>();
vi.mocked(openhumanUpdateScreenIntelligenceSettings).mockReturnValueOnce(deferred.promise);
renderPanel();
await screen.findByText('Screen Intelligence Policy');
// Both the header h2 and section h3 say "Screen Awareness" — wait for either.
await waitFor(() => {
expect(screen.getAllByRole('heading', { name: 'Screen Awareness' }).length).toBeGreaterThan(
0
);
});
const enabledLabel = screen.getByText('Enabled').closest('label');
const enabledCheckbox = enabledLabel?.querySelector(
@@ -132,18 +137,12 @@ describe('ScreenIntelligencePanel', () => {
expect(enabledCheckbox.checked).toBe(false);
fireEvent.click(enabledCheckbox);
fireEvent.click(screen.getByRole('button', { name: 'Save Screen Intelligence Settings' }));
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeInTheDocument();
expect(openhumanUpdateScreenIntelligenceSettings).toHaveBeenCalledWith({
enabled: true,
policy_mode: 'all_except_blacklist',
baseline_fps: 1,
use_vision_model: true,
keep_screenshots: false,
allowlist: ['Code'],
denylist: ['1Password'],
});
expect(openhumanUpdateScreenIntelligenceSettings).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, policy_mode: 'all_except_blacklist' })
);
deferred.resolve({
result: { config: {}, workspace_dir: '/tmp/workspace', config_path: '/tmp/config.toml' },
@@ -151,9 +150,7 @@ describe('ScreenIntelligencePanel', () => {
});
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Save Screen Intelligence Settings' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save Settings' })).toBeInTheDocument();
});
expect(baseState.refreshStatus).toHaveBeenCalledTimes(1);
});
@@ -178,7 +175,7 @@ describe('ScreenIntelligencePanel', () => {
screen.getByRole('button', { name: 'Restart & Refresh Permissions' })
).toBeInTheDocument();
expect(
screen.getByText('Screen Intelligence V1 is currently supported on macOS only.')
screen.getByText('Screen Awareness is currently supported on macOS only.')
).toBeInTheDocument();
});
+99 -104
View File
@@ -1,25 +1,28 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import AccessibilityPanel from '../components/settings/panels/AccessibilityPanel';
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
import AIPanel from '../components/settings/panels/AIPanel';
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
import AutocompletePanel from '../components/settings/panels/AutocompletePanel';
import BillingPanel from '../components/settings/panels/BillingPanel';
import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel';
import CronJobsPanel from '../components/settings/panels/CronJobsPanel';
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel';
import LocalModelPanel from '../components/settings/panels/LocalModelPanel';
import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel';
import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel';
import MessagingPanel from '../components/settings/panels/MessagingPanel';
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel';
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel';
import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel';
import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel';
import TeamPanel from '../components/settings/panels/TeamPanel';
import ToolsPanel from '../components/settings/panels/ToolsPanel';
import VoiceDebugPanel from '../components/settings/panels/VoiceDebugPanel';
import VoicePanel from '../components/settings/panels/VoicePanel';
import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel';
import SettingsHome from '../components/settings/SettingsHome';
@@ -75,29 +78,45 @@ const accountSettingsItems = [
</svg>
),
},
];
const automationSettingsItems = [
{
id: 'accessibility',
title: 'Accessibility Automation',
description: 'Desktop permissions, assisted controls, and safety-bound sessions',
route: 'accessibility',
id: 'billing',
title: 'Billing & Usage',
description: 'Subscription plan, pay-as-you-go credits, and payment methods',
route: 'billing',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-7 9h8a2 2 0 002-2V7a2 2 0 00-2-2h-1l-.707-.707A1 1 0 0013.586 4h-3.172a1 1 0 00-.707.293L9 5H8a2 2 0 00-2 2v12a2 2 0 002 2z"
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
),
},
{
id: 'privacy',
title: 'Privacy',
description: 'Manage data sharing and anonymized usage preferences',
route: 'privacy',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
),
},
];
const featuresSettingsItems = [
{
id: 'screen-intelligence',
title: 'Screen Intelligence',
description: 'Window capture policy, vision summaries, and memory ingestion',
title: 'Screen Awareness',
description: 'Screen capture permissions, monitoring policy, and session controls',
route: 'screen-intelligence',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -112,8 +131,8 @@ const automationSettingsItems = [
},
{
id: 'autocomplete',
title: 'Inline Autocomplete',
description: 'Manage predictive text style, app filters, and live completion controls',
title: 'Autocomplete',
description: 'Inline text completion style and app preferences',
route: 'autocomplete',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -126,6 +145,22 @@ const automationSettingsItems = [
</svg>
),
},
{
id: 'voice',
title: 'Voice Dictation',
description: 'Hotkey, activation mode, writing style, and custom dictionary',
route: 'voice',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3a3 3 0 00-3 3v6a3 3 0 006 0V6a3 3 0 00-3-3zm-7 9a7 7 0 0014 0m-7 7v2m-4 0h8"
/>
</svg>
),
},
{
id: 'messaging',
title: 'Messaging Channels',
@@ -142,73 +177,6 @@ const automationSettingsItems = [
</svg>
),
},
{
id: 'cron-jobs',
title: 'Cron Jobs',
description: 'View and configure scheduled jobs for runtime skills',
route: 'cron-jobs',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
},
];
const aiSettingsItems = [
{
id: 'voice',
title: 'Voice Dictation',
description: 'Manage dictation startup, hotkeys, writing style, and runtime controls',
route: 'voice',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3a3 3 0 00-3 3v6a3 3 0 006 0V6a3 3 0 00-3-3zm-7 9a7 7 0 0014 0m-7 7v2m-4 0h8"
/>
</svg>
),
},
{
id: 'local-model',
title: 'Local AI Model',
description: 'Choose model tier by device capability and manage downloads',
route: 'local-model',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
),
},
{
id: 'ai',
title: 'AI Configuration',
description: 'Configure persona, prompting behavior, and AI runtime settings',
route: 'ai',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3l1.9 3.85 4.25.62-3.08 3 .73 4.23L12 12.77 8.2 14.7l.73-4.23-3.08-3 4.25-.62L12 3z"
/>
</svg>
),
},
{
id: 'tools',
title: 'Tools',
@@ -233,6 +201,25 @@ const aiSettingsItems = [
},
];
const aiModelsSettingsItems = [
{
id: 'local-model',
title: 'Local AI Model',
description: 'Choose model tier by device capability and manage downloads',
route: 'local-model',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
),
},
];
const Settings = () => {
return (
<div className="p-4 pt-6">
@@ -243,56 +230,64 @@ const Settings = () => {
path="account"
element={
<SettingsSectionPage
title="Account & Security"
description="Recovery, team access, and linked account settings."
title="Account & Billing"
description="Recovery phrase, team, connections, billing, and privacy settings."
items={accountSettingsItems}
/>
}
/>
<Route
path="automation"
path="features"
element={
<SettingsSectionPage
title="Automation & Channels"
description="Desktop automation, capture, messaging, and scheduled jobs."
items={automationSettingsItems}
title="Features"
description="Screen awareness, autocomplete, voice, messaging, and tools."
items={featuresSettingsItems}
/>
}
/>
<Route
path="ai-tools"
path="ai-models"
element={
<SettingsSectionPage
title="AI & Skills"
description="Model management, AI behavior, and skill configuration."
items={aiSettingsItems}
title="AI & Models"
description="Local AI model setup and management."
items={aiModelsSettingsItems}
/>
}
/>
<Route path="connections" element={<ConnectionsPanel />} />
<Route path="messaging" element={<MessagingPanel />} />
<Route path="cron-jobs" element={<CronJobsPanel />} />
<Route path="screen-intelligence" element={<ScreenIntelligencePanel />} />
<Route path="autocomplete" element={<AutocompletePanel />} />
<Route path="privacy" element={<PrivacyPanel />} />
<Route path="agent-chat" element={<AgentChatPanel />} />
<Route path="ai" element={<AIPanel />} />
<Route path="accessibility" element={<AccessibilityPanel />} />
<Route path="local-model" element={<LocalModelPanel />} />
<Route path="voice" element={<VoicePanel />} />
<Route path="billing" element={<BillingPanel />} />
<Route path="tools" element={<ToolsPanel />} />
{/* Account & Billing leaf panels */}
<Route path="recovery-phrase" element={<RecoveryPhrasePanel />} />
<Route path="team" element={<TeamPanel />} />
<Route path="team/manage/:teamId" element={<TeamManagementPanel />} />
<Route path="team/manage/:teamId/members" element={<TeamMembersPanel />} />
<Route path="team/manage/:teamId/invites" element={<TeamInvitesPanel />} />
<Route path="team/members" element={<TeamMembersPanel />} />
<Route path="team/invites" element={<TeamInvitesPanel />} />
<Route path="connections" element={<ConnectionsPanel />} />
<Route path="billing" element={<BillingPanel />} />
<Route path="privacy" element={<PrivacyPanel />} />
{/* Features leaf panels */}
<Route path="screen-intelligence" element={<ScreenIntelligencePanel />} />
<Route path="autocomplete" element={<AutocompletePanel />} />
<Route path="voice" element={<VoicePanel />} />
<Route path="messaging" element={<MessagingPanel />} />
<Route path="tools" element={<ToolsPanel />} />
{/* AI & Models leaf panels */}
<Route path="local-model" element={<LocalModelPanel />} />
{/* Developer Options */}
<Route path="developer-options" element={<DeveloperOptionsPanel />} />
<Route path="ai" element={<AIPanel />} />
<Route path="agent-chat" element={<AgentChatPanel />} />
<Route path="cron-jobs" element={<CronJobsPanel />} />
<Route path="screen-awareness-debug" element={<ScreenAwarenessDebugPanel />} />
<Route path="autocomplete-debug" element={<AutocompleteDebugPanel />} />
<Route path="voice-debug" element={<VoiceDebugPanel />} />
<Route path="local-model-debug" element={<LocalModelDebugPanel />} />
<Route path="webhooks-debug" element={<WebhooksDebugPanel />} />
<Route path="memory-data" element={<MemoryDataPanel />} />
<Route path="memory-debug" element={<MemoryDebugPanel />} />
<Route path="recovery-phrase" element={<RecoveryPhrasePanel />} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
<div className="border-t border-stone-100 px-4 py-3 text-center text-[11px] text-stone-400">