import { ChatMarkdown } from "@rakazo/chat-ui/native"; import type { AgentSkillCatalogEntry, Connection, ConnectionCatalogItem, MessageBlock, Routine, } from "@rakazo/contracts"; import { canReactToThreadMessage, MESSAGE_REACTIONS, type MessageReaction, } from "@rakazo/contracts"; import { abortableDelay, attachmentsForThread, buildComposerMentionOptions, type ComposerMention, cloudAgentHttpsUrl, deriveWorkspaceFocus, isApprovalAskBlock, isRoutineEventMessage, isRunTerminalEvent, isSecretAskBlock, latestAnswerableAskMessageId, liveScreenRunId, mentionChipKey, projectMessageReactions, resolveComposerSendPlan, SLASH_ACTIONS, type SlashActionId, selectedAskActionLabel, serializeComposerPrompt, truncateSlashDescription, userVisibleMessages, } from "@rakazo/core"; import * as Clipboard from "expo-clipboard"; import { 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 { ActionSheetIOS, ActivityIndicator, Alert, AppState, FlatList, Image, Linking, Modal, type NativeScrollEvent, type NativeSyntheticEvent, Platform, Pressable, ScrollView, Text, TextInput, type TextProps, View, } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } 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 { RoutineEventLine } from "../components/routine-event-line"; import type { MobileBot, MobileGroup, MobileMessage, MobileMessagePage, MobileSnapshot, } from "../lib/api"; import { applyMobileThreadEvent, blockText, copyableMobileMessageText, currentApiBase, loadSessionToken, mergeMobileSnapshot, messageAttachments, messagingProviderLabel, prependMobileMessagePage, rpc, selectedSpaceId, selectSpace, shouldApplyMobileThreadRefresh, subscribeThread, } from "../lib/api"; import { mobileTokens } from "../lib/appearance"; import { type MobileArtifactTarget, openMobileArtifact } from "../lib/artifact-open"; import { confirmDeleteBot } from "../lib/bot-lifecycle"; import { cancelFocusPrompt, focusPromptThreadActive } from "../lib/focus-prompt"; import { dateLocaleForUi, t, useI18n } from "../lib/i18n"; import { saveLastBotId } from "../lib/last-bot"; import { dismissThreadNotifications, resumeLiveNotifications, setOpenNotificationThread, } from "../lib/live-notifications"; import { presentMessageActionSheet } from "../lib/message-action-sheet"; import { hasVisibleMessagePresentation, isCenteredAgentEvent, messagePresentationSegments, } from "../lib/message-presentation"; import { native, useMobileTokens, useResolvedAppearance } from "../lib/native"; 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 t("Answered"); const selectedAction = actions?.find((action) => action.id === answer); const outcome = selectedAction?.outcome; if (approval && outcome === "created") return t("Created"); if (approval && outcome === "cancelled") return t("Cancelled"); if (approval && answer === "allow") return t("Allowed once"); if (approval && answer === "always") return t("Always allowed"); if (approval && answer === "deny") return t("Denied"); return t("Answered: {answer}", { answer: selectedAskActionLabel(answer, actions) }); } function formatAttachmentSkip(item: { name: string; reason: string }): string { const name = item.name === "camera" ? t("Camera") : item.name; if (item.reason === "permission denied") return t("{name} (permission denied)", { name }); if (item.reason === "over 10 MiB") return t("{name} (over 10 MiB)", { name }); if (item.reason === "unsupported type") return t("{name} (unsupported type)", { name }); const maxMatch = /^max (\d+) attachments$/.exec(item.reason); if (maxMatch) { return t("{name} (max {count} attachments)", { name, count: maxMatch[1] ?? "" }); } return `${name} (${item.reason})`; } 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 tokens = useMobileTokens(); const { t } = useI18n(); 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("/")}> {t("Return to inbox")} )} ); } function Thread() { const colorScheme = useResolvedAppearance(); const tokens = mobileTokens(); const [botActionsOpen, setBotActionsOpen] = useState(false); const { t } = useI18n(); const navigation = useNavigation(); const router = useRouter(); const headerHeight = useHeaderHeight(); const insets = useSafeAreaInsets(); const keyboardVisible = useKeyboardState((state) => state.isVisible); 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 routeName = useRef(name); routeName.current = name; const mentionBotsRefreshGeneration = useRef(0); const mentionBotsAppliedGeneration = useRef(0); 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); /** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */ const [screenRunId, setScreenRunId] = 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 reactionView = useMemo( () => projectMessageReactions( userVisibleMessages(snap?.messages ?? [], { includePeerReceipts: true }).filter((message) => hasVisibleMessagePresentation(message.blocks), ), ), [snap?.messages], ); const visibleMessages = reactionView.visibleMessages; 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) => { if (!slashQueryNormalized) return true; const label = t(action.label); return ( action.label.toLowerCase().includes(slashQueryNormalized) || label.toLowerCase().includes(slashQueryNormalized) ); }) : []; const currentBot = botId ? mentionBots.find((bot) => bot.id === botId) : undefined; const displayName = currentBot?.name ?? name; 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]); const refreshMentionBots = useCallback(async () => { if (!botId && !groupId) return; const generation = ++mentionBotsRefreshGeneration.current; const targetBotId = botId; try { const bots = await rpc("bots/list"); // Apply any successful response that is still the newest applied so far. // A later failed refresh must not discard an earlier success. if (generation < mentionBotsAppliedGeneration.current) return; if (targetBotId !== activeBotId.current) return; mentionBotsAppliedGeneration.current = generation; setMentionBots(bots); if (targetBotId) { const next = bots.find((bot) => bot.id === targetBotId); // Read the route name from a ref so renaming does not recreate this // callback (and restart the SSE subscription that depends on it). if (next?.name && next.name !== routeName.current) { router.setParams({ name: next.name }); } } } catch { // Keep the last known roster if refresh fails. } }, [botId, groupId, router]); useEffect(() => { void refreshMentionBots(); void rpc("groups/list") .then(setMentionGroups) .catch(() => setMentionGroups([])); }, [refreshMentionBots]); 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: displayName || t("Thread"), headerTitle: () => ( { if (!botId || inGroup) return; router.push({ pathname: "/bot-settings", params: { botId } }); }} style={{ flexDirection: "row", alignItems: "center", gap: 10, maxWidth: 220, }} > {!inGroup && currentBot ? ( ) : null} {displayName || t("Thread")} ), headerRight: () => inGroup ? ( router.push({ pathname: "/group-settings", params: { groupId: groupId ?? "" }, }) } > ) : ( ), }); }, [ botId, currentBot, currentBotStatus, displayName, groupId, inGroup, navigation, router, t, tokens, colorScheme, ]); 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 : t("Could not clear conversation")), ); } const botActions = [ { text: t("Chat settings"), onPress: () => router.push({ pathname: "/bot-settings", params: { botId: botId ?? "" }, }), }, { text: t("Open computer"), onPress: () => router.push({ pathname: "/computer", params: { botId: botId ?? "", name: displayName ?? t("Bot") }, }), }, { text: t("Clear conversation"), destructive: true, onPress: () => Alert.alert( t("Clear conversation?"), t( "This removes every message and stops current work. The bot, computer, memory, and routines are kept.", ), [ { text: t("Cancel"), style: "cancel" }, { text: t("Clear"), style: "destructive", onPress: clearConversation }, ], ), }, { text: t("Archive"), onPress: () => void rpc("bots/archive", { botId }) .then(leaveBot) .catch((error) => Alert.alert( t("Could not archive bot"), error instanceof Error ? error.message : t("Try again."), ), ), }, { text: t("Delete…"), destructive: true, onPress: () => confirmDeleteBot({ id: botId ?? "", name: displayName || t("Bot") }, leaveBot), }, ]; function showBotActions() { if (!botId) return; if (Platform.OS === "ios") { ActionSheetIOS.showActionSheetWithOptions( { title: name || t("Bot"), userInterfaceStyle: colorScheme, options: [...botActions.map((action) => action.text), t("Cancel")], cancelButtonIndex: botActions.length, destructiveButtonIndex: botActions.flatMap((action, index) => action.destructive ? [index] : [], ), }, (index) => botActions[index]?.onPress(), ); return; } setBotActionsOpen(true); } 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 : t("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]); // Cancel delayed setup only when leaving this bot's thread (unmount or botId // change). Blur alone is not leave — settings/computer push must keep the timer. useEffect(() => { if (!botId) return; return () => { cancelFocusPrompt(botId); }; }, [botId]); // Covers returning from a pushed screen; the AppState listener covers returning from background. useFocusEffect( useCallback(() => { if (botId) { focusPromptThreadActive(botId); void saveLastBotId(botId).catch(() => undefined); } else { // Group thread focus: prior bot screen may stay mounted, so clear any delayed setup. cancelFocusPrompt(); } if (AppState.currentState === "active" && notificationThreadId) { void setOpenNotificationThread({ botId, threadId: notificationThreadId, }).catch(() => undefined); } void refreshMentionBots(); markReadIfVisible(); return () => { void setOpenNotificationThread(null).catch(() => undefined); }; }, [botId, markReadIfVisible, notificationThreadId, refreshMentionBots]), ); 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; setScreenRunId(null); 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; const screenRun = groupId ? null : liveScreenRunId(event); if (screenRun) setScreenRunId(screenRun); if ( event.type === "thread.progress" || event.type === "agent.tool.called" || event.type === "agent.tool.completed" || event.type === "thread.message.created" || event.type === "thread.message.updated" || event.type === "thread.message.reaction" || event.type === "thread.subagent" || event.type === "thread.cloud_agent" || event.type === "thread.cleared" || event.type === "run.waiting_input" || event.type === "computer.takeover.requested" || isRunTerminalEvent(event) ) { if (event.type === "thread.cleared") { expandedHistoryThread.current = null; pinnedAroundRef.current = null; historyEpoch.current += 1; } setSnap((prev) => applyMobileThreadEvent(prev, event)); } if (event.type === "bot.updated") { void refreshMentionBots(); } if (event.type === "thread.message.created" && event.payload?.role === "bot") { readVisibleTarget.current = null; markReadIfVisible(); } if (isRunTerminalEvent(event)) { void refreshMentionBots(); 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, refreshMentionBots]); 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 : t("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; const dropDelayedSetup = () => { // Only after successful engagement so a failed upload/send keeps the setup card. // Covers group-mention reroute while the bot thread stays mounted underneath. if (initialBotTarget) cancelFocusPrompt(initialBotTarget); }; 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) { dropDelayedSetup(); clearOriginComposer(); if (reroutedToGroup && groupTarget) { router.push({ pathname: "/group-thread", params: { groupId: groupTarget, name: plan.rerouteGroupName ?? t("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, }, ); dropDelayedSetup(); 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 ?? t("Group"), }, }); return; } if (isCurrentTarget(botTarget, groupTarget)) { await refresh(); } } catch (err) { if (reroutedToGroup && groupTarget) { setError(err instanceof Error ? err.message : t("Failed to send message")); } else if (isCurrentTarget(botTarget, groupTarget)) { setError(err instanceof Error ? err.message : t("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 : t("Failed to stop work")); } setSending(false); return; } try { await refresh(); } catch (err) { if (isCurrentTarget(targetBotId, targetGroupId)) { const detail = err instanceof Error ? err.message : t("Failed to refresh"); setError(t("Work stopped, but the thread could not refresh: {detail}", { 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(t("Could not speak"), err instanceof Error ? err.message : t("Try again.")), ), [botId, snap?.members], ); function showAttachMenu() { Alert.alert(t("Attach"), undefined, [ { text: t("Photo library"), onPress: () => void addAttachments(pickFromLibrary), }, { text: t("Camera"), onPress: () => void addAttachments(takePhoto) }, { text: t("File"), onPress: () => void addAttachments(pickDocuments) }, { text: t("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 ? t("Skipped {items}", { items: result.skipped.map((item) => formatAttachmentSkip(item)).join(", "), }) : null, ); } const answerableAskMessageId = latestAnswerableAskMessageId(snap); const screenFocus = useMemo(() => { const computer = snap?.computer; if (!snap || !computer || groupId) return null; const focus = deriveWorkspaceFocus( { ...snap, computer: { kind: computer.kind ?? "", state: computer.state, screenAvailable: computer.screenAvailable, takeoverRequested: computer.takeoverRequested ?? false, }, }, { screenRunId }, ); return focus.pane === "screen" ? focus : null; }, [groupId, screenRunId, 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, reaction: MessageReaction) { const targetBotId = botId; const targetGroupId = groupId; if (!targetBotId && !targetGroupId) return; try { await rpc("threads/react", { ...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }), messageId: message.id, reaction, clientNonce: newClientNonce(), }); } catch (err) { if (!isCurrentTarget(targetBotId, targetGroupId)) return; setError(err instanceof Error ? err.message : t("Could not update reaction")); } } function messageActionProps(message: MobileMessage): MessageActionProps { const actions = [ { name: "reply", text: t("Reply"), onPress: () => setReplyTarget(message) }, ...(canReactToThreadMessage(message) ? [ { name: "react", text: t("React"), onPress: () => presentMessageActionSheet({ cancel: t("Cancel"), more: t("More"), colorScheme, actions: MESSAGE_REACTIONS.map((emoji) => ({ name: emoji, text: emoji, onPress: () => void reactToMessage(message, emoji), })), }), }, ] : []), ...(message.role === "bot" && blockText(message) ? [{ name: "speak", text: t("Speak message"), onPress: () => void speak(message) }] : []), { name: "copy", text: t("Copy"), onPress: () => { const text = copyableMobileMessageText(message); if (text) void Clipboard.setStringAsync(text).catch(() => undefined); }, }, ]; return { onLongPress: () => presentMessageActionSheet({ actions, title: message.createdAt ? new Date(message.createdAt).toLocaleTimeString(dateLocaleForUi(), { hour: "numeric", minute: "2-digit", }) : undefined, cancel: t("Cancel"), more: t("More"), colorScheme, }), accessibilityActions: actions.map((action) => ({ name: action.name, label: action.text })), onAccessibilityAction: (event) => { actions.find((action) => action.name === event.nativeEvent.actionName)?.onPress(); }, }; } function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) { const actionProps = messageActionProps(message); const messageReactions = reactionView.reactions.get(message.id); const activityBotId = !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} {messageReactions ? ( {[...messageReactions].map(([emoji, count]) => ( {emoji} {count > 1 ? ` ${count}` : ""} ))} ) : null} ); } const workingFooter = !inGroup && currentBot && isWorkingStatus(currentBotStatus) && !hasLiveProgress ? ( ) : inGroup && workingGroupBots.length > 0 ? ( {workingGroupBots.map((bot, index) => ( ))} ) : null; const loadEarlierControl = snap?.olderCursor != null ? ( void loadOlderMessages()} style={{ alignSelf: "center", paddingHorizontal: 12, paddingVertical: 10, }} > {loadingOlder ? t("Loading…") : t("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: native.fillPressed, backgroundColor: native.fill, alignItems: "center", justifyContent: "center", }} > {threadScrollState.unread ? ( ) : null} ) : null} {screenFocus && botId ? ( router.push({ pathname: "/computer", params: { botId, name: displayName ?? t("Bot") }, }) } style={({ pressed }) => ({ alignSelf: "center", marginTop: 10, flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 999, borderWidth: 1, borderColor: native.fillPressed, backgroundColor: pressed ? native.fillPressed : native.fill, paddingHorizontal: 14, paddingVertical: 8, })} > {screenFocus.takeover ? t("Needs takeover") : t("Open computer")} ) : null} {replyTarget ? ( {t("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, }} > {t(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 : displayName ? t("Message {name}", { name: displayName }) : t("Message…") } placeholderTextColor={tokens.mutedForeground} keyboardAppearance={colorScheme} multiline textAlignVertical="center" blurOnSubmit={false} style={{ flexGrow: 1, flexShrink: 1, minWidth: 96, color: tokens.foreground, paddingVertical: 2, maxHeight: 100, writingDirection: "auto", }} /> void send()} style={{ backgroundColor: tokens.primary, borderRadius: 22, width: 44, height: 44, alignItems: "center", justifyContent: "center", opacity: sending || !canSend ? 0.5 : 1, }} > {working ? ( void stop()} style={{ borderColor: tokens.border, borderWidth: 1, borderRadius: 22, width: 44, height: 44, alignItems: "center", justifyContent: "center", opacity: sending ? 0.5 : 1, }} > ) : null} setBotActionsOpen(false)} > setBotActionsOpen(false)} style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }} /> {botActions.map((action) => ( { setBotActionsOpen(false); action.onPress(); }} style={{ minHeight: 56, justifyContent: "center", paddingHorizontal: 24 }} > {action.text} ))} {markdownPreview && artifactTarget ? ( setMarkdownPreview(null)} /> ) : null} ); } function MentionOptionIcon({ mention }: { mention: ComposerMention }) { const tokens = useMobileTokens(); 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 }) { const tokens = useMobileTokens(); 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.transport)} · ${block.fromLabel}: ${block.text}`, ]; } return block.kind === "text" && block.text ? [block.text] : []; }) .join(" ") .trim(); if (text) return text; if ( message.blocks.some((block) => ["image", "file", "video", "audio", "media"].includes(block.kind), ) ) { return t("Attachment"); } return t("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(t("Add a voice provider in Voice settings.")); } } type MessageActionProps = Pick< TextProps, "onLongPress" | "accessibilityActions" | "onAccessibilityAction" >; const MessageBubble = memo(function MessageBubble({ botId, botName, bots, groupId, message, members, replyPreview, canAnswer, onAnswer, onOpenBot, onPreviewMarkdown, actionProps, }: { 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; actionProps: MessageActionProps; }) { const colorScheme = useResolvedAppearance(); const tokens = mobileTokens(); const { t } = useI18n(); 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) => ( ))} ); } if (isRoutineEventMessage(message.blocks)) { return ( {message.blocks.map((block, index) => block.kind === "event" ? : null, )} ); } const handoff = message.blocks.find((block) => block.kind === "handoff"); if (handoff) { const from = memberName(members, handoff.fromBotId) ?? t("bot"); const to = memberName(members, handoff.toBotId) ?? t("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 ? t("Messaged {peer}", { peer: peer ?? t("Bot") }) : t("Message from {peer}", { peer: peer ?? t("Bot") }); const peerColor = bots.find((bot) => bot.id === peerBotId)?.color ?? members?.find((member) => member.botId === peerBotId)?.color ?? tokens.mutedForeground; // 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.transport)} ·{" "} {channelMessage.fromLabel}: {channelMessage.text} ); } const special = message.blocks.find( (block) => block.kind === "subagent" || block.kind === "child_bot" || block.kind === "cloud_agent", ); if (special?.kind === "subagent") { const running = special.status === "running"; const failed = special.status === "failed"; return ( {special.name || t("subagent")} {running ? t("Running") : special.status === "failed" ? t("Failed") : special.status === "completed" ? t("Completed") : special.status} {special.task ? ( {special.task} ) : null} {special.result || special.progress ? ( {special.result || special.progress || ""} ) : null} ); } if (special?.kind === "cloud_agent") { const title = special.title || t("Cloud agent"); const statusLabel = special.status === "running" ? t("running") : special.status === "finished" ? t("finished") : special.status === "cancelled" ? t("cancelled") : t("failed"); const running = special.status === "running"; const failed = special.status === "failed" || special.status === "cancelled"; const prHref = cloudAgentHttpsUrl(special.prUrl); const href = prHref ?? cloudAgentHttpsUrl(special.url); return ( { if (href) Linking.openURL(href).catch(() => undefined); }} testID="cloud-agent-card" accessibilityRole={href ? "link" : "text"} accessibilityLabel={`${title}: ${statusLabel}`} disabled={!href} style={{ width: "90%", borderRadius: 18, borderWidth: 1, borderColor: tokens.border, backgroundColor: tokens.card, paddingHorizontal: 16, paddingVertical: 14, }} > {title} {statusLabel} {prHref ? ( {t("Pull request")} ) : special.branch ? ( {special.branch} ) : null} ); } if (special?.kind === "child_bot") { const removed = special.status === "deleted" || special.status === "archived"; return ( onOpenBot(special.botId ?? "", special.name ?? t("Bot")) } style={{ width: "90%", borderRadius: 18, borderWidth: 1, borderColor: tokens.border, backgroundColor: tokens.card, paddingHorizontal: 16, paddingVertical: 14, opacity: removed ? 0.6 : 1, }} > {special.name || t("Bot")} {special.status === "archived" ? t("archived") : special.status === "deleted" ? t("deleted") : t("bot")} {removed ? special.status === "archived" ? t("Archived. Chat, memory, and files kept.") : t("Removed with chat, computer, and memory.") : special.title || t("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)} /> ) : ( {t("No longer active")} )} {appConnectBlocks.map((block, index) => ( ))} ); } const attachments = messageAttachments(message.blocks); const caption = message.blocks .flatMap((block) => { if (block.kind === "channel_message" && block.text) { return [ `${messagingProviderLabel(block.provider, block.transport)} · ${block.fromLabel}: ${block.text}`, ]; } if (block.kind === "media") return [block.title]; 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 || (message.replyToMessageId && message.replyQuote) ? ( {message.replyQuote ? `“${message.replyQuote}”` : replyPreview ? previewMessageText(replyPreview) : ""} ) : null} {caption ? ( {caption} ) : null} {attachments.map((attachment, index) => attachment.kind === "image" ? ( attachment.artifactId ? void openMobileArtifact( artifactTarget, attachment.artifactId, attachment.name ?? t("Image"), attachment.mimeType ?? "image/png", ).catch((err) => Alert.alert( t("Could not open image"), err instanceof Error ? err.message : t("Try again."), ), ) : undefined } > 🖼 {attachment.name ?? t("Image")} ) : ( attachment.artifactId ? attachment.mimeType === "text/markdown" ? onPreviewMarkdown({ artifactId: attachment.artifactId, name: attachment.name ?? t("Markdown file"), mimeType: attachment.mimeType, }) : void openMobileArtifact( artifactTarget, attachment.artifactId, attachment.name ?? t("File"), attachment.mimeType ?? "text/plain", ).catch((err) => Alert.alert( t("Could not open file"), err instanceof Error ? err.message : t("Try again."), ), ) : undefined } > {attachment.kind === "video" ? "🎬" : attachment.kind === "audio" ? "🎵" : "📎"}{" "} {attachment.name ?? t("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"); return ( {segments.map((segment, index) => ( ))} {appConnectBlocks.map((block, index) => ( ))} ); }); function MessageTextCard({ message, speaker, replyPreview, actionProps, }: { message: MobileMessage; speaker?: string; replyPreview?: MobileMessage; actionProps: MessageActionProps; }) { const colorScheme = useResolvedAppearance(); const tokens = mobileTokens(); const contentText = blockText(message); if (!contentText) return null; return ( {speaker ? ( {speaker} ) : null} {replyPreview || (message.replyToMessageId && message.replyQuote) ? ( {message.replyQuote ? `“${message.replyQuote}”` : replyPreview ? previewMessageText(replyPreview) : ""} ) : null} {message.role === "user" ? ( {contentText} ) : ( {contentText} )} ); } function AgentEventLabel({ label, detail, expanded, onToggle, actionProps, }: { label: string; detail?: string; expanded: boolean; onToggle: () => void; actionProps: MessageActionProps; }) { const colorScheme = useResolvedAppearance(); const tokens = mobileTokens(); const { t } = useI18n(); return ( ↔ {label} {expanded && detail ? ( {detail} ) : null} ); } function AskBlock({ ask, canAnswer, onAnswer, actionProps, }: { ask: Extract; canAnswer: boolean; onAnswer: (answer: string) => Promise; actionProps: MessageActionProps; }) { const tokens = useMobileTokens(); const { t } = useI18n(); const [answer, setAnswer] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const answered = ask.status === "answered"; const secretInput = isSecretAskBlock(ask); const secretLabel = ask.purpose === "password" ? t("Password") : ask.purpose === "api_key" ? t("API key") : t("Code"); const submitLabel = secretInput ? t("Save") : t("Send answer"); const submittingLabel = secretInput ? t("Saving…") : t("Sending…"); async function submit() { if (submitting) return; if (secretInput ? answer.length === 0 : !answer.trim()) return; const submitValue = secretInput ? answer : answer.trim(); setSubmitting(true); setError(null); if (secretInput) setAnswer(""); try { await onAnswer(submitValue); } catch (cause) { setError(!secretInput && cause instanceof Error ? cause.message : t("Could not send answer")); } finally { setSubmitting(false); } } return ( {ask.text} {secretInput && ask.credential ? ( {ask.credential.origin} ) : null} {ask.detail && !secretInput ? ( {ask.detail} ) : null} {answered ? ( {secretInput ? t("Saved") : t("Answered: {answer}", { answer: ask.answer ?? t("Done") })} ) : canAnswer ? ( <> void submit()} style={{ minHeight: 42, borderRadius: 12, borderWidth: 1, borderColor: tokens.border, color: tokens.foreground, paddingHorizontal: 12, paddingVertical: 9, }} /> void submit()} style={{ alignSelf: "flex-end", borderRadius: 999, backgroundColor: tokens.foreground, opacity: (secretInput ? answer.length === 0 : !answer.trim()) || submitting ? 0.5 : 1, paddingHorizontal: 16, paddingVertical: 9, }} > {submitting ? submittingLabel : submitLabel} ) : ( {t("Waiting for this bot’s response.")} )} {error ? {error} : null} ); }