mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
refactor(react): extract repeated UI primitives (#3003)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user