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 ( -
-
-

{spec.description}

- -
- - {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)} + 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)} + 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)} + 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) => { )} -
- -
-
+ : 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 ? ( + + ) : null} + +
+ ); +} 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}

-
- )} + {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}

-
- )} + {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}

-
- )} + {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}} +
+ )} - {/* 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} -
- )} - - -
- )} - - {/* ─── Success step ─── */} - {step === 'success' && ( -
-
- - - -
- -
-

{t('skills.setup.autocomplete.activeTitle')}

-

- {t('skills.setup.autocomplete.activeDesc')} -

-
- -
- - -
-
- )} -
-
-
, - 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')} -

- -
-
-
, - 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')} +

+
+ ); + } - {/* 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 ? ( - - ) : ( - - )} -
-
+ )} - {/* ─── 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 ? ( -
- )} - - {/* ─── Step 3: Success ─── */} - {step === 'success' && ( -
-
- - - -
- -
-

{t('skills.setup.screenIntel.activeTitle')}

-

- {t('skills.setup.screenIntel.activeDesc')} -

-
- -
- - -
-
- )} + ) : ( + + )} +
-
-
, - document.body + )} + + {/* ─── Step 2: Enable ─── */} + {step === 'enable' && ( +
+ }> + {t('skills.setup.screenIntel.allGranted')} + + +

+ {t('skills.setup.screenIntel.enableDesc')} +

+ +
+ + + +
+ + {enableError && {enableError}} + + +
+ )} + + {/* ─── 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} +

+
+ +
+ + +
+
+ ); +} 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')} +

+ +
+ +
+
+ )} + + {/* ─── Enable step ─── */} + {step === 'enable' && ( +
+ }> + {t('skills.setup.voice.sttReady')} + + +

+ {t('skills.setup.voice.enableDesc')} +

+ +
+ + +
+ + {enableError && {enableError}} +
+ )} - {/* Body */} -
- {/* ─── Setup step: STT model missing ─── */} - {step === 'setup' && ( -
-
- - - -
-

{t('skills.setup.voice.sttNotReady')}

-

{t('skills.setup.voice.sttNotReadyDesc')}

-
-
- -

- {t('skills.setup.voice.sttReturnHint')} -

- -
- - -
-
- )} - - {/* ─── 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} -
- )} - - -
- )} - - {/* ─── Success step ─── */} - {step === 'success' && ( -
-
- - - -
- -
-

{t('skills.setup.voice.activeTitle')}

-

- {t('skills.setup.voice.activeDescPrefix')} {serverStatus?.hotkey ?? 'Fn'} {t('skills.setup.voice.activeDescSuffix')} -

-
- -
- - -
-
- )} -
-
-
, - 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 ( +
+

{message}

+
+ ); +} + +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} + /> + ); +}