BangSo/apps/mobile/app/thread.tsx

2685 lines
88 KiB
TypeScript
Raw Normal View History

2026-09-01 16:51:19 +00:00
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<Extract<MessageBlock, { kind: "ask" }>["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<NotificationRouteState>(() => {
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 <Thread />;
return (
<View
style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#000" }}
>
{routeState === "loading" ? (
<ActivityIndicator color="#ECECEE" />
) : (
<Pressable accessibilityRole="button" onPress={() => router.replace("/")}>
<Text style={{ color: "#ECECEE", fontSize: 16 }}>Return to inbox</Text>
</Pressable>
)}
</View>
);
}
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<FlatList<MobileMessage>>(null);
const pinnedScroll = useRef<ScrollView>(null);
const scrollBehavior = useRef(new ThreadScrollBehavior());
const userDragging = useRef(false);
const loadingOlderContent = useRef(false);
const expandedHistoryThread = useRef<string | null>(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<string | null>(null);
const activeBotId = useRef(botId);
activeBotId.current = botId;
const activeGroupId = useRef(groupId);
activeGroupId.current = groupId;
const readVisibleTarget = useRef<string | null>(null);
const threadKey = groupId ?? botId;
const [threadScrollState, setThreadScrollState] = useState<ThreadScrollState>(() =>
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<MobileSnapshot | null>(null);
const activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
const [mentionBots, setMentionBots] = useState<MobileBot[]>([]);
const [mentionGroups, setMentionGroups] = useState<MobileGroup[]>([]);
const [mentionRoutines, setMentionRoutines] = useState<Array<Routine & { botName?: string }>>([]);
const [mentionConnectors, setMentionConnectors] = useState<
Array<{
id: string;
name: string;
authStatus: "connected" | "needs_auth";
connectionId?: string;
}>
>([]);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [markdownPreview, setMarkdownPreview] = useState<MarkdownArtifactPreviewTarget | null>(
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<string>();
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<AgentSkillCatalogEntry[]>("agentSkills/list")
.then(setAgentSkills)
.catch(() => setAgentSkills([]));
}, []);
useEffect(() => {
setThreadScrollState(scrollBehavior.current.state());
}, [threadKey]);
useEffect(() => {
void rpc<MobileBot[]>("bots/list")
.then(setMentionBots)
.catch(() => setMentionBots([]));
void rpc<MobileGroup[]>("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<Routine[]>("routines/list", { botId: bot.id })
.then((rows) =>
rows.map((routine) => ({
...routine,
botName: botNameById.get(bot.id) ?? bot.name,
})),
)
.catch(() => [] as Array<Routine & { botName?: string }>),
),
).then((lists) => {
if (!cancelled) setMentionRoutines(lists.flat());
});
void Promise.all([
rpc<Connection[]>("connections/list").catch(() => [] as Connection[]),
rpc<ConnectionCatalogItem[]>("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: () => (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 10,
maxWidth: 220,
}}
>
{!inGroup && currentBot ? (
<BotAvatar
color={currentBot.color}
identity={currentBot.id}
size={34}
status={currentBotStatus}
muted={!currentBot.notifyOnFinish}
shape={currentBot.avatarShape}
imageSrc={botAvatarImageSrc(currentBot)}
/>
) : null}
<Text numberOfLines={1} style={{ color: "#ECECEE", fontSize: 18, fontWeight: "600" }}>
{name || "Thread"}
</Text>
</View>
),
headerRight: () =>
inGroup ? (
<Pressable
accessibilityLabel="Group settings"
hitSlop={8}
onPress={() =>
router.push({
pathname: "/group-settings",
params: { groupId: groupId ?? "" },
})
}
>
<NativeSymbol ios="gearshape" android="settings-outline" size={21} color="#ECECEE" />
</Pressable>
) : (
<Pressable accessibilityLabel="Bot actions" hitSlop={8} onPress={showBotActions}>
<NativeSymbol ios="ellipsis" android="ellipsis-horizontal" size={21} color="#ECECEE" />
</Pressable>
),
});
}, [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<MobileSnapshot>(
"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<MobileSnapshot>("threads/get", threadTarget),
rpc<MobileMessagePage>("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<MobileMessagePage>("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<MobileSnapshot>("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<typeof setTimeout> | 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<NativeScrollEvent>) {
// 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 (
<View
key={message.id}
onLayout={
options?.enableJump
? (event) => {
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 ? (
<View style={{ paddingTop: 22 }}>
<BotAvatar
color={activityBot?.color ?? "#85858A"}
identity={activityBotId}
size={inGroup ? 20 : 28}
status={activityStatus}
shape={
activityBot && "avatarShape" in activityBot ? activityBot.avatarShape : undefined
}
imageSrc={
activityBot
? botAvatarImageSrc({
id: activityBotId,
hasAvatarImage:
"hasAvatarImage" in activityBot ? activityBot.hasAvatarImage : undefined,
updatedAt: "updatedAt" in activityBot ? activityBot.updatedAt : undefined,
})
: undefined
}
/>
</View>
) : null}
<View
style={{
width: isCenteredAgentEvent(message.blocks) ? "100%" : undefined,
maxWidth: isCenteredAgentEvent(message.blocks)
? "100%"
: activityBotId
? undefined
: "90%",
flex: activityBotId ? 1 : undefined,
flexShrink: 1,
}}
>
<View
style={{
alignSelf: message.role === "user" ? "flex-end" : "flex-start",
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 4,
}}
>
<Pressable accessibilityLabel="Reply" onPress={() => setReplyTarget(message)}>
<Text style={{ color: "#6C6C70", fontSize: 12 }}>Reply</Text>
</Pressable>
{canReactToThreadMessage(message) ? (
<Pressable
accessibilityLabel={message.thumbsUp ? "Remove thumbs-up" : "Add thumbs-up"}
accessibilityState={{ selected: Boolean(message.thumbsUp) }}
onPress={() => void reactToMessage(message)}
>
<Text style={{ color: message.thumbsUp ? "#E9C46A" : "#6C6C70", fontSize: 13 }}>
👍
</Text>
</Pressable>
) : null}
</View>
<MessageBubble
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
groupId={groupId}
message={message}
botName={name}
bots={mentionBots}
members={snap?.members}
replyPreview={
message.replyToMessageId ? messagesById.get(message.replyToMessageId) : undefined
}
canAnswer={message.id === answerableAskMessageId}
onAnswer={answerMessage}
onOpenBot={openBot}
onPreviewMarkdown={setMarkdownPreview}
onSpeak={message.role === "bot" ? speak : undefined}
/>
</View>
</View>
);
}
const workingFooter =
!inGroup && currentBot && isWorkingStatus(currentBotStatus) && !hasLiveProgress ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 10,
minHeight: 40,
marginTop: 12,
}}
>
<BotAvatar
color={currentBot.color}
identity={currentBot.id}
size={28}
status={currentBotStatus}
shape={currentBot.avatarShape}
imageSrc={botAvatarImageSrc(currentBot)}
/>
<Text style={{ color: "#85858A", fontSize: 13.5 }}>{currentBot.name} is working</Text>
</View>
) : inGroup && workingGroupBots.length > 0 ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 10,
minHeight: 40,
marginTop: 12,
}}
>
<View style={{ flexDirection: "row", paddingRight: 8 }}>
{workingGroupBots.map((bot, index) => (
<View
key={bot.botId}
style={{
marginLeft: index === 0 ? 0 : -8,
zIndex: workingGroupBots.length - index,
}}
>
<BotAvatar
color={bot.color}
identity={bot.botId}
size={28}
status={bot.status}
shape={bot.avatarShape}
imageSrc={botAvatarImageSrc({
id: bot.botId,
hasAvatarImage: bot.hasAvatarImage,
updatedAt: bot.updatedAt,
})}
/>
</View>
))}
</View>
<Text style={{ color: "#85858A", fontSize: 13.5, flexShrink: 1 }}>
{workingGroupBots.length === 1
? `${workingGroupBots[0]?.name ?? "Agent"} is working`
: `${workingGroupBots.length} agents working`}
</Text>
</View>
) : null;
const loadEarlierControl =
snap?.olderCursor != null ? (
<Pressable
disabled={loadingOlder}
onPress={() => void loadOlderMessages()}
style={{
alignSelf: "center",
paddingHorizontal: 12,
paddingVertical: 10,
}}
>
<Text style={{ color: "#85858A", fontSize: 13 }}>
{loadingOlder ? "Loading…" : "Load earlier messages"}
</Text>
</Pressable>
) : null;
return (
<KeyboardAvoidingView
behavior="height"
keyboardVerticalOffset={headerHeight}
style={{ flex: 1, backgroundColor: "#000", paddingHorizontal: 20 }}
>
{error ? <Text style={{ color: "#8E8E93", marginTop: 12 }}>{error}</Text> : null}
{runError ? <Text style={{ color: "#E65707", marginTop: 12 }}>{runError}</Text> : null}
<View style={{ flex: 1, position: "relative" }}>
{showPinnedPage ? (
<ScrollView
key={jumpScrollTarget.current ?? pinnedTarget?.messageId ?? threadKey}
ref={pinnedScroll}
style={{ flex: 1, marginTop: 8 }}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
>
{loadEarlierControl}
{visibleMessages.map((message) => renderMessageRow(message, { enableJump: true }))}
{workingFooter}
</ScrollView>
) : (
<FlatList
key={threadKey}
ref={scroll}
data={liveMessages}
inverted
keyExtractor={(message) => 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 ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={
threadScrollState.unread ? "Jump to latest, new messages" : "Jump to latest"
}
onPress={() => {
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",
}}
>
<NativeSymbol ios="arrow.down" android="arrow-down" size={18} color="#ECECEE" />
{threadScrollState.unread ? (
<View
style={{
position: "absolute",
top: 3,
right: 3,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "#4C8DFF",
}}
/>
) : null}
</Pressable>
) : null}
</View>
<View style={{ paddingBottom: Math.max(insets.bottom + 12, 24) }}>
{replyTarget ? (
<View
style={{
marginTop: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: "#26262A",
backgroundColor: "#17171A",
paddingHorizontal: 12,
paddingVertical: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<View style={{ flex: 1 }}>
<Text style={{ color: "#85858A", fontSize: 12 }}>Replying to</Text>
<Text style={{ color: "#C9C9CE", fontSize: 13 }} numberOfLines={1}>
{previewMessageText(replyTarget)}
</Text>
</View>
<Pressable accessibilityLabel="Cancel reply" onPress={() => setReplyTarget(null)}>
<Text style={{ color: "#85858A" }}></Text>
</Pressable>
</View>
) : null}
{attachmentNotice ? (
<Text style={{ color: "#D6CFA0", marginTop: 12, fontSize: 13 }}>{attachmentNotice}</Text>
) : null}
{activePendingAttachments.length ? (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
marginTop: 12,
}}
>
{activePendingAttachments.map((attachment) => (
<View
key={attachment.id}
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: "#26262A",
backgroundColor: "#17171A",
paddingHorizontal: 12,
paddingVertical: 8,
}}
>
{attachment.previewUri ? (
<Image
source={{ uri: attachment.previewUri }}
style={{ width: 28, height: 28, borderRadius: 6 }}
/>
) : (
<Text style={{ color: "#C9C9CE" }}>📎</Text>
)}
<Text style={{ color: "#C9C9CE", maxWidth: 140 }} numberOfLines={1}>
{attachment.name}
</Text>
<Pressable
accessibilityLabel={`Remove ${attachment.name}`}
onPress={() =>
setPendingAttachments((current) =>
current.filter((item) => item.id !== attachment.id),
)
}
>
<NativeSymbol ios="xmark" android="close" size={14} color="#85858A" />
</Pressable>
</View>
))}
</View>
) : null}
{mentionOptions.length ? (
<View
testID="mention-picker"
style={{
marginTop: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: "#26262A",
backgroundColor: "#17171A",
overflow: "hidden",
}}
>
{mentionOptions.map((mention) => (
<Pressable
key={mentionChipKey(mention)}
accessibilityLabel={`@${mention.name}`}
onPress={() => insertMention(mention)}
style={{
flexDirection: "row",
alignItems: "flex-start",
gap: 10,
paddingHorizontal: 14,
paddingVertical: 10,
}}
>
<MentionOptionIcon mention={mention} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: "#ECECEE", fontSize: 14 }}>@{mention.name}</Text>
{mention.subtitle ? (
<Text
numberOfLines={1}
style={{
color: "#85858A",
fontSize: 12.5,
marginTop: 2,
}}
>
{mention.subtitle}
</Text>
) : null}
</View>
</Pressable>
))}
</View>
) : null}
{slashSkillOptions.length || slashActionOptions.length ? (
<View
testID="slash-picker"
style={{
marginTop: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: "#26262A",
backgroundColor: "#17171A",
overflow: "hidden",
}}
>
{slashSkillOptions.map((skill) => (
<Pressable
key={skill.id}
accessibilityLabel={`Skill ${skill.name}`}
onPress={() => insertSkill(skill)}
style={{
flexDirection: "row",
alignItems: "flex-start",
gap: 10,
paddingHorizontal: 14,
paddingVertical: 10,
}}
>
<NativeSymbol ios="cube" android="cube-outline" size={16} color="#9A9AA0" />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: "#ECECEE", fontSize: 14 }}>{skill.name}</Text>
<Text
numberOfLines={1}
style={{ color: "#85858A", fontSize: 12.5, marginTop: 2 }}
>
{truncateSlashDescription(skill.description)}
</Text>
</View>
</Pressable>
))}
{slashActionOptions.map((action) => (
<Pressable
key={action.id}
accessibilityLabel={action.label}
onPress={() => runSlashAction(action.id)}
style={{
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 14,
paddingVertical: 10,
}}
>
<NativeSymbol
ios="gearshape"
android="settings-outline"
size={16}
color="#9A9AA0"
/>
<Text style={{ color: "#ECECEE", fontSize: 14 }}>{action.label}</Text>
</Pressable>
))}
</View>
) : null}
<View
style={{
flexDirection: "row",
gap: 8,
marginTop: 16,
alignItems: "flex-end",
}}
>
<Pressable
accessibilityLabel="Attach file"
onPress={showAttachMenu}
style={{
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 1,
borderColor: "#26262A",
alignItems: "center",
justifyContent: "center",
}}
>
<NativeSymbol ios="plus" android="add" size={18} color="#9A9AA0" />
</Pressable>
<View
style={{
flex: 1,
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
gap: 6,
backgroundColor: "#131315",
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 8,
minHeight: 44,
}}
>
{selectedSkill ? (
<View
testID="skill-chip"
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: "#1C1C1F",
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 5,
maxWidth: "100%",
}}
>
<NativeSymbol ios="cube" android="cube-outline" size={13} color="#B0B0B6" />
<Text numberOfLines={1} style={{ color: "#ECECEE", fontSize: 13, flexShrink: 1 }}>
{selectedSkill.name}
</Text>
<Pressable
accessibilityLabel={`Remove skill ${selectedSkill.name}`}
hitSlop={8}
onPress={() => setSelectedSkill(null)}
>
<NativeSymbol ios="xmark" android="close" size={12} color="#85858A" />
</Pressable>
</View>
) : null}
{selectedMentions.map((mention) => (
<View
key={mentionChipKey(mention)}
testID="mention-chip"
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: "#1C1C1F",
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 5,
maxWidth: "100%",
}}
>
<MentionChipIcon mention={mention} />
<Text numberOfLines={1} style={{ color: "#ECECEE", fontSize: 13, flexShrink: 1 }}>
{mention.name}
</Text>
<Pressable
accessibilityLabel={`Remove mention ${mention.name}`}
hitSlop={8}
onPress={() =>
setSelectedMentions((current) =>
current.filter(
(selected) => mentionChipKey(selected) !== mentionChipKey(mention),
),
)
}
>
<NativeSymbol ios="xmark" android="close" size={12} color="#85858A" />
</Pressable>
</View>
))}
<TextInput
value={draft}
onChangeText={updateDraft}
accessibilityLabel={name ? `Message ${name}` : "Message"}
onKeyPress={(event) => {
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",
}}
/>
</View>
<Pressable
accessibilityLabel="Send"
disabled={sending || !canSend}
onPress={() => void send()}
style={{
backgroundColor: "#F1F1EF",
borderRadius: 22,
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
opacity: sending || !canSend ? 0.5 : 1,
}}
>
<NativeSymbol ios="arrow.up" android="arrow-up" size={18} color="#17171A" />
</Pressable>
{working ? (
<Pressable
accessibilityLabel="Stop"
disabled={sending}
onPress={() => void stop()}
style={{
borderColor: "#34343A",
borderWidth: 1,
borderRadius: 22,
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
opacity: sending ? 0.5 : 1,
}}
>
<NativeSymbol ios="stop.fill" android="stop" size={15} color="#C9C9CE" />
</Pressable>
) : null}
</View>
{!inGroup ? (
<Link
href={{
pathname: "/computer",
params: { botId: botId ?? "", name: name ?? "Bot" },
}}
asChild
>
<Pressable style={{ marginTop: 16 }}>
<Text style={{ color: "#C9C9CE" }}>Open computer </Text>
</Pressable>
</Link>
) : null}
</View>
{markdownPreview && artifactTarget ? (
<MarkdownArtifactPreview
threadTarget={artifactTarget}
target={markdownPreview}
onClose={() => setMarkdownPreview(null)}
/>
) : null}
</KeyboardAvoidingView>
);
}
function MentionOptionIcon({ mention }: { mention: ComposerMention }) {
if (mention.kind === "routine") {
return <NativeSymbol ios="clock" android="time-outline" size={16} color="#9A9AA0" />;
}
if (mention.kind === "connector") {
return (
<NativeSymbol
ios="puzzlepiece.extension"
android="extension-puzzle-outline"
size={16}
color="#9A9AA0"
/>
);
}
if (mention.kind === "group") {
return (
<View
style={{
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: "#2A2A2E",
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "#C9C9CE", fontSize: 9 }}>G</Text>
</View>
);
}
if (mention.kind === "everyone") {
return (
<View
style={{
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: "#2A2A2E",
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "#C9C9CE", fontSize: 9 }}>@</Text>
</View>
);
}
return (
<View
style={{
width: 16,
height: 16,
borderRadius: 4,
backgroundColor: mention.color ?? "#85858A",
}}
/>
);
}
function MentionChipIcon({ mention }: { mention: ComposerMention }) {
if (mention.kind === "routine") {
return <NativeSymbol ios="clock" android="time-outline" size={13} color="#B0B0B6" />;
}
if (mention.kind === "connector") {
return (
<NativeSymbol
ios="puzzlepiece.extension"
android="extension-puzzle-outline"
size={13}
color="#B0B0B6"
/>
);
}
if (mention.kind === "group" || mention.kind === "everyone") {
return (
<View
style={{
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: "#2A2A2E",
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "#C9C9CE", fontSize: 9 }}>
{mention.kind === "group" ? "G" : "@"}
</Text>
</View>
);
}
return (
<View
style={{
width: 14,
height: 14,
borderRadius: 4,
backgroundColor: mention.color ?? "#85858A",
}}
/>
);
}
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<void>;
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<MessageBlock, { kind: "app_connect" }> =>
block.kind === "app_connect",
);
const ask = message.blocks.find(
(block): block is Extract<MessageBlock, { kind: "ask" }> =>
block.kind === "ask" && !isApprovalAskBlock(block) && !block.actions?.length,
);
if (ask) {
return (
<View style={{ gap: 8, width: "100%" }}>
<AskBlock
ask={ask}
canAnswer={canAnswer}
onAnswer={(answer) => onAnswer(message, answer)}
/>
{appConnectBlocks.map((block, index) => (
<AppConnectCard key={`${block.provider}-${index}`} botId={cardBotId} block={block} />
))}
</View>
);
}
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 (
<AgentEventLabel
label={`${from} messaged ${to}`}
detail={handoff.text}
expanded={peerExpanded}
onToggle={() => setPeerExpanded((expanded) => !expanded)}
/>
);
}
const peerMessage = message.blocks.find(
(
block,
): block is Extract<MessageBlock, { kind: "bot_message_sent" | "bot_message_received" }> =>
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 (
<View
accessible
accessibilityLabel={label}
style={{
width: "100%",
paddingVertical: 4,
alignItems: "center",
justifyContent: "flex-start",
flexDirection: "row",
gap: 6,
}}
>
<BotAvatar color={peerColor} identity={peerBotId} size={16} />
<Text numberOfLines={1} style={{ color: "#85858A", fontSize: 13.5, flexShrink: 1 }}>
{label}
</Text>
</View>
);
}
const channelMessage = message.blocks.find(
(block): block is Extract<MessageBlock, { kind: "channel_message" }> =>
block.kind === "channel_message",
);
if (channelMessage) {
return (
<View style={{ width: "100%", paddingVertical: 4, alignItems: "center" }}>
<Text style={{ color: "#85858A", fontSize: 13.5, textAlign: "center" }}>
{messagingProviderLabel(channelMessage.provider)} · {channelMessage.fromLabel}:{" "}
{channelMessage.text}
</Text>
</View>
);
}
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 (
<View
style={{
width: "90%",
borderRadius: 18,
borderWidth: 1,
borderColor: "#232326",
backgroundColor: "#17171A",
paddingHorizontal: 16,
paddingVertical: 14,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: "#ECECEE", fontSize: 15, fontWeight: "600" }}>
{special.name || "subagent"}
</Text>
<Text
style={{
color: failed ? "#E65707" : running ? "#F5A03C" : "#4ECB71",
fontSize: 13,
}}
>
{running ? "subagent" : special.status}
</Text>
</View>
{special.task ? (
<Text style={{ color: "#85858A", marginTop: 8, fontSize: 13.5 }}>{special.task}</Text>
) : null}
{special.result || special.progress ? (
<View style={{ marginTop: 8 }}>
<ChatMarkdown streaming={running}>
{special.result || special.progress || ""}
</ChatMarkdown>
</View>
) : null}
</View>
);
}
if (special?.kind === "child_bot") {
const removed = special.status === "deleted" || special.status === "archived";
return (
<Pressable
disabled={removed}
onPress={() => 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,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: "#ECECEE", fontSize: 15, fontWeight: "600" }}>
{special.name || "Bot"}
</Text>
<Text style={{ color: removed ? "#E65707" : "#4ECB71", fontSize: 13 }}>
{special.status === "archived"
? "archived"
: special.status === "deleted"
? "deleted"
: "bot"}
</Text>
</View>
<Text
style={{
color: "#A8A8AD",
marginTop: 8,
fontSize: 14.5,
lineHeight: 21,
}}
>
{removed
? special.status === "archived"
? "Archived. Chat, memory, and files kept."
: "Removed with chat, computer, and memory."
: special.title || "Opened its thread."}
</Text>
</Pressable>
);
}
if (appConnectBlocks.length > 0 && appConnectBlocks.length === message.blocks.length) {
return (
<View style={{ gap: 8, width: "100%" }}>
{appConnectBlocks.map((block, index) => (
<AppConnectCard key={`${block.provider}-${index}`} botId={cardBotId} block={block} />
))}
</View>
);
}
const askBlock = message.blocks.find(
(block) => block.kind === "ask" && Boolean(block.actions?.length),
);
if (askBlock?.kind === "ask" && askBlock.actions?.length) {
return (
<View style={{ gap: 8, width: "100%" }}>
<View
style={{
width: "90%",
borderRadius: 18,
borderWidth: 1,
borderColor: "#232326",
backgroundColor: "#17171A",
paddingHorizontal: 16,
paddingVertical: 14,
}}
>
{askBlock.text ? (
<Text style={{ color: "#ECECEE", fontSize: 15.5, lineHeight: 23 }}>
{askBlock.text}
</Text>
) : null}
{askBlock.detail ? (
<Text
style={{
color: "#85858A",
marginTop: 8,
fontSize: 12.5,
fontFamily: "Menlo",
lineHeight: 20,
}}
>
{askBlock.detail}
</Text>
) : null}
{askBlock.status === "answered" ? (
<Text
style={{
color: "#4ECB71",
marginTop: 12,
fontSize: 13.5,
fontWeight: "600",
}}
>
{formatApprovalAnswer(
askBlock.answer,
askBlock.actions,
isApprovalAskBlock(askBlock),
)}
</Text>
) : canAnswer && onAnswer ? (
<AskActions
actions={askBlock.actions}
onAnswer={(answer) => onAnswer(message, answer)}
/>
) : (
<Text style={{ color: "#85858A", marginTop: 12, fontSize: 13.5 }}>
No longer active
</Text>
)}
</View>
{appConnectBlocks.map((block, index) => (
<AppConnectCard key={`${block.provider}-${index}`} botId={cardBotId} block={block} />
))}
</View>
);
}
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 (
<View
style={{
maxWidth: "100%",
borderRadius: 20,
borderWidth: 1,
borderColor: "#26262A",
backgroundColor: message.role === "user" ? "#F1F1EF" : "#1A1A1D",
paddingHorizontal: 14,
paddingVertical: 12,
gap: 8,
}}
>
{speaker ? (
<Text style={{ color: "#85858A", fontSize: 12.5, fontWeight: "600" }}>{speaker}</Text>
) : null}
{replyPreview ? (
<Text style={{ color: "#85858A", fontSize: 12.5 }} numberOfLines={2}>
{previewMessageText(replyPreview)}
</Text>
) : null}
{caption ? (
<Text
style={{
color: message.role === "user" ? "#1A1A1A" : "#DFDFE2",
fontSize: 15,
}}
>
{caption}
</Text>
) : null}
{attachments.map((attachment, index) =>
attachment.kind === "image" ? (
<Pressable
key={`${attachment.artifactId ?? attachment.name ?? "image"}-${index}`}
onPress={() =>
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
}
>
<Text
style={{
color: message.role === "user" ? "#1A1A1A" : "#DFDFE2",
fontSize: 15,
}}
>
🖼 {attachment.name ?? "Image"}
</Text>
</Pressable>
) : (
<Pressable
key={`${attachment.artifactId ?? attachment.name ?? "file"}-${index}`}
onPress={() =>
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
}
>
<Text
style={{
color: message.role === "user" ? "#1A1A1A" : "#DFDFE2",
fontSize: 15,
}}
>
📎 {attachment.name ?? "File"}
</Text>
{attachment.size ? (
<Text style={{ color: "#85858A", marginTop: 4, fontSize: 13 }}>
{attachment.mimeType ?? "file"} · {attachment.size} bytes
</Text>
) : null}
</Pressable>
),
)}
{appConnectBlocks.map((block, index) => (
<AppConnectCard key={`${block.provider}-${index}`} botId={cardBotId} block={block} />
))}
</View>
);
}
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 (
<View style={{ gap: 8, width: "100%" }}>
{segments.map((segment, index) =>
segment.kind === "tool" ? (
<ExpandableToolBlock
key={`${message.id}-${message.id.startsWith("progress:") ? "working" : "actions"}-${index}`}
block={segment.block}
live={message.id.startsWith("progress:")}
/>
) : (
<MessageTextCard
key={`${message.id}-content-${index}`}
message={{ ...message, blocks: segment.blocks }}
speaker={index === firstContent ? speaker : undefined}
replyPreview={index === firstContent ? replyPreview : undefined}
onSpeak={index === lastContent && onSpeak ? () => onSpeak(message) : undefined}
/>
),
)}
{appConnectBlocks.map((block, index) => (
<AppConnectCard key={`${block.provider}-${index}`} botId={cardBotId} block={block} />
))}
</View>
);
});
function MessageTextCard({
message,
speaker,
replyPreview,
onSpeak,
}: {
message: MobileMessage;
speaker?: string;
replyPreview?: MobileMessage;
onSpeak?: () => void;
}) {
const contentText = blockText(message);
if (!contentText) return null;
return (
<View
style={{
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
backgroundColor: message.role === "user" ? "#F1F1EF" : "#1A1A1D",
padding: 12,
borderRadius: 20,
}}
>
{speaker ? (
<Text style={{ color: "#85858A", fontSize: 12.5, fontWeight: "600", marginBottom: 4 }}>
{speaker}
</Text>
) : null}
{replyPreview ? (
<Text style={{ color: "#85858A", fontSize: 12.5, marginBottom: 6 }} numberOfLines={2}>
{previewMessageText(replyPreview)}
</Text>
) : null}
{message.role === "user" ? (
<Text style={{ color: "#1A1A1A", fontSize: 15.5, lineHeight: 23 }}>{contentText}</Text>
) : (
<>
<ChatMarkdown streaming={message.id.startsWith("progress:")}>{contentText}</ChatMarkdown>
{onSpeak ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Speak message"
onPress={onSpeak}
hitSlop={8}
style={{ marginTop: 8 }}
>
<Text style={{ color: "#85858A", fontSize: 13 }}>Speak</Text>
</Pressable>
) : null}
</>
)}
</View>
);
}
function AgentEventLabel({
label,
detail,
expanded,
onToggle,
}: {
label: string;
detail?: string;
expanded: boolean;
onToggle: () => void;
}) {
return (
<Pressable
onPress={onToggle}
accessibilityRole="button"
accessibilityLabel={`${expanded ? "Hide" : "Show"} ${label}`}
style={{ width: "100%", paddingVertical: 4, alignItems: "center" }}
>
<Text style={{ color: "#85858A", fontSize: 13.5, textAlign: "center" }}> {label}</Text>
{expanded && detail ? (
<View
style={{
width: "100%",
marginTop: 6,
borderRadius: 14,
borderWidth: 1,
borderColor: "#26262A",
backgroundColor: "#101012",
paddingHorizontal: 14,
paddingVertical: 10,
}}
>
<ChatMarkdown>{detail}</ChatMarkdown>
</View>
) : null}
</Pressable>
);
}
function ExpandableToolBlock({
block,
live,
}: {
block: Extract<MessageBlock, { kind: "progress" | "steps" }>;
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 (
<View
style={{
alignSelf: "flex-start",
maxWidth: "100%",
}}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${expanded ? "Hide" : "Show"} ${title}`}
accessibilityState={{ expanded }}
hitSlop={10}
onPress={() => setExpanded((current) => !current)}
style={{
flexDirection: "row",
alignItems: "center",
gap: 5,
paddingVertical: 2,
}}
>
<Text style={{ color: live ? "#C9C9CE" : "#85858A", fontSize: 12.5, fontWeight: "600" }}>
{title}
</Text>
<Text style={{ color: "#6C6C70", fontSize: 12 }}>{expanded ? "⌃" : "⌄"}</Text>
</Pressable>
{expanded ? (
<View
style={{
marginTop: 4,
gap: 3,
}}
>
{tools.map((tool) => (
<Text
key={tool}
style={{
color: "#77777D",
fontSize: 11.5,
fontFamily: "monospace",
}}
>
{tool.split("__").at(-1)?.replaceAll("_", " ")}
</Text>
))}
</View>
) : null}
</View>
);
}
function AskBlock({
ask,
canAnswer,
onAnswer,
}: {
ask: Extract<MobileMessage["blocks"][number], { kind: "ask" }>;
canAnswer: boolean;
onAnswer: (answer: string) => Promise<void>;
}) {
const [answer, setAnswer] = useState("");
const [error, setError] = useState<string | null>(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 (
<View
style={{
width: "90%",
borderRadius: 18,
borderWidth: 1,
borderColor: "#2D2D31",
backgroundColor: "#17171A",
paddingHorizontal: 16,
paddingVertical: 14,
gap: 10,
}}
>
<Text style={{ color: "#ECECEE", fontSize: 15.5, fontWeight: "600" }}>{ask.text}</Text>
{ask.detail ? <Text style={{ color: "#85858A", fontSize: 13.5 }}>{ask.detail}</Text> : null}
{answered ? (
<Text style={{ color: "#4ECB71", fontSize: 14 }}>
{secretInput ? "Submitted" : `Answered: ${ask.answer ?? "Done"}`}
</Text>
) : canAnswer ? (
<>
<TextInput
accessibilityLabel={secretInput ? "Code" : "Answer"}
value={answer}
onChangeText={setAnswer}
placeholder={secretInput ? "Code" : "Type your answer"}
placeholderTextColor="#6C6C70"
secureTextEntry={secretInput}
autoComplete="off"
onSubmitEditing={() => void submit()}
style={{
minHeight: 42,
borderRadius: 12,
borderWidth: 1,
borderColor: "#35353A",
color: "#ECECEE",
paddingHorizontal: 12,
paddingVertical: 9,
}}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel="Send answer"
disabled={(secretInput ? answer.length === 0 : !answer.trim()) || submitting}
onPress={() => 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,
}}
>
<Text style={{ color: "#17171A", fontWeight: "600" }}>
{submitting ? "Sending…" : "Send answer"}
</Text>
</Pressable>
</>
) : (
<Text style={{ color: "#85858A", fontSize: 13.5 }}>Waiting for this bots response.</Text>
)}
{error ? <Text style={{ color: "#E65707", fontSize: 13 }}>{error}</Text> : null}
</View>
);
}