From 029f007e6c7e86f86dd42895c3d60b60d581dd5e Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Fri, 29 May 2026 21:29:58 -0700
Subject: [PATCH] refactor(react): extract repeated UI primitives (#3003)
---
.../components/accounts/AddAccountModal.tsx | 20 +-
.../components/channels/ChannelSetupModal.tsx | 19 +-
.../channels/CredentialChannelConfig.tsx | 117 ++---
app/src/components/channels/DiscordConfig.tsx | 138 ++----
.../components/channels/TelegramConfig.tsx | 127 ++----
.../channels/channelConfigPrimitives.tsx | 158 +++++++
.../settings/panels/TeamInvitesPanel.tsx | 66 +--
.../settings/panels/TeamMembersPanel.tsx | 50 +--
.../components/settings/panels/TeamPanel.tsx | 30 +-
.../panels/billing/SubscriptionPlans.tsx | 17 +-
.../skills/AutocompleteSetupModal.tsx | 206 ++++-----
.../skills/ScreenIntelligenceSetupModal.tsx | 425 ++++++++----------
.../skills/SkillSetupPrimitives.tsx | 117 +++++
app/src/components/skills/VoiceSetupModal.tsx | 275 +++++-------
app/src/components/ui/LoadingState.tsx | 44 ++
app/src/components/ui/ModalShell.tsx | 87 ++++
app/src/components/ui/icons.tsx | 45 ++
app/src/components/ui/index.ts | 4 +
.../useAutocompleteSkillStatus.ts | 77 +---
.../useScreenIntelligenceSkillStatus.ts | 79 +---
app/src/features/skills/skillCardStatus.ts | 80 ++++
app/src/features/voice/useVoiceSkillStatus.ts | 84 +---
app/src/hooks/useEscapeKey.ts | 16 +
.../onboarding/pages/CustomEmbeddingsPage.tsx | 83 +---
.../onboarding/pages/CustomInferencePage.tsx | 87 +---
.../onboarding/pages/CustomOAuthPage.tsx | 86 +---
.../onboarding/pages/CustomSearchPage.tsx | 83 +---
.../onboarding/pages/CustomVoicePage.tsx | 83 +---
.../pages/CustomWizardConfigPage.tsx | 91 ++++
29 files changed, 1235 insertions(+), 1559 deletions(-)
create mode 100644 app/src/components/channels/channelConfigPrimitives.tsx
create mode 100644 app/src/components/skills/SkillSetupPrimitives.tsx
create mode 100644 app/src/components/ui/LoadingState.tsx
create mode 100644 app/src/components/ui/ModalShell.tsx
create mode 100644 app/src/components/ui/icons.tsx
create mode 100644 app/src/features/skills/skillCardStatus.ts
create mode 100644 app/src/hooks/useEscapeKey.ts
create mode 100644 app/src/pages/onboarding/pages/CustomWizardConfigPage.tsx
diff --git a/app/src/components/accounts/AddAccountModal.tsx b/app/src/components/accounts/AddAccountModal.tsx
index 96596d56e..ae7f968f2 100644
--- a/app/src/components/accounts/AddAccountModal.tsx
+++ b/app/src/components/accounts/AddAccountModal.tsx
@@ -1,7 +1,9 @@
import { useEffect, useRef } from 'react';
+import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useT } from '../../lib/i18n/I18nContext';
import { type AccountProvider, type ProviderDescriptor, PROVIDERS } from '../../types/accounts';
+import { CloseIcon } from '../ui';
import { ProviderIcon } from './providerIcons';
interface AddAccountModalProps {
@@ -16,15 +18,12 @@ const AddAccountModal = ({ open, onClose, onPick, connectedProviders }: AddAccou
const { t } = useT();
const closeBtnRef = useRef(null);
+ useEscapeKey(onClose, open);
+
useEffect(() => {
if (!open) return;
- const onKey = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- window.addEventListener('keydown', onKey);
closeBtnRef.current?.focus();
- return () => window.removeEventListener('keydown', onKey);
- }, [open, onClose]);
+ }, [open]);
if (!open) return null;
const available = connectedProviders
@@ -53,14 +52,7 @@ const AddAccountModal = ({ open, onClose, onPick, connectedProviders }: AddAccou
onClick={onClose}
className="rounded p-1 text-stone-500 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60"
aria-label={t('common.close')}>
-
-
-
+
diff --git a/app/src/components/channels/ChannelSetupModal.tsx b/app/src/components/channels/ChannelSetupModal.tsx
index 9e974d26f..3cab44141 100644
--- a/app/src/components/channels/ChannelSetupModal.tsx
+++ b/app/src/components/channels/ChannelSetupModal.tsx
@@ -5,8 +5,10 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
+import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useT } from '../../lib/i18n/I18nContext';
import type { ChannelDefinition, ChannelType } from '../../types/channels';
+import { CloseIcon } from '../ui';
import { renderChannelIcon } from './channelIcon';
import DiscordConfig from './DiscordConfig';
import TelegramConfig from './TelegramConfig';
@@ -40,13 +42,7 @@ export default function ChannelSetupModal({ definition, onClose }: ChannelSetupM
const { t } = useT();
const modalRef = useRef(null);
- useEffect(() => {
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- document.addEventListener('keydown', handleEscape);
- return () => document.removeEventListener('keydown', handleEscape);
- }, [onClose]);
+ useEscapeKey(onClose);
useEffect(() => {
const previousFocus = document.activeElement as HTMLElement;
@@ -99,14 +95,7 @@ export default function ChannelSetupModal({ definition, onClose }: ChannelSetupM
-
-
-
+
diff --git a/app/src/components/channels/CredentialChannelConfig.tsx b/app/src/components/channels/CredentialChannelConfig.tsx
index e1bcded1a..6f1f0989b 100644
--- a/app/src/components/channels/CredentialChannelConfig.tsx
+++ b/app/src/components/channels/CredentialChannelConfig.tsx
@@ -1,5 +1,5 @@
import debug from 'debug';
-import { useCallback, useState } from 'react';
+import { useCallback } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { channelConnectionsApi } from '../../services/api/channelConnectionsApi';
@@ -17,8 +17,13 @@ import type {
ChannelType,
} from '../../types/channels';
import { restartCoreProcess } from '../../utils/tauriCommands/core';
-import ChannelFieldInput from './ChannelFieldInput';
-import ChannelStatusBadge from './ChannelStatusBadge';
+import {
+ ChannelAuthFields,
+ ChannelAuthModeCard,
+ ChannelConfigError,
+ ChannelConnectActions,
+ useChannelAuthFormState,
+} from './channelConfigPrimitives';
const log = debug('channels:credential');
@@ -42,28 +47,7 @@ const CredentialChannelConfig = ({ definition }: CredentialChannelConfigProps) =
const channel = definition.id as ChannelType;
const channelConnections = useAppSelector(state => state.channelConnections);
- const [busyKeys, setBusyKeys] = useState>({});
- const [fieldValues, setFieldValues] = useState>>({});
- const [error, setError] = useState(null);
-
- const runBusy = useCallback(async (key: string, task: () => Promise) => {
- setBusyKeys(prev => ({ ...prev, [key]: true }));
- setError(null);
- try {
- await task();
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- } finally {
- setBusyKeys(prev => ({ ...prev, [key]: false }));
- }
- }, []);
-
- const updateField = useCallback((compositeKey: string, fieldKey: string, value: string) => {
- setFieldValues(prev => ({
- ...prev,
- [compositeKey]: { ...(prev[compositeKey] ?? {}), [fieldKey]: value },
- }));
- }, []);
+ const { busyKeys, fieldValues, error, runBusy, updateField } = useChannelAuthFormState();
const handleConnect = useCallback(
(spec: AuthModeSpec) => {
@@ -164,11 +148,7 @@ const CredentialChannelConfig = ({ definition }: CredentialChannelConfigProps) =
return (
- {error && (
-
- {error}
-
- )}
+ {error &&
}
{definition.auth_modes.map(spec => {
const compositeKey = `${channel}:${spec.mode}`;
@@ -177,60 +157,37 @@ const CredentialChannelConfig = ({ definition }: CredentialChannelConfigProps) =
const busy = busyKeys[compositeKey] ?? false;
return (
-
-
-
- {connection?.lastError && (
-
{connection.lastError}
- )}
-
+ description={spec.description}
+ status={status}
+ lastError={connection?.lastError}>
{spec.fields.length > 0 && status !== 'connected' && (
-
- {spec.fields.map(field => (
- updateField(compositeKey, field.key, val)}
- disabled={busy}
- />
- ))}
-
+
({
+ ...field,
+ label: t(`channels.${channel}.fields.${field.key}.label`, field.label),
+ placeholder: field.placeholder
+ ? t(`channels.${channel}.fields.${field.key}.placeholder`, field.placeholder)
+ : field.placeholder,
+ })}
+ />
)}
-
- {status !== 'connected' && (
- handleConnect(spec)}
- className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
- {t('channels.connect', 'Connect')}
-
- )}
- handleDisconnect(spec.mode)}
- className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
- {t('accounts.disconnect')}
-
-
-
+
handleConnect(spec)}
+ onDisconnect={() => handleDisconnect(spec.mode)}
+ />
+
);
})}
diff --git a/app/src/components/channels/DiscordConfig.tsx b/app/src/components/channels/DiscordConfig.tsx
index 06b74fbae..5d6d72c66 100644
--- a/app/src/components/channels/DiscordConfig.tsx
+++ b/app/src/components/channels/DiscordConfig.tsx
@@ -22,8 +22,13 @@ import type {
import { isLocalSessionToken } from '../../utils/localSession';
import { openUrl } from '../../utils/openUrl';
import { restartCoreProcess } from '../../utils/tauriCommands/core';
-import ChannelFieldInput from './ChannelFieldInput';
-import ChannelStatusBadge from './ChannelStatusBadge';
+import {
+ ChannelAuthFields,
+ ChannelAuthModeCard,
+ ChannelConfigError,
+ ChannelConnectActions,
+ useChannelAuthFormState,
+} from './channelConfigPrimitives';
import DiscordServerChannelPicker from './DiscordServerChannelPicker';
const log = debug('channels:discord');
@@ -44,36 +49,16 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
spec => !isLocalSession || (spec.mode !== 'managed_dm' && spec.mode !== 'oauth')
);
- const [busyKeys, setBusyKeys] = useState>({});
- const [fieldValues, setFieldValues] = useState>>({});
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState>(
{}
);
- const [error, setError] = useState(null);
+ const { busyKeys, fieldValues, error, setError, runBusy, updateField } =
+ useChannelAuthFormState();
/** Pending link tokens, keyed by compositeKey (discord:managed_dm). Only present while polling. */
const [linkToken, setLinkToken] = useState(null);
const [copied, setCopied] = useState(false);
const pollAbort = useRef(null);
- const runBusy = useCallback(async (key: string, task: () => Promise) => {
- setBusyKeys(prev => ({ ...prev, [key]: true }));
- setError(null);
- try {
- await task();
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- } finally {
- setBusyKeys(prev => ({ ...prev, [key]: false }));
- }
- }, []);
-
- const updateField = useCallback((compositeKey: string, fieldKey: string, value: string) => {
- setFieldValues(prev => ({
- ...prev,
- [compositeKey]: { ...(prev[compositeKey] ?? {}), [fieldKey]: value },
- }));
- }, []);
-
// Stop polling on unmount
useEffect(() => {
return () => {
@@ -257,7 +242,7 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
}
});
},
- [dispatch, fieldValues, runBusy, startLinkPolling, t]
+ [dispatch, fieldValues, runBusy, setError, startLinkPolling, t]
);
const handleDisconnect = useCallback(
@@ -287,11 +272,7 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
return (
- {error && (
-
- {error}
-
- )}
+ {error &&
}
{isLocalSession && visibleAuthModes.length !== definition.auth_modes.length && (
@@ -306,43 +287,28 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
const busy = busyKeys[compositeKey] ?? false;
return (
-
-
-
-
- {t(`channels.authMode.${spec.mode}`)}
-
-
- {t(`channels.discord.authMode.${spec.mode}.description`)}
-
- {connection?.lastError && (
-
{connection.lastError}
- )}
-
-
-
-
+ title={t(`channels.authMode.${spec.mode}`)}
+ description={t(`channels.discord.authMode.${spec.mode}.description`)}
+ status={status}
+ lastError={connection?.lastError}>
{/* Field inputs — only for non-managed modes */}
{spec.fields.length > 0 && status !== 'connected' && (
-
- {spec.fields.map(field => (
- updateField(compositeKey, field.key, val)}
- disabled={busy}
- />
- ))}
-
+
({
+ ...field,
+ label: t(`channels.discord.fields.${field.key}.label`, field.label),
+ placeholder: field.placeholder
+ ? t(`channels.discord.fields.${field.key}.placeholder`, field.placeholder)
+ : field.placeholder,
+ })}
+ />
)}
{/* Token card — managed_dm connecting state */}
@@ -399,13 +365,15 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
{t('channels.discord.accountLinked')}
- handleDisconnect(spec.mode)}
- className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
- {t('accounts.disconnect')}
-
+ handleDisconnect(spec.mode)}
+ showConnect={false}
+ className="mt-0"
+ />
>
) : /* Connect / Disconnect buttons for all other modes and states */
@@ -434,24 +402,14 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
)}
-
- {status !== 'connected' && (
- handleConnect(spec)}
- className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
- {t('channels.discord.connect')}
-
- )}
- handleDisconnect(spec.mode)}
- className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
- {t('accounts.disconnect')}
-
-
+
handleConnect(spec)}
+ onDisconnect={() => handleDisconnect(spec.mode)}
+ />
>
) : null}
@@ -467,7 +425,7 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
onChannelSelected={channelId => updateField(compositeKey, 'channel_id', channelId)}
/>
)}
-
+
);
})}
diff --git a/app/src/components/channels/TelegramConfig.tsx b/app/src/components/channels/TelegramConfig.tsx
index c022ab14d..e11d683fd 100644
--- a/app/src/components/channels/TelegramConfig.tsx
+++ b/app/src/components/channels/TelegramConfig.tsx
@@ -22,8 +22,13 @@ import type {
import { isLocalSessionToken } from '../../utils/localSession';
import { openUrl } from '../../utils/openUrl';
import { restartCoreProcess } from '../../utils/tauriCommands/core';
-import ChannelFieldInput from './ChannelFieldInput';
-import ChannelStatusBadge from './ChannelStatusBadge';
+import {
+ ChannelAuthFields,
+ ChannelAuthModeCard,
+ ChannelConfigError,
+ ChannelConnectActions,
+ useChannelAuthFormState,
+} from './channelConfigPrimitives';
const log = debug('channels:telegram');
@@ -44,34 +49,13 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
const MANAGED_DM_CONNECTING_MESSAGE = t('channels.telegram.managedDmConnecting');
const MANAGED_DM_TIMEOUT_MESSAGE = t('channels.telegram.managedDmTimeout');
- const [busyKeys, setBusyKeys] = useState>({});
- const [fieldValues, setFieldValues] = useState>>({});
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState>(
{}
);
- const [error, setError] = useState(null);
+ const { busyKeys, fieldValues, error, setError, runBusy, updateField } =
+ useChannelAuthFormState();
const managedDmPollControllers = useRef>({});
- const runBusy = useCallback(async (key: string, task: () => Promise) => {
- setBusyKeys(prev => ({ ...prev, [key]: true }));
- setError(null);
- try {
- await task();
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- setError(msg);
- } finally {
- setBusyKeys(prev => ({ ...prev, [key]: false }));
- }
- }, []);
-
- const updateField = useCallback((compositeKey: string, fieldKey: string, value: string) => {
- setFieldValues(prev => ({
- ...prev,
- [compositeKey]: { ...(prev[compositeKey] ?? {}), [fieldKey]: value },
- }));
- }, []);
-
const stopManagedDmPolling = useCallback((key: string) => {
managedDmPollControllers.current[key]?.abort();
delete managedDmPollControllers.current[key];
@@ -166,7 +150,7 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
}
})();
},
- [dispatch, stopManagedDmPolling, MANAGED_DM_TIMEOUT_MESSAGE]
+ [dispatch, setError, stopManagedDmPolling, MANAGED_DM_TIMEOUT_MESSAGE]
);
const handleConnect = useCallback(
@@ -316,6 +300,7 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
startManagedDmPolling,
stopManagedDmPolling,
MANAGED_DM_CONNECTING_MESSAGE,
+ setError,
t,
]
);
@@ -347,11 +332,7 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
- {error && (
-
- {error}
-
- )}
+ {error && }
{isLocalSession && visibleAuthModes.length !== definition.auth_modes.length && (
@@ -365,42 +346,27 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
const status: ChannelConnectionStatus = connection?.status ?? 'disconnected';
return (
-
-
-
-
- {t(`channels.authMode.${spec.mode}`)}
-
-
- {t(`channels.telegram.authMode.${spec.mode}.description`)}
-
- {connection?.lastError && (
-
{connection.lastError}
- )}
-
-
-
-
+ title={t(`channels.authMode.${spec.mode}`)}
+ description={t(`channels.telegram.authMode.${spec.mode}.description`)}
+ status={status}
+ lastError={connection?.lastError}>
{spec.fields.length > 0 && (
-
- {spec.fields.map(field => (
- updateField(compositeKey, field.key, val)}
- disabled={busyKeys[compositeKey]}
- />
- ))}
-
+
({
+ ...field,
+ label: t(`channels.telegram.fields.${field.key}.label`, field.label),
+ placeholder: field.placeholder
+ ? t(`channels.telegram.fields.${field.key}.placeholder`, field.placeholder)
+ : field.placeholder,
+ })}
+ />
)}
{status === 'connected' && (
@@ -427,25 +393,20 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
)}
-
- handleConnect(spec)}
- className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
- {status === 'connected'
+
- handleDisconnect(spec.mode)}
- className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
- {t('accounts.disconnect')}
-
-
-
+ : t('channels.telegram.connect')
+ }
+ disconnectLabel={t('accounts.disconnect')}
+ onConnect={() => handleConnect(spec)}
+ onDisconnect={() => handleDisconnect(spec.mode)}
+ showConnect
+ />
+
);
})}
diff --git a/app/src/components/channels/channelConfigPrimitives.tsx b/app/src/components/channels/channelConfigPrimitives.tsx
new file mode 100644
index 000000000..468935a5e
--- /dev/null
+++ b/app/src/components/channels/channelConfigPrimitives.tsx
@@ -0,0 +1,158 @@
+import { type ReactNode, useCallback, useState } from 'react';
+
+import type { AuthModeSpec, ChannelConnectionStatus } from '../../types/channels';
+import ChannelFieldInput from './ChannelFieldInput';
+import ChannelStatusBadge from './ChannelStatusBadge';
+
+export function useChannelAuthFormState() {
+ const [busyKeys, setBusyKeys] = useState>({});
+ const [fieldValues, setFieldValues] = useState>>({});
+ const [error, setError] = useState(null);
+
+ const runBusy = useCallback(async (key: string, task: () => Promise) => {
+ setBusyKeys(prev => ({ ...prev, [key]: true }));
+ setError(null);
+ try {
+ await task();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusyKeys(prev => ({ ...prev, [key]: false }));
+ }
+ }, []);
+
+ const updateField = useCallback((compositeKey: string, fieldKey: string, value: string) => {
+ setFieldValues(prev => ({
+ ...prev,
+ [compositeKey]: { ...(prev[compositeKey] ?? {}), [fieldKey]: value },
+ }));
+ }, []);
+
+ return { busyKeys, fieldValues, error, setError, runBusy, updateField };
+}
+
+interface ChannelConfigErrorProps {
+ message: string;
+}
+
+export function ChannelConfigError({ message }: ChannelConfigErrorProps) {
+ return (
+
+ {message}
+
+ );
+}
+
+interface ChannelAuthModeCardProps {
+ children: ReactNode;
+ title?: ReactNode;
+ description: ReactNode;
+ status: ChannelConnectionStatus;
+ lastError?: string;
+}
+
+export function ChannelAuthModeCard({
+ children,
+ title,
+ description,
+ status,
+ lastError,
+}: ChannelAuthModeCardProps) {
+ return (
+
+
+
+ {title ? (
+
{title}
+ ) : null}
+
+ {description}
+
+ {lastError ?
{lastError}
: null}
+
+
+
+ {children}
+
+ );
+}
+
+interface ChannelAuthFieldsProps {
+ spec: AuthModeSpec;
+ compositeKey: string;
+ fieldValues: Record>;
+ onChange: (compositeKey: string, fieldKey: string, value: string) => void;
+ disabled?: boolean;
+ mapField?: (field: AuthModeSpec['fields'][number]) => AuthModeSpec['fields'][number];
+}
+
+export function ChannelAuthFields({
+ spec,
+ compositeKey,
+ fieldValues,
+ onChange,
+ disabled,
+ mapField,
+}: ChannelAuthFieldsProps) {
+ if (spec.fields.length === 0) return null;
+
+ return (
+
+ {spec.fields.map(field => {
+ const mapped = mapField ? mapField(field) : field;
+ return (
+ onChange(compositeKey, field.key, val)}
+ disabled={disabled}
+ />
+ );
+ })}
+
+ );
+}
+
+interface ChannelConnectActionsProps {
+ busy?: boolean;
+ status: ChannelConnectionStatus;
+ connectLabel: ReactNode;
+ disconnectLabel: ReactNode;
+ onConnect?: () => void;
+ onDisconnect: () => void;
+ showConnect?: boolean;
+ className?: string;
+}
+
+export function ChannelConnectActions({
+ busy,
+ status,
+ connectLabel,
+ disconnectLabel,
+ onConnect,
+ onDisconnect,
+ showConnect = status !== 'connected',
+ className,
+}: ChannelConnectActionsProps) {
+ return (
+
+ {showConnect && onConnect ? (
+
+ {connectLabel}
+
+ ) : null}
+
+ {disconnectLabel}
+
+
+ );
+}
diff --git a/app/src/components/settings/panels/TeamInvitesPanel.tsx b/app/src/components/settings/panels/TeamInvitesPanel.tsx
index c7bd7cd18..9315ad353 100644
--- a/app/src/components/settings/panels/TeamInvitesPanel.tsx
+++ b/app/src/components/settings/panels/TeamInvitesPanel.tsx
@@ -6,6 +6,7 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { sanitizeError } from '../../../utils/sanitize';
+import { CenteredLoadingState, ErrorBanner, InlineLoadingStatus, Spinner } from '../../ui';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -125,11 +126,7 @@ const TeamInvitesPanel = () => {
- {error && (
-
- )}
+ {error &&
}
{/* Generate button */}
{isAdmin && (
@@ -139,21 +136,7 @@ const TeamInvitesPanel = () => {
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50">
{isGenerating ? (
<>
-
-
-
-
+
{t('invites.generating')}
>
) : (
@@ -174,51 +157,12 @@ const TeamInvitesPanel = () => {
{/* Refreshing indicator - only when loading and has existing data */}
{isLoadingInvites && invites.length > 0 && (
-
-
-
-
-
- {t('invites.refreshing')}
-
+
)}
{/* Invites list */}
{isLoadingInvites && invites.length === 0 ? (
-
-
-
-
-
-
- {t('invites.loading')}
-
-
+
) : invites.length > 0 ? (
{invites.map(invite => {
diff --git a/app/src/components/settings/panels/TeamMembersPanel.tsx b/app/src/components/settings/panels/TeamMembersPanel.tsx
index 0a70bd786..cda132241 100644
--- a/app/src/components/settings/panels/TeamMembersPanel.tsx
+++ b/app/src/components/settings/panels/TeamMembersPanel.tsx
@@ -7,6 +7,7 @@ import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import type { TeamMember, TeamRole } from '../../../types/team';
import { sanitizeError } from '../../../utils/sanitize';
+import { CenteredLoadingState, ErrorBanner, InlineLoadingStatus } from '../../ui';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -138,32 +139,11 @@ const TeamMembersPanel = () => {
- {error && (
-
- )}
+ {error &&
}
{/* Refreshing indicator - only when loading and has existing data */}
{isLoadingMembers && members.length > 0 && (
-
-
-
-
-
- {t('team.refreshingMembers')}
-
+
)}
{/* Member count */}
@@ -176,29 +156,7 @@ const TeamMembersPanel = () => {
{/* Full loading state - only when loading and no existing data */}
{isLoadingMembers && members.length === 0 ? (
-
-
-
-
-
-
- {t('team.loadingMembers')}
-
-
+
) : (
{members.map(member => (
diff --git a/app/src/components/settings/panels/TeamPanel.tsx b/app/src/components/settings/panels/TeamPanel.tsx
index 746d46d8b..f78a623aa 100644
--- a/app/src/components/settings/panels/TeamPanel.tsx
+++ b/app/src/components/settings/panels/TeamPanel.tsx
@@ -7,6 +7,7 @@ import { teamApi } from '../../../services/api/teamApi';
import { CoreRpcError } from '../../../services/coreRpcClient';
import type { TeamWithRole } from '../../../types/team';
import { sanitizeError } from '../../../utils/sanitize';
+import { CenteredLoadingState, ErrorBanner } from '../../ui';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -262,34 +263,9 @@ const TeamPanel = () => {
- {error && (
-
- )}
+ {error &&
}
- {isLoading && teams.length === 0 && (
-
- )}
+ {isLoading && teams.length === 0 &&
}
{teams.length > 0 && (
diff --git a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx
index f6bed1c0d..3230cdc70 100644
--- a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx
+++ b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx
@@ -1,5 +1,6 @@
import { useT } from '../../../../lib/i18n/I18nContext';
import type { PlanTier } from '../../../../types/api';
+import { Spinner } from '../../../ui';
import { annualSavings, isUpgrade as checkIsUpgrade, displayPrice, PLANS } from '../billingHelpers';
interface SubscriptionPlansProps {
@@ -113,21 +114,7 @@ const SubscriptionPlans = ({
{isPurchasing && (
-
-
-
-
+
{t('settings.billing.subscription.waitingPayment')}
diff --git a/app/src/components/skills/AutocompleteSetupModal.tsx b/app/src/components/skills/AutocompleteSetupModal.tsx
index 6aca92c9b..c4cc0a5d3 100644
--- a/app/src/components/skills/AutocompleteSetupModal.tsx
+++ b/app/src/components/skills/AutocompleteSetupModal.tsx
@@ -5,16 +5,21 @@
* and shows a success confirmation — matching the UX of the Screen
* Intelligence setup modal.
*/
-import { useEffect, useState } from 'react';
-import { createPortal } from 'react-dom';
+import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { useCoreState } from '../../providers/CoreStateProvider';
import { useT } from '../../lib/i18n/I18nContext';
+import { useCoreState } from '../../providers/CoreStateProvider';
import {
openhumanAutocompleteSetStyle,
openhumanAutocompleteStart,
} from '../../utils/tauriCommands/autocomplete';
+import {
+ SetupNotice,
+ SetupSettingRow,
+ SetupSuccess,
+ SkillSetupModalShell,
+} from './SkillSetupPrimitives';
type Step = 'enable' | 'success';
@@ -32,15 +37,6 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
const [isEnabling, setIsEnabling] = useState(false);
const [enableError, setEnableError] = useState
(null);
- // Close on Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [onClose]);
-
const handleEnable = async () => {
setIsEnabling(true);
setEnableError(null);
@@ -52,7 +48,9 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
await refresh();
setStep('success');
} catch (error) {
- setEnableError(error instanceof Error ? error.message : t('skills.setup.autocomplete.enableError'));
+ setEnableError(
+ error instanceof Error ? error.message : t('skills.setup.autocomplete.enableError')
+ );
} finally {
setIsEnabling(false);
}
@@ -63,129 +61,75 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
navigate('/settings/autocomplete');
};
- return createPortal(
- {
- if (e.target === e.currentTarget) onClose();
- }}>
-
- {/* Header */}
-
-
-
-
-
{t('skills.setup.autocomplete.title')}
-
- {step === 'enable' && t('skills.setup.autocomplete.stepEnable')}
- {step === 'success' && t('skills.setup.autocomplete.stepSuccess')}
-
-
+ return (
+
+ {step === 'enable' && t('skills.setup.autocomplete.stepEnable')}
+ {step === 'success' && t('skills.setup.autocomplete.stepSuccess')}
+ >
+ }
+ icon={
+
+
+
+ }>
+ {/* ─── Enable step ─── */}
+ {step === 'enable' && (
+
+
+ {t('skills.setup.autocomplete.description')}
+
+
+ {!status?.platform_supported && status !== null && (
+
{t('skills.setup.autocomplete.notSupported')}
+ )}
+
+
+
+
+
+
+ {enableError &&
{enableError} }
+
-
-
-
+ onClick={() => void handleEnable()}
+ disabled={isEnabling || (status !== null && !status.platform_supported)}
+ className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
+ {isEnabling
+ ? t('skills.setup.autocomplete.enabling')
+ : t('skills.setup.autocomplete.enableBtn')}
+ )}
- {/* Body */}
-
- {/* ─── Enable step ─── */}
- {step === 'enable' && (
-
-
- {t('skills.setup.autocomplete.description')}
-
-
- {!status?.platform_supported && status !== null && (
-
- {t('skills.setup.autocomplete.notSupported')}
-
- )}
-
-
-
- {t('skills.setup.autocomplete.stylePreset')}
- {t('skills.setup.autocomplete.stylePresetValue')}
-
-
- {t('skills.setup.autocomplete.acceptKey')}
- Tab
-
-
- {t('skills.setup.autocomplete.debounce')}
- {status?.debounce_ms ?? 120}ms
-
-
-
- {enableError && (
-
- {enableError}
-
- )}
-
-
void handleEnable()}
- disabled={isEnabling || (status !== null && !status.platform_supported)}
- className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
- {isEnabling ? t('skills.setup.autocomplete.enabling') : t('skills.setup.autocomplete.enableBtn')}
-
-
- )}
-
- {/* ─── Success step ─── */}
- {step === 'success' && (
-
-
-
-
-
{t('skills.setup.autocomplete.activeTitle')}
-
- {t('skills.setup.autocomplete.activeDesc')}
-
-
-
-
-
- {t('skills.setup.autocomplete.customizeSettings')}
-
-
- {t('common.finish')}
-
-
-
- )}
-
-
-
,
- document.body
+ {/* ─── Success step ─── */}
+ {step === 'success' && (
+
+ )}
+
);
}
diff --git a/app/src/components/skills/ScreenIntelligenceSetupModal.tsx b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx
index 1dbddd7f2..e75e054b9 100644
--- a/app/src/components/skills/ScreenIntelligenceSetupModal.tsx
+++ b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx
@@ -6,12 +6,18 @@
* skill setup flows (Gmail, etc.).
*/
import { useEffect, useMemo, useState } from 'react';
-import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
-import { useT } from '../../lib/i18n/I18nContext';
import { useScreenIntelligenceState } from '../../features/screen-intelligence/useScreenIntelligenceState';
+import { useT } from '../../lib/i18n/I18nContext';
import { openhumanUpdateScreenIntelligenceSettings } from '../../utils/tauriCommands';
+import { CheckIcon } from '../ui';
+import {
+ SetupNotice,
+ SetupSettingRow,
+ SetupSuccess,
+ SkillSetupModalShell,
+} from './SkillSetupPrimitives';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -48,18 +54,21 @@ const PermissionRow = ({
{granted ? (
-
-
-
+
) : (
-
+
)}
{label}
{granted ? (
-
+
{t('skills.setup.screenIntel.granted')}
) : (
@@ -68,7 +77,9 @@ const PermissionRow = ({
disabled={isRequesting}
onClick={onRequest}
className="rounded-lg border border-primary-300 bg-primary-50 px-2.5 py-1 text-[11px] font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50 transition-colors">
- {isRequesting ? t('skills.setup.screenIntel.opening') : t('skills.setup.screenIntel.grant')}
+ {isRequesting
+ ? t('skills.setup.screenIntel.opening')
+ : t('skills.setup.screenIntel.grant')}
)}
@@ -122,15 +133,6 @@ export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: P
}
}, [step, allGranted]);
- // Close on Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [onClose]);
-
const handleEnable = async () => {
setIsEnabling(true);
setEnableError(null);
@@ -139,7 +141,9 @@ export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: P
await refreshStatus();
setStep('success');
} catch (error) {
- setEnableError(error instanceof Error ? error.message : t('skills.setup.screenIntel.enableError'));
+ setEnableError(
+ error instanceof Error ? error.message : t('skills.setup.screenIntel.enableError')
+ );
} finally {
setIsEnabling(false);
}
@@ -151,249 +155,180 @@ export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: P
};
if (status?.platform_supported === false) {
- return createPortal(
-
{
- if (e.target === e.currentTarget) onClose();
- }}>
-
-
-
-
-
{t('skills.setup.screenIntel.title')}
-
-
-
-
-
-
-
-
-
- {t('skills.setup.screenIntel.macosOnly')}
-
-
- {t('common.close')}
-
-
-
-
,
- document.body
- );
- }
-
- return createPortal(
-
{
- if (e.target === e.currentTarget) onClose();
- }}>
-
- {/* Header */}
-
-
-
-
-
{t('skills.setup.screenIntel.title')}
-
- {step === 'permissions' && t('skills.setup.screenIntel.stepPermissions')}
- {step === 'enable' && t('skills.setup.screenIntel.stepEnable')}
- {step === 'success' && t('skills.setup.screenIntel.stepSuccess')}
-
-
-
+ return (
+
+
+
+ }>
+
+
+ {t('skills.setup.screenIntel.macosOnly')}
+
-
-
-
+ className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-4 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
+ {t('common.close')}
+
+ );
+ }
- {/* Body */}
-
- {/* ─── Step 1: Permissions ─── */}
- {step === 'permissions' && (
-
-
- {t('skills.setup.screenIntel.permissionsDesc')}
-
+ return (
+
+ {step === 'permissions' && t('skills.setup.screenIntel.stepPermissions')}
+ {step === 'enable' && t('skills.setup.screenIntel.stepEnable')}
+ {step === 'success' && t('skills.setup.screenIntel.stepSuccess')}
+ >
+ }
+ icon={
+
+
+
+ }>
+ {/* ─── Step 1: Permissions ─── */}
+ {step === 'permissions' && (
+
+
+ {t('skills.setup.screenIntel.permissionsDesc')}
+
-
-
void requestPermission('screen_recording')}
- isRequesting={isRequestingPermissions}
- />
- void requestPermission('accessibility')}
- isRequesting={isRequestingPermissions}
- />
- void requestPermission('input_monitoring')}
- isRequesting={isRequestingPermissions}
- />
-
+
+
void requestPermission('screen_recording')}
+ isRequesting={isRequestingPermissions}
+ />
+ void requestPermission('accessibility')}
+ isRequesting={isRequestingPermissions}
+ />
+ void requestPermission('input_monitoring')}
+ isRequesting={isRequestingPermissions}
+ />
+
- {anyDenied && (
-
-
{t('skills.setup.screenIntel.deniedHint')}
- {status?.permission_check_process_path && (
-
- {t('skills.setup.screenIntel.permissionPathLabel')}{' '}
-
- {status.permission_check_process_path}
-
-
- )}
-
+ {anyDenied && (
+
+ {t('skills.setup.screenIntel.deniedHint')}
+ {status?.permission_check_process_path && (
+
+ {t('skills.setup.screenIntel.permissionPathLabel')}{' '}
+
+ {status.permission_check_process_path}
+
+
)}
-
- {lastRestartSummary && (
-
- {lastRestartSummary}
-
- )}
-
- {lastError && (
-
- {lastError}
-
- )}
-
-
- {anyDenied ? (
- void refreshPermissionsWithRestart()}
- disabled={isRestartingCore}
- className="flex-1 rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm font-medium text-amber-700 hover:bg-amber-100 disabled:opacity-50 transition-colors">
- {isRestartingCore ? t('skills.setup.screenIntel.restarting') : t('skills.setup.screenIntel.restartRefresh')}
-
- ) : (
- void refreshStatus()}
- disabled={isRestartingCore}
- className="flex-1 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50 transition-colors">
- {t('skills.setup.screenIntel.refreshStatus')}
-
- )}
-
-
+
)}
- {/* ─── Step 2: Enable ─── */}
- {step === 'enable' && (
-
-
-
-
-
-
{t('skills.setup.screenIntel.allGranted')}
-
+ {lastRestartSummary &&
{lastRestartSummary} }
-
- {t('skills.setup.screenIntel.enableDesc')}
-
-
-
-
- {t('skills.setup.screenIntel.captureMode')}
- {t('skills.setup.screenIntel.captureModeValue')}
-
-
- {t('skills.setup.screenIntel.visionModel')}
- {t('common.enabled')}
-
-
- {t('skills.setup.screenIntel.panicHotkey')}
- {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}
-
-
-
- {enableError && (
-
- {enableError}
-
- )}
+ {lastError &&
{lastError} }
+
+ {anyDenied ? (
void handleEnable()}
- disabled={isEnabling}
- className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
- {isEnabling ? t('skills.setup.screenIntel.enabling') : t('skills.setup.screenIntel.enableBtn')}
+ onClick={() => void refreshPermissionsWithRestart()}
+ disabled={isRestartingCore}
+ className="flex-1 rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm font-medium text-amber-700 hover:bg-amber-100 disabled:opacity-50 transition-colors">
+ {isRestartingCore
+ ? t('skills.setup.screenIntel.restarting')
+ : t('skills.setup.screenIntel.restartRefresh')}
-
- )}
-
- {/* ─── Step 3: Success ─── */}
- {step === 'success' && (
-
-
-
-
-
{t('skills.setup.screenIntel.activeTitle')}
-
- {t('skills.setup.screenIntel.activeDesc')}
-
-
-
-
-
- {t('skills.setup.screenIntel.advancedSettings')}
-
-
- {t('common.finish')}
-
-
-
- )}
+ ) : (
+
void refreshStatus()}
+ disabled={isRestartingCore}
+ className="flex-1 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50 transition-colors">
+ {t('skills.setup.screenIntel.refreshStatus')}
+
+ )}
+
-
-
,
- document.body
+ )}
+
+ {/* ─── Step 2: Enable ─── */}
+ {step === 'enable' && (
+
+
}>
+ {t('skills.setup.screenIntel.allGranted')}
+
+
+
+ {t('skills.setup.screenIntel.enableDesc')}
+
+
+
+
+
+
+
+
+ {enableError &&
{enableError} }
+
+
void handleEnable()}
+ disabled={isEnabling}
+ className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
+ {isEnabling
+ ? t('skills.setup.screenIntel.enabling')
+ : t('skills.setup.screenIntel.enableBtn')}
+
+
+ )}
+
+ {/* ─── Step 3: Success ─── */}
+ {step === 'success' && (
+
+ )}
+
);
}
diff --git a/app/src/components/skills/SkillSetupPrimitives.tsx b/app/src/components/skills/SkillSetupPrimitives.tsx
new file mode 100644
index 000000000..bb589dfe8
--- /dev/null
+++ b/app/src/components/skills/SkillSetupPrimitives.tsx
@@ -0,0 +1,117 @@
+import type { ReactNode } from 'react';
+
+import { CheckIcon, ModalShell } from '../ui';
+
+interface SkillSetupModalShellProps {
+ children: ReactNode;
+ onClose: () => void;
+ title: ReactNode;
+ titleId: string;
+ subtitle?: ReactNode;
+ icon: ReactNode;
+}
+
+export function SkillSetupModalShell({
+ children,
+ onClose,
+ title,
+ titleId,
+ subtitle,
+ icon,
+}: SkillSetupModalShellProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface SetupNoticeProps {
+ children: ReactNode;
+ tone?: 'sage' | 'amber' | 'coral';
+ icon?: ReactNode;
+ className?: string;
+}
+
+const NOTICE_CLASSES = {
+ sage: 'border-sage-200 bg-sage-50 text-sage-700',
+ amber: 'border-amber-200 bg-amber-50 text-amber-700',
+ coral: 'border-coral-200 bg-coral-50 text-coral-700',
+};
+
+export function SetupNotice({ children, tone = 'sage', icon, className }: SetupNoticeProps) {
+ return (
+
+ {icon ?
{icon} : null}
+
{children}
+
+ );
+}
+
+interface SetupSettingRowProps {
+ label: ReactNode;
+ value: ReactNode;
+ mono?: boolean;
+}
+
+export function SetupSettingRow({ label, value, mono = false }: SetupSettingRowProps) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+interface SetupSuccessProps {
+ title: ReactNode;
+ description: ReactNode;
+ settingsLabel: ReactNode;
+ finishLabel: ReactNode;
+ onSettings: () => void;
+ onFinish: () => void;
+}
+
+export function SetupSuccess({
+ title,
+ description,
+ settingsLabel,
+ finishLabel,
+ onSettings,
+ onFinish,
+}: SetupSuccessProps) {
+ return (
+
+
+
+
+
+
+
{title}
+
+ {description}
+
+
+
+
+
+ {settingsLabel}
+
+
+ {finishLabel}
+
+
+
+ );
+}
diff --git a/app/src/components/skills/VoiceSetupModal.tsx b/app/src/components/skills/VoiceSetupModal.tsx
index 1dbe77208..3eadfdb2c 100644
--- a/app/src/components/skills/VoiceSetupModal.tsx
+++ b/app/src/components/skills/VoiceSetupModal.tsx
@@ -4,16 +4,22 @@
* Two-step flow: if STT model isn't downloaded, directs to Local Model
* settings. Otherwise, starts the voice server and shows success.
*/
-import { useEffect, useState } from 'react';
-import { createPortal } from 'react-dom';
+import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { useT } from '../../lib/i18n/I18nContext';
import type { VoiceSkillStatus } from '../../features/voice/useVoiceSkillStatus';
+import { useT } from '../../lib/i18n/I18nContext';
import {
- openhumanVoiceServerStart,
openhumanUpdateVoiceServerSettings,
+ openhumanVoiceServerStart,
} from '../../utils/tauriCommands/voice';
+import { CheckIcon, WarningIcon } from '../ui';
+import {
+ SetupNotice,
+ SetupSettingRow,
+ SetupSuccess,
+ SkillSetupModalShell,
+} from './SkillSetupPrimitives';
type Step = 'setup' | 'enable' | 'success';
@@ -31,15 +37,6 @@ export default function VoiceSetupModal({ onClose, skillStatus }: Props) {
const [isEnabling, setIsEnabling] = useState(false);
const [enableError, setEnableError] = useState
(null);
- // Close on Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [onClose]);
-
const handleEnable = async () => {
setIsEnabling(true);
setEnableError(null);
@@ -68,161 +65,115 @@ export default function VoiceSetupModal({ onClose, skillStatus }: Props) {
navigate('/settings/voice');
};
- return createPortal(
- {
- if (e.target === e.currentTarget) onClose();
- }}>
-
- {/* Header */}
-
-
-
-
-
{t('skills.setup.voice.title')}
-
- {step === 'setup' && t('skills.setup.voice.stepSetup')}
- {step === 'enable' && t('skills.setup.voice.stepEnable')}
- {step === 'success' && t('skills.setup.voice.stepSuccess')}
-
+ return (
+
+ {step === 'setup' && t('skills.setup.voice.stepSetup')}
+ {step === 'enable' && t('skills.setup.voice.stepEnable')}
+ {step === 'success' && t('skills.setup.voice.stepSuccess')}
+ >
+ }
+ icon={
+
+
+
+ }>
+ {/* ─── Setup step: STT model missing ─── */}
+ {step === 'setup' && (
+
+
}>
+
+
{t('skills.setup.voice.sttNotReady')}
+
{t('skills.setup.voice.sttNotReadyDesc')}
+
+
+
+ {t('skills.setup.voice.sttReturnHint')}
+
+
+
+
+ {t('skills.setup.voice.downloadSttBtn')}
+
+
+ {t('common.cancel')}
+
+
+ )}
+
+ {/* ─── Enable step ─── */}
+ {step === 'enable' && (
+
+
}>
+ {t('skills.setup.voice.sttReady')}
+
+
+
+ {t('skills.setup.voice.enableDesc')}
+
+
+
+
+
+
+
+ {enableError &&
{enableError} }
+
-
-
-
+ onClick={() => void handleEnable()}
+ disabled={isEnabling}
+ className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
+ {isEnabling ? t('skills.setup.voice.starting') : t('skills.setup.voice.startBtn')}
+ )}
- {/* Body */}
-
- {/* ─── Setup step: STT model missing ─── */}
- {step === 'setup' && (
-
-
-
-
-
-
-
{t('skills.setup.voice.sttNotReady')}
-
{t('skills.setup.voice.sttNotReadyDesc')}
-
-
-
-
- {t('skills.setup.voice.sttReturnHint')}
-
-
-
-
- {t('skills.setup.voice.downloadSttBtn')}
-
-
- {t('common.cancel')}
-
-
-
- )}
-
- {/* ─── Enable step ─── */}
- {step === 'enable' && (
-
-
-
-
-
-
{t('skills.setup.voice.sttReady')}
-
-
-
- {t('skills.setup.voice.enableDesc')}
-
-
-
-
- {t('skills.setup.voice.hotkey')}
- {serverStatus?.hotkey ?? 'Fn'}
-
-
- {t('skills.setup.voice.activation')}
- {serverStatus?.activation_mode === 'push' ? t('voice.pushToTalk') : t('voice.tapToToggle')}
-
-
-
- {enableError && (
-
- {enableError}
-
- )}
-
-
void handleEnable()}
- disabled={isEnabling}
- className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
- {isEnabling ? t('skills.setup.voice.starting') : t('skills.setup.voice.startBtn')}
-
-
- )}
-
- {/* ─── Success step ─── */}
- {step === 'success' && (
-
-
-
-
-
{t('skills.setup.voice.activeTitle')}
-
- {t('skills.setup.voice.activeDescPrefix')} {serverStatus?.hotkey ?? 'Fn'} {t('skills.setup.voice.activeDescSuffix')}
-
-
-
-
-
- {t('skills.setup.voice.customizeSettings')}
-
-
- {t('common.finish')}
-
-
-
- )}
-
-
-
,
- document.body
+ {/* ─── Success step ─── */}
+ {step === 'success' && (
+
+ {t('skills.setup.voice.activeDescPrefix')}{' '}
+ {serverStatus?.hotkey ?? 'Fn'} {' '}
+ {t('skills.setup.voice.activeDescSuffix')}
+ >
+ }
+ settingsLabel={t('skills.setup.voice.customizeSettings')}
+ finishLabel={t('common.finish')}
+ onSettings={handleGoToSettings}
+ onFinish={onClose}
+ />
+ )}
+
);
}
diff --git a/app/src/components/ui/LoadingState.tsx b/app/src/components/ui/LoadingState.tsx
new file mode 100644
index 000000000..2373aa810
--- /dev/null
+++ b/app/src/components/ui/LoadingState.tsx
@@ -0,0 +1,44 @@
+import { Spinner } from './icons';
+
+interface ErrorBannerProps {
+ message: string;
+ className?: string;
+}
+
+export function ErrorBanner({ message, className }: ErrorBannerProps) {
+ return (
+
+ );
+}
+
+interface InlineLoadingStatusProps {
+ label: string;
+ className?: string;
+}
+
+export function InlineLoadingStatus({ label, className }: InlineLoadingStatusProps) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+interface CenteredLoadingStateProps {
+ label?: string;
+ className?: string;
+}
+
+export function CenteredLoadingState({ label, className }: CenteredLoadingStateProps) {
+ return (
+
+
+ {label ? (
+ {label}
+ ) : null}
+
+ );
+}
diff --git a/app/src/components/ui/ModalShell.tsx b/app/src/components/ui/ModalShell.tsx
new file mode 100644
index 000000000..196d62b8c
--- /dev/null
+++ b/app/src/components/ui/ModalShell.tsx
@@ -0,0 +1,87 @@
+import { type ReactNode, useEffect, useRef } from 'react';
+import { createPortal } from 'react-dom';
+
+import { useEscapeKey } from '../../hooks/useEscapeKey';
+import { useT } from '../../lib/i18n/I18nContext';
+import { CloseIcon } from './icons';
+
+interface ModalShellProps {
+ children: ReactNode;
+ onClose: () => void;
+ title: ReactNode;
+ titleId: string;
+ subtitle?: ReactNode;
+ icon?: ReactNode;
+ maxWidthClassName?: string;
+ contentClassName?: string;
+ labelledBy?: string;
+}
+
+export function ModalShell({
+ children,
+ onClose,
+ title,
+ titleId,
+ subtitle,
+ icon,
+ maxWidthClassName = 'max-w-md',
+ contentClassName = 'px-5 py-4',
+ labelledBy,
+}: ModalShellProps) {
+ const { t } = useT();
+ const dialogRef = useRef(null);
+
+ useEscapeKey(onClose);
+
+ useEffect(() => {
+ const previousFocus = document.activeElement as HTMLElement | null;
+ dialogRef.current?.focus();
+ return () => previousFocus?.focus?.();
+ }, []);
+
+ return createPortal(
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}>
+
event.stopPropagation()}>
+
+
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+
+
+ {title}
+
+ {subtitle ? (
+
{subtitle}
+ ) : null}
+
+
+
+
+
+
+
{children}
+
+
,
+ document.body
+ );
+}
diff --git a/app/src/components/ui/icons.tsx b/app/src/components/ui/icons.tsx
new file mode 100644
index 000000000..6ecab4c4c
--- /dev/null
+++ b/app/src/components/ui/icons.tsx
@@ -0,0 +1,45 @@
+import type { SVGProps } from 'react';
+
+type IconProps = SVGProps;
+
+export function Spinner({ className = 'h-4 w-4', ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function CheckIcon({ className = 'h-4 w-4', ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function CloseIcon({ className = 'h-4 w-4', ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function WarningIcon({ className = 'h-4 w-4', ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/components/ui/index.ts b/app/src/components/ui/index.ts
index ad28ac248..5f40f1dcd 100644
--- a/app/src/components/ui/index.ts
+++ b/app/src/components/ui/index.ts
@@ -6,3 +6,7 @@ export type { CardProps, CardVariant, CardPadding } from './Card';
export { default as Input } from './Input';
export type { InputProps, InputSize } from './Input';
+
+export { Spinner, CheckIcon, CloseIcon, WarningIcon } from './icons';
+export { CenteredLoadingState, ErrorBanner, InlineLoadingStatus } from './LoadingState';
+export { ModalShell } from './ModalShell';
diff --git a/app/src/features/autocomplete/useAutocompleteSkillStatus.ts b/app/src/features/autocomplete/useAutocompleteSkillStatus.ts
index a9a66bd74..53fd95c60 100644
--- a/app/src/features/autocomplete/useAutocompleteSkillStatus.ts
+++ b/app/src/features/autocomplete/useAutocompleteSkillStatus.ts
@@ -5,15 +5,16 @@
import { useMemo } from 'react';
import { useCoreState } from '../../providers/CoreStateProvider';
-import type { SkillConnectionStatus } from '../../types/skillStatus';
+import {
+ activeStatus,
+ enabledStatus,
+ errorStatus,
+ offlineStatus,
+ type SkillCardStatusDescriptor,
+ unsupportedStatus,
+} from '../skills/skillCardStatus';
-export interface AutocompleteSkillStatus {
- connectionStatus: SkillConnectionStatus;
- statusDot: string;
- statusLabel: string;
- statusColor: string;
- ctaLabel: string;
- ctaVariant: 'primary' | 'sage' | 'amber';
+export interface AutocompleteSkillStatus extends SkillCardStatusDescriptor {
/** True when the platform doesn't support autocomplete. */
platformUnsupported: boolean;
}
@@ -25,78 +26,30 @@ export function useAutocompleteSkillStatus(): AutocompleteSkillStatus {
return useMemo(() => {
// No status yet (core not ready or not in Tauri)
if (!status) {
- return {
- connectionStatus: 'offline' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Offline',
- statusColor: 'text-stone-500',
- ctaLabel: 'Enable',
- ctaVariant: 'sage' as const,
- platformUnsupported: false,
- };
+ return { ...offlineStatus(), platformUnsupported: false };
}
if (!status.platform_supported) {
- return {
- connectionStatus: 'offline' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Unsupported',
- statusColor: 'text-stone-500',
- ctaLabel: 'Details',
- ctaVariant: 'primary' as const,
- platformUnsupported: true,
- };
+ return { ...unsupportedStatus(), platformUnsupported: true };
}
// Running — fully active (checked before error so a stale last_error
// doesn't mask a successfully running service)
if (status.running) {
- return {
- connectionStatus: 'connected' as SkillConnectionStatus,
- statusDot: 'bg-sage-500',
- statusLabel: 'Active',
- statusColor: 'text-sage-400',
- ctaLabel: 'Manage',
- ctaVariant: 'primary' as const,
- platformUnsupported: false,
- };
+ return { ...activeStatus(), platformUnsupported: false };
}
// Error state (only when not running)
if (status.last_error) {
- return {
- connectionStatus: 'error' as SkillConnectionStatus,
- statusDot: 'bg-coral-500',
- statusLabel: 'Error',
- statusColor: 'text-coral-400',
- ctaLabel: 'Retry',
- ctaVariant: 'amber' as const,
- platformUnsupported: false,
- };
+ return { ...errorStatus(), platformUnsupported: false };
}
// Enabled in config but not running
if (status.enabled) {
- return {
- connectionStatus: 'disconnected' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Enabled',
- statusColor: 'text-stone-400',
- ctaLabel: 'Manage',
- ctaVariant: 'primary' as const,
- platformUnsupported: false,
- };
+ return { ...enabledStatus(), platformUnsupported: false };
}
// Not enabled
- return {
- connectionStatus: 'offline' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Disabled',
- statusColor: 'text-stone-500',
- ctaLabel: 'Enable',
- ctaVariant: 'sage' as const,
- platformUnsupported: false,
- };
+ return { ...offlineStatus('Disabled'), platformUnsupported: false };
}, [status]);
}
diff --git a/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts b/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts
index 69466c252..9512a1cac 100644
--- a/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts
+++ b/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts
@@ -5,15 +5,16 @@
import { useMemo } from 'react';
import { useCoreState } from '../../providers/CoreStateProvider';
-import type { SkillConnectionStatus } from '../../types/skillStatus';
+import {
+ activeStatus,
+ enabledStatus,
+ offlineStatus,
+ setupRequiredStatus,
+ type SkillCardStatusDescriptor,
+ unsupportedStatus,
+} from '../skills/skillCardStatus';
-export interface ScreenIntelligenceSkillStatus {
- connectionStatus: SkillConnectionStatus;
- statusDot: string;
- statusLabel: string;
- statusColor: string;
- ctaLabel: string;
- ctaVariant: 'primary' | 'sage' | 'amber';
+export interface ScreenIntelligenceSkillStatus extends SkillCardStatusDescriptor {
/** True when all three macOS permissions are granted. */
allPermissionsGranted: boolean;
/** True when the platform doesn't support screen intelligence. */
@@ -27,29 +28,11 @@ export function useScreenIntelligenceSkillStatus(): ScreenIntelligenceSkillStatu
return useMemo(() => {
// No status yet (core not ready or not in Tauri)
if (!status) {
- return {
- connectionStatus: 'offline' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Offline',
- statusColor: 'text-stone-500',
- ctaLabel: 'Enable',
- ctaVariant: 'sage' as const,
- allPermissionsGranted: false,
- platformUnsupported: false,
- };
+ return { ...offlineStatus(), allPermissionsGranted: false, platformUnsupported: false };
}
if (!status.platform_supported) {
- return {
- connectionStatus: 'offline' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Unsupported',
- statusColor: 'text-stone-500',
- ctaLabel: 'Details',
- ctaVariant: 'primary' as const,
- allPermissionsGranted: false,
- platformUnsupported: true,
- };
+ return { ...unsupportedStatus(), allPermissionsGranted: false, platformUnsupported: true };
}
const { permissions, session, config } = status;
@@ -60,54 +43,22 @@ export function useScreenIntelligenceSkillStatus(): ScreenIntelligenceSkillStatu
// Permissions missing — needs setup
if (!allGranted) {
- return {
- connectionStatus: 'setup_required' as SkillConnectionStatus,
- statusDot: 'bg-primary-400',
- statusLabel: 'Setup',
- statusColor: 'text-primary-400',
- ctaLabel: 'Setup',
- ctaVariant: 'primary' as const,
- allPermissionsGranted: false,
- platformUnsupported: false,
- };
+ return { ...setupRequiredStatus(), allPermissionsGranted: false, platformUnsupported: false };
}
// Session active — fully connected
if (session.active) {
- return {
- connectionStatus: 'connected' as SkillConnectionStatus,
- statusDot: 'bg-sage-500',
- statusLabel: 'Active',
- statusColor: 'text-sage-400',
- ctaLabel: 'Manage',
- ctaVariant: 'primary' as const,
- allPermissionsGranted: true,
- platformUnsupported: false,
- };
+ return { ...activeStatus(), allPermissionsGranted: true, platformUnsupported: false };
}
// Permissions granted, enabled in config, but session not active
if (config.enabled) {
- return {
- connectionStatus: 'disconnected' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Enabled',
- statusColor: 'text-stone-400',
- ctaLabel: 'Manage',
- ctaVariant: 'primary' as const,
- allPermissionsGranted: true,
- platformUnsupported: false,
- };
+ return { ...enabledStatus(), allPermissionsGranted: true, platformUnsupported: false };
}
// Permissions granted but not enabled
return {
- connectionStatus: 'offline' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Disabled',
- statusColor: 'text-stone-500',
- ctaLabel: 'Enable',
- ctaVariant: 'sage' as const,
+ ...offlineStatus('Disabled'),
allPermissionsGranted: true,
platformUnsupported: false,
};
diff --git a/app/src/features/skills/skillCardStatus.ts b/app/src/features/skills/skillCardStatus.ts
new file mode 100644
index 000000000..ea9a15053
--- /dev/null
+++ b/app/src/features/skills/skillCardStatus.ts
@@ -0,0 +1,80 @@
+import type { SkillConnectionStatus } from '../../types/skillStatus';
+
+export interface SkillCardStatusDescriptor {
+ connectionStatus: SkillConnectionStatus;
+ statusDot: string;
+ statusLabel: string;
+ statusColor: string;
+ ctaLabel: string;
+ ctaVariant: 'primary' | 'sage' | 'amber';
+}
+
+export function offlineStatus(label = 'Offline', ctaLabel = 'Enable'): SkillCardStatusDescriptor {
+ return {
+ connectionStatus: 'offline',
+ statusDot: 'bg-stone-400',
+ statusLabel: label,
+ statusColor: 'text-stone-500',
+ ctaLabel,
+ ctaVariant: 'sage',
+ };
+}
+
+export function unsupportedStatus(): SkillCardStatusDescriptor {
+ return { ...offlineStatus('Unsupported', 'Details'), ctaVariant: 'primary' };
+}
+
+export function setupRequiredStatus(): SkillCardStatusDescriptor {
+ return {
+ connectionStatus: 'setup_required',
+ statusDot: 'bg-primary-400',
+ statusLabel: 'Setup',
+ statusColor: 'text-primary-400',
+ ctaLabel: 'Setup',
+ ctaVariant: 'primary',
+ };
+}
+
+export function errorStatus(): SkillCardStatusDescriptor {
+ return {
+ connectionStatus: 'error',
+ statusDot: 'bg-coral-500',
+ statusLabel: 'Error',
+ statusColor: 'text-coral-400',
+ ctaLabel: 'Retry',
+ ctaVariant: 'amber',
+ };
+}
+
+export function activeStatus(label = 'Active'): SkillCardStatusDescriptor {
+ return {
+ connectionStatus: 'connected',
+ statusDot: 'bg-sage-500',
+ statusLabel: label,
+ statusColor: 'text-sage-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary',
+ };
+}
+
+export function transientStatus(label: string): SkillCardStatusDescriptor {
+ return {
+ connectionStatus: 'connecting',
+ statusDot: 'bg-amber-500 animate-pulse',
+ statusLabel: label,
+ statusColor: 'text-amber-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary',
+ };
+}
+
+export function enabledStatus(): SkillCardStatusDescriptor {
+ return {
+ connectionStatus: 'disconnected',
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Enabled',
+ statusColor: 'text-stone-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary',
+ };
+}
diff --git a/app/src/features/voice/useVoiceSkillStatus.ts b/app/src/features/voice/useVoiceSkillStatus.ts
index 26d75faff..66a98623f 100644
--- a/app/src/features/voice/useVoiceSkillStatus.ts
+++ b/app/src/features/voice/useVoiceSkillStatus.ts
@@ -8,7 +8,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCoreState } from '../../providers/CoreStateProvider';
-import type { SkillConnectionStatus } from '../../types/skillStatus';
import { isTauri } from '../../utils/tauriCommands/common';
import {
openhumanVoiceServerStatus,
@@ -16,14 +15,16 @@ import {
type VoiceServerStatus,
type VoiceStatus,
} from '../../utils/tauriCommands/voice';
+import {
+ activeStatus,
+ errorStatus,
+ offlineStatus,
+ setupRequiredStatus,
+ type SkillCardStatusDescriptor,
+ transientStatus,
+} from '../skills/skillCardStatus';
-export interface VoiceSkillStatus {
- connectionStatus: SkillConnectionStatus;
- statusDot: string;
- statusLabel: string;
- statusColor: string;
- ctaLabel: string;
- ctaVariant: 'primary' | 'sage' | 'amber';
+export interface VoiceSkillStatus extends SkillCardStatusDescriptor {
/** True when STT model is not yet downloaded. */
sttModelMissing: boolean;
/** Voice system availability info (null before first fetch). */
@@ -72,58 +73,23 @@ export function useVoiceSkillStatus(): VoiceSkillStatus {
return useMemo(() => {
// No data yet
if (!voiceStatus || !serverStatus) {
- return {
- connectionStatus: 'offline' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Offline',
- statusColor: 'text-stone-500',
- ctaLabel: 'Enable',
- ctaVariant: 'sage' as const,
- sttModelMissing: false,
- voiceStatus,
- serverStatus,
- };
+ return { ...offlineStatus(), sttModelMissing: false, voiceStatus, serverStatus };
}
// STT model not downloaded — needs setup
if (!sttReady) {
- return {
- connectionStatus: 'setup_required' as SkillConnectionStatus,
- statusDot: 'bg-primary-400',
- statusLabel: 'Setup',
- statusColor: 'text-primary-400',
- ctaLabel: 'Setup',
- ctaVariant: 'primary' as const,
- sttModelMissing: true,
- voiceStatus,
- serverStatus,
- };
+ return { ...setupRequiredStatus(), sttModelMissing: true, voiceStatus, serverStatus };
}
// Error
if (serverStatus.last_error) {
- return {
- connectionStatus: 'error' as SkillConnectionStatus,
- statusDot: 'bg-coral-500',
- statusLabel: 'Error',
- statusColor: 'text-coral-400',
- ctaLabel: 'Retry',
- ctaVariant: 'amber' as const,
- sttModelMissing: false,
- voiceStatus,
- serverStatus,
- };
+ return { ...errorStatus(), sttModelMissing: false, voiceStatus, serverStatus };
}
// Active states: recording, transcribing, or idle (server running)
if (serverStatus.state === 'recording' || serverStatus.state === 'transcribing') {
return {
- connectionStatus: 'connecting' as SkillConnectionStatus,
- statusDot: 'bg-amber-500 animate-pulse',
- statusLabel: serverStatus.state === 'recording' ? 'Recording' : 'Transcribing',
- statusColor: 'text-amber-400',
- ctaLabel: 'Manage',
- ctaVariant: 'primary' as const,
+ ...transientStatus(serverStatus.state === 'recording' ? 'Recording' : 'Transcribing'),
sttModelMissing: false,
voiceStatus,
serverStatus,
@@ -131,30 +97,10 @@ export function useVoiceSkillStatus(): VoiceSkillStatus {
}
if (serverStatus.state === 'idle') {
- return {
- connectionStatus: 'connected' as SkillConnectionStatus,
- statusDot: 'bg-sage-500',
- statusLabel: 'Active',
- statusColor: 'text-sage-400',
- ctaLabel: 'Manage',
- ctaVariant: 'primary' as const,
- sttModelMissing: false,
- voiceStatus,
- serverStatus,
- };
+ return { ...activeStatus(), sttModelMissing: false, voiceStatus, serverStatus };
}
// Stopped
- return {
- connectionStatus: 'offline' as SkillConnectionStatus,
- statusDot: 'bg-stone-400',
- statusLabel: 'Stopped',
- statusColor: 'text-stone-500',
- ctaLabel: 'Enable',
- ctaVariant: 'sage' as const,
- sttModelMissing: false,
- voiceStatus,
- serverStatus,
- };
+ return { ...offlineStatus('Stopped'), sttModelMissing: false, voiceStatus, serverStatus };
}, [voiceStatus, serverStatus, sttReady]);
}
diff --git a/app/src/hooks/useEscapeKey.ts b/app/src/hooks/useEscapeKey.ts
new file mode 100644
index 000000000..fa6ba6bf7
--- /dev/null
+++ b/app/src/hooks/useEscapeKey.ts
@@ -0,0 +1,16 @@
+import { useEffect } from 'react';
+
+export function useEscapeKey(onEscape: () => void, enabled = true) {
+ useEffect(() => {
+ if (!enabled) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onEscape();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [enabled, onEscape]);
+}
diff --git a/app/src/pages/onboarding/pages/CustomEmbeddingsPage.tsx b/app/src/pages/onboarding/pages/CustomEmbeddingsPage.tsx
index bf5f33064..27335484b 100644
--- a/app/src/pages/onboarding/pages/CustomEmbeddingsPage.tsx
+++ b/app/src/pages/onboarding/pages/CustomEmbeddingsPage.tsx
@@ -1,83 +1,8 @@
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
import EmbeddingsPanel from '../../../components/settings/panels/EmbeddingsPanel';
-import { useT } from '../../../lib/i18n/I18nContext';
-import { useCoreState } from '../../../providers/CoreStateProvider';
-import { trackEvent } from '../../../services/analytics';
-import { isLocalSessionToken } from '../../../utils/localSession';
-import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
-import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
-import CustomWizardStep from '../steps/CustomWizardStep';
+import CustomWizardConfigPage from './CustomWizardConfigPage';
-const STEP_KEY = 'embeddings' as const;
-const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
-const LOCAL_DEFAULT_DISABLED_REASON =
- 'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
-
-const CustomEmbeddingsPage = () => {
- const { t } = useT();
- const navigate = useNavigate();
- const { snapshot } = useCoreState();
- const { draft, setDraft, completeAndExit } = useOnboardingContext();
- const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
-
- const [choice, setChoice] = useState(
- draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
- );
-
- useEffect(() => {
- if (!isLocalSession) {
- return;
- }
- setChoice('configure');
- setDraft(prev => ({
- ...prev,
- customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
- }));
- }, [isLocalSession, setDraft]);
-
- const persistChoice = (next: CustomStepChoice) => {
- setChoice(next);
- setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
- };
-
- const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
-
- return (
- }
- defaultDisabled={isLocalSession}
- defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
- hideChoiceCards={isLocalSession}
- choice={choice}
- onChoiceChange={persistChoice}
- onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
- onContinue={async () => {
- trackEvent('onboarding_step_complete', {
- step_name: 'custom_embeddings',
- choice: choice ?? 'default',
- });
- if (isLast) {
- try {
- await completeAndExit();
- } catch (err) {
- console.error('[onboarding:custom-embeddings] completeAndExit failed', err);
- }
- return;
- }
- navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
- }}
- continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
- />
- );
-};
+const CustomEmbeddingsPage = () => (
+ } />
+);
export default CustomEmbeddingsPage;
diff --git a/app/src/pages/onboarding/pages/CustomInferencePage.tsx b/app/src/pages/onboarding/pages/CustomInferencePage.tsx
index 2f703f51a..4f1842be2 100644
--- a/app/src/pages/onboarding/pages/CustomInferencePage.tsx
+++ b/app/src/pages/onboarding/pages/CustomInferencePage.tsx
@@ -1,83 +1,12 @@
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
import AIPanel from '../../../components/settings/panels/AIPanel';
-import { useT } from '../../../lib/i18n/I18nContext';
-import { useCoreState } from '../../../providers/CoreStateProvider';
-import { trackEvent } from '../../../services/analytics';
-import { isLocalSessionToken } from '../../../utils/localSession';
-import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
-import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
-import CustomWizardStep from '../steps/CustomWizardStep';
+import CustomWizardConfigPage from './CustomWizardConfigPage';
-const STEP_KEY = 'inference' as const;
-const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
-const LOCAL_DEFAULT_DISABLED_REASON =
- 'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
-
-const CustomInferencePage = () => {
- const { t } = useT();
- const navigate = useNavigate();
- const { snapshot } = useCoreState();
- const { draft, setDraft, completeAndExit } = useOnboardingContext();
- const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
-
- const [choice, setChoice] = useState(
- draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
- );
-
- useEffect(() => {
- if (!isLocalSession) {
- return;
- }
- setChoice('configure');
- setDraft(prev => ({
- ...prev,
- customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
- }));
- }, [isLocalSession, setDraft]);
-
- const persistChoice = (next: CustomStepChoice) => {
- setChoice(next);
- setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
- };
-
- const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
-
- return (
- }
- defaultDisabled={isLocalSession}
- defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
- hideChoiceCards={isLocalSession}
- choice={choice}
- onChoiceChange={persistChoice}
- onBack={() => navigate('/onboarding/runtime-choice')}
- onContinue={async () => {
- trackEvent('onboarding_step_complete', {
- step_name: 'custom_inference',
- choice: choice ?? 'default',
- });
- if (isLast) {
- try {
- await completeAndExit();
- } catch (err) {
- console.error('[onboarding:custom-inference] completeAndExit failed', err);
- }
- return;
- }
- navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
- }}
- continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
- />
- );
-};
+const CustomInferencePage = () => (
+ }
+ />
+);
export default CustomInferencePage;
diff --git a/app/src/pages/onboarding/pages/CustomOAuthPage.tsx b/app/src/pages/onboarding/pages/CustomOAuthPage.tsx
index a5c86e31b..8c2e24600 100644
--- a/app/src/pages/onboarding/pages/CustomOAuthPage.tsx
+++ b/app/src/pages/onboarding/pages/CustomOAuthPage.tsx
@@ -1,83 +1,11 @@
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
import ComposioPanel from '../../../components/settings/panels/ComposioPanel';
-import { useT } from '../../../lib/i18n/I18nContext';
-import { useCoreState } from '../../../providers/CoreStateProvider';
-import { trackEvent } from '../../../services/analytics';
-import { isLocalSessionToken } from '../../../utils/localSession';
-import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
-import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
-import CustomWizardStep from '../steps/CustomWizardStep';
+import CustomWizardConfigPage from './CustomWizardConfigPage';
-const STEP_KEY = 'oauth' as const;
-const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
-const LOCAL_DEFAULT_DISABLED_REASON =
- 'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
-
-const CustomOAuthPage = () => {
- const { t } = useT();
- const navigate = useNavigate();
- const { snapshot } = useCoreState();
- const { draft, setDraft, completeAndExit } = useOnboardingContext();
- const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
-
- const [choice, setChoice] = useState(
- draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
- );
-
- useEffect(() => {
- if (!isLocalSession) {
- return;
- }
- setChoice('configure');
- setDraft(prev => ({
- ...prev,
- customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
- }));
- }, [isLocalSession, setDraft]);
-
- const persistChoice = (next: CustomStepChoice) => {
- setChoice(next);
- setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
- };
-
- const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
-
- return (
- }
- defaultDisabled={isLocalSession}
- defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
- hideChoiceCards={isLocalSession}
- choice={choice}
- onChoiceChange={persistChoice}
- onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
- onContinue={async () => {
- trackEvent('onboarding_step_complete', {
- step_name: 'custom_oauth',
- choice: choice ?? 'default',
- });
- if (isLast) {
- try {
- await completeAndExit();
- } catch (err) {
- console.error('[onboarding:custom-oauth] completeAndExit failed', err);
- }
- return;
- }
- navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
- }}
- continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
- />
- );
-};
+const CustomOAuthPage = () => (
+ }
+ />
+);
export default CustomOAuthPage;
diff --git a/app/src/pages/onboarding/pages/CustomSearchPage.tsx b/app/src/pages/onboarding/pages/CustomSearchPage.tsx
index 5f627fa94..c27ef7c22 100644
--- a/app/src/pages/onboarding/pages/CustomSearchPage.tsx
+++ b/app/src/pages/onboarding/pages/CustomSearchPage.tsx
@@ -1,83 +1,8 @@
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
import SearchPanel from '../../../components/settings/panels/SearchPanel';
-import { useT } from '../../../lib/i18n/I18nContext';
-import { useCoreState } from '../../../providers/CoreStateProvider';
-import { trackEvent } from '../../../services/analytics';
-import { isLocalSessionToken } from '../../../utils/localSession';
-import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
-import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
-import CustomWizardStep from '../steps/CustomWizardStep';
+import CustomWizardConfigPage from './CustomWizardConfigPage';
-const STEP_KEY = 'search' as const;
-const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
-const LOCAL_DEFAULT_DISABLED_REASON =
- 'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
-
-const CustomSearchPage = () => {
- const { t } = useT();
- const navigate = useNavigate();
- const { snapshot } = useCoreState();
- const { draft, setDraft, completeAndExit } = useOnboardingContext();
- const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
-
- const [choice, setChoice] = useState(
- draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
- );
-
- useEffect(() => {
- if (!isLocalSession) {
- return;
- }
- setChoice('configure');
- setDraft(prev => ({
- ...prev,
- customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
- }));
- }, [isLocalSession, setDraft]);
-
- const persistChoice = (next: CustomStepChoice) => {
- setChoice(next);
- setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
- };
-
- const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
-
- return (
- }
- defaultDisabled={isLocalSession}
- defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
- hideChoiceCards={isLocalSession}
- choice={choice}
- onChoiceChange={persistChoice}
- onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
- onContinue={async () => {
- trackEvent('onboarding_step_complete', {
- step_name: 'custom_search',
- choice: choice ?? 'default',
- });
- if (isLast) {
- try {
- await completeAndExit();
- } catch (err) {
- console.error('[onboarding:custom-search] completeAndExit failed', err);
- }
- return;
- }
- navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
- }}
- continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
- />
- );
-};
+const CustomSearchPage = () => (
+ } />
+);
export default CustomSearchPage;
diff --git a/app/src/pages/onboarding/pages/CustomVoicePage.tsx b/app/src/pages/onboarding/pages/CustomVoicePage.tsx
index 45ca5c8f1..199fe7da1 100644
--- a/app/src/pages/onboarding/pages/CustomVoicePage.tsx
+++ b/app/src/pages/onboarding/pages/CustomVoicePage.tsx
@@ -1,83 +1,8 @@
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
import VoicePanel from '../../../components/settings/panels/VoicePanel';
-import { useT } from '../../../lib/i18n/I18nContext';
-import { useCoreState } from '../../../providers/CoreStateProvider';
-import { trackEvent } from '../../../services/analytics';
-import { isLocalSessionToken } from '../../../utils/localSession';
-import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
-import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
-import CustomWizardStep from '../steps/CustomWizardStep';
+import CustomWizardConfigPage from './CustomWizardConfigPage';
-const STEP_KEY = 'voice' as const;
-const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
-const LOCAL_DEFAULT_DISABLED_REASON =
- 'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
-
-const CustomVoicePage = () => {
- const { t } = useT();
- const navigate = useNavigate();
- const { snapshot } = useCoreState();
- const { draft, setDraft, completeAndExit } = useOnboardingContext();
- const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
-
- const [choice, setChoice] = useState(
- draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
- );
-
- useEffect(() => {
- if (!isLocalSession) {
- return;
- }
- setChoice('configure');
- setDraft(prev => ({
- ...prev,
- customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
- }));
- }, [isLocalSession, setDraft]);
-
- const persistChoice = (next: CustomStepChoice) => {
- setChoice(next);
- setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
- };
-
- const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
-
- return (
- }
- defaultDisabled={isLocalSession}
- defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
- hideChoiceCards={isLocalSession}
- choice={choice}
- onChoiceChange={persistChoice}
- onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
- onContinue={async () => {
- trackEvent('onboarding_step_complete', {
- step_name: 'custom_voice',
- choice: choice ?? 'default',
- });
- if (isLast) {
- try {
- await completeAndExit();
- } catch (err) {
- console.error('[onboarding:custom-voice] completeAndExit failed', err);
- }
- return;
- }
- navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
- }}
- continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
- />
- );
-};
+const CustomVoicePage = () => (
+ } />
+);
export default CustomVoicePage;
diff --git a/app/src/pages/onboarding/pages/CustomWizardConfigPage.tsx b/app/src/pages/onboarding/pages/CustomWizardConfigPage.tsx
new file mode 100644
index 000000000..c776e57e6
--- /dev/null
+++ b/app/src/pages/onboarding/pages/CustomWizardConfigPage.tsx
@@ -0,0 +1,91 @@
+import { type ReactNode, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { useT } from '../../../lib/i18n/I18nContext';
+import { useCoreState } from '../../../providers/CoreStateProvider';
+import { trackEvent } from '../../../services/analytics';
+import { isLocalSessionToken } from '../../../utils/localSession';
+import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
+import {
+ type CustomStepChoice,
+ type CustomStepKey,
+ useOnboardingContext,
+} from '../OnboardingContext';
+import CustomWizardStep from '../steps/CustomWizardStep';
+
+const LOCAL_DEFAULT_DISABLED_REASON =
+ 'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
+
+interface CustomWizardConfigPageProps {
+ stepKey: CustomStepKey;
+ configureContent: ReactNode;
+ backRoute?: string;
+}
+
+export default function CustomWizardConfigPage({
+ stepKey,
+ configureContent,
+ backRoute,
+}: CustomWizardConfigPageProps) {
+ const { t } = useT();
+ const navigate = useNavigate();
+ const { snapshot } = useCoreState();
+ const { draft, setDraft, completeAndExit } = useOnboardingContext();
+ const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
+ const stepIndex = CUSTOM_WIZARD_STEPS.indexOf(stepKey);
+ const [choice, setChoice] = useState(
+ draft.customChoices?.[stepKey] ?? (isLocalSession ? 'configure' : null)
+ );
+
+ useEffect(() => {
+ if (!isLocalSession) return;
+ setChoice('configure');
+ setDraft(prev => ({
+ ...prev,
+ customChoices: { ...prev.customChoices, [stepKey]: 'configure' },
+ }));
+ }, [isLocalSession, setDraft, stepKey]);
+
+ const persistChoice = (next: CustomStepChoice) => {
+ setChoice(next);
+ setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [stepKey]: next } }));
+ };
+
+ const isLast = stepIndex === CUSTOM_WIZARD_STEPS.length - 1;
+ const namespace = `onboarding.custom.${stepKey}`;
+
+ return (
+ navigate(backRoute ?? CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[stepIndex - 1]])}
+ onContinue={async () => {
+ trackEvent('onboarding_step_complete', {
+ step_name: `custom_${stepKey}`,
+ choice: choice ?? 'default',
+ });
+ if (isLast) {
+ try {
+ await completeAndExit();
+ } catch (err) {
+ console.error(`[onboarding:custom-${stepKey}] completeAndExit failed`, err);
+ }
+ return;
+ }
+ navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[stepIndex + 1]]);
+ }}
+ continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
+ />
+ );
+}