import {useMutation, useQuery, type QueryClient} from '@tanstack/react-query'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import { Alert } from 'react-native'; import { createAgentListQueryOptions, mergeCachedAgent, prependCachedAgent, removeCachedAgent, superagentQueryKeys, } from './agentQueries'; import { createSuperagentNativeClient } from './nativeClient'; import { isChannelResultForActiveAgent, mergeLoadedChannelStatus } from '../features/channels/channelStatusUtils'; import { DEFAULT_SANDBOX_FILE_PATHS, normalizeFilePaths, sanitizeSandboxFilePath } from '../features/editor/fileTreeUtils'; import { useAgentBi } from '../analytics/mixpanelContext'; import { routeIdentityKey } from './routeKey'; import { createSuperagentSocketClient, type SuperagentSocketLike, } from './socketClient'; import { type SuperagentAgent, type SuperagentAgentActionInput, type SuperagentAutomation, type SuperagentAutomationActionInput, type SuperagentAutomationCreditsSummary, type SuperagentChannelActionInput, type SuperagentChannelId, type SuperagentChannelStatus, type SuperagentChannelUrlActionInput, type SuperagentCollaborator, type SuperagentConnector, type SuperagentConnectorActionInput, type SuperagentShareAgentInput, type SuperagentIMessageCodeShareInput, type SuperagentLineCodeShareInput, type SuperagentLiveVoiceInput, type SuperagentMediaActionContext, type SuperagentModelUpdateInput, type SuperagentOpenWorkspaceMembersInput, type SuperagentRealtimeClient, type SuperagentRenameAgentInput, type SuperagentSandboxFileActionInput, type SuperagentSandboxFileSaveInput, type SuperagentSandboxFileUploadInput, type SuperagentSecret, type SuperagentSecretDeleteInput, type SuperagentSecretSaveInput, type SuperagentTelegramSetupInput, type SuperagentRoute, type SuperagentToolPermissionsUpdateInput, type SuperagentWorkflow, type SuperagentWorkflowActionInput, } from '../types'; import { createSuperagentApiClient, type SuperagentVoiceLiveSession } from './superagentApiClient'; import { useSpeechToText } from './useSpeechToText'; import { getUserProfileImageUrl } from './userProfile'; import type { SuperagentAttachmentPickerAdapters } from '../features/attachments/useSuperagentAttachmentPicker'; import type { SuperagentUser } from '../types'; // 3s × 100 ≈ 5 min, matching the web connect poll window. const SLACK_CONNECT_POLL_INTERVAL_MS = 3000; const SLACK_CONNECT_POLL_ATTEMPTS = 100; const TELEGRAM_CONNECT_POLL_INTERVAL_MS = 2500; const TELEGRAM_CONNECT_POLL_ATTEMPTS = 120; const EMPTY_AGENTS: SuperagentAgent[] = []; export type SuperagentExternalAuthCallbackStatus = 'success' | 'error' | 'unknown'; export type SuperagentExternalAuthCallbackEvent = { callbackUrl: string; ok: boolean; status: SuperagentExternalAuthCallbackStatus; }; export type SuperagentSessionConfig = { baseUrl: string; /** Stable, opaque host-owned scope for isolating cached session data (for example a workspace id). */ cacheScope: string; getAccessToken: () => Promise | string | null | undefined; getHeaders?: () => Promise | undefined> | Record | undefined; /** Host-owned client shared by every native-navigation root. */ queryClient: QueryClient; webUrl?: string; }; export type SuperagentSandboxPickedFile = { content: string; name: string; }; export type SuperagentRealtimeSocketFactoryInput = { appId: string; baseUrl: string; token: string; }; export type SuperagentRuntimeSocket = SuperagentSocketLike & { disconnect?: () => void; }; export type SuperagentLiveAudioChunk = { data: string; mimeType?: string; }; export type SuperagentLiveAudioCapture = { stop?: () => Promise | void; }; export type SuperagentLiveAudioCaptureInput = { mimeType: string; onAudioChunk: (chunk: SuperagentLiveAudioChunk) => void; onError?: (error: Error) => void; onStop?: () => void; sampleRate: number; }; export type SuperagentLiveVoiceAudioAdapters = { playAudioChunk?: (chunk: SuperagentLiveAudioChunk) => Promise | void; prepareAudioSession?: () => Promise | void; requestMicrophonePermission?: () => Promise | boolean; startAudioCapture: (input: SuperagentLiveAudioCaptureInput) => Promise | SuperagentLiveAudioCapture | void; stopAudioCapture?: () => Promise | void; stopAudioPlayback?: () => Promise | void; }; /** * Thin recorder passthrough for speech-to-text (dictation) — distinct from * `SuperagentLiveVoiceAudioAdapters` (streaming Live Voice). The host records a * clip and hands back a local file; the package uploads it and appends the * transcript to the composer. No metering in v1. `stopRecording` returns the * captured clip (native produces m4a/mp4). */ export type SuperagentVoiceRecorderAdapter = { requestMicrophonePermission?: () => Promise | boolean; startRecording: () => Promise; stopRecording: () => Promise<{ uri: string; mimeType?: string; name?: string }>; // Discard an in-flight recording (e.g. the user left the conversation mid-record). // When absent, the package falls back to stopRecording() and drops the clip. cancelRecording?: () => Promise; // Current mic input level (0..1) while recording, polled by the dictation // waveform (e.g. from AVAudioRecorder/MediaRecorder metering). When absent, // the waveform falls back to a synthetic idle animation. getInputLevel?: () => number; }; export type SuperagentNativeRuntimeAdapters = { alert?: (title: string, message?: string) => void; completeExternalAuthCallback?: (url: string) => Promise; confirm?: (input: { confirmText?: string; destructive?: boolean; message?: string; title: string; }) => Promise | boolean; copyToClipboard?: (text: string) => Promise | void; createRealtimeSocket?: (input: SuperagentRealtimeSocketFactoryInput) => SuperagentRuntimeSocket | null | undefined; isAttachmentPickerCancel?: (error: unknown) => boolean; isExternalAuthCallbackUrl?: (url: string) => boolean; liveVoiceAudio?: SuperagentLiveVoiceAudioAdapters; voiceRecorder: SuperagentVoiceRecorderAdapter; onAgentMessageDone?: () => Promise | void; openUrl?: (url: string) => Promise | void; openWebUrl?: (url: string) => Promise | void; pickSandboxFiles?: () => Promise | SuperagentSandboxPickedFile[] | null | undefined; share?: (input: { message: string; title?: string; url?: string }) => Promise | void; subscribeToAppResume?: (listener: () => void) => () => void; subscribeToDeepLinks?: (listener: (url: string) => void) => () => void; subscribeToExternalAuthCallbacks?: (listener: (event: SuperagentExternalAuthCallbackEvent) => void) => () => void; }; /** * The full native-adapter surface the host passes to `SuperagentHomeScreen`: the * runtime adapters above, plus the optional attachment-picker adapters. When * `attachments` is present the package drives file/photo/camera picking and renders * its own upload-status modal; when absent, those composer affordances are hidden. */ export type SuperagentShellAdapters = SuperagentNativeRuntimeAdapters & { attachments?: SuperagentAttachmentPickerAdapters; }; type UseSuperagentRuntimeParams = { session: SuperagentSessionConfig; user?: SuperagentUser | null; adapters: SuperagentShellAdapters; /** Opening route (seeds `currentRoute`). */ initialRoute?: SuperagentRoute; }; type ConnectorFlow = { agentId: string; cancelled: boolean; connectorId: string; }; export function useSuperagentRuntime({ session, user, adapters: nativeAdapters, initialRoute, }: UseSuperagentRuntimeParams) { const bi = useAgentBi(); const initialAgentId = initialRoute?.name === 'agent' ? initialRoute.agentId : undefined; // Reconstruct the internal config from the lean session + the injected user, so // the rest of the runtime keeps reading `config.*` unchanged. The current user's // id / name / avatar are derived from `user` (no separate host props). const config = useMemo(() => ({ baseUrl: session.baseUrl, webUrl: session.webUrl, getAccessToken: session.getAccessToken, getHeaders: session.getHeaders, currentUserId: user?.id ?? null, currentUserName: user?.full_name, currentUserAvatarUrl: getUserProfileImageUrl(user), }), [session, user]); const superagentService = useMemo(() => createSuperagentApiClient({ baseUrl: config.baseUrl, currentUserId: config.currentUserId, getAccessToken: config.getAccessToken, getHeaders: config.getHeaders, }), [config.baseUrl, config.currentUserId, config.getAccessToken, config.getHeaders]); // Pass the host client directly to every hook. Modular native bundlers can load // the host provider and this package from separate React Query module instances, // whose contexts are intentionally distinct even though the client is shared. const queryClient = session.queryClient; const agentsQueryKey = useMemo(() => superagentQueryKeys.agents({ baseUrl: config.baseUrl, cacheScope: session.cacheScope, userId: config.currentUserId ?? 'signed-out', }), [config.baseUrl, config.currentUserId, session.cacheScope]); const agentsQuery = useQuery( createAgentListQueryOptions({ enabled: config.currentUserId != null, listAgents: () => superagentService.listAgents(), queryKey: agentsQueryKey, }), queryClient, ); const agents = agentsQuery.data ?? EMPTY_AGENTS; const [activeAgentId, setActiveAgentId] = useState(initialAgentId ?? null); // Always-latest active agent id, so a slow load for a previous agent can detect // that the user has since switched and skip applying its (now stale) result. const activeAgentIdRef = useRef(activeAgentId); activeAgentIdRef.current = activeAgentId; // Bumped (per agent) on an authoritative local Slack mutation — a confirmed // connect or a disconnect. A loadChannels whose generation changed mid-fetch // keeps its current Slack state rather than clobbering that mutation with a // stale payload. const slackConnectGenRef = useRef>({}); // Native app-resume reloads can overlap managed-bot polling, so Telegram needs // the same per-agent stale-response guard as Slack. const telegramConnectGenRef = useRef>({}); // WhatsApp disconnect needs the same guard: an in-flight loadChannels can // otherwise land its pre-delete "connected" status after the disconnect writes. const whatsappConnectGenRef = useRef>({}); const [currentRoute, setCurrentRoute] = useState( initialRoute ?? {name: 'home'}, ); // Follow host-driven route changes AFTER mount — a deep link or retargeting a // reused shell to another agent — so `currentRoute` + `activeAgentId` (and thus // the view, apiClient, and realtime client) stay in sync. Keyed on the route's // identity string so internal navigation (which updates `currentRoute` but not // this prop) doesn't retrigger it; the mount render is skipped since the state // above already seeds from `initialRoute`. const hostRouteKey = initialRoute ? routeIdentityKey(initialRoute) : ''; const hostRouteMountedRef = useRef(false); useEffect(() => { if (!hostRouteMountedRef.current) { hostRouteMountedRef.current = true; return; } if (!initialRoute) return; setCurrentRoute(initialRoute); if (initialRoute.name === 'agent') setActiveAgentId(initialRoute.agentId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hostRouteKey]); const [availableConnectors, setAvailableConnectors] = useState([]); const [automationCredits, setAutomationCredits] = useState>({}); const [automationLoadError, setAutomationLoadError] = useState(null); const [automations, setAutomations] = useState([]); const [workflows, setWorkflows] = useState([]); const [workflowLoadError, setWorkflowLoadError] = useState(null); const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(false); const [channelStatus, setChannelStatus] = useState({}); const [connectingChannelId, setConnectingChannelId] = useState(null); // Tracked separately from connectingChannelId so a channel that shows connect // and disconnect actions at once (Slack pending-cleanup) only spins the button // whose action is in flight. Mirrors the web builder's connecting/disconnecting split. const [disconnectingChannelId, setDisconnectingChannelId] = useState(null); const [collaborators, setCollaborators] = useState([]); const [connectedConnectors, setConnectedConnectors] = useState([]); const [connectingConnectorId, setConnectingConnectorId] = useState(null); const [filePaths, setFilePaths] = useState([]); const [fileLoadError, setFileLoadError] = useState(null); const [fileLoadFailed, setFileLoadFailed] = useState(false); const [isLoadingAgentSettings, setIsLoadingAgentSettings] = useState(false); const [isLoadingChannels, setIsLoadingChannels] = useState(false); const [isLoadingCollaborators, setIsLoadingCollaborators] = useState(false); const [isLoadingFiles, setIsLoadingFiles] = useState(false); const [isLoadingAutomations, setIsLoadingAutomations] = useState(false); const [isLoadingConnectors, setIsLoadingConnectors] = useState(false); const [runtimeAuthToken, setRuntimeAuthToken] = useState(null); // Caches the resolved runtime token keyed by the agent it belongs to. The // runtimeAuthToken state lags an agent switch (it's cleared in a later effect), // so resolveRuntimeToken must not trust it as a cache — this ref can never hand // back a different agent's token. const runtimeTokenRef = useRef<{ agentId: string; token: string } | null>(null); const [realtimeClient, setRealtimeClient] = useState(); const [secrets, setSecrets] = useState([]); const connectorFlowRef = useRef(null); // A scope change must not retain an agent id from the previous workspace. The // new scoped query may already be cached, in which case the following effect // selects its first agent without issuing another request. useEffect(() => { setActiveAgentId(initialAgentId ?? null); }, [agentsQueryKey, initialAgentId]); useEffect(() => { const loadedAgents = agentsQuery.data; if (!loadedAgents) return; setActiveAgentId((current) => current ?? initialAgentId ?? loadedAgents[0]?.id ?? null); }, [agentsQuery.data, initialAgentId]); const loadConnectors = useCallback(async (agentId: string) => { setIsLoadingConnectors(true); try { const connectors = await superagentService.listConnectors(agentId); if (activeAgentIdRef.current !== agentId) return; setAvailableConnectors(connectors.availableConnectors); setConnectedConnectors(connectors.connectedConnectors); } catch { if (activeAgentIdRef.current !== agentId) return; setAvailableConnectors([]); setConnectedConnectors([]); } finally { if (activeAgentIdRef.current === agentId) setIsLoadingConnectors(false); } }, [superagentService]); const loadAutomations = useCallback(async (agentId: string) => { setIsLoadingAutomations(true); setAutomationLoadError(null); try { const loadedAutomations = await superagentService.listAutomations(agentId); if (activeAgentIdRef.current !== agentId) return; setAutomations(loadedAutomations); } catch (error) { if (activeAgentIdRef.current !== agentId) return; setAutomationLoadError(getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setIsLoadingAutomations(false); } }, [superagentService]); const loadAutomationCredits = useCallback(async (agentId: string) => { try { const loadedCredits = await superagentService.listAutomationCredits(agentId); if (activeAgentIdRef.current !== agentId) return; setAutomationCredits(loadedCredits); } catch { if (activeAgentIdRef.current !== agentId) return; setAutomationCredits({}); } }, [superagentService]); const loadWorkflows = useCallback(async (agentId: string) => { setIsLoadingWorkflows(true); setWorkflowLoadError(null); try { const loadedWorkflows = await superagentService.listWorkflows(agentId); if (activeAgentIdRef.current !== agentId) return; setWorkflows(loadedWorkflows); } catch (error) { if (activeAgentIdRef.current !== agentId) return; setWorkflowLoadError(getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setIsLoadingWorkflows(false); } }, [superagentService]); const loadFiles = useCallback(async (agentId: string) => { setIsLoadingFiles(true); try { const loadedFilePaths = await superagentService.listSandboxFiles(agentId); if (activeAgentIdRef.current !== agentId) return; const normalizedFilePaths = normalizeFilePaths(loadedFilePaths); setFilePaths(normalizedFilePaths.length > 0 ? normalizedFilePaths : DEFAULT_SANDBOX_FILE_PATHS); setFileLoadError(null); setFileLoadFailed(false); } catch (error) { if (activeAgentIdRef.current !== agentId) return; setFilePaths(DEFAULT_SANDBOX_FILE_PATHS); setFileLoadError(getErrorMessage(error)); setFileLoadFailed(true); } finally { if (activeAgentIdRef.current === agentId) setIsLoadingFiles(false); } }, [superagentService]); const loadAgentSettings = useCallback(async (agentId: string) => { setIsLoadingAgentSettings(true); try { const loadedSecrets = await superagentService.listSecrets(agentId); if (activeAgentIdRef.current !== agentId) return; setSecrets(loadedSecrets); } catch { if (activeAgentIdRef.current !== agentId) return; setSecrets([]); } finally { if (activeAgentIdRef.current === agentId) setIsLoadingAgentSettings(false); } }, [superagentService]); const loadCollaborators = useCallback(async (agentId: string) => { setIsLoadingCollaborators(true); try { const loadedCollaborators = await superagentService.listCollaborators(agentId); if (activeAgentIdRef.current !== agentId) return; setCollaborators(loadedCollaborators); } catch { if (activeAgentIdRef.current !== agentId) return; setCollaborators([]); } finally { if (activeAgentIdRef.current === agentId) setIsLoadingCollaborators(false); } }, [superagentService]); const resolveRuntimeToken = useCallback(async (agentId: string) => { // Use the agent-keyed ref, not the runtimeAuthToken state: the state still // holds the previous agent's token during a switch (it's cleared in a later // effect), so trusting it could return the wrong agent's token here. const cached = runtimeTokenRef.current; if (cached && cached.agentId === agentId) { return cached.token; } const token = await superagentService.getRuntimeAuthToken(agentId); runtimeTokenRef.current = { agentId, token }; // Compare against the latest active agent (ref), not the closure-captured // activeAgentId: the user may have switched agents while the fetch was in // flight, and writing a stale agent's token into shared state would point // realtime/channel calls at the wrong agent. if (agentId === activeAgentIdRef.current) { setRuntimeAuthToken(token); } return token; }, [superagentService]); const loadChannels = useCallback(async (agentId: string) => { setIsLoadingChannels(true); const slackGenAtStart = slackConnectGenRef.current[agentId] ?? 0; const telegramGenAtStart = telegramConnectGenRef.current[agentId] ?? 0; const whatsappGenAtStart = whatsappConnectGenRef.current[agentId] ?? 0; try { const token = await resolveRuntimeToken(agentId); const status = await superagentService.getChannelStatus(agentId, token); if (activeAgentIdRef.current !== agentId) return; const slackConnectOverlapped = (slackConnectGenRef.current[agentId] ?? 0) !== slackGenAtStart; const telegramConnectOverlapped = (telegramConnectGenRef.current[agentId] ?? 0) !== telegramGenAtStart; const whatsappConnectOverlapped = (whatsappConnectGenRef.current[agentId] ?? 0) !== whatsappGenAtStart; setChannelStatus((current) => mergeLoadedChannelStatus(current, status, { slack: slackConnectOverlapped, telegram: telegramConnectOverlapped, whatsapp: whatsappConnectOverlapped, })); } catch { if (activeAgentIdRef.current !== agentId) return; setChannelStatus({}); } finally { if (activeAgentIdRef.current === agentId) setIsLoadingChannels(false); } }, [resolveRuntimeToken, superagentService]); const refreshAutomations = useCallback(async (agentId: string) => { await Promise.all([ loadAutomations(agentId), loadAutomationCredits(agentId), ]); }, [loadAutomationCredits, loadAutomations]); useEffect(() => { // Reset per-agent state on every active-agent change (not just when it goes // null) so the drawer never shows the previous agent's secrets/connectors/ // files/collaborators/automations during the new agent's load window — which // could otherwise let a destructive action target the wrong agent. setAvailableConnectors([]); setAutomationCredits({}); setAutomationLoadError(null); setAutomations([]); setWorkflows([]); setWorkflowLoadError(null); setChannelStatus({}); setCollaborators([]); setConnectingChannelId(null); setDisconnectingChannelId(null); setConnectingConnectorId(null); setConnectedConnectors([]); setFilePaths([]); setFileLoadError(null); setFileLoadFailed(false); setSecrets([]); if (!activeAgentId) { return; } loadAgentSettings(activeAgentId); loadCollaborators(activeAgentId); loadConnectors(activeAgentId); loadFiles(activeAgentId); refreshAutomations(activeAgentId); }, [activeAgentId, loadAgentSettings, loadCollaborators, loadConnectors, loadFiles, refreshAutomations]); useEffect(() => { if (!activeAgentId || !runtimeAuthToken) { setChannelStatus({}); return; } loadChannels(activeAgentId); }, [activeAgentId, loadChannels, runtimeAuthToken]); // Workflows are the successor to automations; an agent runs one OR the other. // Only fetch workflows for a workflows-enabled agent — the facade 403s for // automations agents, and the Tasks panel renders one surface or the other. // Deriving the flag as a boolean (not the agents array) keeps this effect // from re-running on every agent-list refetch. const activeAgentWorkflowsEnabled = useMemo( () => Boolean(agents.find((agent) => agent.id === activeAgentId)?.workflowsEnabled), [agents, activeAgentId], ); useEffect(() => { if (!activeAgentId || !activeAgentWorkflowsEnabled) return; loadWorkflows(activeAgentId); }, [activeAgentId, activeAgentWorkflowsEnabled, loadWorkflows]); const reloadPendingConnector = useCallback(() => { const flow = connectorFlowRef.current; if (!flow || flow.cancelled) { return; } setTimeout(() => { loadConnectors(flow.agentId); }, 1200); }, [loadConnectors]); const reloadChannelsOnResume = useCallback(() => { if (!activeAgentId) { return; } setTimeout(() => { loadChannels(activeAgentId); }, 1200); }, [activeAgentId, loadChannels]); const completePendingConnectorFromCallback = useCallback(async (event: SuperagentExternalAuthCallbackEvent) => { const flow = connectorFlowRef.current; if (!flow || flow.cancelled) { return; } // The callback event carries no connection/connector identifier, so it can't // be reliably attributed to this flow — a late callback from a superseded // connect could otherwise mark the wrong flow. Don't mutate flow state here; // waitForConnectorAuthorization polls the authoritative per-connection status // (it detects ACTIVE/FAILED on its own). Just refresh the connector list so // the UI reflects the latest state. if (event.status === 'success' || event.status === 'error') { await loadConnectors(flow.agentId); } }, [loadConnectors]); useEffect(() => { return nativeAdapters.subscribeToExternalAuthCallbacks?.(completePendingConnectorFromCallback); }, [completePendingConnectorFromCallback, nativeAdapters]); useEffect(() => { const unsubscribeResume = nativeAdapters.subscribeToAppResume?.(() => { reloadPendingConnector(); reloadChannelsOnResume(); }); const unsubscribeDeepLinks = nativeAdapters.subscribeToDeepLinks?.((url) => { if (nativeAdapters.isExternalAuthCallbackUrl?.(url)) { nativeAdapters.completeExternalAuthCallback?.(url); reloadPendingConnector(); } }); return () => { unsubscribeResume?.(); unsubscribeDeepLinks?.(); }; }, [nativeAdapters, reloadChannelsOnResume, reloadPendingConnector]); useEffect(() => { return () => { if (connectorFlowRef.current) { connectorFlowRef.current.cancelled = true; } }; }, []); const apiClient = useMemo(() => { if (!activeAgentId) { return undefined; } return createSuperagentNativeClient({ apiBaseUrl: `${normalizeBaseUrl(config.baseUrl)}/api/apps`, appId: activeAgentId, getAuthToken: config.getAccessToken, getHeaders: config.getHeaders, }); }, [activeAgentId, config.baseUrl, config.getAccessToken, config.getHeaders]); const createAgentMutation = useMutation({ mutationFn: () => superagentService.createAgent(), onSuccess: (agent) => prependCachedAgent({agent, queryClient, queryKey: agentsQueryKey}), }, queryClient); const renameAgentMutation = useMutation({ mutationFn: ({agentId, name}: SuperagentRenameAgentInput) => ( superagentService.renameAgent(agentId, name) ), onSuccess: (agent) => mergeCachedAgent({agent, queryClient, queryKey: agentsQueryKey}), }, queryClient); const updateAgentModelMutation = useMutation({ mutationFn: ({agentId, model}: SuperagentModelUpdateInput) => ( superagentService.updateAgentModel(agentId, model) ), onSuccess: (agent) => mergeCachedAgent({agent, queryClient, queryKey: agentsQueryKey}), }, queryClient); const updateAgentAutomationModelMutation = useMutation({ mutationFn: ({agentId, model}: SuperagentModelUpdateInput) => ( superagentService.updateAgentAutomationModel(agentId, model) ), onSuccess: (agent) => mergeCachedAgent({agent, queryClient, queryKey: agentsQueryKey}), }, queryClient); const updateToolPermissionsMutation = useMutation({ mutationFn: ({agentId, config}: SuperagentToolPermissionsUpdateInput) => ( superagentService.updateToolPermissions(agentId, config) ), onSuccess: (agent) => mergeCachedAgent({agent, queryClient, queryKey: agentsQueryKey}), }, queryClient); const deleteAgentMutation = useMutation({ mutationFn: (agentId: string) => superagentService.deleteAgent(agentId), onSuccess: (_result, agentId) => removeCachedAgent({agentId, queryClient, queryKey: agentsQueryKey}), }, queryClient); useEffect(() => { if (!activeAgentId) { setRuntimeAuthToken(null); return; } let cancelled = false; setRuntimeAuthToken(null); superagentService.getRuntimeAuthToken(activeAgentId) .then((token) => { if (!cancelled) { setRuntimeAuthToken(token); } }) .catch(() => { if (!cancelled) { setRuntimeAuthToken(null); } }); return () => { cancelled = true; }; }, [activeAgentId, superagentService]); useEffect(() => { if (!activeAgentId || !runtimeAuthToken) { setRealtimeClient(undefined); return; } const socket = nativeAdapters.createRealtimeSocket?.({ appId: activeAgentId, baseUrl: config.baseUrl, token: runtimeAuthToken, }); if (!socket) { setRealtimeClient(undefined); return; } setRealtimeClient(createSuperagentSocketClient({ socket, })); return () => { socket.disconnect?.(); }; }, [activeAgentId, config.baseUrl, nativeAdapters, runtimeAuthToken]); const onCreateAgent = useCallback(async () => { const createdAgent = await createAgentMutation.mutateAsync(); setActiveAgentId(createdAgent.id); return createdAgent; }, [createAgentMutation]); const onOpenAgent = useCallback((agentId: string) => { setActiveAgentId(agentId); }, []); const onRenameAgent = useCallback(async (input: SuperagentRenameAgentInput) => { const result = await renameAgentMutation.mutateAsync(input); void bi.trackEditor('Agent Rename'); // native renames are title-only — not web's broader Identity Save return result; }, [bi, renameAgentMutation]); const onUpdateAgentModel = useCallback(async (input: SuperagentModelUpdateInput) => { try { await updateAgentModelMutation.mutateAsync(input); void bi.trackEditor('Model Change', { model: input.model, target: 'chat' }); } catch (error) { showAlert(nativeAdapters, 'Model update failed', getErrorMessage(error)); } }, [bi, nativeAdapters, updateAgentModelMutation]); const onUpdateAgentAutomationModel = useCallback(async (input: SuperagentModelUpdateInput) => { try { await updateAgentAutomationModelMutation.mutateAsync(input); void bi.trackEditor('Model Change', { model: input.model, target: 'automation' }); } catch (error) { showAlert(nativeAdapters, 'Model update failed', getErrorMessage(error)); } }, [bi, nativeAdapters, updateAgentAutomationModelMutation]); const onUpdateToolPermissions = useCallback(async (input: SuperagentToolPermissionsUpdateInput) => { try { await updateToolPermissionsMutation.mutateAsync(input); } catch (error) { showAlert(nativeAdapters, 'Permissions update failed', getErrorMessage(error)); // Rethrow so optimistic callers (the permission toggle) can revert; the // guard-config caller wraps this in try/catch since the alert already fired. throw error; } }, [nativeAdapters, updateToolPermissionsMutation]); const onSaveSecret = useCallback(async ({agentId, name, value}: SuperagentSecretSaveInput) => { try { await superagentService.saveSecret(agentId, name, value); await loadAgentSettings(agentId); } catch (error) { showAlert(nativeAdapters, 'Secret save failed', getErrorMessage(error)); // Rethrow so the form keeps the user's input instead of clearing on a // failed save. throw error; } }, [loadAgentSettings, nativeAdapters, superagentService]); const onDeleteSecret = useCallback(async ({agentId, name}: SuperagentSecretDeleteInput) => { const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Delete', destructive: true, message: `Delete "${name}" permanently?`, title: 'Delete secret?', }); if (!confirmed) { return; } try { await superagentService.deleteSecret(agentId, name); await loadAgentSettings(agentId); void bi.trackEditor('Secret Delete'); // only after the delete + reload actually succeed (confirm accepted) } catch (error) { showAlert(nativeAdapters, 'Secret delete failed', getErrorMessage(error)); } }, [bi, loadAgentSettings, nativeAdapters, superagentService]); const onShareAgent = useCallback(async ({agentId, addAsGuest, emails}: SuperagentShareAgentInput) => { const result = await superagentService.inviteCollaborators(agentId, emails, addAsGuest); await loadCollaborators(agentId); return result; }, [loadCollaborators, superagentService]); const onShareAgentLink = useCallback(async ({agentId}: SuperagentAgentActionInput) => { const agent = agents.find((item) => item.id === agentId); const url = buildWebUrl(config.webUrl ?? config.baseUrl, `/superagent/${encodeURIComponent(agentId)}`); if (nativeAdapters.share) { await nativeAdapters.share({ message: `Open ${agent?.name || 'this Superagent'} in Base44:\n${url}`, title: agent?.name || 'Superagent', url, }); return; } if (nativeAdapters.openWebUrl) { await nativeAdapters.openWebUrl(url); return; } showAlert(nativeAdapters, 'Share unavailable', url); }, [agents, config.baseUrl, config.webUrl, nativeAdapters]); const onOpenWorkspaceMembers = useCallback(({organizationId}: SuperagentOpenWorkspaceMembersInput) => { if (!organizationId) { showAlert(nativeAdapters, 'Workspace unavailable', 'Open workspace settings from the web to add this person as a workspace member.'); return; } nativeAdapters.openWebUrl?.( buildWebUrl(config.webUrl ?? config.baseUrl, `/workspace/${encodeURIComponent(organizationId)}/settings/seats-members`), ); }, [config.baseUrl, config.webUrl, nativeAdapters]); const onCloneAgent = useCallback(({agentId}: SuperagentAgentActionInput) => { // Use the Superagent clone route — /remix-app is an app route that the web // builder redirects back to /superagent/:id for user_agent apps (reopening the // original instead of cloning). /clone-superagent/:id is the agent clone flow. nativeAdapters.openWebUrl?.(buildWebUrl(config.webUrl ?? config.baseUrl, `/clone-superagent/${encodeURIComponent(agentId)}`)); }, [config.baseUrl, config.webUrl, nativeAdapters]); const onDeleteAgent = useCallback(async ({agentId}: SuperagentAgentActionInput) => { const agent = agents.find((item) => item.id === agentId); const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Delete', destructive: true, message: `Delete "${agent?.name || 'this Superagent'}" permanently?`, title: 'Delete Superagent?', }); if (!confirmed) { return; } try { await deleteAgentMutation.mutateAsync(agentId); void bi.trackEditor('Agent Delete Confirm'); setSecrets([]); setActiveAgentId((current) => (current === agentId ? null : current)); setCurrentRoute({name: 'home'}); } catch (error) { showAlert(nativeAdapters, 'Delete failed', getErrorMessage(error)); } }, [agents, bi, deleteAgentMutation, nativeAdapters]); const onToggleAutomation = useCallback(async ({agentId, automation}: SuperagentAutomationActionInput) => { try { const updatedAutomation = await superagentService.toggleAutomation(agentId, automation); setAutomations((current) => current.map((item) => ( item.id === automation.id ? {...item, ...updatedAutomation} : item ))); void bi.trackEditor('Automation Toggle', { enabled: updatedAutomation.is_active ?? !automation.is_active }); void loadAutomationCredits(agentId); } catch (error) { showAlert(nativeAdapters, 'Automation failed', getErrorMessage(error)); } }, [bi, loadAutomationCredits, nativeAdapters, superagentService]); const onArchiveAutomation = useCallback(async ({agentId, automation}: SuperagentAutomationActionInput) => { try { const updatedAutomation = await superagentService.archiveAutomation(agentId, automation); setAutomations((current) => current.map((item) => ( item.id === automation.id ? {...item, ...updatedAutomation, is_active: false, is_archived: true} : item ))); void bi.trackEditor('Automation Archive'); void loadAutomationCredits(agentId); } catch (error) { showAlert(nativeAdapters, 'Archive failed', getErrorMessage(error)); } }, [bi, loadAutomationCredits, nativeAdapters, superagentService]); const onRestoreAutomation = useCallback(async ({agentId, automation}: SuperagentAutomationActionInput) => { try { const updatedAutomation = await superagentService.restoreAutomation(agentId, automation); setAutomations((current) => current.map((item) => ( item.id === automation.id ? {...item, ...updatedAutomation, is_active: true, is_archived: false} : item ))); void bi.trackEditor('Automation Restore'); void loadAutomationCredits(agentId); } catch (error) { showAlert(nativeAdapters, 'Restore failed', getErrorMessage(error)); } }, [bi, loadAutomationCredits, nativeAdapters, superagentService]); const onDeleteAutomation = useCallback(async ({agentId, automation}: SuperagentAutomationActionInput) => { const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Delete', destructive: true, message: `Delete "${automation.name}" permanently?`, title: 'Delete automation?', }); if (!confirmed) { return; } try { await superagentService.deleteAutomation(agentId, automation); setAutomations((current) => current.filter((item) => item.id !== automation.id)); setAutomationCredits((current) => { const next = {...current}; delete next[automation.id]; return next; }); void bi.trackEditor('Automation Delete'); } catch (error) { showAlert(nativeAdapters, 'Delete failed', getErrorMessage(error)); } }, [bi, nativeAdapters, superagentService]); const onRunAutomationNow = useCallback(async ({agentId, automation}: SuperagentAutomationActionInput) => { try { await superagentService.runAutomationNow(agentId, automation); void bi.trackEditor('Automation Run Now'); await refreshAutomations(agentId); } catch (error) { showAlert(nativeAdapters, 'Run failed', getErrorMessage(error)); } }, [bi, nativeAdapters, refreshAutomations, superagentService]); const onEditAutomation = useCallback(({agentId}: SuperagentAutomationActionInput) => { nativeAdapters.openWebUrl?.(buildWebUrl(config.webUrl ?? config.baseUrl, `/superagent/${encodeURIComponent(agentId)}`)); }, [config.baseUrl, config.webUrl, nativeAdapters]); const onToggleWorkflow = useCallback(async ({agentId, workflow}: SuperagentWorkflowActionInput) => { // Optimistic status flip (active ↔ inactive), then reconcile with the // authoritative status the facade returns. Mirrors the automations toggle. const optimisticStatus = workflow.status === 'active' ? 'inactive' : 'active'; setWorkflows((current) => current.map((item) => ( item.id === workflow.id ? {...item, status: optimisticStatus} : item ))); try { const result = await superagentService.toggleWorkflow(agentId, workflow); if (result.status) { setWorkflows((current) => current.map((item) => ( item.id === workflow.id ? {...item, status: result.status as SuperagentWorkflow['status']} : item ))); } void bi.trackEditor('Workflow Toggle', { enabled: (result.status ?? optimisticStatus) === 'active' }); } catch (error) { await loadWorkflows(agentId); showAlert(nativeAdapters, 'Workflow failed', getErrorMessage(error)); } }, [bi, loadWorkflows, nativeAdapters, superagentService]); const onArchiveWorkflow = useCallback(async ({agentId, workflow}: SuperagentWorkflowActionInput) => { try { await superagentService.archiveWorkflow(agentId, workflow); setWorkflows((current) => current.map((item) => ( item.id === workflow.id ? {...item, status: 'archived'} : item ))); void bi.trackEditor('Workflow Archive'); } catch (error) { showAlert(nativeAdapters, 'Archive failed', getErrorMessage(error)); } }, [bi, nativeAdapters, superagentService]); const onRestoreWorkflow = useCallback(async ({agentId, workflow}: SuperagentWorkflowActionInput) => { try { const result = await superagentService.restoreWorkflow(agentId, workflow); // A scheduled workflow whose end-condition is already met lands 'inactive', // not 'active' — trust the status the facade reports. const landedStatus = (result.status as SuperagentWorkflow['status']) ?? 'inactive'; setWorkflows((current) => current.map((item) => ( item.id === workflow.id ? {...item, status: landedStatus} : item ))); void bi.trackEditor('Workflow Restore'); } catch (error) { showAlert(nativeAdapters, 'Restore failed', getErrorMessage(error)); } }, [bi, nativeAdapters, superagentService]); const onRunWorkflowNow = useCallback(async ({agentId, workflow}: SuperagentWorkflowActionInput) => { try { await superagentService.runWorkflowNow(agentId, workflow); void bi.trackEditor('Workflow Run Now'); // The run dispatches asynchronously, so last_run_at/total_runs won't have // updated yet — surface explicit feedback (mirrors the web mobile "Run // started" toast) so the tap doesn't read as a no-op and invite a // duplicate run. showAlert(nativeAdapters, 'Run started', 'Your task is running. Its status updates once it finishes.'); } catch (error) { showAlert(nativeAdapters, 'Run failed', getErrorMessage(error)); } }, [bi, nativeAdapters, superagentService]); const onOpenSandboxFile = useCallback(async ({agentId, path}: SuperagentSandboxFileActionInput) => { return superagentService.readSandboxFile(agentId, sanitizeSandboxFilePath(path)); }, [superagentService]); const onSaveSandboxFile = useCallback(async ({agentId, content, path}: SuperagentSandboxFileSaveInput) => { try { await superagentService.writeSandboxFile(agentId, sanitizeSandboxFilePath(path), content); await loadFiles(agentId); } catch (error) { showAlert(nativeAdapters, 'Save failed', getErrorMessage(error)); throw error; } }, [loadFiles, nativeAdapters, superagentService]); const onUploadSandboxFiles = useCallback(async ({agentId}: SuperagentSandboxFileUploadInput) => { try { const files = await nativeAdapters.pickSandboxFiles?.(); if (!files?.length) { return []; } const writtenPaths: string[] = []; for (const file of files) { const safeName = sanitizeSandboxFilePath(file.name) || 'upload'; // Stage uploads under a dedicated folder so a picked file named like a // generated-app file (package.json, README.md, ...) can't overwrite it. const safePath = `incoming_files/${safeName}`; await superagentService.writeSandboxFile(agentId, safePath, file.content); writtenPaths.push(safePath); } await loadFiles(agentId); return writtenPaths.map((path) => ({path})); } catch (error) { if (nativeAdapters.isAttachmentPickerCancel?.(error)) { return []; } showAlert(nativeAdapters, 'Upload failed', getErrorMessage(error)); throw error; } }, [loadFiles, nativeAdapters, superagentService]); const onRouteChange = useCallback((route: SuperagentRoute) => { setCurrentRoute(route); if (route.name === 'agent') { setActiveAgentId(route.agentId); } }, []); const speechToText = useSpeechToText({ // tap-to-dictate speech-to-text baseUrl: config.baseUrl, getAccessToken: config.getAccessToken, getHeaders: config.getHeaders, recorder: nativeAdapters.voiceRecorder, }); const onStartLiveVoice = useCallback(async (context) => { const session = await superagentService.createVoiceLiveSession(context.agentId, context.conversationId); try { await startBackendVoiceLiveSession(session, nativeAdapters.liveVoiceAudio); } finally { await superagentService.deleteVoiceLiveSession(context.agentId, session.sessionId).catch(() => undefined); } }, [nativeAdapters, superagentService]); const onOpenWhatsApp = useCallback(async ({agentId}: SuperagentChannelActionInput) => { void bi.trackEditor('Channel Connect Clicked', { channel: 'whatsapp' }); setConnectingChannelId('whatsapp'); try { const token = await resolveRuntimeToken(agentId); // Editor gate: the connect redirect only authenticates the app-user token and // does not re-check live editor access, so a downgraded viewer could otherwise // open it. /whatsapp/status enforces the two-layer editor check and throws // (403) for viewers — require it to pass before opening the setup page. await superagentService.getWhatsAppStatus(agentId, token); const connectUrl = superagentService.getWhatsAppConnectUrl(agentId, token); if (activeAgentIdRef.current === agentId) { setChannelStatus((current) => ({ ...current, whatsapp: { ...current.whatsapp, connectUrl, }, })); } if (!nativeAdapters.openUrl) { // Without a URL opener the setup page never launches; surface an error // instead of completing silently (mirrors the connector OAuth flow). throw new Error('This app cannot open the WhatsApp setup page. Connect WhatsApp from the web app.'); } await nativeAdapters.openUrl(connectUrl); void bi.trackEditor('Channel Connect', { channel: 'whatsapp' }); // reached the external setup page (no client-side "connected" signal beyond this) } catch (error) { showAlert(nativeAdapters, 'WhatsApp setup failed', getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setConnectingChannelId(null); } }, [bi, nativeAdapters, resolveRuntimeToken, superagentService]); const onDisconnectWhatsApp = useCallback(async ({agentId}: SuperagentChannelActionInput) => { const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Disconnect', destructive: true, message: 'You can reconnect anytime — your memory and history stay.', title: 'Disconnect WhatsApp?', }); if (!confirmed) { return; } setDisconnectingChannelId('whatsapp'); try { const whatsapp = await superagentService.disconnectWhatsApp(agentId); if (activeAgentIdRef.current === agentId) { // Bump the guard so a loadChannels already in flight can't overwrite this // disconnected state with its stale pre-delete "connected" payload. whatsappConnectGenRef.current[agentId] = (whatsappConnectGenRef.current[agentId] ?? 0) + 1; setChannelStatus((current) => ({ ...current, whatsapp, })); } void bi.trackEditor('Channel Disconnect', { channel: 'whatsapp' }); } catch (error) { showAlert(nativeAdapters, 'WhatsApp disconnect failed', getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setDisconnectingChannelId(null); } }, [bi, nativeAdapters, superagentService]); const onSetupTelegram = useCallback(async ({ agentDisplayName, agentId, agentProfilePhotoUrl, }: SuperagentTelegramSetupInput) => { void bi.trackEditor('Channel Connect Clicked', { channel: 'telegram' }); setConnectingChannelId('telegram'); try { const runtimeToken = await resolveRuntimeToken(agentId); const result = await superagentService.createManagedTelegramBot(agentId, runtimeToken, { agentDisplayName, agentProfilePhotoUrl, }); if (activeAgentIdRef.current !== agentId) return; const opened = await openExternalUrl( nativeAdapters, result.link, "Can't open Telegram setup link", ); if (!opened) return; for (let attempt = 0; attempt < TELEGRAM_CONNECT_POLL_ATTEMPTS; attempt += 1) { await delay(TELEGRAM_CONNECT_POLL_INTERVAL_MS); if (activeAgentIdRef.current !== agentId) return; const status = await superagentService .getManagedTelegramBotStatus(agentId, runtimeToken, result.nonce) .catch(() => null); if (!isChannelResultForActiveAgent(agentId, activeAgentIdRef.current)) return; if (!status) continue; if (status.status === 'completed' && status.bot_username) { telegramConnectGenRef.current[agentId] = (telegramConnectGenRef.current[agentId] ?? 0) + 1; setChannelStatus((current) => ({ ...current, telegram: { botLink: status.bot_link, botName: status.bot_name, botUsername: status.bot_username, connected: true, }, })); void bi.trackEditor('Channel Connect', { channel: 'telegram' }); // only once the managed bot is confirmed connected return; } if (status.status === 'expired' || status.status === 'not_found') { throw new Error('Telegram bot creation expired. Please try again.'); } } throw new Error('Telegram bot creation timed out. Please try again.'); } catch (error) { showAlert(nativeAdapters, 'Telegram setup failed', getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setConnectingChannelId(null); } }, [bi, nativeAdapters, resolveRuntimeToken, superagentService]); const onDisconnectTelegram = useCallback(async ({agentId}: SuperagentChannelActionInput) => { const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Disconnect', destructive: true, message: 'The Telegram bot webhook will be removed from this Superagent.', title: 'Disconnect Telegram?', }); if (!confirmed) { return; } setConnectingChannelId('telegram'); try { const token = await resolveRuntimeToken(agentId); await superagentService.disconnectTelegram(agentId, token); if (activeAgentIdRef.current === agentId) { telegramConnectGenRef.current[agentId] = (telegramConnectGenRef.current[agentId] ?? 0) + 1; setChannelStatus((current) => ({ ...current, telegram: {connected: false}, })); } void bi.trackEditor('Channel Disconnect', { channel: 'telegram' }); } catch (error) { showAlert(nativeAdapters, 'Telegram disconnect failed', getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setConnectingChannelId(null); } }, [bi, nativeAdapters, resolveRuntimeToken, superagentService]); const onGenerateLineCode = useCallback(async ({agentId}: SuperagentChannelActionInput) => { setConnectingChannelId('line'); try { const token = await resolveRuntimeToken(agentId); const activation = await superagentService.generateLineCode(agentId, token); if (activeAgentIdRef.current === agentId) { setChannelStatus((current) => ({ ...current, line: { ...current.line, activation, connected: false, }, })); } } catch (error) { showAlert(nativeAdapters, 'LINE setup failed', getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setConnectingChannelId(null); } }, [nativeAdapters, resolveRuntimeToken, superagentService]); const onGenerateIMessageCode = useCallback(async ({agentId}: SuperagentChannelActionInput) => { void bi.trackEditor('Channel Connect Clicked', { channel: 'imessage' }); setConnectingChannelId('imessage'); try { const token = await resolveRuntimeToken(agentId); const activation = await superagentService.generateIMessageCode(agentId, token); if (activeAgentIdRef.current === agentId) { setChannelStatus((current) => ({ ...current, imessage: { ...current.imessage, activation, connected: current.imessage?.connected ?? false, }, })); } void bi.trackEditor('Channel Connect', { channel: 'imessage' }); // activation code generated (connection completes externally when the user texts it) return activation; } catch (error) { showAlert(nativeAdapters, 'iMessage setup failed', getErrorMessage(error)); throw error; } finally { if (activeAgentIdRef.current === agentId) setConnectingChannelId(null); } }, [bi, nativeAdapters, resolveRuntimeToken, superagentService]); const onDisconnectIMessage = useCallback(async ({agentId}: SuperagentChannelActionInput) => { const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Disconnect', destructive: true, message: 'Existing iMessage users will need to reconnect with a new activation code.', title: 'Disconnect iMessage?', }); if (!confirmed) { return; } setConnectingChannelId('imessage'); try { const token = await resolveRuntimeToken(agentId); const imessage = await superagentService.disconnectIMessage(agentId, token); if (activeAgentIdRef.current === agentId) { setChannelStatus((current) => ({ ...current, imessage, })); } void bi.trackEditor('Channel Disconnect', { channel: 'imessage' }); } catch (error) { showAlert(nativeAdapters, 'iMessage disconnect failed', getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setConnectingChannelId(null); } }, [bi, nativeAdapters, resolveRuntimeToken, superagentService]); const onOpenIMessage = useCallback(async ({code, phoneNumber}: SuperagentIMessageCodeShareInput) => { try { if (!nativeAdapters.openUrl) { throw new Error('This app cannot open Messages. Use the web app to share the iMessage code.'); } await nativeAdapters.openUrl(buildIMessageUrl(phoneNumber, code)); } catch (error) { showAlert(nativeAdapters, 'iMessage link unavailable', getErrorMessage(error)); } }, [nativeAdapters]); const onOpenTelegram = useCallback(async ({url}: SuperagentChannelUrlActionInput) => { await openExternalUrl(nativeAdapters, url, 'Telegram is not installed'); }, [nativeAdapters]); const onOpenLine = useCallback(async ({url}: SuperagentChannelUrlActionInput) => { await openExternalUrl(nativeAdapters, url, 'LINE link unavailable'); }, [nativeAdapters]); const onShareLineCode = useCallback(async ({addFriendUrl, code}: SuperagentLineCodeShareInput) => { if (!nativeAdapters.share) { showAlert(nativeAdapters, 'Sharing unavailable', 'This app cannot share the activation code. Copy it manually instead.'); return; } await nativeAdapters.share({ message: `LINE activation code: ${code}\n${addFriendUrl}`, title: 'LINE activation code', url: addFriendUrl, }); }, [nativeAdapters]); const onShareIMessageCode = useCallback(async ({code, phoneNumber}: SuperagentIMessageCodeShareInput) => { if (!nativeAdapters.share) { showAlert(nativeAdapters, 'Sharing unavailable', 'This app cannot share the activation code. Copy it manually instead.'); return; } await nativeAdapters.share({ message: `Text ${code} to ${phoneNumber} to connect this Superagent on iMessage.`, title: 'iMessage activation code', url: buildIMessageUrl(phoneNumber, code), }); }, [nativeAdapters]); const onConnectSlack = useCallback(async ({agentId}: SuperagentChannelActionInput) => { void bi.trackEditor('Channel Connect Clicked', { channel: 'slack' }); setConnectingChannelId('slack'); try { const token = await resolveRuntimeToken(agentId); const result = await superagentService.connectSlack(agentId, token); // Don't open an OAuth URL for an agent the user navigated away from mid-request. if (activeAgentIdRef.current !== agentId) { return; } if (result.supported === false) { if (activeAgentIdRef.current === agentId) { setChannelStatus((current) => ({ ...current, slack: {...current.slack, connected: false, supported: false}, })); } showAlert(nativeAdapters, 'Slack unavailable', 'Slack is not available on this platform yet.'); return; } if (!result.url) { throw new Error('Slack did not return an install link.'); } if (!nativeAdapters.openUrl) { throw new Error('This app cannot open the Slack install page. Connect Slack from the web app.'); } await nativeAdapters.openUrl(result.url); // Poll until the install lands; app-resume reload also refreshes the panel. for (let attempt = 0; attempt < SLACK_CONNECT_POLL_ATTEMPTS; attempt += 1) { await delay(SLACK_CONNECT_POLL_INTERVAL_MS); if (activeAgentIdRef.current !== agentId) return; const slack = await superagentService.getSlackStatus(agentId, token).catch(() => null); if (activeAgentIdRef.current !== agentId) return; if (slack) { if (slack.connected) { // Confirmed — bump so an overlapping reload can't revert the card. slackConnectGenRef.current[agentId] = (slackConnectGenRef.current[agentId] ?? 0) + 1; } setChannelStatus((current) => ({...current, slack})); if (slack.connected) { void bi.trackEditor('Channel Connect', { channel: 'slack' }); // poll confirmed the install landed return; } } } } catch (error) { showAlert(nativeAdapters, 'Slack setup failed', getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setConnectingChannelId(null); } }, [bi, nativeAdapters, resolveRuntimeToken, superagentService]); const onDisconnectSlack = useCallback(async ({agentId}: SuperagentChannelActionInput) => { const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Disconnect', destructive: true, message: 'This Superagent will be removed from your connected Slack workspace(s).', title: 'Disconnect Slack?', }); if (!confirmed) { return; } setDisconnectingChannelId('slack'); try { const token = await resolveRuntimeToken(agentId); await superagentService.disconnectSlack(agentId, token); if (activeAgentIdRef.current !== agentId) return; // Newer than any reload in flight, so bump the guard to stop a stale // reload re-showing the pre-disconnect connected state. slackConnectGenRef.current[agentId] = (slackConnectGenRef.current[agentId] ?? 0) + 1; // Re-fetch authoritative status instead of trusting the DELETE body: a disconnect // that left Slack-side cleanup pending (failed revoke/usergroup disable) reports // pending_cleanup, and the retry affordance must keep showing. Mirrors the web // builder's useSlackChannel.disconnect(). const next = await superagentService.getSlackStatus(agentId, token).catch(() => null); if (activeAgentIdRef.current !== agentId) return; setChannelStatus((current) => ({ ...current, // Fall back to a synthesized disconnected state only when the re-fetch failed, // preserving the last-known `supported` so an unsupported card doesn't flip to // an "Add to Slack" CTA that only 503s. slack: next ?? {connected: false, supported: current.slack?.supported}, })); void bi.trackEditor('Channel Disconnect', { channel: 'slack' }); } catch (error) { showAlert(nativeAdapters, 'Slack disconnect failed', getErrorMessage(error)); } finally { if (activeAgentIdRef.current === agentId) setDisconnectingChannelId(null); } }, [bi, nativeAdapters, resolveRuntimeToken, superagentService]); const onConnectConnector = useCallback(async ({ accessMode, agentId, connectorId, forceReconnect, scopes, }: SuperagentConnectorActionInput) => { if (connectorFlowRef.current) { connectorFlowRef.current.cancelled = true; } const flow: ConnectorFlow = {agentId, cancelled: false, connectorId}; connectorFlowRef.current = flow; setConnectingConnectorId(connectorId); try { const connection = await superagentService.initiateConnectorConnection(agentId, connectorId, { accessMode, forceReconnect, scopes, }); if (connection.error) { throw new Error(connection.error_message || connection.error); } if (connection.already_authorized) { connectorFlowRef.current = null; setConnectingConnectorId(null); await loadConnectors(agentId); // Return the connection_id (string) so the connector tool-approval can // submit it; fall back to `true` when the backend omits it. return connection.connection_id ?? true; } if (!connection.redirect_url || !connection.connection_id) { throw new Error('The backend did not return an authorization URL for this connector.'); } if (!nativeAdapters.openUrl) { // Without a URL opener the browser never launches, so polling would just // time out after ~2 minutes. Fail immediately with an actionable error. throw new Error('This app cannot open the authorization page. Connect this integration from the web app.'); } await nativeAdapters.openUrl(connection.redirect_url); const connected = await waitForConnectorAuthorization(superagentService, agentId, connectorId, connection.connection_id, flow); // Only clear shared state if this flow is still the active one — a newer // connect may have superseded it (and owns connectorFlowRef now). if (connectorFlowRef.current === flow) { connectorFlowRef.current = null; setConnectingConnectorId(null); } if (connected) { await loadConnectors(agentId); } // On success return the connection_id so the connector tool-approval can // submit it for backend verification; `false` on failure. return connected ? connection.connection_id : false; } catch (error) { if (connectorFlowRef.current === flow) { connectorFlowRef.current = null; setConnectingConnectorId(null); showAlert(nativeAdapters, 'Connector failed', getErrorMessage(error)); await loadConnectors(agentId); } return false; } }, [loadConnectors, nativeAdapters, superagentService]); const onCancelConnectorConnection = useCallback(({agentId, connectorId}: {agentId: string; connectorId: string}) => { const flow = connectorFlowRef.current; if (flow && flow.agentId === agentId && flow.connectorId === connectorId) { flow.cancelled = true; connectorFlowRef.current = null; } setConnectingConnectorId(null); loadConnectors(agentId); }, [loadConnectors]); const onDisconnectConnector = useCallback(async ({agentId, connectorId}: SuperagentConnectorActionInput) => { const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Disconnect', destructive: true, message: 'The agent will stop using this connected account.', title: 'Disconnect connector?', }); if (!confirmed) { return; } try { await superagentService.disconnectConnector(agentId, connectorId); void bi.trackEditor('Plugin Disconnect', { connector_type: connectorId }); await loadConnectors(agentId); } catch (error) { showAlert(nativeAdapters, 'Disconnect failed', getErrorMessage(error)); } }, [bi, loadConnectors, nativeAdapters, superagentService]); const onRemoveConnector = useCallback(async ({agentId, connectorId}: SuperagentConnectorActionInput) => { const confirmed = await confirmAction(nativeAdapters, { confirmText: 'Remove', destructive: true, message: 'This removes the connector authorization from this agent.', title: 'Remove connector?', }); if (!confirmed) { return; } try { await superagentService.removeConnector(agentId, connectorId); void bi.trackEditor('Plugin Remove', { connector_type: connectorId }); await loadConnectors(agentId); } catch (error) { showAlert(nativeAdapters, 'Remove failed', getErrorMessage(error)); } }, [bi, loadConnectors, nativeAdapters, superagentService]); // Resolved active agent — computed once here so the shell and `useSuperagentAgents` // don't each re-derive it from `agents` + `activeAgentId`. const activeAgent = useMemo( () => agents.find((agent) => agent.id === activeAgentId) ?? null, [agents, activeAgentId], ); const handleAgentMessageDone = useCallback(async () => { await nativeAdapters.onAgentMessageDone?.(); }, [nativeAdapters]); const copyToClipboard = useCallback(async (text: string) => { await nativeAdapters.copyToClipboard?.(text); }, [nativeAdapters]); return { activeAgent, activeAgentId, agents, apiClient, availableConnectors, automationCredits, automationLoadError, automations, workflows, workflowLoadError, isLoadingWorkflows, channelStatus, collaborators, connectingChannelId, disconnectingChannelId, connectingConnectorId, connectedConnectors, currentRoute, // Also expose as initialRoute, the prop SuperagentHomeScreen actually consumes, // so spreading the runtime opens on the deep-linked agent (initialAgentId) // instead of defaulting to the home route. initialRoute: currentRoute, currentUserAvatarUrl: config.currentUserAvatarUrl, currentUserId: config.currentUserId, currentUserName: config.currentUserName, fileLoadError, fileLoadFailed, filePaths, isLoadingAgentSettings, isLoadingChannels, isLoadingCollaborators, isLoadingConnectors, isLoadingAutomations, isLoadingFiles, isLoading: agentsQuery.isLoading, isRefreshingAgents: agentsQuery.isRefetching, onRefreshAgents: agentsQuery.refetch, latestMessages: [], loadError: agentsQuery.error ? getErrorMessage(agentsQuery.error) : null, messagesByAgentId: {}, onAgentMessageDone: handleAgentMessageDone, // Only surfaced when the host wires clipboard; useClipboard gates the copy // affordance on this capability's presence. copyToClipboard: nativeAdapters.copyToClipboard ? copyToClipboard : undefined, onCancelConnectorConnection, onArchiveAutomation, onCloneAgent, onConnectConnector, onConnectSlack, onCreateAgent, onDeleteAgent, onDeleteAutomation, onDeleteSecret, onDisconnectConnector, onDisconnectIMessage, onDisconnectSlack, onDisconnectTelegram, onDisconnectWhatsApp, onEditAutomation, onGenerateLineCode, onGenerateIMessageCode, onOpenIMessage, onOpenLine, onOpenAgent, onOpenTelegram, onOpenWhatsApp, onOpenSandboxFile, onOpenWorkspaceMembers, onRemoveConnector, onRenameAgent, onRefreshAgentSettings: loadAgentSettings, onRefreshAutomations: refreshAutomations, onRefreshCollaborators: loadCollaborators, onRefreshChannels: loadChannels, onRefreshFiles: loadFiles, onRestoreAutomation, onRouteChange, onRunAutomationNow, onToggleWorkflow, onArchiveWorkflow, onRestoreWorkflow, onRunWorkflowNow, onRefreshWorkflows: loadWorkflows, onSaveSandboxFile, onSaveSecret, onShareAgent, onShareAgentLink, onShareIMessageCode, onShareLineCode, // Only expose Live Voice when the native audio adapter is actually installed; // otherwise the composer would prefer it and fail on tap ("audio callbacks are // not installed") instead of leaving the Live Voice button hidden. onStartLiveVoice: nativeAdapters.liveVoiceAudio?.startAudioCapture ? onStartLiveVoice : undefined, speechToText, onSetupTelegram, onToggleAutomation, onUpdateAgentModel, onUpdateAgentAutomationModel, onUpdateToolPermissions, onUploadSandboxFiles, realtimeClient, secrets, }; } async function waitForConnectorAuthorization( superagentService: ReturnType, agentId: string, connectorId: string, connectionId: string, flow: ConnectorFlow, ): Promise { // The connection status (scoped to this flow's connectionId) is the // authoritative source of truth — not a global callback flag, which can't be // attributed to a specific flow. Returns whether the connector ended up ACTIVE. const isActive = async () => (await superagentService.getConnectorConnectionStatus(agentId, connectorId, connectionId)) === 'ACTIVE'; const maxAttempts = 40; for (let attempt = 0; attempt < maxAttempts; attempt++) { if (flow.cancelled) { // Cancelled — but OAuth may have already completed; confirm with the // authoritative status before reporting not-connected. return isActive().catch(() => false); } await delay(3000); const status = await superagentService.getConnectorConnectionStatus(agentId, connectorId, connectionId); if (status === 'ACTIVE') { return true; } if (status === 'FAILED') { throw new Error('OAuth connection failed.'); } // If the user cancelled during the delay, the next iteration's top-of-loop // check re-confirms via the authoritative status before returning. } throw new Error('Connection timed out after 2 minutes.'); } async function openExternalUrl( nativeAdapters: SuperagentNativeRuntimeAdapters, url: string, fallbackMessage: string, ) { if (!nativeAdapters.openUrl) { // No URL opener: surface it instead of passing the host check and then // silently doing nothing. showAlert(nativeAdapters, fallbackMessage, 'This app cannot open external links. Use the web app instead.'); return false; } try { await nativeAdapters.openUrl(url); return true; } catch (error) { showAlert(nativeAdapters, fallbackMessage, getErrorMessage(error)); return false; } } function showAlert(nativeAdapters: SuperagentNativeRuntimeAdapters, title: string, message?: string) { if (nativeAdapters.alert) { nativeAdapters.alert(title, message); return; } // No host alert adapter — fall back to React Native's Alert (as confirmAction // does) so connector/channel/file/model errors aren't silently swallowed. Alert.alert(title, message); } async function confirmAction( nativeAdapters: SuperagentNativeRuntimeAdapters, input: Parameters>[0], ) { if (nativeAdapters.confirm) { return nativeAdapters.confirm(input); } // No host confirm adapter — fall back to a native prompt so destructive actions // still require explicit confirmation instead of silently proceeding. return new Promise((resolve) => { Alert.alert( input.title, input.message, [ { onPress: () => resolve(false), style: 'cancel', text: 'Cancel' }, { onPress: () => resolve(true), style: input.destructive ? 'destructive' : 'default', text: input.confirmText ?? 'Confirm', }, ], { cancelable: true, onDismiss: () => resolve(false) }, ); }); } function buildWebUrl(baseUrl: string, path: string) { return `${normalizeBaseUrl(baseUrl)}${path.startsWith('/') ? path : `/${path}`}`; } function buildIMessageUrl(phoneNumber: string, code: string) { return `sms:${encodeURIComponent(phoneNumber)}?body=${encodeURIComponent(code)}`; } function normalizeBaseUrl(url: string) { return url.trim().replace(/\/+$/, ''); } function delay(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : 'Failed to load Superagents'; } const GEMINI_LIVE_INPUT_SAMPLE_RATE = 16000; const GEMINI_LIVE_INPUT_MIME_TYPE = `audio/pcm;rate=${GEMINI_LIVE_INPUT_SAMPLE_RATE}`; function startBackendVoiceLiveSession( session: SuperagentVoiceLiveSession, audio: SuperagentLiveVoiceAudioAdapters | undefined, ) { if (!audio?.startAudioCapture) { throw new Error('Native Live Voice audio callbacks are not installed in this app build.'); } return new Promise((resolve, reject) => { const socket = new WebSocket(session.websocketUrl, ['base44-voice']); let capture: SuperagentLiveAudioCapture | void; let captureStarted = false; let setupComplete = false; let settled = false; const finish = async (error?: Error) => { if (settled) { return; } settled = true; clearTimeout(setupTimeout); try { if (!error && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ realtimeInput: { audioStreamEnd: true } })); } await capture?.stop?.(); await audio.stopAudioCapture?.(); await audio.stopAudioPlayback?.(); } catch { // Cleanup should not hide the original Live Voice error. } if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { socket.close(); } if (error) { reject(error); } else { resolve(); } }; const startCapture = async () => { if (captureStarted || settled) return; captureStarted = true; const canUseMicrophone = await audio.requestMicrophonePermission?.(); if (canUseMicrophone === false) { await finish(new Error('Microphone access denied. Please allow microphone access to use Live Voice.')); return; } await audio.prepareAudioSession?.(); capture = await audio.startAudioCapture({ mimeType: GEMINI_LIVE_INPUT_MIME_TYPE, onAudioChunk(chunk) { if (settled || socket.readyState !== WebSocket.OPEN) return; socket.send(JSON.stringify({ realtimeInput: { audio: { data: chunk.data, mimeType: chunk.mimeType ?? GEMINI_LIVE_INPUT_MIME_TYPE, }, }, })); }, onError(error) { finish(error); }, onStop() { finish(); }, sampleRate: GEMINI_LIVE_INPUT_SAMPLE_RATE, }); }; const setupTimeout = setTimeout(() => { finish(new Error('Live Voice setup timed out.')); }, 60000); socket.onopen = () => { socket.send(JSON.stringify({ base44Auth: { streamToken: session.streamToken } })); }; socket.onmessage = (event) => { const payload = parseSocketPayload(event.data); if (payload && ('setupComplete' in payload || 'setup_complete' in payload)) { setupComplete = true; clearTimeout(setupTimeout); startCapture().catch((error) => finish(error instanceof Error ? error : new Error('Failed to start Live Voice audio'))); } if (payload && isGeminiLiveInterrupted(payload)) { audio.stopAudioPlayback?.(); } for (const chunk of getGeminiLiveAudioChunks(payload)) { audio.playAudioChunk?.(chunk); } }; socket.onerror = () => { finish(new Error('Failed to connect to Live Voice')); }; socket.onclose = (event) => { if (!setupComplete) { finish(new Error(getLiveVoiceCloseMessage(event))); return; } finish(); }; }); } function getLiveVoiceCloseMessage(event: {code?: number; reason?: string}) { const reason = event.reason?.trim(); if (reason) { return `Live Voice connection closed: ${event.code ?? 'unknown'} ${reason}`; } return `Live Voice connection closed: ${event.code ?? 'unknown'}`; } function parseSocketPayload(data: unknown): Record | null { if (typeof data !== 'string') { return null; } try { const payload = JSON.parse(data); return payload && typeof payload === 'object' ? payload : null; } catch { return null; } } function getGeminiLiveAudioChunks(payload: Record | null): SuperagentLiveAudioChunk[] { const serverContent = getRecord(payload, 'serverContent') ?? getRecord(payload, 'server_content'); const modelTurn = getRecord(serverContent, 'modelTurn') ?? getRecord(serverContent, 'model_turn'); const parts = modelTurn?.parts; if (!Array.isArray(parts)) { return []; } return parts.flatMap((part) => { const partRecord = asRecord(part); const inlineData = getRecord(partRecord, 'inlineData') ?? getRecord(partRecord, 'inline_data'); const data = inlineData?.data; if (typeof data !== 'string' || data.length === 0) { return []; } const mimeType = inlineData?.mimeType; return [{ data, mimeType: typeof mimeType === 'string' ? mimeType : undefined, }]; }); } function isGeminiLiveInterrupted(payload: Record) { const serverContent = getRecord(payload, 'serverContent') ?? getRecord(payload, 'server_content'); return serverContent?.interrupted === true; } function getRecord(value: Record | null | undefined, key: string) { return asRecord(value?.[key]); } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null; }