import { ChatMarkdown } from "@rakazo/chat-ui/native"; import type { AgentSkillCatalogEntry, Connection, ConnectionCatalogItem, MessageBlock, Routine, } from "@rakazo/contracts"; import { canReactToThreadMessage } from "@rakazo/contracts"; import { abortableDelay, attachmentsForThread, botAvatarImageSrc, buildComposerMentionOptions, type ComposerMention, isApprovalAskBlock, isRunTerminalEvent, isSecretAskBlock, latestAnswerableAskMessageId, mentionChipKey, resolveComposerSendPlan, SLASH_ACTIONS, type SlashActionId, selectedAskActionLabel, serializeComposerPrompt, truncateSlashDescription, userVisibleMessages, } from "@rakazo/core"; import { Link, useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from "expo-router"; import { useHeaderHeight } from "expo-router/react-navigation"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Alert, AppState, FlatList, Image, type NativeScrollEvent, type NativeSyntheticEvent, Pressable, ScrollView, Text, TextInput, View, } from "react-native"; import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { useReducedMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppConnectCard } from "../components/AppConnectCard"; import { AskActions } from "../components/AskActions"; import { BotAvatar } from "../components/bot-avatar"; import { MarkdownArtifactPreview, type MarkdownArtifactPreviewTarget, } from "../components/markdown-artifact-preview"; import { NativeSymbol } from "../components/native-symbol"; import { applyMobileThreadEvent, blockText, currentApiBase, loadSessionToken, type MobileBot, type MobileGroup, type MobileMessage, type MobileMessagePage, type MobileSnapshot, mergeMobileSnapshot, messagingProviderLabel, prependMobileMessagePage, rpc, selectedSpaceId, selectSpace, shouldApplyMobileThreadRefresh, subscribeThread, } from "../lib/api"; import { type MobileArtifactTarget, openMobileArtifact } from "../lib/artifact-open"; import { confirmDeleteBot } from "../lib/bot-lifecycle"; import { saveLastBotId } from "../lib/last-bot"; import { dismissThreadNotifications, resumeLiveNotifications, setOpenNotificationThread, } from "../lib/live-notifications"; import { hasVisibleMessagePresentation, isCenteredAgentEvent, messagePresentationSegments, toolOwnerId, } from "../lib/message-presentation"; import { type PickedAttachment, pickDocuments, pickFromLibrary, takePhoto, } from "../lib/pick-attachments"; import { threadRefreshDelayMs } from "../lib/refresh"; import { type ThreadScrollAction, ThreadScrollBehavior, type ThreadScrollState, } from "../lib/thread-scroll"; import { speakText } from "../lib/voice"; type PendingAttachment = PickedAttachment & { threadKey: string }; type AskAction = NonNullable["actions"]>[number]; function newClientNonce(): string { const webCrypto = globalThis.crypto; if (webCrypto && typeof webCrypto.randomUUID === "function") { return webCrypto.randomUUID(); } return `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function formatApprovalAnswer( answer: string | undefined, actions: AskAction[] | undefined, approval: boolean, ): string { if (!answer) return "Answered"; const selectedAction = actions?.find((action) => action.id === answer); const outcome = selectedAction?.outcome; if (approval && outcome === "created") return "Created"; if (approval && outcome === "cancelled") return "Cancelled"; if (approval && answer === "allow") return "Allowed once"; if (approval && answer === "always") return "Always allowed"; if (approval && answer === "deny") return "Denied"; return `Answered: ${selectedAskActionLabel(answer, actions)}`; } function isWorkingStatus(status: string | undefined): boolean { return ( status === "queued" || status === "leased" || status === "running" || status === "waiting_input" || status === "waiting_takeover" ); } type NotificationRouteState = "loading" | "ready" | "failed"; export default function ThreadRoute() { const router = useRouter(); const { spaceId } = useLocalSearchParams<{ spaceId?: string | string[] }>(); const requestedSpaceId = typeof spaceId === "string" && spaceId ? spaceId : null; const invalidSpaceId = spaceId !== undefined && requestedSpaceId === null; const routeMatchesSelectedSpace = requestedSpaceId === null || selectedSpaceId() === requestedSpaceId; const [routeState, setRouteState] = useState(() => { if (invalidSpaceId) return "failed"; return routeMatchesSelectedSpace ? "ready" : "loading"; }); useEffect(() => { let cancelled = false; if (invalidSpaceId) { setRouteState("failed"); return () => { cancelled = true; }; } if (!requestedSpaceId || selectedSpaceId() === requestedSpaceId) { setRouteState("ready"); return () => { cancelled = true; }; } setRouteState("loading"); void selectSpace(requestedSpaceId).then((selected) => { if (!cancelled) setRouteState(selected ? "ready" : "failed"); }); return () => { cancelled = true; }; }, [invalidSpaceId, requestedSpaceId]); if (routeState === "ready" && !invalidSpaceId && routeMatchesSelectedSpace) return ; return ( {routeState === "loading" ? ( ) : ( router.replace("/")}> Return to inbox )} ); } function Thread() { const navigation = useNavigation(); const router = useRouter(); const headerHeight = useHeaderHeight(); const insets = useSafeAreaInsets(); const { botId, groupId, name, messageId } = useLocalSearchParams<{ botId?: string; groupId?: string; name?: string; messageId?: string; }>(); const inGroup = Boolean(groupId); const scroll = useRef>(null); const pinnedScroll = useRef(null); const scrollBehavior = useRef(new ThreadScrollBehavior()); const userDragging = useRef(false); const loadingOlderContent = useRef(false); const expandedHistoryThread = useRef(null); const historyEpoch = useRef(0); const jumpGeneration = useRef(0); const pinnedAroundRef = useRef<{ botId?: string; groupId?: string; messageId: string; threadId: string; messages: readonly MobileMessage[]; olderCursor: number | null; } | null>(null); const jumpScrollTarget = useRef(null); const activeBotId = useRef(botId); activeBotId.current = botId; const activeGroupId = useRef(groupId); activeGroupId.current = groupId; const readVisibleTarget = useRef(null); const threadKey = groupId ?? botId; const [threadScrollState, setThreadScrollState] = useState(() => scrollBehavior.current.state(), ); useLayoutEffect(() => { scrollBehavior.current.openThread(threadKey ?? ""); expandedHistoryThread.current = null; pinnedAroundRef.current = null; jumpScrollTarget.current = null; loadingOlderContent.current = false; setThreadScrollState(scrollBehavior.current.state()); }, [threadKey]); const reducedMotion = useReducedMotion(); const artifactTarget: MobileArtifactTarget | undefined = groupId ? { groupId } : botId ? { botId } : undefined; const [snap, setSnap] = useState(null); const activeThreadId = useRef(undefined); const [draft, setDraft] = useState(""); const [mentionQuery, setMentionQuery] = useState(null); const [slashQuery, setSlashQuery] = useState(null); const [agentSkills, setAgentSkills] = useState([]); const [mentionBots, setMentionBots] = useState([]); const [mentionGroups, setMentionGroups] = useState([]); const [mentionRoutines, setMentionRoutines] = useState>([]); const [mentionConnectors, setMentionConnectors] = useState< Array<{ id: string; name: string; authStatus: "connected" | "needs_auth"; connectionId?: string; }> >([]); const [selectedMentions, setSelectedMentions] = useState([]); const [selectedSkill, setSelectedSkill] = useState(null); const [pendingAttachments, setPendingAttachments] = useState([]); const [replyTarget, setReplyTarget] = useState(null); const [attachmentNotice, setAttachmentNotice] = useState(null); const [sending, setSending] = useState(false); const [error, setError] = useState(null); const [loadingOlder, setLoadingOlder] = useState(false); const [markdownPreview, setMarkdownPreview] = useState( null, ); const visibleMessages = useMemo( () => userVisibleMessages(snap?.messages ?? [], { includePeerReceipts: true }).filter((message) => hasVisibleMessagePresentation(message.blocks), ), [snap?.messages], ); const latestMessageId = visibleMessages.at(-1)?.id ?? null; const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey); const composerMentionTargets = useMemo( () => buildComposerMentionOptions({ query: "", includeEveryone: inGroup, currentGroupId: groupId, bots: mentionBots.map((bot) => ({ id: bot.id, name: bot.name, color: bot.color, })), groups: mentionGroups.map((group) => ({ id: group.id, name: group.name, })), routines: mentionRoutines.map((routine) => ({ id: routine.id, name: routine.name, crons: routine.crons, botId: routine.botId, botName: routine.botName, })), connectors: mentionConnectors, }), [groupId, inGroup, mentionBots, mentionConnectors, mentionGroups, mentionRoutines], ); const mentionOptions = useMemo(() => { if (mentionQuery === null || composerMentionTargets.length === 0) return []; const query = mentionQuery.trim().toLowerCase(); return composerMentionTargets .filter((target) => !query || target.name.toLowerCase().startsWith(query)) .slice(0, 10); }, [composerMentionTargets, mentionQuery]); const slashQueryNormalized = slashQuery?.trim().toLowerCase() ?? null; const slashSkillOptions = slashQuery !== null && mentionQuery === null ? agentSkills .filter((skill) => { if (!slashQueryNormalized) return true; return ( skill.name.toLowerCase().includes(slashQueryNormalized) || skill.description.toLowerCase().includes(slashQueryNormalized) ); }) .slice(0, 8) : []; const slashActionOptions = slashQuery !== null && mentionQuery === null ? SLASH_ACTIONS.filter( (action) => !slashQueryNormalized || action.label.toLowerCase().includes(slashQueryNormalized), ) : []; const currentBot = botId ? mentionBots.find((bot) => bot.id === botId) : undefined; const notificationThreadId = snap?.threadId ?? currentBot?.threadId; activeThreadId.current = notificationThreadId; const currentBotStatus = snap ? snap.run?.status : currentBot?.status; const hasLiveProgress = visibleMessages.some((message) => message.id.startsWith("progress:")); const workingGroupBots = useMemo(() => { if (!inGroup) return []; const seen = new Set(); const working = snap?.activeRuns ?? (snap?.run ? [snap.run] : []); return working.flatMap((run) => { if (!run.botId || seen.has(run.botId) || !isWorkingStatus(run.status)) return []; const member = snap?.members?.find((candidate) => candidate.botId === run.botId); if (!member) return []; seen.add(run.botId); return [{ ...member, status: run.status }]; }); }, [inGroup, snap?.activeRuns, snap?.members, snap?.run]); const working = inGroup ? workingGroupBots.length > 0 : isWorkingStatus(currentBotStatus); useEffect(() => { void rpc("agentSkills/list") .then(setAgentSkills) .catch(() => setAgentSkills([])); }, []); useEffect(() => { setThreadScrollState(scrollBehavior.current.state()); }, [threadKey]); useEffect(() => { void rpc("bots/list") .then(setMentionBots) .catch(() => setMentionBots([])); void rpc("groups/list") .then(setMentionGroups) .catch(() => setMentionGroups([])); }, []); useEffect(() => { if (mentionBots.length === 0) { setMentionRoutines([]); setMentionConnectors([]); return; } let cancelled = false; const botNameById = new Map(mentionBots.map((bot) => [bot.id, bot.name])); void Promise.all( mentionBots.map((bot) => rpc("routines/list", { botId: bot.id }) .then((rows) => rows.map((routine) => ({ ...routine, botName: botNameById.get(bot.id) ?? bot.name, })), ) .catch(() => [] as Array), ), ).then((lists) => { if (!cancelled) setMentionRoutines(lists.flat()); }); void Promise.all([ rpc("connections/list").catch(() => [] as Connection[]), rpc("connections/catalog", {}).catch( () => [] as ConnectionCatalogItem[], ), ]).then(([connections, catalog]) => { if (cancelled) return; const connected = connections.filter((row) => row.status === "connected"); const options: Array<{ id: string; name: string; authStatus: "connected" | "needs_auth"; connectionId?: string; }> = connected.map((row) => ({ id: row.id, name: row.displayName, authStatus: "connected" as const, connectionId: row.id, })); for (const item of catalog) { if (item.connected || item.noAuth) continue; if ( connected.some( (row) => row.provider.toLowerCase() === item.slug.toLowerCase() || row.displayName.toLowerCase() === item.name.toLowerCase(), ) ) { continue; } options.push({ id: `catalog:${item.connectorId}:${item.slug}`, name: item.name, authStatus: "needs_auth", }); } setMentionConnectors(options); }); return () => { cancelled = true; }; }, [mentionBots]); function isCurrentTarget(targetBotId: string | undefined, targetGroupId: string | undefined) { return activeBotId.current === targetBotId && activeGroupId.current === targetGroupId; } useLayoutEffect(() => { navigation.setOptions({ title: name || "Thread", headerTitle: () => ( {!inGroup && currentBot ? ( ) : null} {name || "Thread"} ), headerRight: () => inGroup ? ( router.push({ pathname: "/group-settings", params: { groupId: groupId ?? "" }, }) } > ) : ( ), }); }, [botId, currentBot, currentBotStatus, groupId, inGroup, name, navigation, router]); function leaveBot() { router.dismissAll(); router.replace("/"); } function clearConversation() { if (!botId) return; setError(null); void rpc("threads/clear", { botId }) .then(() => { expandedHistoryThread.current = null; pinnedAroundRef.current = null; historyEpoch.current += 1; setSnap((current) => current ? { ...current, messages: [], olderCursor: null, run: null } : current, ); }) .catch((err: unknown) => setError(err instanceof Error ? err.message : "Could not clear conversation"), ); } function showBotActions() { if (!botId) return; const bot = { id: botId, name: name || "Bot" }; Alert.alert(bot.name, "Archive keeps everything and can be undone. Delete is permanent.", [ { text: "Cancel", style: "cancel" }, { text: "Clear conversation", style: "destructive", onPress: () => { Alert.alert( "Clear conversation?", "This removes every message and stops current work. The bot, computer, memory, and routines are kept.", [ { text: "Cancel", style: "cancel" }, { text: "Clear", style: "destructive", onPress: clearConversation, }, ], ); }, }, { text: "Archive", onPress: () => void rpc("bots/archive", { botId }) .then(leaveBot) .catch((error) => Alert.alert( "Could not archive bot", error instanceof Error ? error.message : "Try again.", ), ), }, { text: "Delete…", style: "destructive", onPress: () => confirmDeleteBot(bot, leaveBot), }, ]); } async function refresh() { if (!botId && !groupId) return; const targetBotId = botId; const targetGroupId = groupId; const epoch = historyEpoch.current; const next = await rpc( "threads/get", targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }, ); if ( !shouldApplyMobileThreadRefresh({ requestEpoch: epoch, currentEpoch: historyEpoch.current, targetBotId, targetGroupId, activeBotId: activeBotId.current, activeGroupId: activeGroupId.current, }) ) return next; setSnap((prev) => mergeMobileSnapshot(prev, next, expandedHistoryThread.current === next.threadId), ); return next; } async function applyMessageJump(target: { botId?: string; groupId?: string; messageId: string }) { const threadTarget = target.groupId ? { groupId: target.groupId } : { botId: target.botId! }; const epoch = historyEpoch.current; jumpGeneration.current += 1; const jumpId = jumpGeneration.current; const [snap, page] = await Promise.all([ rpc("threads/get", threadTarget), rpc("threads/messages", { ...threadTarget, around: { messageId: target.messageId }, }), ]); // The epoch check drops a jump that raced a conversation clear (or a bot switch); the // generation check drops an older same-thread jump that finished after a newer one. if (epoch !== historyEpoch.current || jumpId !== jumpGeneration.current) return; if (target.groupId && activeGroupId.current !== target.groupId) return; if (target.botId && activeBotId.current !== target.botId) return; const targetInPage = page.messages.some((message) => message.id === target.messageId); expandedHistoryThread.current = targetInPage ? page.threadId : null; pinnedAroundRef.current = targetInPage ? { ...threadTarget, messageId: target.messageId, threadId: page.threadId, messages: [...page.messages], olderCursor: page.olderCursor, } : null; jumpScrollTarget.current = targetInPage ? target.messageId : null; setSnap({ ...snap, messages: targetInPage ? [...page.messages] : snap.messages, olderCursor: targetInPage ? page.olderCursor : snap.olderCursor, }); } async function loadOlderMessages() { if ((!botId && !groupId) || snap?.olderCursor == null || loadingOlder) return; loadingOlderContent.current = true; setLoadingOlder(true); const epoch = historyEpoch.current; try { const page = await rpc("threads/messages", { ...(groupId ? { groupId } : { botId: botId! }), before: snap.olderCursor, includePeerReceipts: true, }); if (epoch !== historyEpoch.current) { loadingOlderContent.current = false; return; } expandedHistoryThread.current = page.threadId; setSnap((prev) => prependMobileMessagePage(prev, page)); } catch (err) { loadingOlderContent.current = false; setError(err instanceof Error ? err.message : "Could not load earlier messages"); } finally { setLoadingOlder(false); } } const markReadIfVisible = useCallback(() => { if (AppState.currentState !== "active" || !navigation.isFocused()) return; const target = groupId ?? botId; if (!target || readVisibleTarget.current === target) return; readVisibleTarget.current = target; if (activeThreadId.current) { void dismissThreadNotifications({ threadId: activeThreadId.current }).catch(() => undefined); } if (groupId) { void rpc("threads/markRead", { groupId }).catch(() => { if (readVisibleTarget.current === target) readVisibleTarget.current = null; }); return; } void rpc("threads/markRead", { botId: botId! }).catch(() => { if (readVisibleTarget.current === target) readVisibleTarget.current = null; }); }, [botId, groupId, navigation]); useEffect(() => { if (!notificationThreadId || AppState.currentState !== "active" || !navigation.isFocused()) return; void setOpenNotificationThread({ botId, threadId: notificationThreadId }).catch( () => undefined, ); void dismissThreadNotifications({ threadId: notificationThreadId }).catch(() => undefined); }, [botId, navigation, notificationThreadId]); // Covers returning from a pushed screen; the AppState listener covers returning from background. useFocusEffect( useCallback(() => { if (botId) void saveLastBotId(botId).catch(() => undefined); if (AppState.currentState === "active" && notificationThreadId) { void setOpenNotificationThread({ botId, threadId: notificationThreadId, }).catch(() => undefined); } markReadIfVisible(); return () => { void setOpenNotificationThread(null).catch(() => undefined); }; }, [botId, markReadIfVisible, notificationThreadId]), ); useEffect(() => { const appState = AppState.addEventListener("change", (state) => { if (state === "active") { if (!navigation.isFocused() || !notificationThreadId) return; void setOpenNotificationThread({ botId, threadId: notificationThreadId, }).catch(() => undefined); markReadIfVisible(); return; } void setOpenNotificationThread(null).catch(() => undefined); }); return () => appState.remove(); }, [botId, markReadIfVisible, navigation, notificationThreadId]); useEffect(() => { if (!botId && !groupId) return; if (!messageId) { pinnedAroundRef.current = null; jumpScrollTarget.current = null; } expandedHistoryThread.current = null; historyEpoch.current += 1; const abort = new AbortController(); void (async () => { // Pending search jumps load the around-page separately; avoid replacing it with latest. const next = messageId ? await rpc("threads/get", groupId ? { groupId } : { botId: botId! }).catch( (err: Error) => { setError(err.message); return null; }, ) : await refresh().catch((err: Error) => { setError(err.message); return null; }); if (abort.signal.aborted) return; let cursor = next?.cursor ?? -1; let retryMs = 250; while (!abort.signal.aborted) { try { await subscribeThread( groupId ? { groupId } : { botId: botId! }, cursor, (event) => { cursor = Math.max(cursor, event.seq ?? -1); retryMs = 250; if ( event.type === "thread.progress" || event.type === "agent.tool.called" || event.type === "thread.message.created" || event.type === "thread.message.updated" || event.type === "thread.message.reaction" || event.type === "thread.subagent" || event.type === "thread.cleared" || event.type === "run.waiting_input" || isRunTerminalEvent(event) ) { if (event.type === "thread.cleared") { expandedHistoryThread.current = null; pinnedAroundRef.current = null; historyEpoch.current += 1; } setSnap((prev) => applyMobileThreadEvent(prev, event)); } if (event.type === "thread.message.created" && event.payload?.role === "bot") { readVisibleTarget.current = null; markReadIfVisible(); } if (isRunTerminalEvent(event)) { if (!jumpScrollTarget.current && !expandedHistoryThread.current) { void refresh().catch(() => undefined); } } }, abort.signal, ); } catch { // A full refresh reconciles visible state; the event cursor still resumes without gaps. } if (abort.signal.aborted) break; if (!jumpScrollTarget.current && !expandedHistoryThread.current) { await refresh().catch(() => undefined); } await abortableDelay(retryMs, abort.signal); retryMs = Math.min(retryMs * 2, 5_000); } })(); return () => { abort.abort(); }; }, [botId, groupId, markReadIfVisible]); useEffect(() => { if (!botId && !groupId) return; let cancelled = false; let timer: ReturnType | undefined; const tick = async () => { if ( AppState.currentState === "active" && navigation.isFocused() && !jumpScrollTarget.current && !expandedHistoryThread.current ) { await refresh().catch(() => undefined); } if (!cancelled) { timer = setTimeout(() => void tick(), threadRefreshDelayMs(snap?.run?.status)); } }; timer = setTimeout(() => void tick(), threadRefreshDelayMs(snap?.run?.status)); return () => { cancelled = true; if (timer !== undefined) clearTimeout(timer); }; }, [botId, groupId, navigation, snap?.run?.status]); useEffect(() => { if ((!botId && !groupId) || !messageId) return; void applyMessageJump(groupId ? { groupId, messageId } : { botId: botId!, messageId }).catch( (err) => { setError(err instanceof Error ? err.message : "Could not open message"); }, ); }, [botId, groupId, messageId]); useEffect(() => { setPendingAttachments((current) => attachmentsForThread(current, threadKey)); setDraft(""); setMentionQuery(null); setSlashQuery(null); setSelectedSkill(null); setSelectedMentions([]); setReplyTarget(null); setAttachmentNotice(null); setError(null); }, [threadKey]); function updateDraft(value: string) { setDraft(value); const match = /(?:^|\s)@([\w-]*)$/.exec(value); setMentionQuery(match ? (match[1] ?? "") : null); const slashMatch = selectedSkill === null ? /^\/([^\n]*)$/.exec(value) : null; setSlashQuery(slashMatch ? (slashMatch[1] ?? "") : null); } function insertMention(mention: ComposerMention) { setDraft((current) => current.replace(/@([\w-]*)$/, "")); setMentionQuery(null); setSelectedMentions((current) => current.some((selected) => mentionChipKey(selected) === mentionChipKey(mention)) ? current : [...current, mention], ); } function insertSkill(skill: AgentSkillCatalogEntry) { setSelectedSkill(skill); setDraft(""); setSlashQuery(null); } function removeLastChip() { if (selectedMentions.length > 0) { setSelectedMentions((current) => current.slice(0, -1)); return; } if (selectedSkill) setSelectedSkill(null); } function serializeComposerPromptText(): string { return serializeComposerPrompt(draft, selectedSkill, selectedMentions); } function runSlashAction(action: SlashActionId) { setDraft(""); setSlashQuery(null); if (action === "chat-settings") { if (inGroup && groupId) { router.push({ pathname: "/group-settings", params: { groupId } }); } else if (botId) { router.push({ pathname: "/bot-settings", params: { botId } }); } return; } router.push({ pathname: "/account", params: action === "settings-usage" ? { focus: "usage" } : undefined, }); } const canSend = Boolean(draft.trim()) || selectedSkill !== null || selectedMentions.length > 0 || activePendingAttachments.length > 0; async function send() { const initialBotTarget = botId; const initialGroupTarget = groupId; if ((!initialBotTarget && !initialGroupTarget) || sending) return; const originThreadKey = initialGroupTarget ?? initialBotTarget; const attachments = attachmentsForThread(pendingAttachments, originThreadKey); const plan = resolveComposerSendPlan({ text: serializeComposerPromptText(), mentions: selectedMentions, hasAttachments: attachments.length > 0, }); if (plan.isNoOp) return; const reroutedToGroup = Boolean( plan.rerouteGroupId && plan.rerouteGroupId !== initialGroupTarget, ); const groupTarget = plan.rerouteGroupId ?? initialGroupTarget; const botTarget = reroutedToGroup ? undefined : initialBotTarget; const trimmed = plan.trimmed; setSending(true); setError(null); try { if (plan.shouldRunRoutines) { const sendNonce = newClientNonce(); await Promise.all( plan.routineIds.map((routineId) => rpc("routines/testRun", { routineId, clientNonce: `routine-mention:${sendNonce}:${routineId}`, }), ), ); } const clearOriginComposer = () => { setPendingAttachments((current) => current.filter((attachment) => attachment.threadKey !== originThreadKey), ); setDraft(""); setMentionQuery(null); setSlashQuery(null); setSelectedSkill(null); setSelectedMentions([]); setReplyTarget(null); setAttachmentNotice(null); }; if (!plan.shouldSend) { clearOriginComposer(); if (reroutedToGroup && groupTarget) { router.push({ pathname: "/group-thread", params: { groupId: groupTarget, name: plan.rerouteGroupName ?? "Group", }, }); return; } if (isCurrentTarget(botTarget, groupTarget)) { await refresh(); } return; } const artifactIds: string[] = []; for (const pending of attachments) { const artifact = await rpc<{ id: string }>("artifacts/create", { ...(groupTarget ? { groupId: groupTarget } : { botId: botTarget! }), name: pending.name, mimeType: pending.mimeType, contentBase64: pending.contentBase64, }); artifactIds.push(artifact.id); } const clientNonce = newClientNonce(); await rpc( "threads/send", groupTarget ? { groupId: groupTarget, clientNonce, text: trimmed || undefined, mentions: plan.mentionPayload.length ? plan.mentionPayload : undefined, artifactIds: artifactIds.length ? artifactIds : undefined, replyToMessageId: reroutedToGroup ? undefined : replyTarget?.id, } : { botId: botTarget!, clientNonce, text: trimmed || undefined, mentions: plan.mentionPayload.length ? plan.mentionPayload : undefined, artifactIds: artifactIds.length ? artifactIds : undefined, replyToMessageId: replyTarget?.id, }, ); void loadSessionToken() .then((token) => resumeLiveNotifications(currentApiBase(), token, selectedSpaceId() ?? "")) .catch(() => undefined); clearOriginComposer(); if (reroutedToGroup && groupTarget) { router.push({ pathname: "/group-thread", params: { groupId: groupTarget, name: plan.rerouteGroupName ?? "Group", }, }); return; } if (isCurrentTarget(botTarget, groupTarget)) { await refresh(); } } catch (err) { if (reroutedToGroup && groupTarget) { setError(err instanceof Error ? err.message : "Failed to send message"); } else if (isCurrentTarget(botTarget, groupTarget)) { setError(err instanceof Error ? err.message : "Failed to send message"); } } finally { setSending(false); } } async function stop() { const targetBotId = botId; const targetGroupId = groupId; if ((!targetBotId && !targetGroupId) || sending) return; setSending(true); setError(null); try { await rpc( "threads/stop", targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }, ); } catch (err) { if (isCurrentTarget(targetBotId, targetGroupId)) { setError(err instanceof Error ? err.message : "Failed to stop work"); } setSending(false); return; } try { await refresh(); } catch (err) { if (isCurrentTarget(targetBotId, targetGroupId)) { const detail = err instanceof Error ? err.message : "Failed to refresh"; setError(`Work stopped, but the thread could not refresh: ${detail}`); } } finally { setSending(false); } } const answerMessage = useCallback( async (message: MobileMessage, answer: string) => { const targetBotId = botId; const targetGroupId = groupId; if ((!targetBotId && !targetGroupId) || !message.runId) return; await rpc("threads/answer", { ...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }), runId: message.runId, messageId: message.id, answer, }); if (isCurrentTarget(targetBotId, targetGroupId)) await refresh(); }, [botId, groupId], ); const openBot = useCallback( (id: string, botName: string) => router.push({ pathname: "/thread", params: { botId: id, name: botName } }), [router], ); const speak = useCallback( (message: MobileMessage) => void speakMessage(message.botId ?? botId ?? snap?.members?.[0]?.botId ?? "", message).catch( (err) => Alert.alert("Could not speak", err instanceof Error ? err.message : "Try again."), ), [botId, snap?.members], ); function showAttachMenu() { Alert.alert("Attach", undefined, [ { text: "Photo library", onPress: () => void addAttachments(pickFromLibrary), }, { text: "Camera", onPress: () => void addAttachments(takePhoto) }, { text: "File", onPress: () => void addAttachments(pickDocuments) }, { text: "Cancel", style: "cancel" }, ]); } async function addAttachments( picker: (existingCount: number) => Promise<{ attachments: PickedAttachment[]; skipped: Array<{ name: string; reason: string }>; }>, ) { const targetKey = groupId ?? botId; if (!targetKey) return; const result = await picker(activePendingAttachments.length); if ((groupId ?? botId) !== targetKey) return; if (result.attachments.length) { setPendingAttachments((current) => [ ...current, ...result.attachments.map((attachment) => ({ ...attachment, threadKey: targetKey, })), ]); } setAttachmentNotice( result.skipped.length ? `Skipped ${result.skipped.map((item) => `${item.name} (${item.reason})`).join(", ")}` : null, ); } const answerableAskMessageId = latestAnswerableAskMessageId(snap); const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null; const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]); const messagesById = useMemo( () => new Map((snap?.messages ?? []).map((message) => [message.id, message])), [snap?.messages], ); const pinnedTarget = pinnedAroundRef.current; const showPinnedPage = Boolean( jumpScrollTarget.current || (pinnedTarget && ((pinnedTarget.botId && pinnedTarget.botId === botId) || (pinnedTarget.groupId && pinnedTarget.groupId === groupId))), ); function performScroll(action: ThreadScrollAction) { if (!action || showPinnedPage) return; scroll.current?.scrollToOffset({ offset: 0, animated: action === "smooth" && !reducedMotion, }); } function updateUserScroll(event: NativeSyntheticEvent) { // Inverted FlatList: contentOffset.y is distance from the latest messages. setThreadScrollState( scrollBehavior.current.onUserScroll(Math.max(0, event.nativeEvent.contentOffset.y)), ); } async function reactToMessage(message: MobileMessage) { const targetBotId = botId; const targetGroupId = groupId; if (!targetBotId && !targetGroupId) return; try { await rpc("threads/react", { ...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }), messageId: message.id, thumbsUp: !message.thumbsUp, }); } catch (err) { if (!isCurrentTarget(targetBotId, targetGroupId)) return; setError(err instanceof Error ? err.message : "Could not update reaction"); } } function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) { const ownerId = toolOwnerId(message, inGroup); const activityBotId = ownerId ?? (!inGroup && message.role === "bot" && message.id.startsWith("progress:") ? (message.botId ?? botId) : undefined); const activityBot = activityBotId ? (snap?.members?.find((member) => member.botId === activityBotId) ?? (currentBot?.id === activityBotId ? currentBot : undefined)) : undefined; const activityStatus = activityBotId ? (snap?.activeRuns?.find((run) => run.botId === activityBotId)?.status ?? (snap?.run?.botId === activityBotId ? snap.run.status : currentBotStatus)) : undefined; return ( { if (jumpScrollTarget.current !== message.id) return; const y = Math.max(0, event.nativeEvent.layout.y - 24); requestAnimationFrame(() => { if (jumpScrollTarget.current !== message.id) return; pinnedScroll.current?.scrollTo({ y, animated: true }); jumpScrollTarget.current = null; }); } : undefined } style={{ marginTop: 12, width: "100%", flexDirection: "row", alignItems: "flex-start", gap: 8, justifyContent: message.role === "user" ? "flex-end" : "flex-start", }} > {activityBotId ? ( ) : null} setReplyTarget(message)}> Reply {canReactToThreadMessage(message) ? ( void reactToMessage(message)} > 👍 ) : null} ); } const workingFooter = !inGroup && currentBot && isWorkingStatus(currentBotStatus) && !hasLiveProgress ? ( {currentBot.name} is working ) : inGroup && workingGroupBots.length > 0 ? ( {workingGroupBots.map((bot, index) => ( ))} {workingGroupBots.length === 1 ? `${workingGroupBots[0]?.name ?? "Agent"} is working` : `${workingGroupBots.length} agents working`} ) : null; const loadEarlierControl = snap?.olderCursor != null ? ( void loadOlderMessages()} style={{ alignSelf: "center", paddingHorizontal: 12, paddingVertical: 10, }} > {loadingOlder ? "Loading…" : "Load earlier messages"} ) : null; return ( {error ? {error} : null} {runError ? {runError} : null} {showPinnedPage ? ( {loadEarlierControl} {visibleMessages.map((message) => renderMessageRow(message, { enableJump: true }))} {workingFooter} ) : ( message.id} extraData={answerableAskMessageId} style={{ flex: 1, marginTop: 8 }} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} scrollEventThrottle={16} onScrollBeginDrag={() => { userDragging.current = true; }} onScroll={(event) => { if (userDragging.current) updateUserScroll(event); }} onScrollEndDrag={(event) => { updateUserScroll(event); userDragging.current = false; }} onMomentumScrollEnd={updateUserScroll} onLayout={() => performScroll(scrollBehavior.current.onLayout())} onContentSizeChange={() => { if (loadingOlderContent.current) { loadingOlderContent.current = false; return; } const blocked = Boolean( jumpScrollTarget.current || (pinnedAroundRef.current && ((pinnedAroundRef.current.botId && pinnedAroundRef.current.botId === botId) || (pinnedAroundRef.current.groupId && pinnedAroundRef.current.groupId === groupId))) || expandedHistoryThread.current === snap?.threadId, ); performScroll(scrollBehavior.current.onContentChanged(blocked, latestMessageId)); setThreadScrollState(scrollBehavior.current.state()); }} ListFooterComponent={loadEarlierControl} ListHeaderComponent={workingFooter} renderItem={({ item }) => renderMessageRow(item)} /> )} {!showPinnedPage && threadScrollState.detached ? ( { performScroll(scrollBehavior.current.jumpToLatest()); setThreadScrollState(scrollBehavior.current.state()); }} style={{ position: "absolute", left: "50%", marginLeft: -21, bottom: 12, width: 42, height: 42, borderRadius: 21, borderWidth: 1, borderColor: "#303035", backgroundColor: "#1A1A1D", alignItems: "center", justifyContent: "center", }} > {threadScrollState.unread ? ( ) : null} ) : null} {replyTarget ? ( Replying to {previewMessageText(replyTarget)} setReplyTarget(null)}> ) : null} {attachmentNotice ? ( {attachmentNotice} ) : null} {activePendingAttachments.length ? ( {activePendingAttachments.map((attachment) => ( {attachment.previewUri ? ( ) : ( 📎 )} {attachment.name} setPendingAttachments((current) => current.filter((item) => item.id !== attachment.id), ) } > ))} ) : null} {mentionOptions.length ? ( {mentionOptions.map((mention) => ( insertMention(mention)} style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 14, paddingVertical: 10, }} > @{mention.name} {mention.subtitle ? ( {mention.subtitle} ) : null} ))} ) : null} {slashSkillOptions.length || slashActionOptions.length ? ( {slashSkillOptions.map((skill) => ( insertSkill(skill)} style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 14, paddingVertical: 10, }} > {skill.name} {truncateSlashDescription(skill.description)} ))} {slashActionOptions.map((action) => ( runSlashAction(action.id)} style={{ flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 14, paddingVertical: 10, }} > {action.label} ))} ) : null} {selectedSkill ? ( {selectedSkill.name} setSelectedSkill(null)} > ) : null} {selectedMentions.map((mention) => ( {mention.name} setSelectedMentions((current) => current.filter( (selected) => mentionChipKey(selected) !== mentionChipKey(mention), ), ) } > ))} { if ( event.nativeEvent.key === "Backspace" && draft.length === 0 && (selectedSkill !== null || selectedMentions.length > 0) ) { removeLastChip(); } }} placeholder={ selectedSkill || selectedMentions.length ? undefined : name ? `Message ${name}` : "Message…" } placeholderTextColor="#6C6C70" keyboardAppearance="dark" multiline textAlignVertical="center" blurOnSubmit={false} style={{ flexGrow: 1, flexShrink: 1, minWidth: 96, color: "#ECECEE", paddingVertical: 2, maxHeight: 100, writingDirection: "auto", }} /> void send()} style={{ backgroundColor: "#F1F1EF", borderRadius: 22, width: 44, height: 44, alignItems: "center", justifyContent: "center", opacity: sending || !canSend ? 0.5 : 1, }} > {working ? ( void stop()} style={{ borderColor: "#34343A", borderWidth: 1, borderRadius: 22, width: 44, height: 44, alignItems: "center", justifyContent: "center", opacity: sending ? 0.5 : 1, }} > ) : null} {!inGroup ? ( Open computer → ) : null} {markdownPreview && artifactTarget ? ( setMarkdownPreview(null)} /> ) : null} ); } function MentionOptionIcon({ mention }: { mention: ComposerMention }) { if (mention.kind === "routine") { return ; } if (mention.kind === "connector") { return ( ); } if (mention.kind === "group") { return ( G ); } if (mention.kind === "everyone") { return ( @ ); } return ( ); } function MentionChipIcon({ mention }: { mention: ComposerMention }) { if (mention.kind === "routine") { return ; } if (mention.kind === "connector") { return ( ); } if (mention.kind === "group" || mention.kind === "everyone") { return ( {mention.kind === "group" ? "G" : "@"} ); } return ( ); } function previewMessageText(message: MobileMessage): string { const text = message.blocks .flatMap((block) => { if (block.kind === "channel_message" && block.text) { return [`${messagingProviderLabel(block.provider)} · ${block.fromLabel}: ${block.text}`]; } return block.kind === "text" && block.text ? [block.text] : []; }) .join(" ") .trim(); if (text) return text; if (message.blocks.some((block) => block.kind === "image" || block.kind === "file")) { return "Attachment"; } return "Message"; } function memberName( members: MobileSnapshot["members"] | undefined, botId: string | undefined, ): string | undefined { if (!botId || !members) return undefined; return members.find((member) => member.botId === botId)?.name; } async function speakMessage(botId: string, message: MobileMessage) { const text = blockText(message); if (!text.trim()) return; if (!(await speakText(text, { botId }))) { throw new Error("Add a voice provider in Voice settings."); } } const MessageBubble = memo(function MessageBubble({ botId, botName, bots, groupId, message, members, replyPreview, canAnswer, onAnswer, onOpenBot, onPreviewMarkdown, onSpeak, }: { botId: string; botName?: string; bots: MobileBot[]; groupId?: string; message: MobileMessage; members?: MobileSnapshot["members"]; replyPreview?: MobileMessage; canAnswer: boolean; onAnswer: (message: MobileMessage, answer: string) => Promise; onOpenBot: (botId: string, name: string) => void; onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void; onSpeak?: (message: MobileMessage) => void; }) { const [peerExpanded, setPeerExpanded] = useState(false); const artifactTarget: MobileArtifactTarget = groupId ? { groupId } : { botId }; const cardBotId = message.botId ?? botId; const appConnectBlocks = message.blocks.filter( (block): block is Extract => block.kind === "app_connect", ); const ask = message.blocks.find( (block): block is Extract => block.kind === "ask" && !isApprovalAskBlock(block) && !block.actions?.length, ); if (ask) { return ( onAnswer(message, answer)} /> {appConnectBlocks.map((block, index) => ( ))} ); } const handoff = message.blocks.find((block) => block.kind === "handoff"); if (handoff) { const from = memberName(members, handoff.fromBotId) ?? "bot"; const to = memberName(members, handoff.toBotId) ?? "bot"; return ( setPeerExpanded((expanded) => !expanded)} /> ); } const peerMessage = message.blocks.find( ( block, ): block is Extract => block.kind === "bot_message_sent" || block.kind === "bot_message_received", ); if (peerMessage) { const sent = peerMessage.kind === "bot_message_sent"; const peer = sent ? peerMessage.toBotName : peerMessage.fromBotName; const peerBotId = sent ? peerMessage.toBotId : peerMessage.fromBotId; const label = sent ? `Messaged ${peer}` : `Message from ${peer}`; const peerColor = bots.find((bot) => bot.id === peerBotId)?.color ?? members?.find((member) => member.botId === peerBotId)?.color ?? "#85858A"; // Compact receipt only: peer bodies stay out of the human thread. // Full view-only peer chat is web-first; mobile keeps the chip without expand. return ( {label} ); } const channelMessage = message.blocks.find( (block): block is Extract => block.kind === "channel_message", ); if (channelMessage) { return ( {messagingProviderLabel(channelMessage.provider)} · {channelMessage.fromLabel}:{" "} {channelMessage.text} ); } const special = message.blocks.find( (block) => block.kind === "subagent" || block.kind === "child_bot", ); if (special?.kind === "subagent") { const running = special.status === "running"; const failed = special.status === "failed"; return ( {special.name || "subagent"} {running ? "subagent" : special.status} {special.task ? ( {special.task} ) : null} {special.result || special.progress ? ( {special.result || special.progress || ""} ) : null} ); } if (special?.kind === "child_bot") { const removed = special.status === "deleted" || special.status === "archived"; return ( onOpenBot(special.botId ?? "", special.name ?? "Bot")} style={{ width: "90%", borderRadius: 18, borderWidth: 1, borderColor: "#232326", backgroundColor: "#17171A", paddingHorizontal: 16, paddingVertical: 14, opacity: removed ? 0.6 : 1, }} > {special.name || "Bot"} {special.status === "archived" ? "archived" : special.status === "deleted" ? "deleted" : "bot"} {removed ? special.status === "archived" ? "Archived. Chat, memory, and files kept." : "Removed with chat, computer, and memory." : special.title || "Opened its thread."} ); } if (appConnectBlocks.length > 0 && appConnectBlocks.length === message.blocks.length) { return ( {appConnectBlocks.map((block, index) => ( ))} ); } const askBlock = message.blocks.find( (block) => block.kind === "ask" && Boolean(block.actions?.length), ); if (askBlock?.kind === "ask" && askBlock.actions?.length) { return ( {askBlock.text ? ( {askBlock.text} ) : null} {askBlock.detail ? ( {askBlock.detail} ) : null} {askBlock.status === "answered" ? ( {formatApprovalAnswer( askBlock.answer, askBlock.actions, isApprovalAskBlock(askBlock), )} ) : canAnswer && onAnswer ? ( onAnswer(message, answer)} /> ) : ( No longer active )} {appConnectBlocks.map((block, index) => ( ))} ); } const attachments = message.blocks.filter( (block) => block.kind === "image" || block.kind === "file", ); const caption = message.blocks .flatMap((block) => { if (block.kind === "channel_message" && block.text) { return [`${messagingProviderLabel(block.provider)} · ${block.fromLabel}: ${block.text}`]; } return block.kind === "text" && block.text ? [block.text] : []; }) .join("\n"); if (attachments.length > 0) { const speaker = message.role === "bot" ? (memberName(members, message.botId) ?? botName) : undefined; return ( {speaker ? ( {speaker} ) : null} {replyPreview ? ( {previewMessageText(replyPreview)} ) : null} {caption ? ( {caption} ) : null} {attachments.map((attachment, index) => attachment.kind === "image" ? ( attachment.artifactId ? void openMobileArtifact( artifactTarget, attachment.artifactId, attachment.name ?? "Image", attachment.mimeType ?? "image/png", ).catch((err) => Alert.alert( "Could not open image", err instanceof Error ? err.message : "Try again.", ), ) : undefined } > 🖼 {attachment.name ?? "Image"} ) : ( attachment.artifactId ? attachment.mimeType === "text/markdown" ? onPreviewMarkdown({ artifactId: attachment.artifactId, name: attachment.name ?? "Markdown file", mimeType: attachment.mimeType, }) : void openMobileArtifact( artifactTarget, attachment.artifactId, attachment.name ?? "File", attachment.mimeType ?? "text/plain", ).catch((err) => Alert.alert( "Could not open file", err instanceof Error ? err.message : "Try again.", ), ) : undefined } > 📎 {attachment.name ?? "File"} {attachment.size ? ( {attachment.mimeType ?? "file"} · {attachment.size} bytes ) : null} ), )} {appConnectBlocks.map((block, index) => ( ))} ); } const segments = messagePresentationSegments(message.blocks); const speaker = message.role === "bot" ? (memberName(members, message.botId) ?? botName) : undefined; const firstContent = segments.findIndex((segment) => segment.kind === "content"); const lastContent = segments.reduce( (last, segment, index) => (segment.kind === "content" ? index : last), -1, ); return ( {segments.map((segment, index) => segment.kind === "tool" ? ( ) : ( onSpeak(message) : undefined} /> ), )} {appConnectBlocks.map((block, index) => ( ))} ); }); function MessageTextCard({ message, speaker, replyPreview, onSpeak, }: { message: MobileMessage; speaker?: string; replyPreview?: MobileMessage; onSpeak?: () => void; }) { const contentText = blockText(message); if (!contentText) return null; return ( {speaker ? ( {speaker} ) : null} {replyPreview ? ( {previewMessageText(replyPreview)} ) : null} {message.role === "user" ? ( {contentText} ) : ( <> {contentText} {onSpeak ? ( Speak ) : null} )} ); } function AgentEventLabel({ label, detail, expanded, onToggle, }: { label: string; detail?: string; expanded: boolean; onToggle: () => void; }) { return ( ↔ {label} {expanded && detail ? ( {detail} ) : null} ); } function ExpandableToolBlock({ block, live, }: { block: Extract; live: boolean; }) { const [expanded, setExpanded] = useState(false); const provider = block.kind === "progress" ? /^Using\s+([^:]+)/i.exec(block.text)?.[1] : undefined; const tools = block.kind === "steps" ? block.steps.map((step) => `${step.label}${step.count > 1 ? ` ×${step.count}` : ""}`) : [ ...(provider && block.text.includes(":") ? [block.text.split(":").slice(1).join(":").trim()] : []), ...(block.pendingToolNames ?? []), ].filter(Boolean); const title = live ? "Working…" : "Actions"; return ( setExpanded((current) => !current)} style={{ flexDirection: "row", alignItems: "center", gap: 5, paddingVertical: 2, }} > {title} {expanded ? "⌃" : "⌄"} {expanded ? ( {tools.map((tool) => ( {tool.split("__").at(-1)?.replaceAll("_", " ")} ))} ) : null} ); } function AskBlock({ ask, canAnswer, onAnswer, }: { ask: Extract; canAnswer: boolean; onAnswer: (answer: string) => Promise; }) { const [answer, setAnswer] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const answered = ask.status === "answered"; const secretInput = isSecretAskBlock(ask); async function submit() { if (submitting) return; if (secretInput ? answer.length === 0 : !answer.trim()) return; const submitValue = secretInput ? answer : answer.trim(); setSubmitting(true); setError(null); try { await onAnswer(submitValue); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not send answer"); } finally { setSubmitting(false); } } return ( {ask.text} {ask.detail ? {ask.detail} : null} {answered ? ( {secretInput ? "Submitted" : `Answered: ${ask.answer ?? "Done"}`} ) : canAnswer ? ( <> void submit()} style={{ minHeight: 42, borderRadius: 12, borderWidth: 1, borderColor: "#35353A", color: "#ECECEE", paddingHorizontal: 12, paddingVertical: 9, }} /> void submit()} style={{ alignSelf: "flex-end", borderRadius: 999, backgroundColor: "#ECECEE", opacity: (secretInput ? answer.length === 0 : !answer.trim()) || submitting ? 0.5 : 1, paddingHorizontal: 16, paddingVertical: 9, }} > {submitting ? "Sending…" : "Send answer"} ) : ( Waiting for this bot’s response. )} {error ? {error} : null} ); }