2685 lines
88 KiB
TypeScript
2685 lines
88 KiB
TypeScript
|
|
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 bot’s response.</Text>
|
|||
|
|
)}
|
|||
|
|
{error ? <Text style={{ color: "#E65707", fontSize: 13 }}>{error}</Text> : null}
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
}
|