refactor(react): extract repeated UI primitives (#3003)

This commit is contained in:
Steven Enamakel
2026-05-29 21:29:58 -07:00
committed by GitHub
parent e6bd1bd7b8
commit 029f007e6c
29 changed files with 1235 additions and 1559 deletions
@@ -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<HTMLButtonElement | null>(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')}>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<CloseIcon className="h-5 w-5" />
</button>
</div>
@@ -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<HTMLDivElement>(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
<button
onClick={onClose}
className="p-1 text-stone-400 dark:text-neutral-500 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100 dark:hover:text-neutral-100 transition-colors rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 flex-shrink-0">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<CloseIcon className="w-5 h-5" />
</button>
</div>
</div>
@@ -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<Record<string, boolean>>({});
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
const [error, setError] = useState<string | null>(null);
const runBusy = useCallback(async (key: string, task: () => Promise<void>) => {
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 (
<div className="space-y-3">
{error && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{error}
</div>
)}
{error && <ChannelConfigError message={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 (
<div
<ChannelAuthModeCard
key={spec.mode}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-stone-500 dark:text-neutral-400">{spec.description}</p>
<ChannelStatusBadge status={status} />
</div>
{connection?.lastError && (
<p className="text-xs text-coral-600 mt-2">{connection.lastError}</p>
)}
description={spec.description}
status={status}
lastError={connection?.lastError}>
{spec.fields.length > 0 && status !== 'connected' && (
<div className="mt-3 space-y-2">
{spec.fields.map(field => (
<ChannelFieldInput
key={field.key}
field={{
...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,
}}
value={fieldValues[compositeKey]?.[field.key] ?? ''}
onChange={val => updateField(compositeKey, field.key, val)}
disabled={busy}
/>
))}
</div>
<ChannelAuthFields
spec={spec}
compositeKey={compositeKey}
fieldValues={fieldValues}
onChange={updateField}
disabled={busy}
mapField={field => ({
...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,
})}
/>
)}
<div className="mt-3 flex gap-2">
{status !== 'connected' && (
<button
type="button"
disabled={busy}
onClick={() => 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')}
</button>
)}
<button
type="button"
disabled={busy || status === 'disconnected'}
onClick={() => 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')}
</button>
</div>
</div>
<ChannelConnectActions
busy={busy}
status={status}
connectLabel={t('channels.connect', 'Connect')}
disconnectLabel={t('accounts.disconnect')}
onConnect={() => handleConnect(spec)}
onDisconnect={() => handleDisconnect(spec.mode)}
/>
</ChannelAuthModeCard>
);
})}
</div>
+48 -90
View File
@@ -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<Record<string, boolean>>({});
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState<Record<string, boolean>>(
{}
);
const [error, setError] = useState<string | null>(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<string | null>(null);
const [copied, setCopied] = useState(false);
const pollAbort = useRef<AbortController | null>(null);
const runBusy = useCallback(async (key: string, task: () => Promise<void>) => {
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 (
<div className="space-y-3">
{error && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{error}
</div>
)}
{error && <ChannelConfigError message={error} />}
{isLocalSession && visibleAuthModes.length !== definition.auth_modes.length && (
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-4 py-3 text-sm text-stone-700 dark:text-neutral-200">
@@ -306,43 +287,28 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
const busy = busyKeys[compositeKey] ?? false;
return (
<div
<ChannelAuthModeCard
key={spec.mode}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t(`channels.authMode.${spec.mode}`)}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t(`channels.discord.authMode.${spec.mode}.description`)}
</p>
{connection?.lastError && (
<p className="text-xs text-coral-600 mt-1">{connection.lastError}</p>
)}
</div>
<ChannelStatusBadge status={status} />
</div>
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' && (
<div className="mt-3 space-y-2">
{spec.fields.map(field => (
<ChannelFieldInput
key={field.key}
field={{
...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,
}}
value={fieldValues[compositeKey]?.[field.key] ?? ''}
onChange={val => updateField(compositeKey, field.key, val)}
disabled={busy}
/>
))}
</div>
<ChannelAuthFields
spec={spec}
compositeKey={compositeKey}
fieldValues={fieldValues}
onChange={updateField}
disabled={busy}
mapField={field => ({
...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) => {
<p className="text-xs text-sage-700 dark:text-sage-300 font-medium">
{t('channels.discord.accountLinked')}
</p>
<button
type="button"
disabled={busy}
onClick={() => 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')}
</button>
<ChannelConnectActions
busy={busy}
status={status}
connectLabel={t('channels.discord.connect')}
disconnectLabel={t('accounts.disconnect')}
onDisconnect={() => handleDisconnect(spec.mode)}
showConnect={false}
className="mt-0"
/>
</div>
</>
) : /* Connect / Disconnect buttons for all other modes and states */
@@ -434,24 +402,14 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
</span>
</label>
)}
<div className="mt-3 flex gap-2">
{status !== 'connected' && (
<button
type="button"
disabled={busy}
onClick={() => 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')}
</button>
)}
<button
type="button"
disabled={busy || status === 'disconnected'}
onClick={() => 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')}
</button>
</div>
<ChannelConnectActions
busy={busy}
status={status}
connectLabel={t('channels.discord.connect')}
disconnectLabel={t('accounts.disconnect')}
onConnect={() => handleConnect(spec)}
onDisconnect={() => handleDisconnect(spec.mode)}
/>
</>
) : null}
@@ -467,7 +425,7 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
onChannelSelected={channelId => updateField(compositeKey, 'channel_id', channelId)}
/>
)}
</div>
</ChannelAuthModeCard>
);
})}
</div>
+44 -83
View File
@@ -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<Record<string, boolean>>({});
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState<Record<string, boolean>>(
{}
);
const [error, setError] = useState<string | null>(null);
const { busyKeys, fieldValues, error, setError, runBusy, updateField } =
useChannelAuthFormState();
const managedDmPollControllers = useRef<Record<string, AbortController>>({});
const runBusy = useCallback(async (key: string, task: () => Promise<void>) => {
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) => {
</p>
</div>
{error && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{error}
</div>
)}
{error && <ChannelConfigError message={error} />}
{isLocalSession && visibleAuthModes.length !== definition.auth_modes.length && (
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-4 py-3 text-sm text-stone-700 dark:text-neutral-200">
@@ -365,42 +346,27 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
const status: ChannelConnectionStatus = connection?.status ?? 'disconnected';
return (
<div
<ChannelAuthModeCard
key={spec.mode}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t(`channels.authMode.${spec.mode}`)}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t(`channels.telegram.authMode.${spec.mode}.description`)}
</p>
{connection?.lastError && (
<p className="text-xs text-coral-600 mt-1">{connection.lastError}</p>
)}
</div>
<ChannelStatusBadge status={status} />
</div>
title={t(`channels.authMode.${spec.mode}`)}
description={t(`channels.telegram.authMode.${spec.mode}.description`)}
status={status}
lastError={connection?.lastError}>
{spec.fields.length > 0 && (
<div className="mt-3 space-y-2">
{spec.fields.map(field => (
<ChannelFieldInput
key={field.key}
field={{
...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,
}}
value={fieldValues[compositeKey]?.[field.key] ?? ''}
onChange={val => updateField(compositeKey, field.key, val)}
disabled={busyKeys[compositeKey]}
/>
))}
</div>
<ChannelAuthFields
spec={spec}
compositeKey={compositeKey}
fieldValues={fieldValues}
onChange={updateField}
disabled={busyKeys[compositeKey]}
mapField={field => ({
...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) => {
</label>
)}
<div className="mt-3 flex gap-2">
<button
type="button"
disabled={busyKeys[compositeKey]}
onClick={() => 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'
<ChannelConnectActions
busy={busyKeys[compositeKey]}
status={status}
connectLabel={
status === 'connected'
? t('channels.telegram.reconnect')
: t('channels.telegram.connect')}
</button>
<button
type="button"
disabled={busyKeys[compositeKey] || status === 'disconnected'}
onClick={() => 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')}
</button>
</div>
</div>
: t('channels.telegram.connect')
}
disconnectLabel={t('accounts.disconnect')}
onConnect={() => handleConnect(spec)}
onDisconnect={() => handleDisconnect(spec.mode)}
showConnect
/>
</ChannelAuthModeCard>
);
})}
</div>
@@ -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<Record<string, boolean>>({});
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
const [error, setError] = useState<string | null>(null);
const runBusy = useCallback(async (key: string, task: () => Promise<void>) => {
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 (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{message}
</div>
);
}
interface ChannelAuthModeCardProps {
children: ReactNode;
title?: ReactNode;
description: ReactNode;
status: ChannelConnectionStatus;
lastError?: string;
}
export function ChannelAuthModeCard({
children,
title,
description,
status,
lastError,
}: ChannelAuthModeCardProps) {
return (
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
{title ? (
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">{title}</p>
) : null}
<p className={`text-xs text-stone-500 dark:text-neutral-400 ${title ? 'mt-1' : ''}`}>
{description}
</p>
{lastError ? <p className="text-xs text-coral-600 mt-1">{lastError}</p> : null}
</div>
<ChannelStatusBadge status={status} />
</div>
{children}
</div>
);
}
interface ChannelAuthFieldsProps {
spec: AuthModeSpec;
compositeKey: string;
fieldValues: Record<string, Record<string, string>>;
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 (
<div className="mt-3 space-y-2">
{spec.fields.map(field => {
const mapped = mapField ? mapField(field) : field;
return (
<ChannelFieldInput
key={field.key}
field={mapped}
value={fieldValues[compositeKey]?.[field.key] ?? ''}
onChange={val => onChange(compositeKey, field.key, val)}
disabled={disabled}
/>
);
})}
</div>
);
}
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 (
<div className={`mt-3 flex gap-2 ${className ?? ''}`}>
{showConnect && onConnect ? (
<button
type="button"
disabled={busy}
onClick={onConnect}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
{connectLabel}
</button>
) : null}
<button
type="button"
disabled={busy || status === 'disconnected'}
onClick={onDisconnect}
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">
{disconnectLabel}
</button>
</div>
);
}
@@ -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 = () => {
<div>
<div className="p-4 space-y-4">
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
{error && <ErrorBanner message={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 ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<Spinner className="w-4 h-4" />
{t('invites.generating')}
</>
) : (
@@ -174,51 +157,12 @@ const TeamInvitesPanel = () => {
{/* Refreshing indicator - only when loading and has existing data */}
{isLoadingInvites && invites.length > 0 && (
<div className="flex items-center gap-2 px-1 py-2 text-xs text-amber-400">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{t('invites.refreshing')}
</div>
<InlineLoadingStatus label={t('invites.refreshing')} />
)}
{/* Invites list */}
{isLoadingInvites && invites.length === 0 ? (
<div className="flex items-center justify-center py-8">
<svg
className="w-5 h-5 text-stone-500 dark:text-neutral-400 animate-spin"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="ml-3 text-sm text-stone-500 dark:text-neutral-400">
{t('invites.loading')}
</span>
</div>
<CenteredLoadingState label={t('invites.loading')} />
) : invites.length > 0 ? (
<div className="space-y-2">
{invites.map(invite => {
@@ -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 = () => {
<div>
<div className="p-4 space-y-4">
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
{error && <ErrorBanner message={error} />}
{/* Refreshing indicator - only when loading and has existing data */}
{isLoadingMembers && members.length > 0 && (
<div className="flex items-center gap-2 px-1 py-2 text-xs text-amber-400">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{t('team.refreshingMembers')}
</div>
<InlineLoadingStatus label={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 ? (
<div className="flex items-center justify-center py-8">
<svg
className="w-5 h-5 text-stone-500 dark:text-neutral-400 animate-spin"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="ml-3 text-sm text-stone-500 dark:text-neutral-400">
{t('team.loadingMembers')}
</span>
</div>
<CenteredLoadingState label={t('team.loadingMembers')} />
) : (
<div className="space-y-2">
{members.map(member => (
@@ -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 = () => {
<div>
<div className="p-4 space-y-4">
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
{error && <ErrorBanner message={error} />}
{isLoading && teams.length === 0 && (
<div className="flex items-center justify-center py-8">
<svg
className="w-5 h-5 text-stone-500 dark:text-neutral-400 animate-spin"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
)}
{isLoading && teams.length === 0 && <CenteredLoadingState />}
{teams.length > 0 && (
<div className="space-y-3">
@@ -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 && (
<div className="rounded-2xl border border-amber-500/20 bg-amber-100/90 p-4">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin text-amber-500" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<Spinner className="h-4 w-4 text-amber-500" />
<p className="text-sm text-amber-700 dark:text-amber-300">
{t('settings.billing.subscription.waitingPayment')}
</p>
@@ -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<string | null>(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(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={e => {
if (e.target === e.currentTarget) onClose();
}}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="ac-setup-title"
className="w-full max-w-md mx-4 rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden animate-fade-up">
{/* Header */}
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 7h16M4 12h10m-10 5h7m10 0l3 3m0 0l3-3m-3 3v-8"
/>
</svg>
</div>
<div>
<h2 id="ac-setup-title" className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.autocomplete.title')}</h2>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{step === 'enable' && t('skills.setup.autocomplete.stepEnable')}
{step === 'success' && t('skills.setup.autocomplete.stepSuccess')}
</p>
</div>
return (
<SkillSetupModalShell
onClose={onClose}
title={t('skills.setup.autocomplete.title')}
titleId="ac-setup-title"
subtitle={
<>
{step === 'enable' && t('skills.setup.autocomplete.stepEnable')}
{step === 'success' && t('skills.setup.autocomplete.stepSuccess')}
</>
}
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 7h16M4 12h10m-10 5h7m10 0l3 3m0 0l3-3m-3 3v-8"
/>
</svg>
}>
{/* ─── Enable step ─── */}
{step === 'enable' && (
<div className="space-y-4">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.autocomplete.description')}
</p>
{!status?.platform_supported && status !== null && (
<SetupNotice tone="amber">{t('skills.setup.autocomplete.notSupported')}</SetupNotice>
)}
<div className="space-y-2">
<SetupSettingRow
label={t('skills.setup.autocomplete.stylePreset')}
value={t('skills.setup.autocomplete.stylePresetValue')}
/>
<SetupSettingRow label={t('skills.setup.autocomplete.acceptKey')} value="Tab" mono />
<SetupSettingRow
label={t('skills.setup.autocomplete.debounce')}
value={`${status?.debounce_ms ?? 120}ms`}
/>
</div>
{enableError && <SetupNotice tone="coral">{enableError}</SetupNotice>}
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
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')}
</button>
</div>
)}
{/* Body */}
<div className="px-5 py-4">
{/* ─── Enable step ─── */}
{step === 'enable' && (
<div className="space-y-4">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.autocomplete.description')}
</p>
{!status?.platform_supported && status !== null && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
{t('skills.setup.autocomplete.notSupported')}
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.autocomplete.stylePreset')}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">{t('skills.setup.autocomplete.stylePresetValue')}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.autocomplete.acceptKey')}</span>
<span className="text-xs font-mono text-stone-500 dark:text-neutral-400">Tab</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.autocomplete.debounce')}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">{status?.debounce_ms ?? 120}ms</span>
</div>
</div>
{enableError && (
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
{enableError}
</div>
)}
<button
type="button"
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')}
</button>
</div>
)}
{/* ─── Success step ─── */}
{step === 'success' && (
<div className="space-y-4 text-center py-2">
<div className="mx-auto w-12 h-12 rounded-full bg-sage-50 flex items-center justify-center">
<svg className="w-6 h-6 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.autocomplete.activeTitle')}</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.autocomplete.activeDesc')}
</p>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleGoToSettings}
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-4 py-2.5 text-sm font-medium text-primary-700 hover:bg-primary-100 transition-colors">
{t('skills.setup.autocomplete.customizeSettings')}
</button>
<button
type="button"
onClick={onClose}
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.finish')}
</button>
</div>
</div>
)}
</div>
</div>
</div>,
document.body
{/* ─── Success step ─── */}
{step === 'success' && (
<SetupSuccess
title={t('skills.setup.autocomplete.activeTitle')}
description={t('skills.setup.autocomplete.activeDesc')}
settingsLabel={t('skills.setup.autocomplete.customizeSettings')}
finishLabel={t('common.finish')}
onSettings={handleGoToSettings}
onFinish={onClose}
/>
)}
</SkillSetupModalShell>
);
}
@@ -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 = ({
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2.5">
<div className="flex items-center gap-2">
{granted ? (
<svg className="w-4 h-4 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<CheckIcon className="w-4 h-4 text-sage-500" />
) : (
<svg className="w-4 h-4 text-stone-400 dark:text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
className="w-4 h-4 text-stone-400 dark:text-neutral-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" strokeWidth={2} />
</svg>
)}
<span className="text-sm text-stone-700 dark:text-neutral-200">{label}</span>
</div>
{granted ? (
<span className={`rounded-md border px-2 py-0.5 text-[10px] uppercase tracking-wide ${badgeColor}`}>
<span
className={`rounded-md border px-2 py-0.5 text-[10px] uppercase tracking-wide ${badgeColor}`}>
{t('skills.setup.screenIntel.granted')}
</span>
) : (
@@ -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')}
</button>
)}
</div>
@@ -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(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={e => {
if (e.target === e.currentTarget) onClose();
}}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="si-setup-title"
className="w-full max-w-md mx-4 rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden animate-fade-up">
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 5h18v12H3zM8 21h8m-4-4v4" />
</svg>
</div>
<h2 id="si-setup-title" className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.screenIntel.title')}</h2>
</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-5 py-6 space-y-4">
<p className="text-sm text-stone-600 dark:text-neutral-300 leading-relaxed">
{t('skills.setup.screenIntel.macosOnly')}
</p>
<button
type="button"
onClick={onClose}
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')}
</button>
</div>
</div>
</div>,
document.body
);
}
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={e => {
if (e.target === e.currentTarget) onClose();
}}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="si-setup-title"
className="w-full max-w-md mx-4 rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden animate-fade-up">
{/* Header */}
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 5h18v12H3zM8 21h8m-4-4v4" />
</svg>
</div>
<div>
<h2 id="si-setup-title" className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.screenIntel.title')}</h2>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{step === 'permissions' && t('skills.setup.screenIntel.stepPermissions')}
{step === 'enable' && t('skills.setup.screenIntel.stepEnable')}
{step === 'success' && t('skills.setup.screenIntel.stepSuccess')}
</p>
</div>
</div>
return (
<SkillSetupModalShell
onClose={onClose}
title={t('skills.setup.screenIntel.title')}
titleId="si-setup-title"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 5h18v12H3zM8 21h8m-4-4v4"
/>
</svg>
}>
<div className="space-y-4 py-2">
<p className="text-sm text-stone-600 dark:text-neutral-300 leading-relaxed">
{t('skills.setup.screenIntel.macosOnly')}
</p>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
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')}
</button>
</div>
</SkillSetupModalShell>
);
}
{/* Body */}
<div className="px-5 py-4">
{/* ─── Step 1: Permissions ─── */}
{step === 'permissions' && (
<div className="space-y-3">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.screenIntel.permissionsDesc')}
</p>
return (
<SkillSetupModalShell
onClose={onClose}
title={t('skills.setup.screenIntel.title')}
titleId="si-setup-title"
subtitle={
<>
{step === 'permissions' && t('skills.setup.screenIntel.stepPermissions')}
{step === 'enable' && t('skills.setup.screenIntel.stepEnable')}
{step === 'success' && t('skills.setup.screenIntel.stepSuccess')}
</>
}
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 5h18v12H3zM8 21h8m-4-4v4"
/>
</svg>
}>
{/* ─── Step 1: Permissions ─── */}
{step === 'permissions' && (
<div className="space-y-3">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.screenIntel.permissionsDesc')}
</p>
<div className="space-y-2">
<PermissionRow
label={t('skills.setup.screenIntel.permScreenRecording')}
value={status?.permissions.screen_recording ?? 'unknown'}
onRequest={() => void requestPermission('screen_recording')}
isRequesting={isRequestingPermissions}
/>
<PermissionRow
label={t('skills.setup.screenIntel.permAccessibility')}
value={status?.permissions.accessibility ?? 'unknown'}
onRequest={() => void requestPermission('accessibility')}
isRequesting={isRequestingPermissions}
/>
<PermissionRow
label={t('skills.setup.screenIntel.permInputMonitoring')}
value={status?.permissions.input_monitoring ?? 'unknown'}
onRequest={() => void requestPermission('input_monitoring')}
isRequesting={isRequestingPermissions}
/>
</div>
<div className="space-y-2">
<PermissionRow
label={t('skills.setup.screenIntel.permScreenRecording')}
value={status?.permissions.screen_recording ?? 'unknown'}
onRequest={() => void requestPermission('screen_recording')}
isRequesting={isRequestingPermissions}
/>
<PermissionRow
label={t('skills.setup.screenIntel.permAccessibility')}
value={status?.permissions.accessibility ?? 'unknown'}
onRequest={() => void requestPermission('accessibility')}
isRequesting={isRequestingPermissions}
/>
<PermissionRow
label={t('skills.setup.screenIntel.permInputMonitoring')}
value={status?.permissions.input_monitoring ?? 'unknown'}
onRequest={() => void requestPermission('input_monitoring')}
isRequesting={isRequestingPermissions}
/>
</div>
{anyDenied && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700 leading-relaxed">
<p>{t('skills.setup.screenIntel.deniedHint')}</p>
{status?.permission_check_process_path && (
<p className="mt-1 opacity-75 text-[10px]">
{t('skills.setup.screenIntel.permissionPathLabel')}{' '}
<span className="font-mono break-all text-stone-600 dark:text-neutral-300">
{status.permission_check_process_path}
</span>
</p>
)}
</div>
{anyDenied && (
<SetupNotice tone="amber" className="leading-relaxed">
<p>{t('skills.setup.screenIntel.deniedHint')}</p>
{status?.permission_check_process_path && (
<p className="mt-1 opacity-75 text-[10px]">
{t('skills.setup.screenIntel.permissionPathLabel')}{' '}
<span className="font-mono break-all text-stone-600 dark:text-neutral-300">
{status.permission_check_process_path}
</span>
</p>
)}
{lastRestartSummary && (
<div className="rounded-xl border border-sage-200 bg-sage-50 p-3 text-xs text-sage-700">
{lastRestartSummary}
</div>
)}
{lastError && (
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
{lastError}
</div>
)}
<div className="flex items-center gap-2 pt-1">
{anyDenied ? (
<button
type="button"
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')}
</button>
) : (
<button
type="button"
onClick={() => 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')}
</button>
)}
</div>
</div>
</SetupNotice>
)}
{/* ─── Step 2: Enable ─── */}
{step === 'enable' && (
<div className="space-y-4">
<div className="rounded-xl border border-sage-200 bg-sage-50 p-3 flex items-center gap-2">
<svg className="w-4 h-4 text-sage-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-xs text-sage-700">{t('skills.setup.screenIntel.allGranted')}</span>
</div>
{lastRestartSummary && <SetupNotice tone="sage">{lastRestartSummary}</SetupNotice>}
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.screenIntel.enableDesc')}
</p>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.screenIntel.captureMode')}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">{t('skills.setup.screenIntel.captureModeValue')}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.screenIntel.visionModel')}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">{t('common.enabled')}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.screenIntel.panicHotkey')}</span>
<span className="text-xs font-mono text-stone-500 dark:text-neutral-400">{status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</span>
</div>
</div>
{enableError && (
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
{enableError}
</div>
)}
{lastError && <SetupNotice tone="coral">{lastError}</SetupNotice>}
<div className="flex items-center gap-2 pt-1">
{anyDenied ? (
<button
type="button"
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.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')}
</button>
</div>
)}
{/* ─── Step 3: Success ─── */}
{step === 'success' && (
<div className="space-y-4 text-center py-2">
<div className="mx-auto w-12 h-12 rounded-full bg-sage-50 flex items-center justify-center">
<svg className="w-6 h-6 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.screenIntel.activeTitle')}</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.screenIntel.activeDesc')}
</p>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleGoToSettings}
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-4 py-2.5 text-sm font-medium text-primary-700 hover:bg-primary-100 transition-colors">
{t('skills.setup.screenIntel.advancedSettings')}
</button>
<button
type="button"
onClick={onClose}
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.finish')}
</button>
</div>
</div>
)}
) : (
<button
type="button"
onClick={() => 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')}
</button>
)}
</div>
</div>
</div>
</div>,
document.body
)}
{/* ─── Step 2: Enable ─── */}
{step === 'enable' && (
<div className="space-y-4">
<SetupNotice tone="sage" icon={<CheckIcon className="w-4 h-4 text-sage-500" />}>
{t('skills.setup.screenIntel.allGranted')}
</SetupNotice>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.screenIntel.enableDesc')}
</p>
<div className="space-y-2">
<SetupSettingRow
label={t('skills.setup.screenIntel.captureMode')}
value={t('skills.setup.screenIntel.captureModeValue')}
/>
<SetupSettingRow
label={t('skills.setup.screenIntel.visionModel')}
value={t('common.enabled')}
/>
<SetupSettingRow
label={t('skills.setup.screenIntel.panicHotkey')}
value={status?.session.panic_hotkey ?? 'Cmd+Shift+.'}
mono
/>
</div>
{enableError && <SetupNotice tone="coral">{enableError}</SetupNotice>}
<button
type="button"
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.screenIntel.enabling')
: t('skills.setup.screenIntel.enableBtn')}
</button>
</div>
)}
{/* ─── Step 3: Success ─── */}
{step === 'success' && (
<SetupSuccess
title={t('skills.setup.screenIntel.activeTitle')}
description={t('skills.setup.screenIntel.activeDesc')}
settingsLabel={t('skills.setup.screenIntel.advancedSettings')}
finishLabel={t('common.finish')}
onSettings={handleGoToSettings}
onFinish={onClose}
/>
)}
</SkillSetupModalShell>
);
}
@@ -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 (
<ModalShell onClose={onClose} title={title} titleId={titleId} subtitle={subtitle} icon={icon}>
{children}
</ModalShell>
);
}
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 (
<div
className={`rounded-xl border p-3 text-xs ${NOTICE_CLASSES[tone]} ${
icon ? 'flex items-start gap-2' : ''
} ${className ?? ''}`}>
{icon ? <span className="mt-0.5 flex-shrink-0">{icon}</span> : null}
<div className="min-w-0">{children}</div>
</div>
);
}
interface SetupSettingRowProps {
label: ReactNode;
value: ReactNode;
mono?: boolean;
}
export function SetupSettingRow({ label, value, mono = false }: SetupSettingRowProps) {
return (
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{label}</span>
<span className={`text-xs text-stone-500 dark:text-neutral-400 ${mono ? 'font-mono' : ''}`}>
{value}
</span>
</div>
);
}
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 (
<div className="space-y-4 text-center py-2">
<div className="mx-auto w-12 h-12 rounded-full bg-sage-50 flex items-center justify-center">
<CheckIcon className="w-6 h-6 text-sage-500" />
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{title}</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{description}
</p>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onSettings}
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-4 py-2.5 text-sm font-medium text-primary-700 hover:bg-primary-100 transition-colors">
{settingsLabel}
</button>
<button
type="button"
onClick={onFinish}
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">
{finishLabel}
</button>
</div>
</div>
);
}
+113 -162
View File
@@ -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<string | null>(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(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={e => {
if (e.target === e.currentTarget) onClose();
}}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="voice-setup-title"
className="w-full max-w-md mx-4 rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden animate-fade-up">
{/* Header */}
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
</div>
<div>
<h2 id="voice-setup-title" className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.voice.title')}</h2>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{step === 'setup' && t('skills.setup.voice.stepSetup')}
{step === 'enable' && t('skills.setup.voice.stepEnable')}
{step === 'success' && t('skills.setup.voice.stepSuccess')}
</p>
return (
<SkillSetupModalShell
onClose={onClose}
title={t('skills.setup.voice.title')}
titleId="voice-setup-title"
subtitle={
<>
{step === 'setup' && t('skills.setup.voice.stepSetup')}
{step === 'enable' && t('skills.setup.voice.stepEnable')}
{step === 'success' && t('skills.setup.voice.stepSuccess')}
</>
}
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
}>
{/* ─── Setup step: STT model missing ─── */}
{step === 'setup' && (
<div className="space-y-4">
<SetupNotice tone="amber" icon={<WarningIcon className="w-4 h-4 text-amber-500" />}>
<div className="text-xs text-amber-700 leading-relaxed">
<p className="font-medium">{t('skills.setup.voice.sttNotReady')}</p>
<p className="mt-1">{t('skills.setup.voice.sttNotReadyDesc')}</p>
</div>
</SetupNotice>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.voice.sttReturnHint')}
</p>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleGoToLocalModel}
className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 transition-colors">
{t('skills.setup.voice.downloadSttBtn')}
</button>
<button
type="button"
onClick={onClose}
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.cancel')}
</button>
</div>
</div>
)}
{/* ─── Enable step ─── */}
{step === 'enable' && (
<div className="space-y-4">
<SetupNotice tone="sage" icon={<CheckIcon className="w-4 h-4 text-sage-500" />}>
{t('skills.setup.voice.sttReady')}
</SetupNotice>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.voice.enableDesc')}
</p>
<div className="space-y-2">
<SetupSettingRow
label={t('skills.setup.voice.hotkey')}
value={serverStatus?.hotkey ?? 'Fn'}
mono
/>
<SetupSettingRow
label={t('skills.setup.voice.activation')}
value={
serverStatus?.activation_mode === 'push'
? t('voice.pushToTalk')
: t('voice.tapToToggle')
}
/>
</div>
{enableError && <SetupNotice tone="coral">{enableError}</SetupNotice>}
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
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')}
</button>
</div>
)}
{/* Body */}
<div className="px-5 py-4">
{/* ─── Setup step: STT model missing ─── */}
{step === 'setup' && (
<div className="space-y-4">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
<svg className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div className="text-xs text-amber-700 leading-relaxed">
<p className="font-medium">{t('skills.setup.voice.sttNotReady')}</p>
<p className="mt-1">{t('skills.setup.voice.sttNotReadyDesc')}</p>
</div>
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.voice.sttReturnHint')}
</p>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleGoToLocalModel}
className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 transition-colors">
{t('skills.setup.voice.downloadSttBtn')}
</button>
<button
type="button"
onClick={onClose}
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.cancel')}
</button>
</div>
</div>
)}
{/* ─── Enable step ─── */}
{step === 'enable' && (
<div className="space-y-4">
<div className="rounded-xl border border-sage-200 bg-sage-50 p-3 flex items-center gap-2">
<svg className="w-4 h-4 text-sage-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-xs text-sage-700">{t('skills.setup.voice.sttReady')}</span>
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.voice.enableDesc')}
</p>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.voice.hotkey')}</span>
<span className="text-xs font-mono text-stone-500 dark:text-neutral-400">{serverStatus?.hotkey ?? 'Fn'}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.voice.activation')}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">{serverStatus?.activation_mode === 'push' ? t('voice.pushToTalk') : t('voice.tapToToggle')}</span>
</div>
</div>
{enableError && (
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
{enableError}
</div>
)}
<button
type="button"
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')}
</button>
</div>
)}
{/* ─── Success step ─── */}
{step === 'success' && (
<div className="space-y-4 text-center py-2">
<div className="mx-auto w-12 h-12 rounded-full bg-sage-50 flex items-center justify-center">
<svg className="w-6 h-6 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.voice.activeTitle')}</h3>
<p className="text-center mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.voice.activeDescPrefix')} <span className="font-mono font-medium">{serverStatus?.hotkey ?? 'Fn'}</span> {t('skills.setup.voice.activeDescSuffix')}
</p>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleGoToSettings}
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-4 py-2.5 text-sm font-medium text-primary-700 hover:bg-primary-100 transition-colors">
{t('skills.setup.voice.customizeSettings')}
</button>
<button
type="button"
onClick={onClose}
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.finish')}
</button>
</div>
</div>
)}
</div>
</div>
</div>,
document.body
{/* ─── Success step ─── */}
{step === 'success' && (
<SetupSuccess
title={t('skills.setup.voice.activeTitle')}
description={
<>
{t('skills.setup.voice.activeDescPrefix')}{' '}
<span className="font-mono font-medium">{serverStatus?.hotkey ?? 'Fn'}</span>{' '}
{t('skills.setup.voice.activeDescSuffix')}
</>
}
settingsLabel={t('skills.setup.voice.customizeSettings')}
finishLabel={t('common.finish')}
onSettings={handleGoToSettings}
onFinish={onClose}
/>
)}
</SkillSetupModalShell>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Spinner } from './icons';
interface ErrorBannerProps {
message: string;
className?: string;
}
export function ErrorBanner({ message, className }: ErrorBannerProps) {
return (
<div className={`rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 ${className ?? ''}`}>
<p className="text-xs text-coral-400">{message}</p>
</div>
);
}
interface InlineLoadingStatusProps {
label: string;
className?: string;
}
export function InlineLoadingStatus({ label, className }: InlineLoadingStatusProps) {
return (
<div className={`flex items-center gap-2 px-1 py-2 text-xs text-amber-400 ${className ?? ''}`}>
<Spinner className="w-3 h-3" />
{label}
</div>
);
}
interface CenteredLoadingStateProps {
label?: string;
className?: string;
}
export function CenteredLoadingState({ label, className }: CenteredLoadingStateProps) {
return (
<div className={`flex items-center justify-center py-8 ${className ?? ''}`}>
<Spinner className="w-5 h-5 text-stone-500 dark:text-neutral-400" />
{label ? (
<span className="ml-3 text-sm text-stone-500 dark:text-neutral-400">{label}</span>
) : null}
</div>
);
}
+87
View File
@@ -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<HTMLDivElement>(null);
useEscapeKey(onClose);
useEffect(() => {
const previousFocus = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => previousFocus?.focus?.();
}, []);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={event => {
if (event.target === event.currentTarget) onClose();
}}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy ?? titleId}
tabIndex={-1}
className={`w-full ${maxWidthClassName} mx-4 rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden animate-fade-up focus:outline-none`}
onClick={event => event.stopPropagation()}>
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="flex min-w-0 items-center gap-3">
{icon ? (
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600 flex-shrink-0">
{icon}
</div>
) : null}
<div className="min-w-0">
<h2
id={titleId}
className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{title}
</h2>
{subtitle ? (
<p className="text-xs text-stone-500 dark:text-neutral-400">{subtitle}</p>
) : null}
</div>
</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
<CloseIcon className="w-4 h-4" />
</button>
</div>
<div className={contentClassName}>{children}</div>
</div>
</div>,
document.body
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { SVGProps } from 'react';
type IconProps = SVGProps<SVGSVGElement>;
export function Spinner({ className = 'h-4 w-4', ...props }: IconProps) {
return (
<svg className={`animate-spin ${className}`} fill="none" viewBox="0 0 24 24" {...props}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
export function CheckIcon({ className = 'h-4 w-4', ...props }: IconProps) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
}
export function CloseIcon({ className = 'h-4 w-4', ...props }: IconProps) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
export function WarningIcon({ className = 'h-4 w-4', ...props }: IconProps) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
);
}
+4
View File
@@ -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';
@@ -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]);
}
@@ -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,
};
@@ -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',
};
}
+15 -69
View File
@@ -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]);
}
+16
View File
@@ -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]);
}
@@ -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<CustomStepChoice | null>(
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 (
<CustomWizardStep
testId="onboarding-custom-embeddings-step"
stepIndex={STEP_INDEX}
stepCount={CUSTOM_WIZARD_STEPS.length}
title={t('onboarding.custom.embeddings.title')}
subtitle={t('onboarding.custom.embeddings.subtitle')}
defaultDescription={t('onboarding.custom.embeddings.defaultDesc')}
configureDescription={t('onboarding.custom.embeddings.configureDesc')}
configureContent={<EmbeddingsPanel embedded />}
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 = () => (
<CustomWizardConfigPage stepKey="embeddings" configureContent={<EmbeddingsPanel embedded />} />
);
export default CustomEmbeddingsPage;
@@ -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<CustomStepChoice | null>(
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 (
<CustomWizardStep
testId="onboarding-custom-inference-step"
stepIndex={STEP_INDEX}
stepCount={CUSTOM_WIZARD_STEPS.length}
title={t('onboarding.custom.inference.title')}
subtitle={t('onboarding.custom.inference.subtitle')}
defaultDescription={t('onboarding.custom.inference.defaultDesc')}
configureDescription={t('onboarding.custom.inference.configureDesc')}
configureContent={<AIPanel embedded />}
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 = () => (
<CustomWizardConfigPage
stepKey="inference"
backRoute="/onboarding/runtime-choice"
configureContent={<AIPanel embedded />}
/>
);
export default CustomInferencePage;
@@ -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<CustomStepChoice | null>(
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 (
<CustomWizardStep
testId="onboarding-custom-oauth-step"
stepIndex={STEP_INDEX}
stepCount={CUSTOM_WIZARD_STEPS.length}
title={t('onboarding.custom.oauth.title')}
subtitle={t('onboarding.custom.oauth.subtitle')}
defaultDescription={t('onboarding.custom.oauth.defaultDesc')}
configureDescription={t('onboarding.custom.oauth.configureDesc')}
configureContent={<ComposioPanel embedded managedAuthEnabled={false} />}
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 = () => (
<CustomWizardConfigPage
stepKey="oauth"
configureContent={<ComposioPanel embedded managedAuthEnabled={false} />}
/>
);
export default CustomOAuthPage;
@@ -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<CustomStepChoice | null>(
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 (
<CustomWizardStep
testId="onboarding-custom-search-step"
stepIndex={STEP_INDEX}
stepCount={CUSTOM_WIZARD_STEPS.length}
title={t('onboarding.custom.search.title')}
subtitle={t('onboarding.custom.search.subtitle')}
defaultDescription={t('onboarding.custom.search.defaultDesc')}
configureDescription={t('onboarding.custom.search.configureDesc')}
configureContent={<SearchPanel embedded />}
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 = () => (
<CustomWizardConfigPage stepKey="search" configureContent={<SearchPanel embedded />} />
);
export default CustomSearchPage;
@@ -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<CustomStepChoice | null>(
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 (
<CustomWizardStep
testId="onboarding-custom-voice-step"
stepIndex={STEP_INDEX}
stepCount={CUSTOM_WIZARD_STEPS.length}
title={t('onboarding.custom.voice.title')}
subtitle={t('onboarding.custom.voice.subtitle')}
defaultDescription={t('onboarding.custom.voice.defaultDesc')}
configureDescription={t('onboarding.custom.voice.configureDesc')}
configureContent={<VoicePanel embedded />}
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 = () => (
<CustomWizardConfigPage stepKey="voice" configureContent={<VoicePanel embedded />} />
);
export default CustomVoicePage;
@@ -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<CustomStepChoice | null>(
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 (
<CustomWizardStep
testId={`onboarding-custom-${stepKey}-step`}
stepIndex={stepIndex}
stepCount={CUSTOM_WIZARD_STEPS.length}
title={t(`${namespace}.title`)}
subtitle={t(`${namespace}.subtitle`)}
defaultDescription={t(`${namespace}.defaultDesc`)}
configureDescription={t(`${namespace}.configureDesc`)}
configureContent={configureContent}
defaultDisabled={isLocalSession}
defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
hideChoiceCards={isLocalSession}
choice={choice}
onChoiceChange={persistChoice}
onBack={() => 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}
/>
);
}