The attach sheet's autopilot now offers "Until a time" with the native time picker from Expo UI: Android's clock dialog, an iOS wheel in a sheet with Start. The home list opens with the autopilots that run, wait or ended within a day, as Today does on web, and each row opens its thread. The attach and until menus move to the shared action sheet, so Android pages them instead of dropping buttons past three. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
3302 lines
108 KiB
TypeScript
3302 lines
108 KiB
TypeScript
import { ChatMarkdown } from "@rakazo/chat-ui/native";
|
||
import type {
|
||
AgentSkillCatalogEntry,
|
||
CardAct,
|
||
CardBlock,
|
||
Connection,
|
||
ConnectionCatalogItem,
|
||
MessageBlock,
|
||
Routine,
|
||
} from "@rakazo/contracts";
|
||
import {
|
||
AUTOPILOT_LIMITS,
|
||
canReactToThreadMessage,
|
||
MESSAGE_REACTIONS,
|
||
type MessageReaction,
|
||
} from "@rakazo/contracts";
|
||
import type { AutopilotUntilChoice } from "@rakazo/core";
|
||
import {
|
||
abortableDelay,
|
||
attachmentsForThread,
|
||
autopilotUntil,
|
||
buildComposerMentionOptions,
|
||
type ComposerMention,
|
||
cloudAgentHttpsUrl,
|
||
deriveWorkspaceFocus,
|
||
isApprovalAskBlock,
|
||
isRoutineEventMessage,
|
||
isRunTerminalEvent,
|
||
isSecretAskBlock,
|
||
latestAnswerableAskMessageId,
|
||
liveScreenRunId,
|
||
mentionChipKey,
|
||
projectMessageReactions,
|
||
resolveComposerSendPlan,
|
||
SLASH_ACTIONS,
|
||
type SlashActionId,
|
||
selectedAskActionLabel,
|
||
serializeComposerPrompt,
|
||
truncateSlashDescription,
|
||
userVisibleMessages,
|
||
} from "@rakazo/core";
|
||
import * as Clipboard from "expo-clipboard";
|
||
import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||
import { useHeaderHeight } from "expo-router/react-navigation";
|
||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
ActionSheetIOS,
|
||
ActivityIndicator,
|
||
Alert,
|
||
AppState,
|
||
FlatList,
|
||
Image,
|
||
Linking,
|
||
Modal,
|
||
type NativeScrollEvent,
|
||
type NativeSyntheticEvent,
|
||
Platform,
|
||
Pressable,
|
||
ScrollView,
|
||
Text,
|
||
TextInput,
|
||
type TextProps,
|
||
View,
|
||
} from "react-native";
|
||
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
|
||
import { useReducedMotion } from "react-native-reanimated";
|
||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||
import { AppConnectCard } from "../components/AppConnectCard";
|
||
import { AskActions } from "../components/AskActions";
|
||
import type { AutopilotAction } from "../components/autopilot-line";
|
||
import { AutopilotLine } from "../components/autopilot-line";
|
||
import { AutopilotTimePicker } from "../components/autopilot-time-picker";
|
||
import { BotAvatar } from "../components/bot-avatar";
|
||
import {
|
||
MarkdownArtifactPreview,
|
||
type MarkdownArtifactPreviewTarget,
|
||
} from "../components/markdown-artifact-preview";
|
||
import { NativeSymbol } from "../components/native-symbol";
|
||
import { RoutineEventLine } from "../components/routine-event-line";
|
||
import { ThreadCard } from "../components/thread-card";
|
||
import type {
|
||
MobileBot,
|
||
MobileGroup,
|
||
MobileMessage,
|
||
MobileMessagePage,
|
||
MobileSnapshot,
|
||
} from "../lib/api";
|
||
import {
|
||
applyMobileThreadEvent,
|
||
blockText,
|
||
copyableMobileMessageText,
|
||
currentApiBase,
|
||
loadSessionToken,
|
||
mergeMobileSnapshot,
|
||
messageAttachments,
|
||
messagingProviderLabel,
|
||
prependMobileMessagePage,
|
||
rpc,
|
||
selectedSpaceId,
|
||
selectSpace,
|
||
shouldApplyMobileThreadRefresh,
|
||
subscribeThread,
|
||
} from "../lib/api";
|
||
import { mobileTokens } from "../lib/appearance";
|
||
import { type MobileArtifactTarget, openMobileArtifact } from "../lib/artifact-open";
|
||
import { confirmDeleteBot } from "../lib/bot-lifecycle";
|
||
import { cancelFocusPrompt, focusPromptThreadActive } from "../lib/focus-prompt";
|
||
import { dateLocaleForUi, t, useI18n } from "../lib/i18n";
|
||
import { saveLastBotId } from "../lib/last-bot";
|
||
import {
|
||
dismissThreadNotifications,
|
||
resumeLiveNotifications,
|
||
setOpenNotificationThread,
|
||
} from "../lib/live-notifications";
|
||
import { presentMessageActionSheet } from "../lib/message-action-sheet";
|
||
import {
|
||
hasVisibleMessagePresentation,
|
||
isCenteredAgentEvent,
|
||
messagePresentationSegments,
|
||
} from "../lib/message-presentation";
|
||
import { native, useMobileTokens, useResolvedAppearance } from "../lib/native";
|
||
import {
|
||
type PickedAttachment,
|
||
pickDocuments,
|
||
pickFromLibrary,
|
||
takePhoto,
|
||
} from "../lib/pick-attachments";
|
||
import { threadRefreshDelayMs } from "../lib/refresh";
|
||
import {
|
||
type ThreadScrollAction,
|
||
ThreadScrollBehavior,
|
||
type ThreadScrollState,
|
||
} from "../lib/thread-scroll";
|
||
import { speakText } from "../lib/voice";
|
||
|
||
type PendingAttachment = PickedAttachment & { threadKey: string };
|
||
type AskAction = NonNullable<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 t("Answered");
|
||
const selectedAction = actions?.find((action) => action.id === answer);
|
||
const outcome = selectedAction?.outcome;
|
||
if (approval && outcome === "created") return t("Created");
|
||
if (approval && outcome === "cancelled") return t("Cancelled");
|
||
if (approval && answer === "allow") return t("Allowed once");
|
||
if (approval && answer === "always") return t("Always allowed");
|
||
if (approval && answer === "deny") return t("Denied");
|
||
return t("Answered: {answer}", { answer: selectedAskActionLabel(answer, actions) });
|
||
}
|
||
|
||
function formatAttachmentSkip(item: { name: string; reason: string }): string {
|
||
const name = item.name === "camera" ? t("Camera") : item.name;
|
||
if (item.reason === "permission denied") return t("{name} (permission denied)", { name });
|
||
if (item.reason === "over 10 MiB") return t("{name} (over 10 MiB)", { name });
|
||
if (item.reason === "unsupported type") return t("{name} (unsupported type)", { name });
|
||
const maxMatch = /^max (\d+) attachments$/.exec(item.reason);
|
||
if (maxMatch) {
|
||
return t("{name} (max {count} attachments)", { name, count: maxMatch[1] ?? "" });
|
||
}
|
||
return `${name} (${item.reason})`;
|
||
}
|
||
|
||
function isWorkingStatus(status: string | undefined): boolean {
|
||
return (
|
||
status === "queued" ||
|
||
status === "leased" ||
|
||
status === "running" ||
|
||
status === "waiting_input" ||
|
||
status === "waiting_takeover"
|
||
);
|
||
}
|
||
|
||
type NotificationRouteState = "loading" | "ready" | "failed";
|
||
|
||
export default function ThreadRoute() {
|
||
const tokens = useMobileTokens();
|
||
const { t } = useI18n();
|
||
const router = useRouter();
|
||
const { spaceId } = useLocalSearchParams<{ spaceId?: string | string[] }>();
|
||
const requestedSpaceId = typeof spaceId === "string" && spaceId ? spaceId : null;
|
||
const invalidSpaceId = spaceId !== undefined && requestedSpaceId === null;
|
||
const routeMatchesSelectedSpace =
|
||
requestedSpaceId === null || selectedSpaceId() === requestedSpaceId;
|
||
const [routeState, setRouteState] = useState<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: tokens.background,
|
||
}}
|
||
>
|
||
{routeState === "loading" ? (
|
||
<ActivityIndicator color={tokens.foreground} />
|
||
) : (
|
||
<Pressable accessibilityRole="button" onPress={() => router.replace("/")}>
|
||
<Text style={{ color: tokens.foreground, fontSize: 16 }}>{t("Return to inbox")}</Text>
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function Thread() {
|
||
const colorScheme = useResolvedAppearance();
|
||
const tokens = mobileTokens();
|
||
const [botActionsOpen, setBotActionsOpen] = useState(false);
|
||
const [autopilotTimeFor, setAutopilotTimeFor] = useState<((time: string) => void) | null>(null);
|
||
const { t } = useI18n();
|
||
const navigation = useNavigation();
|
||
const router = useRouter();
|
||
const headerHeight = useHeaderHeight();
|
||
const insets = useSafeAreaInsets();
|
||
const keyboardVisible = useKeyboardState((state) => state.isVisible);
|
||
const { botId, groupId, name, messageId } = useLocalSearchParams<{
|
||
botId?: string;
|
||
groupId?: string;
|
||
name?: string;
|
||
messageId?: string;
|
||
}>();
|
||
const inGroup = Boolean(groupId);
|
||
const scroll = useRef<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 routeName = useRef(name);
|
||
routeName.current = name;
|
||
const mentionBotsRefreshGeneration = useRef(0);
|
||
const mentionBotsAppliedGeneration = useRef(0);
|
||
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);
|
||
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
|
||
const [screenRunId, setScreenRunId] = useState<string | 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 reactionView = useMemo(
|
||
() =>
|
||
projectMessageReactions(
|
||
userVisibleMessages(snap?.messages ?? [], { includePeerReceipts: true }).filter((message) =>
|
||
hasVisibleMessagePresentation(message.blocks),
|
||
),
|
||
),
|
||
[snap?.messages],
|
||
);
|
||
const visibleMessages = reactionView.visibleMessages;
|
||
const latestMessageId = visibleMessages.at(-1)?.id ?? null;
|
||
const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey);
|
||
const composerMentionTargets = useMemo(
|
||
() =>
|
||
buildComposerMentionOptions({
|
||
query: "",
|
||
includeEveryone: inGroup,
|
||
currentGroupId: groupId,
|
||
bots: mentionBots.map((bot) => ({
|
||
id: bot.id,
|
||
name: bot.name,
|
||
color: bot.color,
|
||
})),
|
||
groups: mentionGroups.map((group) => ({
|
||
id: group.id,
|
||
name: group.name,
|
||
})),
|
||
routines: mentionRoutines.map((routine) => ({
|
||
id: routine.id,
|
||
name: routine.name,
|
||
crons: routine.crons,
|
||
botId: routine.botId,
|
||
botName: routine.botName,
|
||
})),
|
||
connectors: mentionConnectors,
|
||
}),
|
||
[groupId, inGroup, mentionBots, mentionConnectors, mentionGroups, mentionRoutines],
|
||
);
|
||
const mentionOptions = useMemo(() => {
|
||
if (mentionQuery === null || composerMentionTargets.length === 0) return [];
|
||
const query = mentionQuery.trim().toLowerCase();
|
||
return composerMentionTargets
|
||
.filter((target) => !query || target.name.toLowerCase().startsWith(query))
|
||
.slice(0, 10);
|
||
}, [composerMentionTargets, mentionQuery]);
|
||
const slashQueryNormalized = slashQuery?.trim().toLowerCase() ?? null;
|
||
const slashSkillOptions =
|
||
slashQuery !== null && mentionQuery === null
|
||
? agentSkills
|
||
.filter((skill) => {
|
||
if (!slashQueryNormalized) return true;
|
||
return (
|
||
skill.name.toLowerCase().includes(slashQueryNormalized) ||
|
||
skill.description.toLowerCase().includes(slashQueryNormalized)
|
||
);
|
||
})
|
||
.slice(0, 8)
|
||
: [];
|
||
const slashActionOptions =
|
||
slashQuery !== null && mentionQuery === null
|
||
? SLASH_ACTIONS.filter((action) => {
|
||
if (!slashQueryNormalized) return true;
|
||
const label = t(action.label);
|
||
return (
|
||
action.label.toLowerCase().includes(slashQueryNormalized) ||
|
||
label.toLowerCase().includes(slashQueryNormalized)
|
||
);
|
||
})
|
||
: [];
|
||
const currentBot = botId ? mentionBots.find((bot) => bot.id === botId) : undefined;
|
||
const displayName = currentBot?.name ?? name;
|
||
const notificationThreadId = snap?.threadId ?? currentBot?.threadId;
|
||
activeThreadId.current = notificationThreadId;
|
||
const currentBotStatus = snap ? snap.run?.status : currentBot?.status;
|
||
const hasLiveProgress = visibleMessages.some((message) => message.id.startsWith("progress:"));
|
||
const workingGroupBots = useMemo(() => {
|
||
if (!inGroup) return [];
|
||
const seen = new Set<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]);
|
||
|
||
const refreshMentionBots = useCallback(async () => {
|
||
if (!botId && !groupId) return;
|
||
const generation = ++mentionBotsRefreshGeneration.current;
|
||
const targetBotId = botId;
|
||
try {
|
||
const bots = await rpc<MobileBot[]>("bots/list");
|
||
// Apply any successful response that is still the newest applied so far.
|
||
// A later failed refresh must not discard an earlier success.
|
||
if (generation < mentionBotsAppliedGeneration.current) return;
|
||
if (targetBotId !== activeBotId.current) return;
|
||
mentionBotsAppliedGeneration.current = generation;
|
||
setMentionBots(bots);
|
||
if (targetBotId) {
|
||
const next = bots.find((bot) => bot.id === targetBotId);
|
||
// Read the route name from a ref so renaming does not recreate this
|
||
// callback (and restart the SSE subscription that depends on it).
|
||
if (next?.name && next.name !== routeName.current) {
|
||
router.setParams({ name: next.name });
|
||
}
|
||
}
|
||
} catch {
|
||
// Keep the last known roster if refresh fails.
|
||
}
|
||
}, [botId, groupId, router]);
|
||
|
||
useEffect(() => {
|
||
void refreshMentionBots();
|
||
void rpc<MobileGroup[]>("groups/list")
|
||
.then(setMentionGroups)
|
||
.catch(() => setMentionGroups([]));
|
||
}, [refreshMentionBots]);
|
||
|
||
useEffect(() => {
|
||
if (mentionBots.length === 0) {
|
||
setMentionRoutines([]);
|
||
setMentionConnectors([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const botNameById = new Map(mentionBots.map((bot) => [bot.id, bot.name]));
|
||
void Promise.all(
|
||
mentionBots.map((bot) =>
|
||
rpc<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: displayName || t("Thread"),
|
||
headerTitle: () => (
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={!inGroup && botId ? t("Chat settings") : displayName || t("Thread")}
|
||
disabled={inGroup || !botId}
|
||
onPress={() => {
|
||
if (!botId || inGroup) return;
|
||
router.push({ pathname: "/bot-settings", params: { botId } });
|
||
}}
|
||
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}
|
||
/>
|
||
) : null}
|
||
<Text
|
||
numberOfLines={1}
|
||
style={{ color: tokens.foreground, fontSize: 18, fontWeight: "600" }}
|
||
>
|
||
{displayName || t("Thread")}
|
||
</Text>
|
||
</Pressable>
|
||
),
|
||
headerRight: () =>
|
||
inGroup ? (
|
||
<Pressable
|
||
accessibilityLabel={t("Group settings")}
|
||
hitSlop={8}
|
||
onPress={() =>
|
||
router.push({
|
||
pathname: "/group-settings",
|
||
params: { groupId: groupId ?? "" },
|
||
})
|
||
}
|
||
>
|
||
<NativeSymbol
|
||
ios="gearshape"
|
||
android="settings-outline"
|
||
size={21}
|
||
color={tokens.foreground}
|
||
/>
|
||
</Pressable>
|
||
) : (
|
||
<Pressable accessibilityLabel={t("Bot actions")} hitSlop={8} onPress={showBotActions}>
|
||
<NativeSymbol
|
||
ios="ellipsis"
|
||
android="ellipsis-horizontal"
|
||
size={21}
|
||
color={tokens.foreground}
|
||
/>
|
||
</Pressable>
|
||
),
|
||
});
|
||
}, [
|
||
botId,
|
||
currentBot,
|
||
currentBotStatus,
|
||
displayName,
|
||
groupId,
|
||
inGroup,
|
||
navigation,
|
||
router,
|
||
t,
|
||
tokens,
|
||
colorScheme,
|
||
]);
|
||
|
||
function leaveBot() {
|
||
router.dismissAll();
|
||
router.replace("/");
|
||
}
|
||
|
||
function clearConversation() {
|
||
if (!botId) return;
|
||
setError(null);
|
||
void rpc("threads/clear", { botId })
|
||
.then(() => {
|
||
expandedHistoryThread.current = null;
|
||
pinnedAroundRef.current = null;
|
||
historyEpoch.current += 1;
|
||
setSnap((current) =>
|
||
current ? { ...current, messages: [], olderCursor: null, run: null } : current,
|
||
);
|
||
})
|
||
.catch((err: unknown) =>
|
||
setError(err instanceof Error ? err.message : t("Could not clear conversation")),
|
||
);
|
||
}
|
||
|
||
const botActions = [
|
||
{
|
||
text: t("Chat settings"),
|
||
onPress: () =>
|
||
router.push({
|
||
pathname: "/bot-settings",
|
||
params: { botId: botId ?? "" },
|
||
}),
|
||
},
|
||
{
|
||
text: t("Open computer"),
|
||
onPress: () =>
|
||
router.push({
|
||
pathname: "/computer",
|
||
params: { botId: botId ?? "", name: displayName ?? t("Bot") },
|
||
}),
|
||
},
|
||
{
|
||
text: t("Clear conversation"),
|
||
destructive: true,
|
||
onPress: () =>
|
||
Alert.alert(
|
||
t("Clear conversation?"),
|
||
t(
|
||
"This removes every message and stops current work. The bot, computer, memory, and routines are kept.",
|
||
),
|
||
[
|
||
{ text: t("Cancel"), style: "cancel" },
|
||
{ text: t("Clear"), style: "destructive", onPress: clearConversation },
|
||
],
|
||
),
|
||
},
|
||
{
|
||
text: t("Archive"),
|
||
onPress: () =>
|
||
void rpc("bots/archive", { botId })
|
||
.then(leaveBot)
|
||
.catch((error) =>
|
||
Alert.alert(
|
||
t("Could not archive bot"),
|
||
error instanceof Error ? error.message : t("Try again."),
|
||
),
|
||
),
|
||
},
|
||
{
|
||
text: t("Delete…"),
|
||
destructive: true,
|
||
onPress: () => confirmDeleteBot({ id: botId ?? "", name: displayName || t("Bot") }, leaveBot),
|
||
},
|
||
];
|
||
|
||
function showBotActions() {
|
||
if (!botId) return;
|
||
if (Platform.OS === "ios") {
|
||
ActionSheetIOS.showActionSheetWithOptions(
|
||
{
|
||
title: name || t("Bot"),
|
||
userInterfaceStyle: colorScheme,
|
||
options: [...botActions.map((action) => action.text), t("Cancel")],
|
||
cancelButtonIndex: botActions.length,
|
||
destructiveButtonIndex: botActions.flatMap((action, index) =>
|
||
action.destructive ? [index] : [],
|
||
),
|
||
},
|
||
(index) => botActions[index]?.onPress(),
|
||
);
|
||
return;
|
||
}
|
||
setBotActionsOpen(true);
|
||
}
|
||
|
||
async function refresh() {
|
||
if (!botId && !groupId) return;
|
||
const targetBotId = botId;
|
||
const targetGroupId = groupId;
|
||
const epoch = historyEpoch.current;
|
||
const next = await rpc<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 : t("Could not load earlier messages"));
|
||
} finally {
|
||
setLoadingOlder(false);
|
||
}
|
||
}
|
||
|
||
const markReadIfVisible = useCallback(() => {
|
||
if (AppState.currentState !== "active" || !navigation.isFocused()) return;
|
||
const target = groupId ?? botId;
|
||
if (!target || readVisibleTarget.current === target) return;
|
||
readVisibleTarget.current = target;
|
||
if (activeThreadId.current) {
|
||
void dismissThreadNotifications({ threadId: activeThreadId.current }).catch(() => undefined);
|
||
}
|
||
if (groupId) {
|
||
void rpc("threads/markRead", { groupId }).catch(() => {
|
||
if (readVisibleTarget.current === target) readVisibleTarget.current = null;
|
||
});
|
||
return;
|
||
}
|
||
void rpc("threads/markRead", { botId: botId! }).catch(() => {
|
||
if (readVisibleTarget.current === target) readVisibleTarget.current = null;
|
||
});
|
||
}, [botId, groupId, navigation]);
|
||
|
||
useEffect(() => {
|
||
if (!notificationThreadId || AppState.currentState !== "active" || !navigation.isFocused())
|
||
return;
|
||
void setOpenNotificationThread({ botId, threadId: notificationThreadId }).catch(
|
||
() => undefined,
|
||
);
|
||
void dismissThreadNotifications({ threadId: notificationThreadId }).catch(() => undefined);
|
||
}, [botId, navigation, notificationThreadId]);
|
||
|
||
// Cancel delayed setup only when leaving this bot's thread (unmount or botId
|
||
// change). Blur alone is not leave — settings/computer push must keep the timer.
|
||
useEffect(() => {
|
||
if (!botId) return;
|
||
return () => {
|
||
cancelFocusPrompt(botId);
|
||
};
|
||
}, [botId]);
|
||
|
||
// Covers returning from a pushed screen; the AppState listener covers returning from background.
|
||
useFocusEffect(
|
||
useCallback(() => {
|
||
if (botId) {
|
||
focusPromptThreadActive(botId);
|
||
void saveLastBotId(botId).catch(() => undefined);
|
||
} else {
|
||
// Group thread focus: prior bot screen may stay mounted, so clear any delayed setup.
|
||
cancelFocusPrompt();
|
||
}
|
||
if (AppState.currentState === "active" && notificationThreadId) {
|
||
void setOpenNotificationThread({
|
||
botId,
|
||
threadId: notificationThreadId,
|
||
}).catch(() => undefined);
|
||
}
|
||
void refreshMentionBots();
|
||
markReadIfVisible();
|
||
return () => {
|
||
void setOpenNotificationThread(null).catch(() => undefined);
|
||
};
|
||
}, [botId, markReadIfVisible, notificationThreadId, refreshMentionBots]),
|
||
);
|
||
|
||
useEffect(() => {
|
||
const appState = AppState.addEventListener("change", (state) => {
|
||
if (state === "active") {
|
||
if (!navigation.isFocused() || !notificationThreadId) return;
|
||
void setOpenNotificationThread({
|
||
botId,
|
||
threadId: notificationThreadId,
|
||
}).catch(() => undefined);
|
||
markReadIfVisible();
|
||
return;
|
||
}
|
||
void setOpenNotificationThread(null).catch(() => undefined);
|
||
});
|
||
return () => appState.remove();
|
||
}, [botId, markReadIfVisible, navigation, notificationThreadId]);
|
||
|
||
useEffect(() => {
|
||
if (!botId && !groupId) return;
|
||
if (!messageId) {
|
||
pinnedAroundRef.current = null;
|
||
jumpScrollTarget.current = null;
|
||
}
|
||
expandedHistoryThread.current = null;
|
||
historyEpoch.current += 1;
|
||
setScreenRunId(null);
|
||
const abort = new AbortController();
|
||
void (async () => {
|
||
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
||
const next = messageId
|
||
? await rpc<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;
|
||
const screenRun = groupId ? null : liveScreenRunId(event);
|
||
if (screenRun) setScreenRunId(screenRun);
|
||
if (
|
||
event.type === "thread.progress" ||
|
||
event.type === "agent.tool.called" ||
|
||
event.type === "agent.tool.completed" ||
|
||
event.type === "thread.message.created" ||
|
||
event.type === "thread.message.updated" ||
|
||
event.type === "thread.message.reaction" ||
|
||
event.type === "thread.subagent" ||
|
||
event.type === "thread.cloud_agent" ||
|
||
event.type === "thread.cleared" ||
|
||
event.type === "run.waiting_input" ||
|
||
event.type === "computer.takeover.requested" ||
|
||
isRunTerminalEvent(event)
|
||
) {
|
||
if (event.type === "thread.cleared") {
|
||
expandedHistoryThread.current = null;
|
||
pinnedAroundRef.current = null;
|
||
historyEpoch.current += 1;
|
||
}
|
||
setSnap((prev) => applyMobileThreadEvent(prev, event));
|
||
}
|
||
if (event.type === "bot.updated") {
|
||
void refreshMentionBots();
|
||
}
|
||
if (event.type === "thread.message.created" && event.payload?.role === "bot") {
|
||
readVisibleTarget.current = null;
|
||
markReadIfVisible();
|
||
}
|
||
if (isRunTerminalEvent(event)) {
|
||
void refreshMentionBots();
|
||
if (!jumpScrollTarget.current && !expandedHistoryThread.current) {
|
||
void refresh().catch(() => undefined);
|
||
}
|
||
}
|
||
},
|
||
abort.signal,
|
||
);
|
||
} catch {
|
||
// A full refresh reconciles visible state; the event cursor still resumes without gaps.
|
||
}
|
||
if (abort.signal.aborted) break;
|
||
if (!jumpScrollTarget.current && !expandedHistoryThread.current) {
|
||
await refresh().catch(() => undefined);
|
||
}
|
||
await abortableDelay(retryMs, abort.signal);
|
||
retryMs = Math.min(retryMs * 2, 5_000);
|
||
}
|
||
})();
|
||
return () => {
|
||
abort.abort();
|
||
};
|
||
}, [botId, groupId, markReadIfVisible, refreshMentionBots]);
|
||
|
||
useEffect(() => {
|
||
if (!botId && !groupId) return;
|
||
let cancelled = false;
|
||
let timer: ReturnType<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 : t("Could not open message"));
|
||
},
|
||
);
|
||
}, [botId, groupId, messageId]);
|
||
|
||
useEffect(() => {
|
||
setPendingAttachments((current) => attachmentsForThread(current, threadKey));
|
||
setDraft("");
|
||
setMentionQuery(null);
|
||
setSlashQuery(null);
|
||
setSelectedSkill(null);
|
||
setSelectedMentions([]);
|
||
setReplyTarget(null);
|
||
setAttachmentNotice(null);
|
||
setError(null);
|
||
}, [threadKey]);
|
||
|
||
function updateDraft(value: string) {
|
||
setDraft(value);
|
||
const match = /(?:^|\s)@([\w-]*)$/.exec(value);
|
||
setMentionQuery(match ? (match[1] ?? "") : null);
|
||
const slashMatch = selectedSkill === null ? /^\/([^\n]*)$/.exec(value) : null;
|
||
setSlashQuery(slashMatch ? (slashMatch[1] ?? "") : null);
|
||
}
|
||
|
||
function insertMention(mention: ComposerMention) {
|
||
setDraft((current) => current.replace(/@([\w-]*)$/, ""));
|
||
setMentionQuery(null);
|
||
setSelectedMentions((current) =>
|
||
current.some((selected) => mentionChipKey(selected) === mentionChipKey(mention))
|
||
? current
|
||
: [...current, mention],
|
||
);
|
||
}
|
||
|
||
function insertSkill(skill: AgentSkillCatalogEntry) {
|
||
setSelectedSkill(skill);
|
||
setDraft("");
|
||
setSlashQuery(null);
|
||
}
|
||
|
||
function removeLastChip() {
|
||
if (selectedMentions.length > 0) {
|
||
setSelectedMentions((current) => current.slice(0, -1));
|
||
return;
|
||
}
|
||
if (selectedSkill) setSelectedSkill(null);
|
||
}
|
||
|
||
function serializeComposerPromptText(): string {
|
||
return serializeComposerPrompt(draft, selectedSkill, selectedMentions);
|
||
}
|
||
|
||
function runSlashAction(action: SlashActionId) {
|
||
setDraft("");
|
||
setSlashQuery(null);
|
||
if (action === "chat-settings") {
|
||
if (inGroup && groupId) {
|
||
router.push({ pathname: "/group-settings", params: { groupId } });
|
||
} else if (botId) {
|
||
router.push({ pathname: "/bot-settings", params: { botId } });
|
||
}
|
||
return;
|
||
}
|
||
router.push({
|
||
pathname: "/account",
|
||
params: action === "settings-usage" ? { focus: "usage" } : undefined,
|
||
});
|
||
}
|
||
|
||
const canSend =
|
||
Boolean(draft.trim()) ||
|
||
selectedSkill !== null ||
|
||
selectedMentions.length > 0 ||
|
||
activePendingAttachments.length > 0;
|
||
|
||
async function send() {
|
||
const initialBotTarget = botId;
|
||
const initialGroupTarget = groupId;
|
||
if ((!initialBotTarget && !initialGroupTarget) || sending) return;
|
||
const originThreadKey = initialGroupTarget ?? initialBotTarget;
|
||
const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
|
||
const plan = resolveComposerSendPlan({
|
||
text: serializeComposerPromptText(),
|
||
mentions: selectedMentions,
|
||
hasAttachments: attachments.length > 0,
|
||
});
|
||
if (plan.isNoOp) return;
|
||
const reroutedToGroup = Boolean(
|
||
plan.rerouteGroupId && plan.rerouteGroupId !== initialGroupTarget,
|
||
);
|
||
const groupTarget = plan.rerouteGroupId ?? initialGroupTarget;
|
||
const botTarget = reroutedToGroup ? undefined : initialBotTarget;
|
||
const trimmed = plan.trimmed;
|
||
const dropDelayedSetup = () => {
|
||
// Only after successful engagement so a failed upload/send keeps the setup card.
|
||
// Covers group-mention reroute while the bot thread stays mounted underneath.
|
||
if (initialBotTarget) cancelFocusPrompt(initialBotTarget);
|
||
};
|
||
setSending(true);
|
||
setError(null);
|
||
try {
|
||
if (plan.shouldRunRoutines) {
|
||
const sendNonce = newClientNonce();
|
||
await Promise.all(
|
||
plan.routineIds.map((routineId) =>
|
||
rpc("routines/testRun", {
|
||
routineId,
|
||
clientNonce: `routine-mention:${sendNonce}:${routineId}`,
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
const clearOriginComposer = () => {
|
||
setPendingAttachments((current) =>
|
||
current.filter((attachment) => attachment.threadKey !== originThreadKey),
|
||
);
|
||
setDraft("");
|
||
setMentionQuery(null);
|
||
setSlashQuery(null);
|
||
setSelectedSkill(null);
|
||
setSelectedMentions([]);
|
||
setReplyTarget(null);
|
||
setAttachmentNotice(null);
|
||
};
|
||
if (!plan.shouldSend) {
|
||
dropDelayedSetup();
|
||
clearOriginComposer();
|
||
if (reroutedToGroup && groupTarget) {
|
||
router.push({
|
||
pathname: "/group-thread",
|
||
params: {
|
||
groupId: groupTarget,
|
||
name: plan.rerouteGroupName ?? t("Group"),
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
if (isCurrentTarget(botTarget, groupTarget)) {
|
||
await refresh();
|
||
}
|
||
return;
|
||
}
|
||
const artifactIds: string[] = [];
|
||
for (const pending of attachments) {
|
||
const artifact = await rpc<{ id: string }>("artifacts/create", {
|
||
...(groupTarget ? { groupId: groupTarget } : { botId: botTarget! }),
|
||
name: pending.name,
|
||
mimeType: pending.mimeType,
|
||
contentBase64: pending.contentBase64,
|
||
});
|
||
artifactIds.push(artifact.id);
|
||
}
|
||
const clientNonce = newClientNonce();
|
||
await rpc(
|
||
"threads/send",
|
||
groupTarget
|
||
? {
|
||
groupId: groupTarget,
|
||
clientNonce,
|
||
text: trimmed || undefined,
|
||
mentions: plan.mentionPayload.length ? plan.mentionPayload : undefined,
|
||
artifactIds: artifactIds.length ? artifactIds : undefined,
|
||
replyToMessageId: reroutedToGroup ? undefined : replyTarget?.id,
|
||
}
|
||
: {
|
||
botId: botTarget!,
|
||
clientNonce,
|
||
text: trimmed || undefined,
|
||
mentions: plan.mentionPayload.length ? plan.mentionPayload : undefined,
|
||
artifactIds: artifactIds.length ? artifactIds : undefined,
|
||
replyToMessageId: replyTarget?.id,
|
||
},
|
||
);
|
||
dropDelayedSetup();
|
||
void loadSessionToken()
|
||
.then((token) => resumeLiveNotifications(currentApiBase(), token, selectedSpaceId() ?? ""))
|
||
.catch(() => undefined);
|
||
clearOriginComposer();
|
||
if (reroutedToGroup && groupTarget) {
|
||
router.push({
|
||
pathname: "/group-thread",
|
||
params: {
|
||
groupId: groupTarget,
|
||
name: plan.rerouteGroupName ?? t("Group"),
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
if (isCurrentTarget(botTarget, groupTarget)) {
|
||
await refresh();
|
||
}
|
||
} catch (err) {
|
||
if (reroutedToGroup && groupTarget) {
|
||
setError(err instanceof Error ? err.message : t("Failed to send message"));
|
||
} else if (isCurrentTarget(botTarget, groupTarget)) {
|
||
setError(err instanceof Error ? err.message : t("Failed to send message"));
|
||
}
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
async function stop() {
|
||
const targetBotId = botId;
|
||
const targetGroupId = groupId;
|
||
if ((!targetBotId && !targetGroupId) || sending) return;
|
||
setSending(true);
|
||
setError(null);
|
||
try {
|
||
await rpc(
|
||
"threads/stop",
|
||
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
|
||
);
|
||
} catch (err) {
|
||
if (isCurrentTarget(targetBotId, targetGroupId)) {
|
||
setError(err instanceof Error ? err.message : t("Failed to stop work"));
|
||
}
|
||
setSending(false);
|
||
return;
|
||
}
|
||
try {
|
||
await refresh();
|
||
} catch (err) {
|
||
if (isCurrentTarget(targetBotId, targetGroupId)) {
|
||
const detail = err instanceof Error ? err.message : t("Failed to refresh");
|
||
setError(t("Work stopped, but the thread could not refresh: {detail}", { detail }));
|
||
}
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
const answerMessage = useCallback(
|
||
async (message: MobileMessage, answer: string) => {
|
||
const targetBotId = botId;
|
||
const targetGroupId = groupId;
|
||
if ((!targetBotId && !targetGroupId) || !message.runId) return;
|
||
await rpc("threads/answer", {
|
||
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
|
||
runId: message.runId,
|
||
messageId: message.id,
|
||
answer,
|
||
});
|
||
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
|
||
},
|
||
[botId, groupId],
|
||
);
|
||
|
||
const actOnCard = useCallback(
|
||
async (message: MobileMessage, cardId: string, act: CardAct) => {
|
||
const targetBotId = botId;
|
||
const targetGroupId = groupId;
|
||
if (!targetBotId && !targetGroupId) return;
|
||
await rpc("threads/act", {
|
||
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
|
||
messageId: message.id,
|
||
cardId,
|
||
act,
|
||
clientNonce: newClientNonce(),
|
||
});
|
||
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
|
||
},
|
||
[botId, groupId],
|
||
);
|
||
|
||
const actOnAutopilot = useCallback(
|
||
async (action: AutopilotAction) => {
|
||
const targetBotId = botId;
|
||
const targetGroupId = groupId;
|
||
if (!targetBotId && !targetGroupId) return;
|
||
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
|
||
await rpc(action === "stop" ? "autopilot/stop" : "autopilot/resume", target);
|
||
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
|
||
},
|
||
[botId, groupId],
|
||
);
|
||
|
||
const openBot = useCallback(
|
||
(id: string, botName: string) =>
|
||
router.push({ pathname: "/thread", params: { botId: id, name: botName } }),
|
||
[router],
|
||
);
|
||
|
||
const speak = useCallback(
|
||
(message: MobileMessage) =>
|
||
void speakMessage(message.botId ?? botId ?? snap?.members?.[0]?.botId ?? "", message).catch(
|
||
(err) =>
|
||
Alert.alert(t("Could not speak"), err instanceof Error ? err.message : t("Try again.")),
|
||
),
|
||
[botId, snap?.members],
|
||
);
|
||
|
||
function showAttachMenu() {
|
||
// The autopilot takes the typed order, so it is offered once there is one.
|
||
const order = draft.trim();
|
||
presentMessageActionSheet({
|
||
title: t("Attach"),
|
||
actions: [
|
||
{ text: t("Photo library"), onPress: () => void addAttachments(pickFromLibrary) },
|
||
{ text: t("Camera"), onPress: () => void addAttachments(takePhoto) },
|
||
{ text: t("File"), onPress: () => void addAttachments(pickDocuments) },
|
||
...(order ? [{ text: t("Autopilot"), onPress: () => askAutopilotUntil(order) }] : []),
|
||
],
|
||
colorScheme,
|
||
cancel: t("Cancel"),
|
||
more: t("More"),
|
||
});
|
||
}
|
||
|
||
function askAutopilotUntil(order: string) {
|
||
const start = (choice: AutopilotUntilChoice) => {
|
||
const targetBotId = botId;
|
||
const targetGroupId = groupId;
|
||
if (!targetBotId && !targetGroupId) return;
|
||
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
|
||
const until = autopilotUntil(choice, new Date());
|
||
void rpc("autopilot/start", {
|
||
...target,
|
||
goal: order.slice(0, AUTOPILOT_LIMITS.goal),
|
||
...(until ? { until: until.toISOString() } : {}),
|
||
})
|
||
.then(async () => {
|
||
setDraft("");
|
||
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
|
||
})
|
||
.catch((err) =>
|
||
Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")),
|
||
);
|
||
};
|
||
presentMessageActionSheet({
|
||
title: t("Until when?"),
|
||
actions: [
|
||
{ text: t("Until done"), onPress: () => start({ kind: "done" }) },
|
||
{ text: t("Until tomorrow morning"), onPress: () => start({ kind: "morning" }) },
|
||
{
|
||
text: t("Until a time"),
|
||
onPress: () => setAutopilotTimeFor(() => (time: string) => start({ kind: "time", time })),
|
||
},
|
||
],
|
||
colorScheme,
|
||
cancel: t("Cancel"),
|
||
more: t("More"),
|
||
});
|
||
}
|
||
|
||
async function addAttachments(
|
||
picker: (existingCount: number) => Promise<{
|
||
attachments: PickedAttachment[];
|
||
skipped: Array<{ name: string; reason: string }>;
|
||
}>,
|
||
) {
|
||
const targetKey = groupId ?? botId;
|
||
if (!targetKey) return;
|
||
const result = await picker(activePendingAttachments.length);
|
||
if ((groupId ?? botId) !== targetKey) return;
|
||
if (result.attachments.length) {
|
||
setPendingAttachments((current) => [
|
||
...current,
|
||
...result.attachments.map((attachment) => ({
|
||
...attachment,
|
||
threadKey: targetKey,
|
||
})),
|
||
]);
|
||
}
|
||
setAttachmentNotice(
|
||
result.skipped.length
|
||
? t("Skipped {items}", {
|
||
items: result.skipped.map((item) => formatAttachmentSkip(item)).join(", "),
|
||
})
|
||
: null,
|
||
);
|
||
}
|
||
|
||
const answerableAskMessageId = latestAnswerableAskMessageId(snap);
|
||
const screenFocus = useMemo(() => {
|
||
const computer = snap?.computer;
|
||
if (!snap || !computer || groupId) return null;
|
||
const focus = deriveWorkspaceFocus(
|
||
{
|
||
...snap,
|
||
computer: {
|
||
kind: computer.kind ?? "",
|
||
state: computer.state,
|
||
screenAvailable: computer.screenAvailable,
|
||
takeoverRequested: computer.takeoverRequested ?? false,
|
||
},
|
||
},
|
||
{ screenRunId },
|
||
);
|
||
return focus.pane === "screen" ? focus : null;
|
||
}, [groupId, screenRunId, snap]);
|
||
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
|
||
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
|
||
const messagesById = useMemo(
|
||
() => new Map((snap?.messages ?? []).map((message) => [message.id, message])),
|
||
[snap?.messages],
|
||
);
|
||
const pinnedTarget = pinnedAroundRef.current;
|
||
const showPinnedPage = Boolean(
|
||
jumpScrollTarget.current ||
|
||
(pinnedTarget &&
|
||
((pinnedTarget.botId && pinnedTarget.botId === botId) ||
|
||
(pinnedTarget.groupId && pinnedTarget.groupId === groupId))),
|
||
);
|
||
|
||
function performScroll(action: ThreadScrollAction) {
|
||
if (!action || showPinnedPage) return;
|
||
scroll.current?.scrollToOffset({
|
||
offset: 0,
|
||
animated: action === "smooth" && !reducedMotion,
|
||
});
|
||
}
|
||
|
||
function updateUserScroll(event: NativeSyntheticEvent<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, reaction: MessageReaction) {
|
||
const targetBotId = botId;
|
||
const targetGroupId = groupId;
|
||
if (!targetBotId && !targetGroupId) return;
|
||
try {
|
||
await rpc("threads/react", {
|
||
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
|
||
messageId: message.id,
|
||
reaction,
|
||
clientNonce: newClientNonce(),
|
||
});
|
||
} catch (err) {
|
||
if (!isCurrentTarget(targetBotId, targetGroupId)) return;
|
||
setError(err instanceof Error ? err.message : t("Could not update reaction"));
|
||
}
|
||
}
|
||
|
||
function messageActionProps(message: MobileMessage): MessageActionProps {
|
||
const actions = [
|
||
{ name: "reply", text: t("Reply"), onPress: () => setReplyTarget(message) },
|
||
...(canReactToThreadMessage(message)
|
||
? [
|
||
{
|
||
name: "react",
|
||
text: t("React"),
|
||
onPress: () =>
|
||
presentMessageActionSheet({
|
||
cancel: t("Cancel"),
|
||
more: t("More"),
|
||
colorScheme,
|
||
actions: MESSAGE_REACTIONS.map((emoji) => ({
|
||
name: emoji,
|
||
text: emoji,
|
||
onPress: () => void reactToMessage(message, emoji),
|
||
})),
|
||
}),
|
||
},
|
||
]
|
||
: []),
|
||
...(message.role === "bot" && blockText(message)
|
||
? [{ name: "speak", text: t("Speak message"), onPress: () => void speak(message) }]
|
||
: []),
|
||
{
|
||
name: "copy",
|
||
text: t("Copy"),
|
||
onPress: () => {
|
||
const text = copyableMobileMessageText(message);
|
||
if (text) void Clipboard.setStringAsync(text).catch(() => undefined);
|
||
},
|
||
},
|
||
];
|
||
return {
|
||
onLongPress: () =>
|
||
presentMessageActionSheet({
|
||
actions,
|
||
title: message.createdAt
|
||
? new Date(message.createdAt).toLocaleTimeString(dateLocaleForUi(), {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
})
|
||
: undefined,
|
||
cancel: t("Cancel"),
|
||
more: t("More"),
|
||
colorScheme,
|
||
}),
|
||
accessibilityActions: actions.map((action) => ({ name: action.name, label: action.text })),
|
||
onAccessibilityAction: (event) => {
|
||
actions.find((action) => action.name === event.nativeEvent.actionName)?.onPress();
|
||
},
|
||
};
|
||
}
|
||
|
||
function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) {
|
||
const actionProps = messageActionProps(message);
|
||
const messageReactions = reactionView.reactions.get(message.id);
|
||
const activityBotId =
|
||
!inGroup && message.role === "bot" && message.id.startsWith("progress:")
|
||
? (message.botId ?? botId)
|
||
: undefined;
|
||
const activityBot = activityBotId
|
||
? (snap?.members?.find((member) => member.botId === activityBotId) ??
|
||
(currentBot?.id === activityBotId ? currentBot : undefined))
|
||
: undefined;
|
||
const activityStatus = activityBotId
|
||
? (snap?.activeRuns?.find((run) => run.botId === activityBotId)?.status ??
|
||
(snap?.run?.botId === activityBotId ? snap.run.status : currentBotStatus))
|
||
: undefined;
|
||
return (
|
||
<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 ?? tokens.mutedForeground}
|
||
identity={activityBotId}
|
||
size={inGroup ? 20 : 28}
|
||
status={activityStatus}
|
||
/>
|
||
</View>
|
||
) : null}
|
||
<View
|
||
style={{
|
||
width: isCenteredAgentEvent(message.blocks) ? "100%" : undefined,
|
||
maxWidth: isCenteredAgentEvent(message.blocks)
|
||
? "100%"
|
||
: activityBotId
|
||
? undefined
|
||
: "90%",
|
||
flex: activityBotId ? 1 : undefined,
|
||
flexShrink: 1,
|
||
}}
|
||
>
|
||
<Pressable accessible={false} onLongPress={actionProps.onLongPress}>
|
||
<MessageBubble
|
||
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
|
||
groupId={groupId}
|
||
message={message}
|
||
botName={displayName}
|
||
bots={mentionBots}
|
||
members={snap?.members}
|
||
replyPreview={
|
||
message.replyToMessageId ? messagesById.get(message.replyToMessageId) : undefined
|
||
}
|
||
canAnswer={message.id === answerableAskMessageId}
|
||
onAnswer={answerMessage}
|
||
onCardAct={actOnCard}
|
||
onAutopilot={actOnAutopilot}
|
||
onOpenBot={openBot}
|
||
onPreviewMarkdown={setMarkdownPreview}
|
||
actionProps={actionProps}
|
||
/>
|
||
</Pressable>
|
||
{messageReactions ? (
|
||
<View
|
||
style={{
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
gap: 4,
|
||
marginTop: 4,
|
||
justifyContent: message.role === "user" ? "flex-end" : "flex-start",
|
||
}}
|
||
>
|
||
{[...messageReactions].map(([emoji, count]) => (
|
||
<Text
|
||
key={emoji}
|
||
style={{
|
||
color: tokens.foreground,
|
||
backgroundColor: tokens.muted,
|
||
borderColor: tokens.border,
|
||
borderWidth: 1,
|
||
borderRadius: 16,
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 2,
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{emoji}
|
||
{count > 1 ? ` ${count}` : ""}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const workingFooter =
|
||
!inGroup && currentBot && isWorkingStatus(currentBotStatus) && !hasLiveProgress ? (
|
||
<View
|
||
accessibilityLabel={t("{name} is working", { name: currentBot.name })}
|
||
accessibilityRole="text"
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
minHeight: 40,
|
||
marginTop: 12,
|
||
}}
|
||
>
|
||
<BotAvatar
|
||
color={currentBot.color}
|
||
identity={currentBot.id}
|
||
size={28}
|
||
status={currentBotStatus}
|
||
/>
|
||
</View>
|
||
) : inGroup && workingGroupBots.length > 0 ? (
|
||
<View
|
||
accessibilityLabel={
|
||
workingGroupBots.length === 1
|
||
? t("{name} is working", { name: workingGroupBots[0]?.name ?? t("Agent") })
|
||
: t("{count} agents working", { count: workingGroupBots.length })
|
||
}
|
||
accessibilityRole="text"
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
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} />
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
) : null;
|
||
|
||
const loadEarlierControl =
|
||
snap?.olderCursor != null ? (
|
||
<Pressable
|
||
disabled={loadingOlder}
|
||
onPress={() => void loadOlderMessages()}
|
||
style={{
|
||
alignSelf: "center",
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 10,
|
||
}}
|
||
>
|
||
<Text style={{ color: tokens.mutedForeground, fontSize: 13 }}>
|
||
{loadingOlder ? t("Loading…") : t("Load earlier messages")}
|
||
</Text>
|
||
</Pressable>
|
||
) : null;
|
||
|
||
return (
|
||
<KeyboardAvoidingView
|
||
behavior="height"
|
||
keyboardVerticalOffset={headerHeight}
|
||
style={{ flex: 1, backgroundColor: tokens.background, paddingHorizontal: 20 }}
|
||
>
|
||
{error ? <Text style={{ color: tokens.mutedForeground, marginTop: 12 }}>{error}</Text> : null}
|
||
{runError ? (
|
||
<Text style={{ color: tokens.destructive, 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 ? t("Jump to latest, new messages") : t("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: native.fillPressed,
|
||
backgroundColor: native.fill,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<NativeSymbol
|
||
ios="arrow.down"
|
||
android="arrow-down"
|
||
size={18}
|
||
color={tokens.foreground}
|
||
/>
|
||
{threadScrollState.unread ? (
|
||
<View
|
||
style={{
|
||
position: "absolute",
|
||
top: 3,
|
||
right: 3,
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: 4,
|
||
backgroundColor: tokens.primary,
|
||
}}
|
||
/>
|
||
) : null}
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
|
||
{screenFocus && botId ? (
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
testID="live-screen-pill"
|
||
onPress={() =>
|
||
router.push({
|
||
pathname: "/computer",
|
||
params: { botId, name: displayName ?? t("Bot") },
|
||
})
|
||
}
|
||
style={({ pressed }) => ({
|
||
alignSelf: "center",
|
||
marginTop: 10,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
borderRadius: 999,
|
||
borderWidth: 1,
|
||
borderColor: native.fillPressed,
|
||
backgroundColor: pressed ? native.fillPressed : native.fill,
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 8,
|
||
})}
|
||
>
|
||
<View
|
||
style={{
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: 4,
|
||
backgroundColor: screenFocus.takeover ? tokens.warning : tokens.success,
|
||
}}
|
||
/>
|
||
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "500" }}>
|
||
{screenFocus.takeover ? t("Needs takeover") : t("Open computer")}
|
||
</Text>
|
||
</Pressable>
|
||
) : null}
|
||
{replyTarget ? (
|
||
<View
|
||
style={{
|
||
marginTop: 12,
|
||
borderRadius: 14,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 10,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>
|
||
{t("Replying to")}
|
||
</Text>
|
||
<Text style={{ color: tokens.foreground, fontSize: 13 }} numberOfLines={1}>
|
||
{previewMessageText(replyTarget)}
|
||
</Text>
|
||
</View>
|
||
<Pressable accessibilityLabel={t("Cancel reply")} onPress={() => setReplyTarget(null)}>
|
||
<Text style={{ color: tokens.mutedForeground }}>✕</Text>
|
||
</Pressable>
|
||
</View>
|
||
) : null}
|
||
{attachmentNotice ? (
|
||
<Text style={{ color: tokens.warning, 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: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
}}
|
||
>
|
||
{attachment.previewUri ? (
|
||
<Image
|
||
source={{ uri: attachment.previewUri }}
|
||
style={{ width: 28, height: 28, borderRadius: 6 }}
|
||
/>
|
||
) : (
|
||
<Text style={{ color: tokens.foreground }}>📎</Text>
|
||
)}
|
||
<Text style={{ color: tokens.foreground, maxWidth: 140 }} numberOfLines={1}>
|
||
{attachment.name}
|
||
</Text>
|
||
<Pressable
|
||
accessibilityLabel={t("Remove {name}", { name: attachment.name })}
|
||
onPress={() =>
|
||
setPendingAttachments((current) =>
|
||
current.filter((item) => item.id !== attachment.id),
|
||
)
|
||
}
|
||
>
|
||
<NativeSymbol
|
||
ios="xmark"
|
||
android="close"
|
||
size={14}
|
||
color={tokens.mutedForeground}
|
||
/>
|
||
</Pressable>
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
{mentionOptions.length ? (
|
||
<View
|
||
testID="mention-picker"
|
||
style={{
|
||
marginTop: 12,
|
||
borderRadius: 14,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{mentionOptions.map((mention) => (
|
||
<Pressable
|
||
key={mentionChipKey(mention)}
|
||
accessibilityLabel={t("@{name}", { name: 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: tokens.foreground, fontSize: 14 }}>@{mention.name}</Text>
|
||
{mention.subtitle ? (
|
||
<Text
|
||
numberOfLines={1}
|
||
style={{
|
||
color: tokens.mutedForeground,
|
||
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: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{slashSkillOptions.map((skill) => (
|
||
<Pressable
|
||
key={skill.id}
|
||
accessibilityLabel={t("Skill {name}", { name: 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={tokens.mutedForeground}
|
||
/>
|
||
<View style={{ flex: 1, minWidth: 0 }}>
|
||
<Text style={{ color: tokens.foreground, fontSize: 14 }}>{skill.name}</Text>
|
||
<Text
|
||
numberOfLines={1}
|
||
style={{ color: tokens.mutedForeground, fontSize: 12.5, marginTop: 2 }}
|
||
>
|
||
{truncateSlashDescription(skill.description)}
|
||
</Text>
|
||
</View>
|
||
</Pressable>
|
||
))}
|
||
{slashActionOptions.map((action) => (
|
||
<Pressable
|
||
key={action.id}
|
||
accessibilityLabel={t(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={tokens.mutedForeground}
|
||
/>
|
||
<Text style={{ color: tokens.foreground, fontSize: 14 }}>{t(action.label)}</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
<View
|
||
style={{
|
||
flexDirection: "row",
|
||
gap: 8,
|
||
marginTop: 16,
|
||
alignItems: "flex-end",
|
||
}}
|
||
>
|
||
<Pressable
|
||
accessibilityLabel={t("Attach file")}
|
||
onPress={showAttachMenu}
|
||
style={{
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 22,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<NativeSymbol ios="plus" android="add" size={18} color={tokens.mutedForeground} />
|
||
</Pressable>
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
backgroundColor: tokens.card,
|
||
borderRadius: 20,
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 8,
|
||
minHeight: 44,
|
||
}}
|
||
>
|
||
{selectedSkill ? (
|
||
<View
|
||
testID="skill-chip"
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
backgroundColor: tokens.muted,
|
||
borderRadius: 999,
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 5,
|
||
maxWidth: "100%",
|
||
}}
|
||
>
|
||
<NativeSymbol
|
||
ios="cube"
|
||
android="cube-outline"
|
||
size={13}
|
||
color={tokens.mutedForeground}
|
||
/>
|
||
<Text
|
||
numberOfLines={1}
|
||
style={{ color: tokens.foreground, fontSize: 13, flexShrink: 1 }}
|
||
>
|
||
{selectedSkill.name}
|
||
</Text>
|
||
<Pressable
|
||
accessibilityLabel={t("Remove skill {name}", { name: selectedSkill.name })}
|
||
hitSlop={8}
|
||
onPress={() => setSelectedSkill(null)}
|
||
>
|
||
<NativeSymbol
|
||
ios="xmark"
|
||
android="close"
|
||
size={12}
|
||
color={tokens.mutedForeground}
|
||
/>
|
||
</Pressable>
|
||
</View>
|
||
) : null}
|
||
{selectedMentions.map((mention) => (
|
||
<View
|
||
key={mentionChipKey(mention)}
|
||
testID="mention-chip"
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
backgroundColor: tokens.muted,
|
||
borderRadius: 999,
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 5,
|
||
maxWidth: "100%",
|
||
}}
|
||
>
|
||
<MentionChipIcon mention={mention} />
|
||
<Text
|
||
numberOfLines={1}
|
||
style={{ color: tokens.foreground, fontSize: 13, flexShrink: 1 }}
|
||
>
|
||
{mention.name}
|
||
</Text>
|
||
<Pressable
|
||
accessibilityLabel={t("Remove mention {name}", { name: mention.name })}
|
||
hitSlop={8}
|
||
onPress={() =>
|
||
setSelectedMentions((current) =>
|
||
current.filter(
|
||
(selected) => mentionChipKey(selected) !== mentionChipKey(mention),
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<NativeSymbol
|
||
ios="xmark"
|
||
android="close"
|
||
size={12}
|
||
color={tokens.mutedForeground}
|
||
/>
|
||
</Pressable>
|
||
</View>
|
||
))}
|
||
<TextInput
|
||
value={draft}
|
||
onChangeText={updateDraft}
|
||
accessibilityLabel={
|
||
displayName ? t("Message {name}", { name: displayName }) : t("Message")
|
||
}
|
||
onKeyPress={(event) => {
|
||
if (
|
||
event.nativeEvent.key === "Backspace" &&
|
||
draft.length === 0 &&
|
||
(selectedSkill !== null || selectedMentions.length > 0)
|
||
) {
|
||
removeLastChip();
|
||
}
|
||
}}
|
||
placeholder={
|
||
selectedSkill || selectedMentions.length
|
||
? undefined
|
||
: displayName
|
||
? t("Message {name}", { name: displayName })
|
||
: t("Message…")
|
||
}
|
||
placeholderTextColor={tokens.mutedForeground}
|
||
keyboardAppearance={colorScheme}
|
||
multiline
|
||
textAlignVertical="center"
|
||
blurOnSubmit={false}
|
||
style={{
|
||
flexGrow: 1,
|
||
flexShrink: 1,
|
||
minWidth: 96,
|
||
color: tokens.foreground,
|
||
paddingVertical: 2,
|
||
maxHeight: 100,
|
||
writingDirection: "auto",
|
||
}}
|
||
/>
|
||
</View>
|
||
<Pressable
|
||
accessibilityLabel={t("Send")}
|
||
disabled={sending || !canSend}
|
||
onPress={() => void send()}
|
||
style={{
|
||
backgroundColor: tokens.primary,
|
||
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={tokens.primaryForeground}
|
||
/>
|
||
</Pressable>
|
||
{working ? (
|
||
<Pressable
|
||
accessibilityLabel={t("Stop")}
|
||
disabled={sending}
|
||
onPress={() => void stop()}
|
||
style={{
|
||
borderColor: tokens.border,
|
||
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={tokens.foreground} />
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
{autopilotTimeFor ? (
|
||
<AutopilotTimePicker
|
||
onPick={(time) => {
|
||
setAutopilotTimeFor(null);
|
||
autopilotTimeFor(time);
|
||
}}
|
||
onClose={() => setAutopilotTimeFor(null)}
|
||
/>
|
||
) : null}
|
||
<Modal
|
||
visible={botActionsOpen}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={() => setBotActionsOpen(false)}
|
||
>
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
backgroundColor: tokens.overlay,
|
||
}}
|
||
>
|
||
<Pressable
|
||
accessibilityLabel={t("Cancel")}
|
||
onPress={() => setBotActionsOpen(false)}
|
||
style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }}
|
||
/>
|
||
<View
|
||
accessibilityViewIsModal
|
||
style={{ backgroundColor: tokens.popover, borderRadius: 24, paddingVertical: 12 }}
|
||
>
|
||
{botActions.map((action) => (
|
||
<Pressable
|
||
key={action.text}
|
||
accessibilityRole="button"
|
||
onPress={() => {
|
||
setBotActionsOpen(false);
|
||
action.onPress();
|
||
}}
|
||
style={{ minHeight: 56, justifyContent: "center", paddingHorizontal: 24 }}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: action.destructive ? tokens.destructive : tokens.popoverForeground,
|
||
fontSize: 16,
|
||
}}
|
||
>
|
||
{action.text}
|
||
</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
{markdownPreview && artifactTarget ? (
|
||
<MarkdownArtifactPreview
|
||
threadTarget={artifactTarget}
|
||
target={markdownPreview}
|
||
onClose={() => setMarkdownPreview(null)}
|
||
/>
|
||
) : null}
|
||
</KeyboardAvoidingView>
|
||
);
|
||
}
|
||
|
||
function MentionOptionIcon({ mention }: { mention: ComposerMention }) {
|
||
const tokens = useMobileTokens();
|
||
if (mention.kind === "routine") {
|
||
return (
|
||
<NativeSymbol ios="clock" android="time-outline" size={16} color={tokens.mutedForeground} />
|
||
);
|
||
}
|
||
if (mention.kind === "connector") {
|
||
return (
|
||
<NativeSymbol
|
||
ios="puzzlepiece.extension"
|
||
android="extension-puzzle-outline"
|
||
size={16}
|
||
color={tokens.mutedForeground}
|
||
/>
|
||
);
|
||
}
|
||
if (mention.kind === "group") {
|
||
return (
|
||
<View
|
||
style={{
|
||
width: 16,
|
||
height: 16,
|
||
borderRadius: 8,
|
||
backgroundColor: tokens.muted,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<Text style={{ color: tokens.foreground, fontSize: 9 }}>G</Text>
|
||
</View>
|
||
);
|
||
}
|
||
if (mention.kind === "everyone") {
|
||
return (
|
||
<View
|
||
style={{
|
||
width: 16,
|
||
height: 16,
|
||
borderRadius: 8,
|
||
backgroundColor: tokens.muted,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<Text style={{ color: tokens.foreground, fontSize: 9 }}>@</Text>
|
||
</View>
|
||
);
|
||
}
|
||
return (
|
||
<View
|
||
style={{
|
||
width: 16,
|
||
height: 16,
|
||
borderRadius: 4,
|
||
backgroundColor: mention.color ?? tokens.mutedForeground,
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function MentionChipIcon({ mention }: { mention: ComposerMention }) {
|
||
const tokens = useMobileTokens();
|
||
if (mention.kind === "routine") {
|
||
return (
|
||
<NativeSymbol ios="clock" android="time-outline" size={13} color={tokens.mutedForeground} />
|
||
);
|
||
}
|
||
if (mention.kind === "connector") {
|
||
return (
|
||
<NativeSymbol
|
||
ios="puzzlepiece.extension"
|
||
android="extension-puzzle-outline"
|
||
size={13}
|
||
color={tokens.mutedForeground}
|
||
/>
|
||
);
|
||
}
|
||
if (mention.kind === "group" || mention.kind === "everyone") {
|
||
return (
|
||
<View
|
||
style={{
|
||
width: 14,
|
||
height: 14,
|
||
borderRadius: 7,
|
||
backgroundColor: tokens.muted,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<Text style={{ color: tokens.foreground, fontSize: 9 }}>
|
||
{mention.kind === "group" ? "G" : "@"}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
return (
|
||
<View
|
||
style={{
|
||
width: 14,
|
||
height: 14,
|
||
borderRadius: 4,
|
||
backgroundColor: mention.color ?? tokens.mutedForeground,
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function previewMessageText(message: MobileMessage): string {
|
||
const text = message.blocks
|
||
.flatMap((block) => {
|
||
if (block.kind === "channel_message" && block.text) {
|
||
return [
|
||
`${messagingProviderLabel(block.provider, block.transport)} · ${block.fromLabel}: ${block.text}`,
|
||
];
|
||
}
|
||
return block.kind === "text" && block.text ? [block.text] : [];
|
||
})
|
||
.join(" ")
|
||
.trim();
|
||
if (text) return text;
|
||
if (
|
||
message.blocks.some((block) =>
|
||
["image", "file", "video", "audio", "media"].includes(block.kind),
|
||
)
|
||
) {
|
||
return t("Attachment");
|
||
}
|
||
return t("Message");
|
||
}
|
||
|
||
function memberName(
|
||
members: MobileSnapshot["members"] | undefined,
|
||
botId: string | undefined,
|
||
): string | undefined {
|
||
if (!botId || !members) return undefined;
|
||
return members.find((member) => member.botId === botId)?.name;
|
||
}
|
||
|
||
async function speakMessage(botId: string, message: MobileMessage) {
|
||
const text = blockText(message);
|
||
if (!text.trim()) return;
|
||
if (!(await speakText(text, { botId }))) {
|
||
throw new Error(t("Add a voice provider in Voice settings."));
|
||
}
|
||
}
|
||
|
||
type MessageActionProps = Pick<
|
||
TextProps,
|
||
"onLongPress" | "accessibilityActions" | "onAccessibilityAction"
|
||
>;
|
||
|
||
const MessageBubble = memo(function MessageBubble({
|
||
botId,
|
||
botName,
|
||
bots,
|
||
groupId,
|
||
message,
|
||
members,
|
||
replyPreview,
|
||
canAnswer,
|
||
onAnswer,
|
||
onCardAct,
|
||
onAutopilot,
|
||
onOpenBot,
|
||
onPreviewMarkdown,
|
||
actionProps,
|
||
}: {
|
||
botId: string;
|
||
botName?: string;
|
||
bots: MobileBot[];
|
||
groupId?: string;
|
||
message: MobileMessage;
|
||
members?: MobileSnapshot["members"];
|
||
replyPreview?: MobileMessage;
|
||
canAnswer: boolean;
|
||
onAnswer: (message: MobileMessage, answer: string) => Promise<void>;
|
||
onCardAct: (message: MobileMessage, cardId: string, act: CardAct) => Promise<void>;
|
||
onAutopilot: (action: AutopilotAction) => Promise<void>;
|
||
onOpenBot: (botId: string, name: string) => void;
|
||
onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void;
|
||
actionProps: MessageActionProps;
|
||
}) {
|
||
const colorScheme = useResolvedAppearance();
|
||
const tokens = mobileTokens();
|
||
const { t } = useI18n();
|
||
const [peerExpanded, setPeerExpanded] = useState(false);
|
||
const artifactTarget: MobileArtifactTarget = groupId ? { groupId } : { botId };
|
||
const cardBotId = message.botId ?? botId;
|
||
const appConnectBlocks = message.blocks.filter(
|
||
(block): block is Extract<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}
|
||
actionProps={actionProps}
|
||
canAnswer={canAnswer}
|
||
onAnswer={(answer) => onAnswer(message, answer)}
|
||
/>
|
||
{appConnectBlocks.map((block, index) => (
|
||
<AppConnectCard
|
||
key={`${block.provider}-${index}`}
|
||
botId={cardBotId}
|
||
block={block}
|
||
accessibilityActions={actionProps.accessibilityActions}
|
||
onAccessibilityAction={actionProps.onAccessibilityAction}
|
||
/>
|
||
))}
|
||
</View>
|
||
);
|
||
}
|
||
const cards = message.blocks.filter(
|
||
(block): block is CardBlock => block.kind === "card" && Boolean(block.sections),
|
||
);
|
||
if (cards.length) {
|
||
return (
|
||
<View style={{ gap: 8, width: "100%" }}>
|
||
{cards.map((card, index) => {
|
||
const cardId = card.id;
|
||
return (
|
||
<ThreadCard
|
||
key={cardId ?? index}
|
||
block={card}
|
||
createdAt={message.createdAt}
|
||
onAct={cardId ? (act) => onCardAct(message, cardId, act) : undefined}
|
||
/>
|
||
);
|
||
})}
|
||
</View>
|
||
);
|
||
}
|
||
if (isRoutineEventMessage(message.blocks)) {
|
||
return (
|
||
<View style={{ width: "100%" }}>
|
||
{message.blocks.map((block, index) =>
|
||
block.kind === "event" ? (
|
||
<RoutineEventLine key={index} block={block} />
|
||
) : block.kind === "autopilot" ? (
|
||
<AutopilotLine key={index} block={block} onAction={onAutopilot} />
|
||
) : null,
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
const handoff = message.blocks.find((block) => block.kind === "handoff");
|
||
if (handoff) {
|
||
const from = memberName(members, handoff.fromBotId) ?? t("bot");
|
||
const to = memberName(members, handoff.toBotId) ?? t("bot");
|
||
return (
|
||
<AgentEventLabel
|
||
actionProps={actionProps}
|
||
label={t("{from} messaged {to}", { from, 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
|
||
? t("Messaged {peer}", { peer: peer ?? t("Bot") })
|
||
: t("Message from {peer}", { peer: peer ?? t("Bot") });
|
||
const peerColor =
|
||
bots.find((bot) => bot.id === peerBotId)?.color ??
|
||
members?.find((member) => member.botId === peerBotId)?.color ??
|
||
tokens.mutedForeground;
|
||
// Compact receipt only: peer bodies stay out of the human thread.
|
||
// Full view-only peer chat is web-first; mobile keeps the chip without expand.
|
||
return (
|
||
<Pressable
|
||
{...actionProps}
|
||
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: tokens.mutedForeground, fontSize: 13.5, flexShrink: 1 }}
|
||
>
|
||
{label}
|
||
</Text>
|
||
</Pressable>
|
||
);
|
||
}
|
||
const channelMessage = message.blocks.find(
|
||
(block): block is Extract<MessageBlock, { kind: "channel_message" }> =>
|
||
block.kind === "channel_message",
|
||
);
|
||
if (channelMessage) {
|
||
return (
|
||
<Pressable
|
||
{...actionProps}
|
||
style={{ width: "100%", paddingVertical: 4, alignItems: "center" }}
|
||
>
|
||
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5, textAlign: "center" }}>
|
||
{messagingProviderLabel(channelMessage.provider, channelMessage.transport)} ·{" "}
|
||
{channelMessage.fromLabel}: {channelMessage.text}
|
||
</Text>
|
||
</Pressable>
|
||
);
|
||
}
|
||
const special = message.blocks.find(
|
||
(block) =>
|
||
block.kind === "subagent" || block.kind === "child_bot" || block.kind === "cloud_agent",
|
||
);
|
||
if (special?.kind === "subagent") {
|
||
const running = special.status === "running";
|
||
const failed = special.status === "failed";
|
||
return (
|
||
<Pressable
|
||
{...actionProps}
|
||
style={{
|
||
width: "90%",
|
||
borderRadius: 18,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 14,
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
flexDirection: "row",
|
||
justifyContent: "space-between",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<Text style={{ color: tokens.foreground, fontSize: 15, fontWeight: "600" }}>
|
||
{special.name || t("subagent")}
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
color: failed ? tokens.destructive : running ? tokens.warning : tokens.success,
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{running
|
||
? t("Running")
|
||
: special.status === "failed"
|
||
? t("Failed")
|
||
: special.status === "completed"
|
||
? t("Completed")
|
||
: special.status}
|
||
</Text>
|
||
</View>
|
||
{special.task ? (
|
||
<Text style={{ color: tokens.mutedForeground, marginTop: 8, fontSize: 13.5 }}>
|
||
{special.task}
|
||
</Text>
|
||
) : null}
|
||
{special.result || special.progress ? (
|
||
<View style={{ marginTop: 8 }}>
|
||
<ChatMarkdown palette={tokens} colorScheme={colorScheme} streaming={running}>
|
||
{special.result || special.progress || ""}
|
||
</ChatMarkdown>
|
||
</View>
|
||
) : null}
|
||
</Pressable>
|
||
);
|
||
}
|
||
if (special?.kind === "cloud_agent") {
|
||
const title = special.title || t("Cloud agent");
|
||
const statusLabel =
|
||
special.status === "running"
|
||
? t("running")
|
||
: special.status === "finished"
|
||
? t("finished")
|
||
: special.status === "cancelled"
|
||
? t("cancelled")
|
||
: t("failed");
|
||
const running = special.status === "running";
|
||
const failed = special.status === "failed" || special.status === "cancelled";
|
||
const prHref = cloudAgentHttpsUrl(special.prUrl);
|
||
const href = prHref ?? cloudAgentHttpsUrl(special.url);
|
||
return (
|
||
<Pressable
|
||
onPress={() => {
|
||
if (href) Linking.openURL(href).catch(() => undefined);
|
||
}}
|
||
testID="cloud-agent-card"
|
||
accessibilityRole={href ? "link" : "text"}
|
||
accessibilityLabel={`${title}: ${statusLabel}`}
|
||
disabled={!href}
|
||
style={{
|
||
width: "90%",
|
||
borderRadius: 18,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 14,
|
||
}}
|
||
>
|
||
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 8 }}>
|
||
<Text style={{ color: tokens.cardForeground, fontSize: 15, fontWeight: "600" }}>
|
||
{title}
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
color: failed ? tokens.destructive : running ? tokens.warning : tokens.success,
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{statusLabel}
|
||
</Text>
|
||
</View>
|
||
{prHref ? (
|
||
<Text style={{ color: tokens.mutedForeground, marginTop: 8, fontSize: 14.5 }}>
|
||
{t("Pull request")}
|
||
</Text>
|
||
) : special.branch ? (
|
||
<Text style={{ color: tokens.mutedForeground, marginTop: 8, fontSize: 13.5 }}>
|
||
{special.branch}
|
||
</Text>
|
||
) : null}
|
||
</Pressable>
|
||
);
|
||
}
|
||
if (special?.kind === "child_bot") {
|
||
const removed = special.status === "deleted" || special.status === "archived";
|
||
return (
|
||
<Pressable
|
||
{...actionProps}
|
||
onPress={
|
||
removed ? undefined : () => onOpenBot(special.botId ?? "", special.name ?? t("Bot"))
|
||
}
|
||
style={{
|
||
width: "90%",
|
||
borderRadius: 18,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 14,
|
||
opacity: removed ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
flexDirection: "row",
|
||
justifyContent: "space-between",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<Text style={{ color: tokens.foreground, fontSize: 15, fontWeight: "600" }}>
|
||
{special.name || t("Bot")}
|
||
</Text>
|
||
<Text style={{ color: removed ? tokens.destructive : tokens.success, fontSize: 13 }}>
|
||
{special.status === "archived"
|
||
? t("archived")
|
||
: special.status === "deleted"
|
||
? t("deleted")
|
||
: t("bot")}
|
||
</Text>
|
||
</View>
|
||
<Text
|
||
style={{
|
||
color: tokens.mutedForeground,
|
||
marginTop: 8,
|
||
fontSize: 14.5,
|
||
lineHeight: 21,
|
||
}}
|
||
>
|
||
{removed
|
||
? special.status === "archived"
|
||
? t("Archived. Chat, memory, and files kept.")
|
||
: t("Removed with chat, computer, and memory.")
|
||
: special.title || t("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}
|
||
accessibilityActions={actionProps.accessibilityActions}
|
||
onAccessibilityAction={actionProps.onAccessibilityAction}
|
||
/>
|
||
))}
|
||
</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: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 14,
|
||
}}
|
||
>
|
||
{askBlock.text ? (
|
||
<Text
|
||
{...actionProps}
|
||
style={{ color: tokens.foreground, fontSize: 15.5, lineHeight: 23 }}
|
||
>
|
||
{askBlock.text}
|
||
</Text>
|
||
) : null}
|
||
{askBlock.detail ? (
|
||
<Text
|
||
{...(askBlock.text ? {} : actionProps)}
|
||
style={{
|
||
color: tokens.mutedForeground,
|
||
marginTop: askBlock.text ? 8 : 0,
|
||
fontSize: 12.5,
|
||
fontFamily: "Menlo",
|
||
lineHeight: 20,
|
||
}}
|
||
>
|
||
{askBlock.detail}
|
||
</Text>
|
||
) : null}
|
||
{askBlock.status === "answered" ? (
|
||
<Text
|
||
{...actionProps}
|
||
style={{
|
||
color: tokens.success,
|
||
marginTop: 12,
|
||
fontSize: 13.5,
|
||
fontWeight: "600",
|
||
}}
|
||
>
|
||
{formatApprovalAnswer(
|
||
askBlock.answer,
|
||
askBlock.actions,
|
||
isApprovalAskBlock(askBlock),
|
||
)}
|
||
</Text>
|
||
) : canAnswer && onAnswer ? (
|
||
<AskActions
|
||
actions={askBlock.actions}
|
||
accessibilityActions={actionProps.accessibilityActions}
|
||
onAccessibilityAction={actionProps.onAccessibilityAction}
|
||
onAnswer={(answer) => onAnswer(message, answer)}
|
||
/>
|
||
) : (
|
||
<Text
|
||
{...actionProps}
|
||
style={{ color: tokens.mutedForeground, marginTop: 12, fontSize: 13.5 }}
|
||
>
|
||
{t("No longer active")}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
{appConnectBlocks.map((block, index) => (
|
||
<AppConnectCard
|
||
key={`${block.provider}-${index}`}
|
||
botId={cardBotId}
|
||
block={block}
|
||
accessibilityActions={actionProps.accessibilityActions}
|
||
onAccessibilityAction={actionProps.onAccessibilityAction}
|
||
/>
|
||
))}
|
||
</View>
|
||
);
|
||
}
|
||
const attachments = messageAttachments(message.blocks);
|
||
const caption = message.blocks
|
||
.flatMap((block) => {
|
||
if (block.kind === "channel_message" && block.text) {
|
||
return [
|
||
`${messagingProviderLabel(block.provider, block.transport)} · ${block.fromLabel}: ${block.text}`,
|
||
];
|
||
}
|
||
if (block.kind === "media") return [block.title];
|
||
return block.kind === "text" && block.text ? [block.text] : [];
|
||
})
|
||
.join("\n");
|
||
if (attachments.length > 0) {
|
||
const speaker =
|
||
message.role === "bot" ? (memberName(members, message.botId) ?? botName) : undefined;
|
||
return (
|
||
<View
|
||
style={{
|
||
maxWidth: "100%",
|
||
borderRadius: 20,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
backgroundColor: message.role === "user" ? tokens.secondary : tokens.muted,
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 12,
|
||
gap: 8,
|
||
}}
|
||
>
|
||
{speaker ? (
|
||
<Text style={{ color: tokens.mutedForeground, fontSize: 12.5, fontWeight: "600" }}>
|
||
{speaker}
|
||
</Text>
|
||
) : null}
|
||
{replyPreview || (message.replyToMessageId && message.replyQuote) ? (
|
||
<Text
|
||
style={{
|
||
color: message.role === "user" ? tokens.secondaryForeground : tokens.mutedForeground,
|
||
fontSize: 12.5,
|
||
}}
|
||
numberOfLines={2}
|
||
>
|
||
{message.replyQuote
|
||
? `“${message.replyQuote}”`
|
||
: replyPreview
|
||
? previewMessageText(replyPreview)
|
||
: ""}
|
||
</Text>
|
||
) : null}
|
||
{caption ? (
|
||
<Text
|
||
style={{
|
||
color: message.role === "user" ? tokens.secondaryForeground : tokens.foreground,
|
||
fontSize: 15,
|
||
}}
|
||
>
|
||
{caption}
|
||
</Text>
|
||
) : null}
|
||
{attachments.map((attachment, index) =>
|
||
attachment.kind === "image" ? (
|
||
<Pressable
|
||
{...actionProps}
|
||
key={`${attachment.artifactId ?? attachment.name ?? "image"}-${index}`}
|
||
onPress={() =>
|
||
attachment.artifactId
|
||
? void openMobileArtifact(
|
||
artifactTarget,
|
||
attachment.artifactId,
|
||
attachment.name ?? t("Image"),
|
||
attachment.mimeType ?? "image/png",
|
||
).catch((err) =>
|
||
Alert.alert(
|
||
t("Could not open image"),
|
||
err instanceof Error ? err.message : t("Try again."),
|
||
),
|
||
)
|
||
: undefined
|
||
}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: message.role === "user" ? tokens.secondaryForeground : tokens.foreground,
|
||
fontSize: 15,
|
||
}}
|
||
>
|
||
🖼 {attachment.name ?? t("Image")}
|
||
</Text>
|
||
</Pressable>
|
||
) : (
|
||
<Pressable
|
||
{...actionProps}
|
||
key={`${attachment.artifactId ?? attachment.name ?? "file"}-${index}`}
|
||
onPress={() =>
|
||
attachment.artifactId
|
||
? attachment.mimeType === "text/markdown"
|
||
? onPreviewMarkdown({
|
||
artifactId: attachment.artifactId,
|
||
name: attachment.name ?? t("Markdown file"),
|
||
mimeType: attachment.mimeType,
|
||
})
|
||
: void openMobileArtifact(
|
||
artifactTarget,
|
||
attachment.artifactId,
|
||
attachment.name ?? t("File"),
|
||
attachment.mimeType ?? "text/plain",
|
||
).catch((err) =>
|
||
Alert.alert(
|
||
t("Could not open file"),
|
||
err instanceof Error ? err.message : t("Try again."),
|
||
),
|
||
)
|
||
: undefined
|
||
}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: message.role === "user" ? tokens.secondaryForeground : tokens.foreground,
|
||
fontSize: 15,
|
||
}}
|
||
>
|
||
{attachment.kind === "video" ? "🎬" : attachment.kind === "audio" ? "🎵" : "📎"}{" "}
|
||
{attachment.name ?? t("File")}
|
||
</Text>
|
||
{attachment.size ? (
|
||
<Text
|
||
style={{
|
||
color:
|
||
message.role === "user" ? tokens.secondaryForeground : tokens.mutedForeground,
|
||
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}
|
||
accessibilityActions={actionProps.accessibilityActions}
|
||
onAccessibilityAction={actionProps.onAccessibilityAction}
|
||
/>
|
||
))}
|
||
</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");
|
||
return (
|
||
<View style={{ gap: 8, width: "100%" }}>
|
||
{segments.map((segment, index) => (
|
||
<MessageTextCard
|
||
key={`${message.id}-content-${index}`}
|
||
message={{ ...message, blocks: segment.blocks }}
|
||
speaker={index === firstContent ? speaker : undefined}
|
||
replyPreview={index === firstContent ? replyPreview : undefined}
|
||
actionProps={actionProps}
|
||
/>
|
||
))}
|
||
{appConnectBlocks.map((block, index) => (
|
||
<AppConnectCard
|
||
key={`${block.provider}-${index}`}
|
||
botId={cardBotId}
|
||
block={block}
|
||
accessibilityActions={actionProps.accessibilityActions}
|
||
onAccessibilityAction={actionProps.onAccessibilityAction}
|
||
/>
|
||
))}
|
||
</View>
|
||
);
|
||
});
|
||
|
||
function MessageTextCard({
|
||
message,
|
||
speaker,
|
||
replyPreview,
|
||
actionProps,
|
||
}: {
|
||
message: MobileMessage;
|
||
speaker?: string;
|
||
replyPreview?: MobileMessage;
|
||
actionProps: MessageActionProps;
|
||
}) {
|
||
const colorScheme = useResolvedAppearance();
|
||
const tokens = mobileTokens();
|
||
const contentText = blockText(message);
|
||
if (!contentText) return null;
|
||
return (
|
||
<Pressable
|
||
{...actionProps}
|
||
style={{
|
||
flexShrink: 1,
|
||
minWidth: 0,
|
||
maxWidth: "100%",
|
||
backgroundColor: message.role === "user" ? tokens.secondary : tokens.muted,
|
||
padding: 12,
|
||
borderRadius: 20,
|
||
}}
|
||
>
|
||
{speaker ? (
|
||
<Text
|
||
style={{
|
||
color: tokens.mutedForeground,
|
||
fontSize: 12.5,
|
||
fontWeight: "600",
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
{speaker}
|
||
</Text>
|
||
) : null}
|
||
{replyPreview || (message.replyToMessageId && message.replyQuote) ? (
|
||
<Text
|
||
style={{
|
||
color: message.role === "user" ? tokens.secondaryForeground : tokens.mutedForeground,
|
||
fontSize: 12.5,
|
||
marginBottom: 6,
|
||
}}
|
||
numberOfLines={2}
|
||
>
|
||
{message.replyQuote
|
||
? `“${message.replyQuote}”`
|
||
: replyPreview
|
||
? previewMessageText(replyPreview)
|
||
: ""}
|
||
</Text>
|
||
) : null}
|
||
{message.role === "user" ? (
|
||
<Text style={{ color: tokens.secondaryForeground, fontSize: 15.5, lineHeight: 23 }}>
|
||
{contentText}
|
||
</Text>
|
||
) : (
|
||
<ChatMarkdown
|
||
palette={tokens}
|
||
colorScheme={colorScheme}
|
||
streaming={message.id.startsWith("progress:")}
|
||
>
|
||
{contentText}
|
||
</ChatMarkdown>
|
||
)}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
function AgentEventLabel({
|
||
label,
|
||
detail,
|
||
expanded,
|
||
onToggle,
|
||
actionProps,
|
||
}: {
|
||
label: string;
|
||
detail?: string;
|
||
expanded: boolean;
|
||
onToggle: () => void;
|
||
actionProps: MessageActionProps;
|
||
}) {
|
||
const colorScheme = useResolvedAppearance();
|
||
const tokens = mobileTokens();
|
||
const { t } = useI18n();
|
||
return (
|
||
<Pressable
|
||
{...actionProps}
|
||
onPress={onToggle}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={expanded ? t("Hide {label}", { label }) : t("Show {label}", { label })}
|
||
style={{ width: "100%", paddingVertical: 4, alignItems: "center" }}
|
||
>
|
||
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5, textAlign: "center" }}>
|
||
↔ {label}
|
||
</Text>
|
||
{expanded && detail ? (
|
||
<View
|
||
style={{
|
||
width: "100%",
|
||
marginTop: 6,
|
||
borderRadius: 14,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 10,
|
||
}}
|
||
>
|
||
<ChatMarkdown palette={tokens} colorScheme={colorScheme}>
|
||
{detail}
|
||
</ChatMarkdown>
|
||
</View>
|
||
) : null}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
function AskBlock({
|
||
ask,
|
||
canAnswer,
|
||
onAnswer,
|
||
actionProps,
|
||
}: {
|
||
ask: Extract<MobileMessage["blocks"][number], { kind: "ask" }>;
|
||
canAnswer: boolean;
|
||
onAnswer: (answer: string) => Promise<void>;
|
||
actionProps: MessageActionProps;
|
||
}) {
|
||
const tokens = useMobileTokens();
|
||
const { t } = useI18n();
|
||
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);
|
||
const secretLabel =
|
||
ask.purpose === "password"
|
||
? t("Password")
|
||
: ask.purpose === "api_key"
|
||
? t("API key")
|
||
: t("Code");
|
||
const submitLabel = secretInput ? t("Save") : t("Send answer");
|
||
const submittingLabel = secretInput ? t("Saving…") : t("Sending…");
|
||
|
||
async function submit() {
|
||
if (submitting) return;
|
||
if (secretInput ? answer.length === 0 : !answer.trim()) return;
|
||
const submitValue = secretInput ? answer : answer.trim();
|
||
setSubmitting(true);
|
||
setError(null);
|
||
if (secretInput) setAnswer("");
|
||
try {
|
||
await onAnswer(submitValue);
|
||
} catch (cause) {
|
||
setError(!secretInput && cause instanceof Error ? cause.message : t("Could not send answer"));
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<View
|
||
style={{
|
||
width: "90%",
|
||
borderRadius: 18,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
backgroundColor: tokens.card,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 14,
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<Text
|
||
{...actionProps}
|
||
style={{ color: tokens.foreground, fontSize: 15.5, fontWeight: "600" }}
|
||
>
|
||
{ask.text}
|
||
</Text>
|
||
{secretInput && ask.credential ? (
|
||
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>
|
||
{ask.credential.origin}
|
||
</Text>
|
||
) : null}
|
||
{ask.detail && !secretInput ? (
|
||
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>{ask.detail}</Text>
|
||
) : null}
|
||
{answered ? (
|
||
<Text style={{ color: tokens.success, fontSize: 14 }}>
|
||
{secretInput ? t("Saved") : t("Answered: {answer}", { answer: ask.answer ?? t("Done") })}
|
||
</Text>
|
||
) : canAnswer ? (
|
||
<>
|
||
<TextInput
|
||
accessibilityLabel={secretInput ? secretLabel : t("Answer")}
|
||
value={answer}
|
||
onChangeText={setAnswer}
|
||
placeholder={secretInput ? secretLabel : t("Type your answer")}
|
||
placeholderTextColor={tokens.mutedForeground}
|
||
secureTextEntry={secretInput}
|
||
autoComplete="off"
|
||
autoCorrect={secretInput ? false : undefined}
|
||
autoCapitalize={secretInput ? "none" : "sentences"}
|
||
editable={!submitting}
|
||
onSubmitEditing={() => void submit()}
|
||
style={{
|
||
minHeight: 42,
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
borderColor: tokens.border,
|
||
color: tokens.foreground,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 9,
|
||
}}
|
||
/>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={submitLabel}
|
||
disabled={(secretInput ? answer.length === 0 : !answer.trim()) || submitting}
|
||
onPress={() => void submit()}
|
||
style={{
|
||
alignSelf: "flex-end",
|
||
borderRadius: 999,
|
||
backgroundColor: tokens.foreground,
|
||
opacity: (secretInput ? answer.length === 0 : !answer.trim()) || submitting ? 0.5 : 1,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 9,
|
||
}}
|
||
>
|
||
<Text style={{ color: tokens.primaryForeground, fontWeight: "600" }}>
|
||
{submitting ? submittingLabel : submitLabel}
|
||
</Text>
|
||
</Pressable>
|
||
</>
|
||
) : (
|
||
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>
|
||
{t("Waiting for this bot’s response.")}
|
||
</Text>
|
||
)}
|
||
{error ? <Text style={{ color: tokens.destructive, fontSize: 13 }}>{error}</Text> : null}
|
||
</View>
|
||
);
|
||
}
|