/** * Modal popped open when an `` pill is clicked * inside an agent message bubble. * * The pill dispatches a `window` `CustomEvent('openhuman-link', { detail: { path } })`; * this component listens for it, opens the modal, and routes to a focused * mini-flow per path. Keeps the chat in view (no react-router navigation) * so the user can complete the action and return to the agent without * losing the conversation. * * Mounted once at AppShell root. */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useChannelDefinitions } from '../hooks/useChannelDefinitions'; import { useT } from '../lib/i18n/I18nContext'; import { ensureNotificationPermission, getNotificationPermissionState, type NotificationPermissionState, showNativeNotification, } from '../lib/nativeNotifications/tauriBridge'; import { isTauri, purgeWebviewAccount } from '../services/webviewAccountService'; import { addAccount, removeAccount, setActiveAccount } from '../store/accountsSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { type Account, type AccountProvider, type AccountStatus, PROVIDERS, } from '../types/accounts'; import { BILLING_DASHBOARD_URL, DISCORD_INVITE_URL } from '../utils/links'; import { openUrl } from '../utils/openUrl'; import { ProviderIcon } from './accounts/providerIcons'; import ChannelSetupModal from './channels/ChannelSetupModal'; interface OpenhumanLinkEvent { path: string; } export const OPENHUMAN_LINK_EVENT = 'openhuman-link'; const ALLOWED_PATHS = [ 'settings/notifications', 'settings/billing', 'settings/messaging', 'community/discord', 'community/discord-report', 'accounts/setup', ] as const; type AllowedPath = (typeof ALLOWED_PATHS)[number]; const ALLOWED_PATHS_SET = new Set(ALLOWED_PATHS); const OpenhumanLinkModal = () => { const { t } = useT(); const [activePath, setActivePath] = useState(null); useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent).detail; if (detail?.path && ALLOWED_PATHS_SET.has(detail.path)) { setActivePath(detail.path as AllowedPath); } }; window.addEventListener(OPENHUMAN_LINK_EVENT, handler); return () => window.removeEventListener(OPENHUMAN_LINK_EVENT, handler); }, []); const close = useCallback(() => setActivePath(null), []); if (!activePath) return null; // Telegram (and any future channel) gets the dedicated `ChannelSetupModal` // already used by Skills + Settings instead of a bespoke body wrapper. // It manages its own portal + backdrop, so render it standalone. if (activePath === 'settings/messaging') { return ; } return (
e.stopPropagation()}>

{titleForPath(activePath, t)}

{renderBody(activePath, close)}
); }; /** * Resolves the Telegram channel definition and hands it to the shared * `ChannelSetupModal` (same component the Settings → Messaging panel * uses). When definitions are still loading we render a tiny placeholder * so the user gets feedback instead of a flashing screen. */ const MessagingSetupBridge = ({ onClose }: { onClose: () => void }) => { const { t } = useT(); const { definitions, loading } = useChannelDefinitions(); const telegram = useMemo(() => definitions.find(d => d.id === 'telegram') ?? null, [definitions]); if (loading && !telegram) { return (
{t('app.openhumanLink.loadingChannelSetup')}
); } if (!telegram) { return (
e.stopPropagation()}>

{t('app.openhumanLink.telegramUnavailable')}

); } return ; }; function titleForPath(path: AllowedPath, t: (k: string) => string): string { switch (path) { case 'settings/notifications': return t('app.openhumanLink.title.notifications'); case 'settings/billing': return t('app.openhumanLink.title.billing'); case 'settings/messaging': return t('app.openhumanLink.title.messaging'); case 'community/discord': return t('app.openhumanLink.title.discord'); case 'community/discord-report': return t('app.openhumanLink.title.discordReport'); case 'accounts/setup': return t('app.openhumanLink.title.accounts'); } } function renderBody(path: AllowedPath, close: () => void) { switch (path) { case 'settings/notifications': return ; case 'settings/billing': return ; case 'settings/messaging': // Routed via the dedicated `MessagingSetupBridge` above; this case // is kept to satisfy the path-completeness check but is unreachable // because the parent component returns the bridge before calling // `renderBody`. return null; case 'community/discord': return ; case 'community/discord-report': return ; case 'accounts/setup': return ; } } // ── Notifications ──────────────────────────────────────────────────────── const NotificationsBody = ({ close }: { close: () => void }) => { const { t } = useT(); const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [error, setError] = useState(null); const [permissionState, setPermissionState] = useState('unknown'); useEffect(() => { let mounted = true; void getNotificationPermissionState({ requestIfNeeded: false }).then(next => { if (!mounted) return; setPermissionState(next); }); return () => { mounted = false; }; }, []); const handleAllow = async () => { if (status === 'sending') { return; } setStatus('sending'); setError(null); try { if (!isTauri()) { setStatus('error'); setError(t('app.openhumanLink.notifications.desktopOnly')); return; } const granted = await ensureNotificationPermission(); if (!granted) { const nextState = await getNotificationPermissionState({ requestIfNeeded: false }); setPermissionState(nextState); setStatus('error'); setError(t('app.openhumanLink.notifications.permissionOff')); return; } const sendResult = await showNativeNotification({ title: t('app.openhumanLink.notifications.welcomeTitle'), body: t('app.openhumanLink.notifications.welcomeBody'), tag: 'welcome-notification-test', }); if (!sendResult.delivered) { setStatus('error'); setError(sendResult.error ?? t('app.openhumanLink.notifications.triggerFailed')); return; } setPermissionState('granted'); setStatus('sent'); } catch (e) { setStatus('error'); setError(e instanceof Error ? e.message : String(e)); } }; return (

{t('app.openhumanLink.notifications.intro')}

{permissionState === 'denied' && (
{t('app.openhumanLink.notifications.blocked')}
{t('app.openhumanLink.notifications.blockedStep1')}
{t('app.openhumanLink.notifications.blockedStep2')}
{t('app.openhumanLink.notifications.blockedStep3')}
)} {(permissionState === 'prompt' || permissionState === 'unknown') && (
{t('app.openhumanLink.notifications.promptHint')}
)} {status === 'sent' && (

{t('app.openhumanLink.notifications.sent')}

)} {status === 'error' && (

{t('app.openhumanLink.notifications.sendFailed').replace('{error}', error ?? '')}

)}
); }; // ── Billing ────────────────────────────────────────────────────────────── const BillingBody = ({ close }: { close: () => void }) => { const { t } = useT(); return (

{t('app.openhumanLink.billing.trialCredit')}

{t('onboarding.runtimeChoice.cloud.creditHighlight')}

{t('app.openhumanLink.billing.trialDesc')}

); }; // ── Discord ────────────────────────────────────────────────────────────── const DiscordBody = ({ close }: { close: () => void }) => { const { t } = useT(); return (

{t('app.openhumanLink.discord.intro')}

  • {t('app.openhumanLink.discord.perk1')}
  • {t('app.openhumanLink.discord.perk2')}
  • {t('app.openhumanLink.discord.perk3')}
  • {t('app.openhumanLink.discord.perk4')}
); }; /** * Error-report variant of the Discord modal. Shown when an agent error pill * with path "community/discord-report" is clicked. Distinct from DiscordBody * (join-community flow): * - Leads with an apology/acknowledgement copy. * - Offers an "Open Discord" primary button to jump straight to the server * (and closes the modal). */ const DiscordReportBody = ({ close }: { close: () => void }) => { const { t } = useT(); return (

{t('app.openhumanLink.discordReport.intro')}

); }; // ── Accounts setup (multi-channel toggle list) ────────────────────────── /** * Curated list of providers shown in the welcome flow's "Connect your apps" * step. Excludes call-only surfaces (`google-meet`, `zoom`) and dev-only * (`browserscan`) — those still appear in the full Add Account modal but * aren't a "set this up during onboarding" target. */ const ACCOUNTS_SETUP_PROVIDERS: readonly AccountProvider[] = [ 'whatsapp', 'wechat', 'telegram', 'slack', 'discord', 'linkedin', ]; function makeAccountId(): string { const c = globalThis.crypto; if (c && typeof c.randomUUID === 'function') return c.randomUUID(); if (c && typeof c.getRandomValues === 'function') { const bytes = new Uint8Array(4); c.getRandomValues(bytes); const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); return `acct-${Date.now().toString(36)}-${suffix}`; } return `acct-${Date.now().toString(36)}`; } /** * Translation key + color for a given account lifecycle status. Returns a * `labelKey` (not a literal) so the caller can localize it via `useT()` — * this is a module-level helper with no hook scope of its own. */ export function statusDisplay(status: AccountStatus): { labelKey: string; dotClass: string } { switch (status) { case 'open': return { labelKey: 'app.openhumanLink.status.connected', dotClass: 'bg-emerald-500' }; case 'loading': return { labelKey: 'app.openhumanLink.status.loading', dotClass: 'bg-amber-400' }; case 'pending': return { labelKey: 'app.openhumanLink.status.needsSignIn', dotClass: 'bg-amber-400' }; case 'timeout': return { labelKey: 'app.openhumanLink.status.timedOut', dotClass: 'bg-red-400' }; case 'error': return { labelKey: 'app.openhumanLink.status.error', dotClass: 'bg-red-400' }; case 'closed': return { labelKey: 'app.openhumanLink.status.closed', dotClass: 'bg-stone-300' }; } } const AccountsSetupBody = ({ close }: { close: () => void }) => { const { t } = useT(); const dispatch = useAppDispatch(); const accountsById = useAppSelector(s => s.accounts.accounts); const order = useAppSelector(s => s.accounts.order); // Track accounts added during this modal session so "Done" can navigate. // Uses state (not ref) so the CTA label re-renders when toggles change. const [newlyAdded, setNewlyAdded] = useState>(new Map()); // Map provider → first existing account (one provider, one row). const accountByProvider = useMemo(() => { const map = new Map(); for (const id of order) { const acct = accountsById[id]; if (acct && !map.has(acct.provider)) map.set(acct.provider, acct); } return map; }, [accountsById, order]); const providerDescriptors = useMemo( () => ACCOUNTS_SETUP_PROVIDERS.map(id => PROVIDERS.find(p => p.id === id)).filter( Boolean ) as typeof PROVIDERS, [] ); const handleToggle = (providerId: AccountProvider, label: string, currentlyOn: boolean) => { if (currentlyOn) { const existing = accountByProvider.get(providerId); if (!existing) return; void purgeWebviewAccount(existing.id).catch(() => {}); setNewlyAdded(prev => { const next = new Map(prev); next.delete(existing.id); return next; }); dispatch(removeAccount({ accountId: existing.id })); return; } const acct: Account = { id: makeAccountId(), provider: providerId, label, createdAt: new Date().toISOString(), status: 'pending', }; setNewlyAdded(prev => new Map(prev).set(acct.id, label)); dispatch(addAccount(acct)); }; const handleDone = () => { close(); // Activate the first newly-added account so the shell-level WebviewHost // opens the auth flow immediately without mutating the current route. const firstNew = [...newlyAdded.keys()][0]; if (firstNew) { dispatch(setActiveAccount(firstNew)); } }; // Dynamic CTA based on what's been toggled on const firstNewLabel = [...newlyAdded.values()][0]; const doneLabel = firstNewLabel ? t('app.openhumanLink.accounts.continueWith').replace('{label}', firstNewLabel) : t('app.openhumanLink.accounts.done'); return (

{t('app.openhumanLink.accounts.intro')}

{providerDescriptors.map(p => { const acct = accountByProvider.get(p.id); const on = !!acct; const status = acct?.status; return (
{p.label}
{on && status ? (
{t(statusDisplay(status).labelKey)}
) : (

{p.description}

)}
); })}

{t('app.openhumanLink.accounts.webviewNote')}

); }; // ── Shared footer ──────────────────────────────────────────────────────── const DoneFooter = ({ close, onDone, doneLabel, skipLabel, }: { close: () => void; onDone?: () => void; doneLabel?: string; skipLabel?: string; }) => { const { t } = useT(); const resolvedDone = doneLabel ?? t('app.openhumanLink.done'); const resolvedSkip = skipLabel ?? t('app.openhumanLink.skipForNow'); return (
); }; export default OpenhumanLinkModal;