style(settings): normalize spacing and controls to shared design system (#4162)

This commit is contained in:
Cyrus Gray
2026-06-26 17:57:00 +05:30
committed by GitHub
parent 6d5d62ba63
commit b74f645541
31 changed files with 563 additions and 290 deletions
@@ -3142,12 +3142,12 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
contentClassName=""
description={embedded ? undefined : t('pages.settings.ai.llmDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-6' : 'space-y-6 p-4'}>
<div className={embedded ? 'space-y-5' : 'space-y-5 p-4'}>
{/* ═══════════════════════════════════════════════════════════════
AUTH — provider authentication (cloud providers + local Ollama
setup). Everything the user needs to wire a model up.
═══════════════════════════════════════════════════════════════ */}
<div className="space-y-4">
<div className="space-y-5">
<div className="border-b border-line pb-2">
<h2 className="text-base font-semibold text-content">
{t('settings.ai.llmProviders')}
@@ -3174,7 +3174,7 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
<SettingsStatusLine saving={false} error={error} savedNote={null} savingLabel="" />
)}
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
<ProviderToggleChip
key="openhuman"
slug="openhuman"
@@ -3372,7 +3372,7 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
Own = one provider/model for everything. Custom = fine-grained
per-workload routing.
═══════════════════════════════════════════════════════════════ */}
<div className="space-y-4">
<div className="space-y-5">
<div className="border-b border-line pb-2">
<h2 className="text-base font-semibold text-content">{t('settings.ai.routing')}</h2>
<p className="text-xs text-content-muted mt-0.5">{t('settings.ai.routingDesc')}</p>
@@ -69,7 +69,7 @@ const AboutPanel = () => {
<SettingsPanel description={t('settings.aboutDesc')}>
{/* Version */}
<SettingsSection>
<div className="px-4 py-4">
<div className="px-4 py-3">
<div className="text-xs text-content-muted">{t('settings.about.version')}</div>
<div className="mt-1 text-lg font-semibold text-content">v{APP_VERSION}</div>
{info?.available && info.available_version && (
@@ -97,7 +97,7 @@ const AboutPanel = () => {
}
/>
{lastCheckedAt && (
<div className="px-4 pb-3 text-[11px] text-content-faint">
<div className="px-4 py-3 text-[11px] text-content-faint">
{t('settings.about.lastChecked')} {formatRelative(lastCheckedAt, t)}
</div>
)}
@@ -127,7 +127,7 @@ const AboutPanel = () => {
</span>
}
/>
<div className="px-4 pb-3">
<div className="px-4 py-3">
<p className="text-[11px] text-content-muted leading-relaxed">
{coreMode.kind === 'cloud'
? t('settings.about.connectionHelperCloud')
@@ -138,7 +138,7 @@ const AboutPanel = () => {
{/* Releases */}
<SettingsSection>
<div className="px-4 py-4 space-y-2">
<div className="px-4 py-3 space-y-2">
<div className="text-sm font-medium text-content">{t('settings.about.releases')}</div>
<p className="text-xs text-content-muted leading-relaxed">
{t('settings.about.releasesDesc')}
@@ -1,5 +1,6 @@
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { SettingsSection } from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
import LogoutAndClearActions from '../LogoutAndClearActions';
@@ -22,20 +23,22 @@ const AccountPanel = () => {
testId="account-panel"
description={t('pages.settings.accountSection.description')}>
{(name || username) && (
<div className="flex items-center gap-3 rounded-2xl border border-line px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-500/15 text-sm font-semibold text-primary-700 dark:text-primary-300">
{(name ?? username ?? '?').replace('@', '').slice(0, 1).toUpperCase()}
<SettingsSection>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-500/15 text-sm font-semibold text-primary-700 dark:text-primary-300">
{(name ?? username ?? '?').replace('@', '').slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
{name && <div className="truncate text-sm font-medium text-content">{name}</div>}
{username && <div className="truncate text-xs text-content-muted">{username}</div>}
</div>
</div>
<div className="min-w-0">
{name && <div className="truncate text-sm font-medium text-content">{name}</div>}
{username && <div className="truncate text-xs text-content-muted">{username}</div>}
</div>
</div>
</SettingsSection>
)}
<div className="rounded-2xl overflow-hidden border border-line">
<SettingsSection>
<LogoutAndClearActions />
</div>
</SettingsSection>
</SettingsPanel>
);
};
@@ -9,7 +9,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import { SettingsStatusLine } from '../controls';
import Button from '../../ui/Button';
import { SettingsRow, SettingsSection, SettingsStatusLine } from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
interface AgentBoxProviderInfo {
@@ -29,9 +30,6 @@ type PanelState =
| { kind: 'ready'; status: AgentBoxStatus }
| { kind: 'error'; message: string };
const ROW =
'px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20';
const AgentBoxPanel = () => {
const { t } = useT();
@@ -81,59 +79,57 @@ const AgentBoxPanel = () => {
const modeLabel = s.mode_enabled ? t('common.enabled') : t('common.disabled');
return (
<div className="px-4 pt-3 pb-6 flex flex-col gap-3">
<div className="px-4 pt-3 pb-6 space-y-3">
<div className="text-xs text-sage-700 dark:text-sage-300">
{t('settings.agentbox.intro')}
</div>
<div className={ROW}>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('settings.agentbox.modeLabel')}
</span>
<span
className={`text-xs font-mono px-2 py-0.5 rounded-full ${
s.mode_enabled
? 'bg-sage-100 text-sage-800 dark:bg-sage-500/20 dark:text-sage-200'
: 'bg-surface-subtle text-content-secondary dark:bg-neutral-700/40'
}`}>
{modeLabel}
</span>
</div>
</div>
<SettingsSection>
<SettingsRow
label={t('settings.agentbox.modeLabel')}
control={
<span
className={`text-xs font-mono px-2 py-0.5 rounded-full ${
s.mode_enabled
? 'bg-sage-100 text-sage-800 dark:bg-sage-500/20 dark:text-sage-200'
: 'bg-surface-subtle text-content-secondary dark:bg-neutral-700/40'
}`}>
{modeLabel}
</span>
}
/>
</SettingsSection>
<div className={ROW}>
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200 mb-2">
{t('settings.agentbox.providerHeading')}
<SettingsSection title={t('settings.agentbox.providerHeading')}>
<div className="px-4 py-3">
{s.provider_configured && s.provider ? (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-sage-700 dark:text-sage-300">{t('settings.agentbox.slug')}</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200 break-all">
{s.provider.slug}
</dd>
<dt className="text-sage-700 dark:text-sage-300">
{t('settings.agentbox.baseUrl')}
</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200 break-all">
{s.provider.base_url}
</dd>
<dt className="text-sage-700 dark:text-sage-300">{t('settings.agentbox.model')}</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200 break-all">
{s.provider.model}
</dd>
</dl>
) : (
<div className="text-xs text-sage-700 dark:text-sage-300">
{t('settings.agentbox.notConfigured')}
</div>
)}
</div>
{s.provider_configured && s.provider ? (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-sage-700 dark:text-sage-300">{t('settings.agentbox.slug')}</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200 break-all">
{s.provider.slug}
</dd>
<dt className="text-sage-700 dark:text-sage-300">{t('settings.agentbox.baseUrl')}</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200 break-all">
{s.provider.base_url}
</dd>
<dt className="text-sage-700 dark:text-sage-300">{t('settings.agentbox.model')}</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200 break-all">
{s.provider.model}
</dd>
</dl>
) : (
<div className="text-xs text-sage-700 dark:text-sage-300">
{t('settings.agentbox.notConfigured')}
</div>
)}
</div>
</SettingsSection>
<button
type="button"
onClick={() => void load()}
className="self-start text-xs font-medium px-3 py-1.5 rounded-md border border-sage-300 dark:border-sage-500/40 text-sage-800 dark:text-sage-200 hover:bg-sage-50 dark:hover:bg-sage-500/10">
<Button type="button" variant="secondary" size="xs" onClick={() => void load()}>
{t('common.refresh')}
</button>
</Button>
</div>
);
}, [state, t, load]);
@@ -5,6 +5,7 @@ import { openhumanAgentChat } from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import {
SettingsEmptyState,
SettingsRow,
SettingsSection,
SettingsStatusLine,
SettingsTextArea,
@@ -82,31 +83,35 @@ const AgentChatPanel = () => {
return (
<SettingsPanel description={t('settings.developerMenu.agentChat.desc')}>
<SettingsSection title={t('chat.overrides')} description={t('chat.agentChatDesc')}>
<div className="px-4 py-3 grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label htmlFor="agent-chat-model" className="text-xs text-content-muted">
{t('chat.model')}
</label>
<SettingsTextField
id="agent-chat-model"
placeholder={t('chat.modelPlaceholder')}
value={modelOverride}
onChange={event => setModelOverride(event.target.value)}
aria-label={t('chat.model')}
/>
</div>
<div className="space-y-1">
<label htmlFor="agent-chat-temperature" className="text-xs text-content-muted">
{t('chat.temperature')}
</label>
<SettingsTextField
id="agent-chat-temperature"
placeholder="0.7"
value={temperature}
onChange={event => setTemperature(event.target.value)}
aria-label={t('chat.temperature')}
/>
</div>
<div className="grid md:grid-cols-2">
<SettingsRow
stacked
htmlFor="agent-chat-model"
label={t('chat.model')}
control={
<SettingsTextField
id="agent-chat-model"
placeholder={t('chat.modelPlaceholder')}
value={modelOverride}
onChange={event => setModelOverride(event.target.value)}
aria-label={t('chat.model')}
/>
}
/>
<SettingsRow
stacked
htmlFor="agent-chat-temperature"
label={t('chat.temperature')}
control={
<SettingsTextField
id="agent-chat-temperature"
placeholder="0.7"
value={temperature}
onChange={event => setTemperature(event.target.value)}
aria-label={t('chat.temperature')}
/>
}
/>
</div>
</SettingsSection>
@@ -54,12 +54,12 @@ const AnalysisViewsPanel = () => {
contentClassName=""
description={t('settings.analysisViews.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
<div className="p-4 space-y-5">
<ChipTabs<AnalysisView>
items={views.map(view => ({ id: view.id, label: view.label }))}
value={activeView}
onChange={setActiveView}
className="flex flex-wrap gap-2 pb-1"
className="flex flex-wrap gap-1.5 pb-1"
/>
{activeView === 'diagram' && <DiagramViewerTab />}
@@ -394,7 +394,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
contentClassName=""
description={embedded ? undefined : t('pages.settings.ai.embeddingsDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<div className={embedded ? 'space-y-5' : 'p-4 space-y-5'}>
<p className="text-xs text-content-muted leading-relaxed">
{t('settings.embeddings.description')}
</p>
@@ -41,7 +41,7 @@ import {
SUPPORTED_MASCOT_COLORS,
} from '../../../store/mascotSlice';
import Button from '../../ui/Button';
import { SettingsTextField } from '../controls';
import { SettingsSelect, SettingsTextField } from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
import {
defaultVoiceIdForLocale,
@@ -479,20 +479,20 @@ const MascotPanel = ({ embedded = false }: MascotPanelProps) => {
<span className="text-xs font-medium text-content-muted dark:text-content-secondary">
{t('settings.mascot.voice.presetHeading')}
</span>
<select
<SettingsSelect
aria-label={t('settings.mascot.voice.presetHeading')}
data-testid="mascot-voice-select"
disabled={presetPickerDisabled}
value={isCustomVoice ? '__custom__' : effectiveVoiceId}
onChange={e => onPresetChange(e.target.value)}
className="w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-content focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:cursor-not-allowed">
className="w-full">
{visiblePresets.map(v => (
<option key={v.id} value={v.id}>
{v.label}
</option>
))}
<option value="__custom__">{t('settings.mascot.voice.customOption')}</option>
</select>
</SettingsSelect>
</label>
{isCustomVoice && (
@@ -734,7 +734,7 @@ const MascotPanel = ({ embedded = false }: MascotPanelProps) => {
// Embedded inside the tabbed Personality & Face page: the parent owns the
// header, so render just the padded body.
if (embedded) return <div className="p-4 space-y-4">{body}</div>;
if (embedded) return <div className="p-4 space-y-5">{body}</div>;
return <SettingsPanel>{body}</SettingsPanel>;
};
@@ -177,7 +177,7 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
title={t('settings.mcpServer.toolsSectionTitle')}
description={t('settings.mcpServer.toolsSectionDesc')}>
{MCP_TOOLS.map(tool => (
<div key={tool.name} className="flex items-start gap-3 px-4 py-2.5 bg-surface">
<div key={tool.name} className="flex items-start gap-3 px-4 py-3 bg-surface">
<span className="font-mono text-xs text-primary-700 dark:text-primary-400 mt-0.5 shrink-0">
{tool.name}
</span>
@@ -8,6 +8,7 @@ import { VaultHealthChecklist } from '../../intelligence/VaultHealthChecklist';
import PanelPage from '../../layout/PanelPage';
import MemoryWindowControl from '../components/MemoryWindowControl';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface MemoryDataPanelProps {
@@ -54,10 +55,9 @@ const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => {
contentClassName=""
description={embedded ? undefined : t('devOptions.memoryInspectionDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<section className="rounded-xl border border-line bg-surface p-4 space-y-3">
<h3 className="text-sm font-semibold text-content">{t('memoryData.howItWorks')}</h3>
<dl className="space-y-2.5">
<div className={embedded ? 'space-y-5' : 'p-4 space-y-5'}>
<SettingsSection title={t('memoryData.howItWorks')}>
<dl className="space-y-2.5 px-4 py-3">
<div>
<dt className="text-xs font-semibold text-content">
{t('memoryData.workspaceVault')}
@@ -83,7 +83,7 @@ const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => {
</dd>
</div>
</dl>
</section>
</SettingsSection>
<VaultHealthChecklist onToast={addToast} title={t('vaultHealth.setupTitle')} />
<MemoryWindowControl onError={handleWindowError} onSaved={handleWindowSaved} />
<MemoryWorkspace onToast={addToast} />
@@ -203,7 +203,7 @@ const MemoryDebugPanel = () => {
testId="memory-debug-panel"
description={t('devOptions.debugPanelsDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
<div className="p-4 space-y-5">
{/* Documents */}
<SettingsSection title={t('memory.documents')}>
<div className="px-4 py-3 space-y-3">
@@ -137,7 +137,7 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
<p className="text-sm text-content-secondary">{t('migration.description')}</p>
<SettingsSection>
<div className="p-4 space-y-4" data-testid="migration-form">
<div className="p-4 space-y-5" data-testid="migration-form">
<div className="space-y-1">
<label className="block text-xs font-medium text-content-secondary">
{t('migration.vendorLabel')}
@@ -270,7 +270,7 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
if (embedded) {
return (
<PanelPage className="z-10" contentClassName="">
<div className="space-y-6 p-6">{body}</div>
<div className="space-y-5 p-6">{body}</div>
</PanelPage>
);
}
@@ -233,14 +233,16 @@ const NotificationRoutingPanel = ({ embedded = false }: NotificationRoutingPanel
/>
<span>{s.importance_threshold.toFixed(2)}</span>
</label>
<label className="text-xs text-content-secondary flex items-center gap-2">
<label
htmlFor={`notification-orchestrator-${provider}`}
className="text-xs text-content-secondary flex items-center gap-2">
{t('notifications.routing.routeToOrchestrator')}
<input
type="checkbox"
<SettingsCheckbox
id={`notification-orchestrator-${provider}`}
checked={s.route_to_orchestrator}
disabled={controlsDisabled}
onChange={e => {
void updateSetting(provider, { route_to_orchestrator: e.target.checked });
onCheckedChange={next => {
void updateSetting(provider, { route_to_orchestrator: next });
}}
/>
</label>
@@ -258,7 +260,7 @@ const NotificationRoutingPanel = ({ embedded = false }: NotificationRoutingPanel
// Embedded inside the tabbed Notifications page: the parent owns the header,
// so render just the padded body.
if (embedded) return <div className="p-4 space-y-4">{body}</div>;
if (embedded) return <div className="p-4 space-y-5">{body}</div>;
return <SettingsPanel>{body}</SettingsPanel>;
};
@@ -188,7 +188,7 @@ const PermissionsPanel = () => {
return (
<SettingsPanel>
<div className="space-y-6">
<div className="space-y-5">
{!isTauri() && (
<p className="text-sm text-coral-600 dark:text-coral-300">
{t('settings.agentAccess.desktopOnly')}
@@ -209,12 +209,13 @@ const PermissionsPanel = () => {
</p>
<div className="grid gap-2">
{presets.map(p => (
<button
<Button
key={p.id}
type="button"
variant="tertiary"
onClick={() => selectTier(p.id)}
data-testid={`permissions-preset-${p.id}`}
className={`text-left rounded-lg border p-3 transition ${
className={`!inline-block h-auto w-full !justify-start text-left rounded-lg border p-3 transition ${
level === p.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
: 'border-line hover:border-primary-300 dark:hover:border-primary-500'
@@ -235,7 +236,7 @@ const PermissionsPanel = () => {
)}
</div>
<p className="mt-1 text-xs text-content-muted">{p.description}</p>
</button>
</Button>
))}
{level === 'full' && (
<p className="rounded border border-coral/40 bg-coral/5 dark:bg-coral/10 p-2 text-xs text-coral-600 dark:text-coral-300">
@@ -125,6 +125,11 @@ describe('ProfileEditorPage', () => {
fireEvent.change(chipInput, { target: { value: 'deep-research' } });
fireEvent.keyDown(chipInput, { key: 'Enter' });
expect(screen.getByText('deep-research')).toBeInTheDocument();
// Switching back to All clears the restriction (exercises the All button's
// onChange(null) handler).
fireEvent.click(screen.getAllByText('All')[0]);
expect(screen.queryByPlaceholderText('Type an id, press Enter')).not.toBeInTheDocument();
});
it('toggles the recall-agent-conversations switch into the saved payload', async () => {
@@ -186,7 +186,7 @@ const ProfileEditorPage = () => {
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-5">
{/* Identity */}
<SettingsSection>
<SettingsRow
@@ -438,23 +438,21 @@ function AllowlistField({
stacked
control={
<div className="space-y-2">
<div className="inline-flex overflow-hidden rounded-md border border-line text-xs dark:border-line-strong">
<button
<div className="inline-flex gap-1.5">
<Button
type="button"
onClick={() => onChange(null)}
className={`px-3 py-1 font-medium transition-colors ${
!restricted ? 'bg-ocean-500 text-white' : 'bg-surface text-content-secondary'
}`}>
variant={!restricted ? 'primary' : 'secondary'}
size="xs"
onClick={() => onChange(null)}>
{t('settings.profiles.editor.all')}
</button>
<button
</Button>
<Button
type="button"
onClick={() => onChange(value ?? [])}
className={`px-3 py-1 font-medium transition-colors ${
restricted ? 'bg-ocean-500 text-white' : 'bg-surface text-content-secondary'
}`}>
variant={restricted ? 'primary' : 'secondary'}
size="xs"
onClick={() => onChange(value ?? [])}>
{t('settings.profiles.editor.selected')}
</button>
</Button>
</div>
{restricted && (
@@ -103,7 +103,7 @@ const ProfilesPanel = () => {
return (
<li
key={profile.id}
className="flex items-center justify-between gap-3 py-3 first:pt-1 last:pb-1">
className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-content">
@@ -374,7 +374,7 @@ const RecoveryPhrasePanel = () => {
);
const renderViewMode = () => (
<div className="space-y-4">
<div className="space-y-5">
{statusError ? (
<div
role="alert"
@@ -645,7 +645,7 @@ const RecoveryPhrasePanel = () => {
);
const renderReplaceConfirm = () => (
<div className="space-y-4">
<div className="space-y-5">
{/* Danger warning */}
<div className="flex items-start gap-2.5 p-4 rounded-xl bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30">
<svg
@@ -5,10 +5,10 @@ import { useScreenIntelligenceState } from '../../../features/screen-intelligenc
import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import Input from '../../ui/Input';
import {
SettingsCheckbox,
SettingsEmptyState,
SettingsNumberField,
SettingsRow,
SettingsSection,
SettingsStatusLine,
@@ -120,20 +120,22 @@ const ScreenAwarenessDebugPanel = () => {
{/* Advanced policy settings */}
<SettingsSection title={t('screenAwareness.debug.policyTitle')}>
<div className="px-4 py-3 space-y-3">
<label className="flex items-center justify-between rounded-xl border border-line bg-surface-muted px-3 py-2">
<span className="text-sm text-content">{t('screenAwareness.debug.baselineFps')}</span>
<Input
type="number"
inputSize="sm"
min={0.2}
max={30}
step={0.1}
value={baselineFps}
onChange={event => setBaselineFps(event.target.value)}
className="w-24"
aria-label={t('screenAwareness.debug.baselineFps')}
/>
</label>
<SettingsRow
htmlFor="screen-baseline-fps"
label={t('screenAwareness.debug.baselineFps')}
control={
<SettingsNumberField
id="screen-baseline-fps"
min={0.2}
max={30}
step={0.1}
value={baselineFps}
onChange={setBaselineFps}
onCommit={() => {}}
aria-label={t('screenAwareness.debug.baselineFps')}
/>
}
/>
<SettingsRow
htmlFor="screen-use-vision-model"
@@ -4,7 +4,13 @@ import { useScreenIntelligenceState } from '../../../features/screen-intelligenc
import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import { SettingsRow, SettingsSection, SettingsSelect, SettingsStatusLine } from '../controls';
import {
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsSwitch,
} from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
import PermissionsSection from './screen-intelligence/PermissionsSection';
@@ -131,14 +137,18 @@ const ScreenIntelligencePanel = () => {
{/* Screen awareness config */}
<SettingsSection title={t('settings.features.screenAwareness')}>
{/* Enabled toggle */}
<label className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-content-secondary">{t('common.enabled')}</span>
<input
type="checkbox"
checked={enabled}
onChange={event => setEnabled(event.target.checked)}
/>
</label>
<SettingsRow
htmlFor="screen-intelligence-enabled"
label={t('common.enabled')}
control={
<SettingsSwitch
id="screen-intelligence-enabled"
checked={enabled}
onCheckedChange={setEnabled}
aria-label={t('common.enabled')}
/>
}
/>
{/* Policy mode */}
<SettingsRow
@@ -167,21 +177,20 @@ const ScreenIntelligencePanel = () => {
/>
{/* Screen monitoring toggle */}
<label className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-content-secondary">
{t('settings.screenAwareness.screenMonitoring')}
</span>
<input
type="checkbox"
checked={screenMonitoring}
onChange={event =>
setFeatureOverrides(current => ({
...current,
screen_monitoring: event.target.checked,
}))
}
/>
</label>
<SettingsRow
htmlFor="screen-intelligence-monitoring"
label={t('settings.screenAwareness.screenMonitoring')}
control={
<SettingsSwitch
id="screen-intelligence-monitoring"
checked={screenMonitoring}
onCheckedChange={next =>
setFeatureOverrides(current => ({ ...current, screen_monitoring: next }))
}
aria-label={t('settings.screenAwareness.screenMonitoring')}
/>
}
/>
{/* Save */}
<div className="px-4 py-3 space-y-2">
@@ -231,7 +231,7 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
contentClassName=""
description={embedded ? undefined : t('settings.search.menuDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<div className={embedded ? 'space-y-5' : 'p-4 space-y-5'}>
<p className="text-xs text-content-muted leading-relaxed">
{t('settings.search.description')}
</p>
@@ -165,7 +165,7 @@ const SystemDiagnostics = () => {
<h3 className="px-1 pb-2 text-sm font-medium text-content">
{t('devOptions.titleDiagnostics')}
</h3>
<div className="flex flex-col gap-3">
<div className="space-y-3">
<LogsFolderRow />
{showSentryTest && <SentryTestRow />}
<RestartTourRow />
@@ -96,8 +96,8 @@ const ToolPolicyDiagnosticsPanel = () => {
d.mcp_write_audit.recent_rows === null ? '—' : String(d.mcp_write_audit.recent_rows);
return (
<div className="px-4 pt-3 pb-6 flex flex-col gap-3">
<div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10">
<div className="px-4 pt-3 pb-6 space-y-3">
<div className="px-4 py-3 rounded-xl border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('devOptions.toolPolicyDiagnostics.posture.title')}
</div>
@@ -133,7 +133,7 @@ const ToolPolicyDiagnosticsPanel = () => {
</dl>
</div>
<div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="px-4 py-3 rounded-xl border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('devOptions.toolPolicyDiagnostics.inventory.title')}
</div>
@@ -165,7 +165,7 @@ const ToolPolicyDiagnosticsPanel = () => {
</dl>
</div>
<div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="px-4 py-3 rounded-xl border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('devOptions.toolPolicyDiagnostics.mcpAllowlists.title')}
</div>
@@ -195,7 +195,7 @@ const ToolPolicyDiagnosticsPanel = () => {
)}
</div>
<div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="px-4 py-3 rounded-xl border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('devOptions.toolPolicyDiagnostics.mcpWriteAudit.title')}
</div>
@@ -211,7 +211,7 @@ const ToolPolicyDiagnosticsPanel = () => {
)}
</div>
<div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="px-4 py-3 rounded-xl border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('devOptions.toolPolicyDiagnostics.recentBlocked.title')}
</div>
@@ -242,7 +242,7 @@ const ToolPolicyDiagnosticsPanel = () => {
)}
</div>
<div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="px-4 py-3 rounded-xl border border-sage-300 dark:border-sage-500/40 bg-surface dark:bg-sage-900/20">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('devOptions.toolPolicyDiagnostics.redactedSurfaces.title')}
</div>
@@ -81,7 +81,7 @@ const BackgroundActivityTab = () => {
}, []);
return (
<div className="p-4 space-y-3" data-testid="usage-background-tab">
<div className="p-4 space-y-5" data-testid="usage-background-tab">
<SettingsStatusLine saving={false} error={loadError} savingLabel="" />
{snapshot ? (
<BackgroundLoopControls
@@ -559,7 +559,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
contentClassName=""
description={embedded ? undefined : t('pages.settings.ai.voiceDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<div className={embedded ? 'space-y-5' : 'p-4 space-y-5'}>
{/* Always-on listening moved to Settings → Features → Desktop Agent. */}
{/* ─── Section 1: Voice Provider Chips ─────────────────────────── */}
@@ -0,0 +1,61 @@
/**
* Tests for the Settings → Account landing panel.
*
* Verifies that the signed-in summary header renders the user's display name,
* username and avatar initial when a current user is present, and that the
* summary block is omitted entirely when there is no name/username.
*/
import { screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import AccountPanel from '../AccountPanel';
const mockUseCoreState = vi.fn();
vi.mock('../../../../providers/CoreStateProvider', () => ({
useCoreState: () => mockUseCoreState(),
}));
// Isolate the panel from the destructive logout/clear actions (which pull in
// session + clear-data plumbing we don't exercise here).
vi.mock('../../LogoutAndClearActions', () => ({
default: () => <div data-testid="logout-and-clear-actions" />,
}));
describe('AccountPanel', () => {
it('renders the signed-in summary with name, username and avatar initial', () => {
mockUseCoreState.mockReturnValue({
snapshot: { currentUser: { firstName: 'Test', lastName: 'Human', username: 'testhuman' } },
});
renderWithProviders(<AccountPanel />);
expect(screen.getByText('Test Human')).toBeInTheDocument();
expect(screen.getByText('@testhuman')).toBeInTheDocument();
// Avatar initial derives from the display name (line ~29).
expect(screen.getByText('T')).toBeInTheDocument();
expect(screen.getByTestId('logout-and-clear-actions')).toBeInTheDocument();
});
it('falls back to the username initial when only a username is present', () => {
mockUseCoreState.mockReturnValue({ snapshot: { currentUser: { username: 'solohuman' } } });
renderWithProviders(<AccountPanel />);
expect(screen.getByText('@solohuman')).toBeInTheDocument();
// No display name, so the initial comes from the username (the leading '@'
// is stripped before slicing).
expect(screen.getByText('S')).toBeInTheDocument();
});
it('omits the summary block when there is no current user', () => {
mockUseCoreState.mockReturnValue({ snapshot: { currentUser: null } });
renderWithProviders(<AccountPanel />);
// The summary is gone but the logout/clear actions section still renders.
expect(screen.getByTestId('logout-and-clear-actions')).toBeInTheDocument();
expect(screen.queryByText('@solohuman')).not.toBeInTheDocument();
});
});
@@ -92,6 +92,17 @@ describe('MemoryDataPanel', () => {
});
});
it('renders in embedded mode (embedded padding branch)', async () => {
resolveConfigWith('balanced');
renderWithProviders(<MemoryDataPanel embedded />);
// Body sections still render when embedded (exercises the embedded layout
// branch of the root container).
await waitFor(() => {
expect(screen.getByTestId('memory-sources')).toBeInTheDocument();
});
});
it.skip('keeps all preset buttons accessible when sync connections returns an error', async () => {
resolveConfigWith('balanced');
@@ -0,0 +1,57 @@
/**
* Tests for the Settings → Notification routing panel.
*
* Verifies the per-provider controls become interactive once settings load,
* and that toggling the "route to orchestrator" checkbox persists the change
* via setNotificationSettings.
*/
import { fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import NotificationRoutingPanel from '../NotificationRoutingPanel';
const hoisted = vi.hoisted(() => ({
fetchNotificationStats: vi.fn(),
getNotificationSettings: vi.fn(),
setNotificationSettings: vi.fn(),
}));
vi.mock('../../../../services/notificationService', () => ({
fetchNotificationStats: hoisted.fetchNotificationStats,
getNotificationSettings: hoisted.getNotificationSettings,
setNotificationSettings: hoisted.setNotificationSettings,
}));
const providerSettings = { enabled: true, importance_threshold: 0.4, route_to_orchestrator: true };
describe('NotificationRoutingPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
hoisted.fetchNotificationStats.mockResolvedValue(null);
hoisted.getNotificationSettings.mockResolvedValue(providerSettings);
hoisted.setNotificationSettings.mockResolvedValue(undefined);
});
it('persists a route-to-orchestrator toggle once provider settings load', async () => {
renderWithProviders(<NotificationRoutingPanel embedded />);
// The gmail orchestrator checkbox becomes enabled after settings load.
const orchestratorToggle = await waitFor(() => {
const el = document.getElementById('notification-orchestrator-gmail') as HTMLInputElement;
expect(el).not.toBeNull();
expect(el).not.toBeDisabled();
return el;
});
// It starts checked (route_to_orchestrator: true); toggle it off.
expect(orchestratorToggle.checked).toBe(true);
fireEvent.click(orchestratorToggle);
await waitFor(() =>
expect(hoisted.setNotificationSettings).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'gmail', route_to_orchestrator: false })
)
);
});
});
@@ -128,13 +128,10 @@ describe('ScreenIntelligencePanel', () => {
expect(screen.getAllByText('Screen Awareness').length).toBeGreaterThan(0);
});
const enabledLabel = screen.getByText('Enabled').closest('label');
const enabledCheckbox = enabledLabel?.querySelector(
'input[type="checkbox"]'
) as HTMLInputElement;
expect(enabledCheckbox.checked).toBe(false);
const enabledSwitch = screen.getByRole('switch', { name: 'Enabled' });
expect(enabledSwitch).toHaveAttribute('aria-checked', 'false');
fireEvent.click(enabledCheckbox);
fireEvent.click(enabledSwitch);
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeInTheDocument();
@@ -311,14 +308,13 @@ describe('ScreenIntelligencePanel', () => {
await waitFor(() => expect(screen.getAllByText(/screen awareness/i).length).toBeGreaterThan(0));
const monitoringLabel = screen.getByText('Screen Monitoring').closest('label');
const checkbox = monitoringLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
const monitoringSwitch = screen.getByRole('switch', { name: 'Screen Monitoring' });
// Initially reflects status value (true in baseState.features)
expect(checkbox.checked).toBe(true);
expect(monitoringSwitch).toHaveAttribute('aria-checked', 'true');
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(monitoringSwitch);
expect(monitoringSwitch).toHaveAttribute('aria-checked', 'false');
});
// ─── Session status display ───────────────────────────────────────────────
@@ -1,28 +1,15 @@
import { useT } from '../../../../lib/i18n/I18nContext';
import type { AccessibilityPermissionKind } from '../../../../utils/tauriCommands';
import Button from '../../../ui/Button';
import {
SettingsBadge,
type SettingsBadgeVariant,
SettingsRow,
SettingsSection,
} from '../../controls';
interface PermissionsBadgeProps {
label: string;
value: string;
}
const PermissionBadge = ({ label, value }: PermissionsBadgeProps) => {
const colorClass =
value === 'granted'
? 'bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-300 border-green-200 dark:border-green-500/30'
: value === 'denied'
? 'bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300 border-red-200 dark:border-red-500/30'
: 'bg-surface-subtle text-content-secondary border-line';
return (
<div className="flex items-center justify-between rounded-xl border border-line bg-surface p-3">
<span className="text-sm text-content-secondary">{label}</span>
<span className={`rounded-md border px-2 py-1 text-xs uppercase tracking-wide ${colorClass}`}>
{value}
</span>
</div>
);
};
const badgeVariant = (value: string): SettingsBadgeVariant =>
value === 'granted' ? 'success' : value === 'denied' ? 'danger' : 'neutral';
interface PermissionsSectionProps {
screenRecording: string;
@@ -55,92 +42,102 @@ const PermissionsSection = ({
}: PermissionsSectionProps) => {
const { t } = useT();
return (
<section className="space-y-3">
<h3 className="text-sm font-semibold text-content">
{t('settings.screenIntel.permissions.title')}
</h3>
<PermissionBadge
<SettingsSection title={t('settings.screenIntel.permissions.title')}>
<SettingsRow
label={t('settings.screenIntel.permissions.screenRecording')}
value={screenRecording}
control={
<SettingsBadge variant={badgeVariant(screenRecording)}>{screenRecording}</SettingsBadge>
}
/>
<PermissionBadge
<SettingsRow
label={t('settings.screenIntel.permissions.accessibility')}
value={accessibility}
control={
<SettingsBadge variant={badgeVariant(accessibility)}>{accessibility}</SettingsBadge>
}
/>
<PermissionBadge
<SettingsRow
label={t('settings.screenIntel.permissions.inputMonitoring')}
value={inputMonitoring}
control={
<SettingsBadge variant={badgeVariant(inputMonitoring)}>{inputMonitoring}</SettingsBadge>
}
/>
{anyPermissionDenied && (
<div className="rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300 space-y-1">
<p>{t('settings.screenIntel.permissions.grantHint')}</p>
{permissionCheckProcessPath ? (
<p className="opacity-75 text-xs">
{t('settings.screenIntel.permissions.macosAppliesPrivacy')}{' '}
<span className="font-mono break-all text-content-secondary">
{permissionCheckProcessPath}
</span>
</p>
) : null}
<div className="px-4 py-3">
<div className="space-y-1 rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
<p>{t('settings.screenIntel.permissions.grantHint')}</p>
{permissionCheckProcessPath ? (
<p className="text-xs opacity-75">
{t('settings.screenIntel.permissions.macosAppliesPrivacy')}{' '}
<span className="break-all font-mono text-content-secondary">
{permissionCheckProcessPath}
</span>
</p>
) : null}
</div>
</div>
)}
{lastRestartSummary ? (
<div className="rounded-xl border border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-300">
{lastRestartSummary}
<div className="px-4 py-3">
<div className="rounded-xl border border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-300">
{lastRestartSummary}
</div>
</div>
) : null}
<button
type="button"
onClick={() => void requestPermission('screen_recording')}
disabled={isRequestingPermissions || isRestartingCore}
className="mt-1 rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.requestScreenRecording')}
</button>
<button
type="button"
onClick={() => void requestPermission('accessibility')}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.requestAccessibility')}
</button>
<button
type="button"
onClick={() => void requestPermission('input_monitoring')}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.openInputMonitoring')}
</button>
{anyPermissionDenied ? (
<button
type="button"
onClick={() => void refreshPermissionsWithRestart()}
disabled={isRestartingCore || isLoading}
className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300 disabled:opacity-50">
{isRestartingCore
? t('settings.screenIntel.permissions.restartingCore')
: t('settings.screenIntel.permissions.restartRefresh')}
</button>
) : (
<button
type="button"
onClick={() => void refreshStatus()}
disabled={isLoading || isRestartingCore}
className="rounded-lg border border-line bg-surface-muted px-3 py-2 text-sm text-content-secondary disabled:opacity-50">
{isLoading
? t('settings.screenIntel.permissions.refreshing')
: t('settings.screenIntel.permissions.refreshStatus')}
</button>
)}
</section>
<div className="flex flex-wrap gap-2 px-4 py-3">
<Button
variant="secondary"
size="sm"
onClick={() => void requestPermission('screen_recording')}
disabled={isRequestingPermissions || isRestartingCore}>
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.requestScreenRecording')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void requestPermission('accessibility')}
disabled={isRequestingPermissions || isRestartingCore}>
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.requestAccessibility')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void requestPermission('input_monitoring')}
disabled={isRequestingPermissions || isRestartingCore}>
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.openInputMonitoring')}
</Button>
{anyPermissionDenied ? (
<Button
variant="secondary"
tone="danger"
size="sm"
onClick={() => void refreshPermissionsWithRestart()}
disabled={isRestartingCore || isLoading}>
{isRestartingCore
? t('settings.screenIntel.permissions.restartingCore')
: t('settings.screenIntel.permissions.restartRefresh')}
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => void refreshStatus()}
disabled={isLoading || isRestartingCore}>
{isLoading
? t('settings.screenIntel.permissions.refreshing')
: t('settings.screenIntel.permissions.refreshStatus')}
</Button>
)}
</div>
</SettingsSection>
);
};
@@ -0,0 +1,130 @@
/**
* Tests for the Screen Awareness → Permissions section.
*
* PermissionsSection is a pure, props-driven presentational component (no
* hooks/RPC of its own beyond i18n). These tests exercise both the
* "permission denied" branch (denied alert + macOS process-path hint +
* Restart & Refresh button) and the "all granted" branch (plain Refresh
* Status button), plus the per-permission request buttons and the
* granted/denied/unknown badge variants.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import PermissionsSection from '../PermissionsSection';
type Props = React.ComponentProps<typeof PermissionsSection>;
const baseProps = (): Props => ({
screenRecording: 'granted',
accessibility: 'denied',
inputMonitoring: 'unknown',
anyPermissionDenied: true,
lastRestartSummary: null,
permissionCheckProcessPath: null,
isRequestingPermissions: false,
isRestartingCore: false,
isLoading: false,
requestPermission: vi.fn().mockResolvedValue(null),
refreshPermissionsWithRestart: vi.fn().mockResolvedValue(null),
refreshStatus: vi.fn().mockResolvedValue(null),
});
describe('PermissionsSection', () => {
it('renders denied alert with process path, restart summary, and request buttons', () => {
const props = {
...baseProps(),
anyPermissionDenied: true,
permissionCheckProcessPath: '/tmp/openhuman-core',
lastRestartSummary: 'Core restarted: PID 4000 -> PID 4242.',
};
render(<PermissionsSection {...props} />);
// Mixed badge variants render (granted/denied/unknown).
expect(screen.getByText('granted')).toBeInTheDocument();
expect(screen.getByText('denied')).toBeInTheDocument();
expect(screen.getByText('unknown')).toBeInTheDocument();
// Denied-alert block surfaces the macOS process path (line ~69).
expect(screen.getByText('/tmp/openhuman-core')).toBeInTheDocument();
// Last restart summary is shown.
expect(screen.getByText('Core restarted: PID 4000 -> PID 4242.')).toBeInTheDocument();
// The three per-permission request buttons render.
expect(screen.getByRole('button', { name: 'Request Screen Recording' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Request Accessibility' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open Input Monitoring' })).toBeInTheDocument();
});
it('invokes the request handlers and the restart-with-refresh handler when denied', () => {
const props = { ...baseProps(), anyPermissionDenied: true };
render(<PermissionsSection {...props} />);
fireEvent.click(screen.getByRole('button', { name: 'Request Screen Recording' }));
expect(props.requestPermission).toHaveBeenCalledWith('screen_recording');
fireEvent.click(screen.getByRole('button', { name: 'Request Accessibility' }));
expect(props.requestPermission).toHaveBeenCalledWith('accessibility');
fireEvent.click(screen.getByRole('button', { name: 'Open Input Monitoring' }));
expect(props.requestPermission).toHaveBeenCalledWith('input_monitoring');
// When permissions are denied, the danger Restart & Refresh button shows.
const restartBtn = screen.getByRole('button', { name: 'Restarting core…' });
fireEvent.click(restartBtn);
expect(props.refreshPermissionsWithRestart).toHaveBeenCalledTimes(1);
expect(props.refreshStatus).not.toHaveBeenCalled();
});
it('shows the plain Refresh Status button when nothing is denied', () => {
const props = {
...baseProps(),
screenRecording: 'granted',
accessibility: 'granted',
inputMonitoring: 'granted',
anyPermissionDenied: false,
permissionCheckProcessPath: null,
};
render(<PermissionsSection {...props} />);
// The denied-alert process path is absent when nothing is denied.
expect(screen.queryByText('/tmp/openhuman-core')).not.toBeInTheDocument();
// The restart-with-refresh button is replaced by the plain refresh button.
expect(screen.queryByRole('button', { name: 'Restarting core…' })).not.toBeInTheDocument();
const refreshBtn = screen.getByRole('button', { name: 'Refreshing…' });
fireEvent.click(refreshBtn);
expect(props.refreshStatus).toHaveBeenCalledTimes(1);
expect(props.refreshPermissionsWithRestart).not.toHaveBeenCalled();
});
it('reflects in-flight labels while requesting permissions', () => {
const props = { ...baseProps(), isRequestingPermissions: true };
render(<PermissionsSection {...props} />);
// All three request buttons collapse to the "Requesting…" label.
expect(screen.getAllByRole('button', { name: 'Requesting…' })).toHaveLength(3);
});
it('shows the restarting-core label and disables the restart button while restarting', () => {
// Exercises the `isRestartingCore ? …` branch of the denied restart button.
const props = { ...baseProps(), anyPermissionDenied: true, isRestartingCore: true };
render(<PermissionsSection {...props} />);
const restartBtn = screen.getByRole('button', { name: 'Restarting core…' });
expect(restartBtn).toBeDisabled();
// While restarting, the request buttons are also disabled.
expect(screen.getByRole('button', { name: 'Request Screen Recording' })).toBeDisabled();
});
it('shows the refreshing label and disables the refresh button while loading', () => {
// Exercises the `isLoading ? …` branch of the not-denied refresh button.
const props = { ...baseProps(), anyPermissionDenied: false, isLoading: true };
render(<PermissionsSection {...props} />);
const refreshBtn = screen.getByRole('button', { name: 'Refreshing…' });
expect(refreshBtn).toBeDisabled();
});
});