import type { AutopilotRecent } from "@rakazo/contracts"; import { normalizeCreateBotProfile, type RunActivityRow, type SearchHit, type SpaceBot, type SpaceGroup, } from "@rakazo/contracts"; import { botColors } from "@rakazo/ui-tokens"; import { Redirect, useFocusEffect, useRouter } from "expo-router"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Alert, AppState, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BotAvatar } from "../components/bot-avatar"; import { BotOrganizeModal } from "../components/bot-organize-modal"; import { GroupAvatar } from "../components/group-avatar"; import { NativeSymbol } from "../components/native-symbol"; import { activityStatusLabel, fetchSpaceActivity, formatActivityRelativeTime, } from "../lib/activity"; import { loadActivityMode, saveActivityMode } from "../lib/activity-mode"; import { currentApiBase, loadSessionToken, type MobileBot, type MobileBotSection, type MobileGroup, type MobileMe, type MobileSpace, type MobileSpaceNavigation, rpc, selectedSpaceId, selectInitialSpace, selectSpace, } from "../lib/api"; import { mobileShellIconSizes, mobileTokens, resolveMobileAppearance } from "../lib/appearance"; import { autopilotSummary } from "../lib/autopilot"; import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt"; import { t, useI18n } from "../lib/i18n"; import { botTag, filterBots, formatThreadTime, userInitials } from "../lib/inbox"; import { canDeleteInboxSpace, type InboxSpace, type InboxSpaceItem, removeInboxSpace, retryInboxSpaceFallback, selectInboxSpace, spaceInboxItems, } from "../lib/inbox-spaces"; import { dismissThreadNotifications, resumeLiveNotifications } from "../lib/live-notifications"; import { native, useThemedStyles } from "../lib/native"; import { previewSnippet } from "../lib/preview"; import { registerPushToken } from "../lib/push"; import { querySpaceSearch } from "../lib/search"; import { mobileSearchDestination } from "../lib/search-destination"; const FALLBACK_COLOR = botColors[3]; type InboxItem = InboxSpaceItem | { type: "search"; hit: SearchHit }; async function openMobileSpace(spaceId: string | undefined, open: () => void) { if (spaceId && !(await selectSpace(spaceId))) { Alert.alert(t("Could not switch spaces"), t("Try again.")); return; } open(); } export default function Home() { const tokens = mobileTokens(); const appearance = resolveMobileAppearance(); const styles = useThemedStyles(createHomeStyles); const { t, locale } = useI18n(); const [bots, setBots] = useState([]); const [groups, setGroups] = useState([]); const [botSections, setBotSections] = useState([]); const [spaces, setSpaces] = useState([]); const [me, setMe] = useState(null); const [autopilots, setAutopilots] = useState([]); const [error, setError] = useState(null); const [ready, setReady] = useState(false); const [hasSession, setHasSession] = useState(false); const [refreshing, setRefreshing] = useState(false); const [query, setQuery] = useState(""); const [searching, setSearching] = useState(false); const [searchHits, setSearchHits] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [organizeTarget, setOrganizeTarget] = useState<{ kind: "bot" | "group"; id: string; } | null>(null); const [activityMode, setActivityMode] = useState(false); const [activity, setActivity] = useState<{ active: RunActivityRow[]; recent: RunActivityRow[] }>({ active: [], recent: [], }); const activityRequestId = useRef(0); const inboxRequestId = useRef(0); const creatingBotRef = useRef(false); const spaceActionRef = useRef<{ busy: boolean; recoveryId: string | null }>({ busy: false, recoveryId: null, }); const [spaceBusy, setSpaceBusy] = useState(false); const [spaceRecoveryId, setSpaceRecoveryId] = useState(null); useEffect(() => { void loadActivityMode().then(setActivityMode); }, []); const toggleActivityMode = useCallback(() => { setActivityMode((on) => { const next = !on; void saveActivityMode(next); return next; }); }, []); const loadBots = useCallback(async () => { if (spaceActionRef.current.busy || spaceActionRef.current.recoveryId) return; const requestId = ++inboxRequestId.current; setError(null); try { const [navigation, nextMe, recentAutopilots] = await Promise.all([ rpc("spaces/list"), rpc("me"), rpc<{ items: AutopilotRecent[] }>("autopilot/recent").catch(() => null), ]); if (requestId !== inboxRequestId.current) return; if (!(await selectInitialSpace(nextMe.spaceId))) { throw new Error(t("Could not save the default space")); } if (requestId !== inboxRequestId.current) return; setBots(navigation.current.bots); setBotSections(navigation.current.botSections); setGroups(navigation.current.groups); setSpaces(navigation.spaces); setMe(nextMe); if (recentAutopilots) setAutopilots(recentAutopilots.items); } catch (err) { if (requestId !== inboxRequestId.current) return; setError(err instanceof Error ? err.message : t("Could not load bots")); } }, []); const refreshBots = useCallback(async () => { setRefreshing(true); try { await loadBots(); } finally { setRefreshing(false); } }, [loadBots]); useEffect(() => { void loadSessionToken().then((token) => { setHasSession(Boolean(token)); setReady(true); }); }, []); useEffect(() => { if (!hasSession) return; void registerPushToken().catch(() => undefined); }, [hasSession]); useFocusEffect( useCallback(() => { if (!hasSession) return; let cancelled = false; let timer: ReturnType | undefined; const tick = async () => { if (AppState.currentState === "active") await loadBots(); if (!cancelled) timer = setTimeout(() => void tick(), 5_000); }; void tick(); return () => { cancelled = true; if (timer !== undefined) clearTimeout(timer); }; }, [hasSession, loadBots]), ); const loadActivity = useCallback(async () => { if (spaceActionRef.current.busy || spaceActionRef.current.recoveryId) return; if (!hasSession || !activityMode || searching || query.trim()) { activityRequestId.current += 1; setActivity({ active: [], recent: [] }); return; } const requestId = ++activityRequestId.current; try { const next = await fetchSpaceActivity(); if (requestId !== activityRequestId.current) return; setActivity(next); } catch { // Keep the last good snapshot on transient RPC failures; only drop stale responses. if (requestId !== activityRequestId.current) return; } }, [activityMode, hasSession, query, searching]); useFocusEffect( useCallback(() => { if (!hasSession || !activityMode || searching || query.trim()) return; let cancelled = false; let timer: ReturnType | undefined; const tick = async () => { await loadActivity(); if (!cancelled) { timer = setTimeout(() => void tick(), 15_000); } }; void tick(); return () => { cancelled = true; activityRequestId.current += 1; if (timer !== undefined) clearTimeout(timer); }; }, [activityMode, hasSession, loadActivity, query, searching]), ); useEffect(() => { const trimmed = query.trim(); if (!searching || !trimmed) { setSearchHits([]); setSearchLoading(false); return; } const abort = new AbortController(); const timer = setTimeout(() => { setSearchLoading(true); void querySpaceSearch(trimmed) .then((hits) => { if (!abort.signal.aborted) setSearchHits(hits); }) .catch(() => { if (!abort.signal.aborted) setSearchHits([]); }) .finally(() => { if (!abort.signal.aborted) setSearchLoading(false); }); }, 200); return () => { abort.abort(); clearTimeout(timer); }; }, [query, searching]); const visible = useMemo(() => filterBots(bots, query), [bots, query]); const visibleGroups = useMemo(() => { const needle = query.trim().toLowerCase(); if (!needle) return groups; return groups.filter((group) => `${group.name} ${group.preview}`.toLowerCase().includes(needle), ); }, [groups, query]); const listData = useMemo((): InboxItem[] => { if (query.trim() && searching) { return searchHits.map((hit) => ({ type: "search", hit })); } const sidebarSpaces = spaces.length > 0 ? spaces.map((space) => space.id === me?.spaceId ? { ...space, bots: visible, groups: visibleGroups, botSections } : { ...space, bots: filterBots(space.bots, query), groups: space.groups.filter((group) => `${group.name} ${group.preview}` .toLowerCase() .includes(query.trim().toLowerCase()), ), }, ) : me ? [ { id: me.spaceId, name: t("Personal"), isDefault: true, hasContent: true, bots: visible, groups: visibleGroups, botSections, }, ] : []; return spaceInboxItems(sidebarSpaces); }, [botSections, locale, me, spaces, query, searching, searchHits, visible, visibleGroups]); const initials = userInitials(me?.name ?? ""); const organizeChat = organizeTarget ? organizeTarget.kind === "bot" ? bots.find((bot) => bot.id === organizeTarget.id) : groups.find((group) => group.id === organizeTarget.id) : null; const insets = useSafeAreaInsets(); const router = useRouter(); async function chooseInboxSpace(spaceId: string) { if (spaceActionRef.current.busy) return; spaceActionRef.current.busy = true; setSpaceBusy(true); inboxRequestId.current += 1; activityRequestId.current += 1; setActivity({ active: [], recent: [] }); try { const refresh = async () => { spaceActionRef.current.recoveryId = null; setSpaceRecoveryId(null); spaceActionRef.current.busy = false; await refreshBots(); await loadActivity(); }; const selected = spaceActionRef.current.recoveryId === spaceId ? await retryInboxSpaceFallback(spaceId, refresh) : await selectInboxSpace(spaceId, refresh); if (!selected) throw new Error(t("Could not switch spaces")); } catch (err) { const message = err instanceof Error ? err.message : t("Could not switch spaces"); setError(message); Alert.alert(message, t("Try again.")); } finally { spaceActionRef.current.busy = false; setSpaceBusy(false); } } async function deleteInboxSpace(space: InboxSpace) { if ( !canDeleteInboxSpace(space) || spaceActionRef.current.busy || spaceActionRef.current.recoveryId ) return; spaceActionRef.current.busy = true; setSpaceBusy(true); inboxRequestId.current += 1; activityRequestId.current += 1; try { const recoveryId = await removeInboxSpace(space.id, async () => { spaceActionRef.current.busy = false; setActivity({ active: [], recent: [] }); await refreshBots(); await loadActivity(); }); if (recoveryId) { spaceActionRef.current.recoveryId = recoveryId; setSpaceRecoveryId(recoveryId); setError(t("Could not switch spaces")); } } catch (err) { Alert.alert( t("Could not delete space"), err instanceof Error ? err.message : t("Try again."), ); } finally { spaceActionRef.current.busy = false; setSpaceBusy(false); } } function showSpaceActions(space: InboxSpace) { if ( !canDeleteInboxSpace(space) || spaceActionRef.current.busy || spaceActionRef.current.recoveryId ) return; Alert.alert(space.name, undefined, [ { text: t("Cancel"), style: "cancel" }, { text: t("Delete space"), style: "destructive", onPress: () => Alert.alert( t("Delete {name}?", { name: space.name }), t("This removes the empty space for everyone."), [ { text: t("Cancel"), style: "cancel" }, { text: t("Delete"), style: "destructive", onPress: () => void deleteInboxSpace(space), }, ], ), }, ]); } const createQuickBot = useCallback(async () => { if (creatingBotRef.current || spaceActionRef.current.busy || spaceActionRef.current.recoveryId) return; creatingBotRef.current = true; try { // Authoritative roster so a slow home fetch does not treat later bots as first. // A failed list is unknown — use the delayed path rather than assuming first. const existing = await rpc("bots/list").catch(() => null); const isFirstBot = existing !== null && existing.length === 0; const bot = await rpc("bots/create", { ...normalizeCreateBotProfile({ name: "New Bot", title: "", description: "" }), notifyOnFinish: true, computerMode: "team", }); void refreshBots().catch(() => undefined); allowFocusPrompt(bot.id); router.replace({ pathname: "/thread", params: { botId: bot.id, name: bot.name } }); void (async () => { const started = await rpc("onboarding/start", { botId: bot.id }) .then(() => true) .catch(() => false); if (!started) return; scheduleFocusPrompt(bot.id, isFirstBot); })(); } catch (err) { setError(err instanceof Error ? err.message : t("Could not create bot")); } finally { creatingBotRef.current = false; } }, [refreshBots, router, t]); if (!ready) { return ( ); } if (!hasSession) return ; return ( router.push("/account")}> {initials} setSearching((open) => { if (open) setQuery(""); return !open; }) } > { if (spaceActionRef.current.busy || spaceActionRef.current.recoveryId) return; Alert.alert(t("Create"), undefined, [ { text: t("New bot"), onPress: () => void createQuickBot() }, { text: t("New group"), onPress: () => router.push("/new-group") }, { text: t("New space"), onPress: () => router.push("/new-space") }, { text: t("Cancel"), style: "cancel" }, ]); }} > {searching ? ( ) : null} {error ? {error} : null} {spaceRecoveryId ? ( void chooseInboxSpace(spaceRecoveryId)} style={styles.recoveryAction} > {t("Try again.")} ) : null} data={listData} keyExtractor={(item) => { if (item.type === "heading") return `heading-${item.key}`; if (item.type === "bot") return item.bot.id; if (item.type === "group") return `group-${item.group.id}`; const hit = item.hit; return `${hit.kind}-${hit.botId ?? hit.groupId}-${hit.messageId ?? hit.artifactId ?? hit.routineId ?? hit.url}`; }} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" indicatorStyle={appearance === "dark" ? "white" : "black"} contentContainerStyle={styles.list} refreshControl={ { void refreshBots(); void loadActivity(); }} tintColor={native.secondaryLabel} colors={[tokens.mutedForeground]} progressBackgroundColor={tokens.muted} /> } ListHeaderComponent={ !searching && !query.trim() ? ( <> {autopilots.length > 0 ? ( ) : null} {activityMode && (activity.active.length > 0 || activity.recent.length > 0) ? ( ) : null} ) : null } ListEmptyComponent={ {query.trim() && searching ? searchLoading ? t("Searching…") : t("No results") : query.trim() ? t("No matching bots") : searching ? t("Search conversations, files, and routines") : t("Tap + to create a bot")} } renderItem={({ item }) => item.type === "search" ? ( { setQuery(""); setSearchHits([]); router.push(mobileSearchDestination(item.hit)); }} /> ) : item.type === "heading" ? ( item.space ? ( { if (item.space) void chooseInboxSpace(item.space.id); }} style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]} > {item.title} {canDeleteInboxSpace(item.space) ? ( { if (item.space) showSpaceActions(item.space); }} style={({ pressed }) => [styles.spaceActions, pressed && styles.rowPressed]} > ) : null} ) : ( {item.title} ) ) : item.type === "group" ? ( { if (spaceActionRef.current.busy || spaceActionRef.current.recoveryId) return; void openMobileSpace(item.group.spaceId, () => router.push({ pathname: "/group-thread", params: { groupId: item.group.id, name: item.group.name }, }), ); }} onLongPress={ item.group.spaceId === me?.spaceId ? () => setOrganizeTarget({ kind: "group", id: item.group.id }) : undefined } /> ) : ( { if (spaceActionRef.current.busy || spaceActionRef.current.recoveryId) return; void openMobileSpace(item.bot.spaceId, () => router.push({ pathname: "/thread", params: { botId: item.bot.id, name: item.bot.name }, }), ); }} onLongPress={ item.bot.spaceId === me?.spaceId ? () => setOrganizeTarget({ kind: "bot", id: item.bot.id }) : undefined } /> ) } /> {organizeChat && organizeTarget ? ( setOrganizeTarget(null)} onUpdate={async (update) => { await rpc(`${organizeTarget.kind}s/update`, { [`${organizeTarget.kind}Id`]: organizeChat.id, ...update, }); if (organizeTarget.kind === "bot" && update.notifyOnFinish !== undefined) { await resumeLiveNotifications( currentApiBase(), await loadSessionToken(), selectedSpaceId() ?? "", ).catch(() => undefined); if (!update.notifyOnFinish && "threadId" in organizeChat) { await dismissThreadNotifications({ threadId: organizeChat.threadId }).catch( () => undefined, ); } } await loadBots(); }} onCreateSection={async (name) => { await rpc("botSections/create", { [`${organizeTarget.kind}Id`]: organizeChat.id, name, }); await loadBots(); }} onRenameSection={async (sectionId, name) => { await rpc("botSections/update", { sectionId, name }); await loadBots(); }} /> ) : null} ); } function ActivitySection({ activity, bots, }: { activity: { active: RunActivityRow[]; recent: RunActivityRow[] }; bots: MobileBot[]; }) { const styles = useThemedStyles(createHomeStyles); const { t } = useI18n(); const router = useRouter(); const botsById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot])), [bots]); const openRun = (run: RunActivityRow) => { if (run.groupId) { router.push({ pathname: "/group-thread", params: { groupId: run.groupId, name: run.groupName ?? t("Group") }, }); return; } router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } }); }; return ( {activity.active.length > 0 ? ( <> {t("Now")} {activity.active.map((run) => ( openRun(run)} /> ))} ) : null} {activity.recent.length > 0 ? ( <> 0 && styles.activityGap]}> {t("Recent")} {activity.recent.map((run) => ( openRun(run)} /> ))} ) : null} ); } /** The night's work, like Today on web: each autopilot that runs, waits, or ended within a day. */ function AutopilotSection({ autopilots, bots, groups, }: { autopilots: AutopilotRecent[]; bots: MobileBot[]; groups: MobileGroup[]; }) { const styles = useThemedStyles(createHomeStyles); const { t } = useI18n(); const router = useRouter(); const botsById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot])), [bots]); const open = (autopilot: AutopilotRecent) => void openMobileSpace(autopilot.spaceId, () => router.push( autopilot.groupId ? { pathname: "/group-thread", params: { groupId: autopilot.groupId, name: groups.find((group) => group.id === autopilot.groupId)?.name ?? t("Group"), }, } : { pathname: "/thread", params: { botId: autopilot.botId, name: botsById.get(autopilot.botId)?.name ?? "", }, }, ), ); return ( {t("Autopilot")} {autopilots.map((autopilot) => { const { detail, trail } = autopilotSummary(autopilot); return ( open(autopilot)} avatar={ } /> ); })} ); } function ActivityRow({ run, bot, onPress, }: { run: RunActivityRow; bot?: MobileBot; onPress: () => void; }) { const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName; const status = activityStatusLabel(run.status); const preview = run.promptSnippet ? `${run.promptSnippet} · ${status}` : status; return ( } /> ); } function ConversationRow({ title, preview, time, avatar, tag, unread, onPress, onLongPress, accessibilityLabel, accessibilityHint, }: { title: string; preview: string; time: string; avatar: ReactNode; tag?: string | null; unread?: boolean; onPress: () => void; onLongPress?: () => void; accessibilityLabel: string; accessibilityHint?: string; }) { const styles = useThemedStyles(createHomeStyles); return ( [styles.row, pressed && styles.rowPressed]} > {avatar} {title} {tag ? ( {tag} ) : null} {time ? {time} : null} {unread ? : null} {preview} ); } function CircleButton({ children, onPress, accessibilityLabel, active = false, accent = false, }: { children: ReactNode; onPress: () => void; accessibilityLabel: string; active?: boolean; accent?: boolean; }) { const styles = useThemedStyles(createHomeStyles); return ( [ styles.circleButton, accent && active ? styles.circleAccent : (active || pressed) && styles.circlePressed, ]} > {children} ); } function SearchRow({ hit, onPress }: { hit: SearchHit; onPress: () => void }) { const styles = useThemedStyles(createHomeStyles); return ( [styles.row, pressed && styles.rowPressed]} > {hit.title} {hit.kind} {hit.groupName ?? hit.botName} · {hit.snippet} ); } function BotRow({ bot, onPress, onLongPress, }: { bot: MobileBot | SpaceBot; onPress: () => void; onLongPress?: () => void; }) { const { t } = useI18n(); const preview = previewSnippet(bot.preview, 40) || bot.title || t("No messages yet"); const time = bot.updatedAt ? formatThreadTime(bot.updatedAt) : ""; const tag = botTag(bot.title, bot.name); // Spelled out because an explicit label replaces the one built from the row's children. const label = [ bot.name, tag, bot.notifyOnFinish ? null : t("notifications silenced"), bot.unread ? t("unread") : null, time, preview, ] .filter(Boolean) .join(", "); return ( } /> ); } function GroupRow({ group, onPress, onLongPress, }: { group: MobileGroup | SpaceGroup; onPress: () => void; onLongPress?: () => void; }) { const { t } = useI18n(); const preview = previewSnippet(group.preview, 40) || group.members.map((member) => member.name).join(", "); const time = group.updatedAt ? formatThreadTime(group.updatedAt) : ""; return ( } /> ); } function createHomeStyles() { const tokens = mobileTokens(); return StyleSheet.create({ screen: { flex: 1, backgroundColor: native.page, }, centered: { alignItems: "center", justifyContent: "center", }, header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingTop: 8, paddingBottom: 10, }, headerActions: { flexDirection: "row", alignItems: "center", gap: 12, }, circleButton: { width: 44, height: 44, borderRadius: 22, backgroundColor: native.fillPressed, alignItems: "center", justifyContent: "center", overflow: "hidden", }, circlePressed: { backgroundColor: native.fill, }, circleAccent: { backgroundColor: tokens.primary, }, profileInitials: { color: native.label, fontSize: 15, fontWeight: "600", }, searchField: { marginHorizontal: 16, marginBottom: 8, minHeight: 44, paddingVertical: 10, textAlignVertical: "center", borderRadius: 10, backgroundColor: native.fill, color: native.label, paddingHorizontal: 12, fontSize: 17, writingDirection: "auto", }, error: { color: native.secondaryLabel, paddingHorizontal: 20, paddingBottom: 8, }, list: { flexGrow: 1, paddingBottom: 32, }, empty: { color: native.secondaryLabel, fontSize: 16, paddingHorizontal: 20, paddingTop: 28, }, row: { flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 10, gap: 12, }, rowPressed: { opacity: 0.55, }, rowBody: { flex: 1, minWidth: 0, gap: 2, }, rowTop: { flexDirection: "row", alignItems: "center", gap: 8, }, titleRow: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6, }, rowMeta: { flexDirection: "row", alignItems: "center", gap: 7, }, name: { flexShrink: 1, color: native.label, fontSize: 17, fontWeight: "600", writingDirection: "auto", }, tag: { flexShrink: 1, borderRadius: 999, backgroundColor: native.fill, paddingHorizontal: 7, paddingVertical: 2, }, tagLabel: { color: native.secondaryLabel, fontSize: 11, fontWeight: "500", writingDirection: "auto", }, time: { color: native.secondaryLabel, fontSize: 15, }, preview: { color: native.secondaryLabel, fontSize: 15, lineHeight: 20, writingDirection: "auto", }, unreadPreview: { color: native.label, fontWeight: "600", }, unreadDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: tokens.foreground, }, spaceHeading: { flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingTop: 8, }, spaceSelect: { flex: 1, minHeight: 44, justifyContent: "center", }, spaceTitle: { color: native.label, fontSize: 14, fontWeight: "600", writingDirection: "auto", }, spaceActions: { width: 44, height: 44, alignItems: "center", justifyContent: "center", }, recoveryAction: { minHeight: 44, paddingHorizontal: 20, justifyContent: "center", }, sectionHeading: { color: native.secondaryLabel, fontSize: 14, fontWeight: "600", paddingHorizontal: 16, paddingTop: 12, paddingBottom: 4, }, activitySection: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: native.fillPressed, marginBottom: 4, paddingBottom: 4, }, activityGap: { paddingTop: 16, }, groupAvatar: { width: 48, height: 48, borderRadius: 24, backgroundColor: native.fill, alignItems: "center", justifyContent: "center", }, groupAvatarLabel: { color: native.secondaryLabel, fontSize: 16, fontWeight: "600", }, }); }