mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
style(settings): normalize spacing and controls to shared design system (#4162)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+130
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user