* media: check ffmpeg filter support before subtitle operations * sandbox: handle synchronous spawn errors safely in desktop sandbox * Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency. What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens. Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally. * Update translation catalogs Re-extracted catalogs for the new and moved strings; the catalog sync test passes. * Add project plan and test readiness notes Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes. * fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps * fix(ci): restore space deletion guard, browser toggle, and run-steps contracts * fix(ci): restore model resolution errors, group stage, and onboarding flow * fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs * fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding * test(web): wait for avatar and rig visibility in oo-avatar motion spec * test(web): wait for persisted message IDs in message-hover-actions pagination spec * feat: recruit library agents into project conversations and scan project folders * test(web): expect repo description in GithubRepoPicker onChange * Onboarding: always drop the local voice when the voice step is skipped Skipping waited only for the latest voice load and disconnected only when the client saw the connect succeed. An earlier or client-failed connect could still persist the local credential, so a user who skipped voice was left with one. Wait for every load and always disconnect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs * Shell: hide the optimistic copy once the transcript shows the send A message sent while the bot works streamed into the transcript before its send request resolved, so the same text showed twice until the optimistic bubble was removed. Drop an optimistic message as soon as a persisted user message with the same text appears. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs --------- Co-authored-by: Claude <noreply@anthropic.com>
1268 lines
38 KiB
TypeScript
1268 lines
38 KiB
TypeScript
import type { AutopilotRecent } from "@rakazo/contracts";
|
|
import {
|
|
normalizeCreateBotProfile,
|
|
type RunActivityRow,
|
|
type SearchHit,
|
|
type SpaceBot,
|
|
type SpaceGroup,
|
|
} from "@rakazo/contracts";
|
|
import { projectLook, runChatTitle } from "@rakazo/core";
|
|
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,
|
|
selectLibrarySpace,
|
|
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<MobileBot[]>([]);
|
|
const [groups, setGroups] = useState<MobileGroup[]>([]);
|
|
const [botSections, setBotSections] = useState<MobileBotSection[]>([]);
|
|
const [spaces, setSpaces] = useState<MobileSpace[]>([]);
|
|
const [me, setMe] = useState<MobileMe | null>(null);
|
|
const [autopilots, setAutopilots] = useState<AutopilotRecent[]>([]);
|
|
const [error, setError] = useState<string | null>(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<SearchHit[]>([]);
|
|
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<string | null>(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<MobileSpaceNavigation>("spaces/list"),
|
|
rpc<MobileMe>("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<typeof setTimeout> | 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<typeof setTimeout> | 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 {
|
|
await selectLibrarySpace();
|
|
// 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<MobileBot[]>("bots/list").catch(() => null);
|
|
const isFirstBot = existing !== null && existing.length === 0;
|
|
const bot = await rpc<MobileBot>("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 (
|
|
<View style={[styles.screen, styles.centered]}>
|
|
<ActivityIndicator color={native.secondaryLabel} />
|
|
</View>
|
|
);
|
|
}
|
|
if (!hasSession) return <Redirect href="/sign-in" />;
|
|
|
|
return (
|
|
<View style={[styles.screen, { paddingTop: Math.max(insets.top, 20) }]}>
|
|
<View style={styles.header}>
|
|
<CircleButton accessibilityLabel={t("Account")} onPress={() => router.push("/account")}>
|
|
<Text style={styles.profileInitials}>{initials}</Text>
|
|
</CircleButton>
|
|
<View style={styles.headerActions}>
|
|
<CircleButton
|
|
accessibilityLabel={t("Activity")}
|
|
active={activityMode}
|
|
accent
|
|
onPress={toggleActivityMode}
|
|
>
|
|
<NativeSymbol
|
|
ios={activityMode ? "bell.fill" : "bell"}
|
|
android={activityMode ? "notifications" : "notifications-outline"}
|
|
size={mobileShellIconSizes.md}
|
|
color={activityMode ? tokens.primaryForeground : tokens.foreground}
|
|
/>
|
|
</CircleButton>
|
|
<CircleButton
|
|
accessibilityLabel={t("Search")}
|
|
active={searching}
|
|
onPress={() =>
|
|
setSearching((open) => {
|
|
if (open) setQuery("");
|
|
return !open;
|
|
})
|
|
}
|
|
>
|
|
<NativeSymbol ios="magnifyingglass" android="search" size={mobileShellIconSizes.md} />
|
|
</CircleButton>
|
|
<CircleButton
|
|
accessibilityLabel={t("Create")}
|
|
onPress={() => {
|
|
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" },
|
|
]);
|
|
}}
|
|
>
|
|
<NativeSymbol ios="plus" android="add" size={mobileShellIconSizes.lg} />
|
|
</CircleButton>
|
|
</View>
|
|
</View>
|
|
|
|
{searching ? (
|
|
<TextInput
|
|
autoFocus
|
|
value={query}
|
|
onChangeText={setQuery}
|
|
placeholder={t("Search")}
|
|
placeholderTextColor={tokens.mutedForeground}
|
|
autoCorrect={false}
|
|
autoCapitalize="none"
|
|
returnKeyType="search"
|
|
keyboardAppearance={appearance}
|
|
clearButtonMode="while-editing"
|
|
style={styles.searchField}
|
|
/>
|
|
) : null}
|
|
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
{spaceRecoveryId ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={spaceBusy}
|
|
onPress={() => void chooseInboxSpace(spaceRecoveryId)}
|
|
style={styles.recoveryAction}
|
|
>
|
|
<Text style={styles.spaceTitle}>{t("Try again.")}</Text>
|
|
</Pressable>
|
|
) : null}
|
|
|
|
<FlatList<InboxItem>
|
|
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={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={() => {
|
|
void refreshBots();
|
|
void loadActivity();
|
|
}}
|
|
tintColor={native.secondaryLabel}
|
|
colors={[tokens.mutedForeground]}
|
|
progressBackgroundColor={tokens.muted}
|
|
/>
|
|
}
|
|
ListHeaderComponent={
|
|
!searching && !query.trim() ? (
|
|
<>
|
|
{autopilots.length > 0 ? (
|
|
<AutopilotSection autopilots={autopilots} bots={bots} groups={groups} />
|
|
) : null}
|
|
{activityMode && (activity.active.length > 0 || activity.recent.length > 0) ? (
|
|
<ActivitySection activity={activity} bots={bots} />
|
|
) : null}
|
|
</>
|
|
) : null
|
|
}
|
|
ListEmptyComponent={
|
|
<Text style={styles.empty}>
|
|
{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")}
|
|
</Text>
|
|
}
|
|
renderItem={({ item }) =>
|
|
item.type === "search" ? (
|
|
<SearchRow
|
|
hit={item.hit}
|
|
onPress={() => {
|
|
setQuery("");
|
|
setSearchHits([]);
|
|
router.push(mobileSearchDestination(item.hit));
|
|
}}
|
|
/>
|
|
) : item.type === "heading" ? (
|
|
item.space ? (
|
|
<View style={styles.spaceHeading}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={item.title}
|
|
accessibilityState={{
|
|
selected: item.space.id === me?.spaceId,
|
|
disabled: spaceBusy,
|
|
}}
|
|
disabled={spaceBusy}
|
|
onPress={() => {
|
|
if (item.space) void chooseInboxSpace(item.space.id);
|
|
}}
|
|
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
|
|
>
|
|
<ProjectTile space={item.space} />
|
|
<Text style={styles.spaceTitle}>{item.title}</Text>
|
|
</Pressable>
|
|
{canDeleteInboxSpace(item.space) ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("Space actions for {name}", { name: item.title })}
|
|
disabled={spaceBusy || !!spaceRecoveryId}
|
|
onPress={() => {
|
|
if (item.space) showSpaceActions(item.space);
|
|
}}
|
|
style={({ pressed }) => [styles.spaceActions, pressed && styles.rowPressed]}
|
|
>
|
|
<NativeSymbol
|
|
ios="ellipsis"
|
|
android="ellipsis-horizontal"
|
|
size={mobileShellIconSizes.lg}
|
|
/>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
) : (
|
|
<Text style={styles.sectionHeading}>{item.title}</Text>
|
|
)
|
|
) : item.type === "group" ? (
|
|
<GroupRow
|
|
group={item.group}
|
|
onPress={() => {
|
|
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
|
|
}
|
|
/>
|
|
) : (
|
|
<BotRow
|
|
bot={item.bot}
|
|
onPress={() => {
|
|
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 ? (
|
|
<BotOrganizeModal
|
|
bot={organizeChat}
|
|
sections={botSections}
|
|
onClose={() => 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}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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) =>
|
|
void openMobileSpace(run.spaceId, () => {
|
|
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 (
|
|
<View style={styles.activitySection}>
|
|
{activity.active.length > 0 ? (
|
|
<>
|
|
<Text style={styles.sectionHeading}>{t("Now")}</Text>
|
|
{activity.active.map((run) => (
|
|
<ActivityRow
|
|
key={run.runId}
|
|
run={run}
|
|
bot={botsById.get(run.botId)}
|
|
onPress={() => openRun(run)}
|
|
/>
|
|
))}
|
|
</>
|
|
) : null}
|
|
{activity.recent.length > 0 ? (
|
|
<>
|
|
<Text style={[styles.sectionHeading, activity.active.length > 0 && styles.activityGap]}>
|
|
{t("Recent")}
|
|
</Text>
|
|
{activity.recent.map((run) => (
|
|
<ActivityRow
|
|
key={run.runId}
|
|
run={run}
|
|
bot={botsById.get(run.botId)}
|
|
onPress={() => openRun(run)}
|
|
/>
|
|
))}
|
|
</>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<View style={styles.activitySection} testID="home-autopilot">
|
|
<Text style={styles.sectionHeading}>{t("Autopilot")}</Text>
|
|
{autopilots.map((autopilot) => {
|
|
const { detail, trail } = autopilotSummary(autopilot);
|
|
return (
|
|
<ConversationRow
|
|
key={autopilot.id}
|
|
title={autopilot.goal}
|
|
preview={detail}
|
|
time={trail}
|
|
accessibilityLabel={`${autopilot.goal}, ${trail}`}
|
|
onPress={() => open(autopilot)}
|
|
avatar={
|
|
<BotAvatar
|
|
identity={autopilot.botId}
|
|
color={botsById.get(autopilot.botId)?.color ?? FALLBACK_COLOR}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ActivityRow({
|
|
run,
|
|
bot,
|
|
onPress,
|
|
}: {
|
|
run: RunActivityRow;
|
|
bot?: MobileBot;
|
|
onPress: () => void;
|
|
}) {
|
|
const title = runChatTitle(run);
|
|
const status = activityStatusLabel(run.status);
|
|
const preview = run.promptSnippet ? `${run.promptSnippet} · ${status}` : status;
|
|
return (
|
|
<ConversationRow
|
|
title={title}
|
|
preview={preview}
|
|
time={formatActivityRelativeTime(run.updatedAt)}
|
|
accessibilityLabel={`${title}, ${status}`}
|
|
onPress={onPress}
|
|
avatar={
|
|
<BotAvatar identity={run.botId} color={bot?.color ?? FALLBACK_COLOR} status={run.status} />
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={accessibilityLabel}
|
|
accessibilityHint={accessibilityHint}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
|
>
|
|
{avatar}
|
|
<View style={styles.rowBody}>
|
|
<View style={styles.rowTop}>
|
|
<View style={styles.titleRow}>
|
|
<Text style={styles.name} numberOfLines={1} ellipsizeMode="tail">
|
|
{title}
|
|
</Text>
|
|
{tag ? (
|
|
<View style={styles.tag}>
|
|
<Text style={styles.tagLabel} numberOfLines={1}>
|
|
{tag}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
<View style={styles.rowMeta}>
|
|
{time ? <Text style={styles.time}>{time}</Text> : null}
|
|
{unread ? <View accessibilityElementsHidden style={styles.unreadDot} /> : null}
|
|
</View>
|
|
</View>
|
|
<Text style={[styles.preview, unread && styles.unreadPreview]} numberOfLines={1}>
|
|
{preview}
|
|
</Text>
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function ProjectTile({ space }: { space: Pick<InboxSpace, "icon" | "color"> }) {
|
|
const styles = useThemedStyles(createHomeStyles);
|
|
const { icon, color } = projectLook(space);
|
|
return (
|
|
<View style={[styles.projectTile, { backgroundColor: color.hex }]}>
|
|
<NativeSymbol ios={icon.ios} android={icon.android as never} size={15} color={color.ink} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function CircleButton({
|
|
children,
|
|
onPress,
|
|
accessibilityLabel,
|
|
active = false,
|
|
accent = false,
|
|
}: {
|
|
children: ReactNode;
|
|
onPress: () => void;
|
|
accessibilityLabel: string;
|
|
active?: boolean;
|
|
accent?: boolean;
|
|
}) {
|
|
const styles = useThemedStyles(createHomeStyles);
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={accessibilityLabel}
|
|
accessibilityState={{ selected: active }}
|
|
onPress={onPress}
|
|
hitSlop={4}
|
|
style={({ pressed }) => [
|
|
styles.circleButton,
|
|
accent && active ? styles.circleAccent : (active || pressed) && styles.circlePressed,
|
|
]}
|
|
>
|
|
{children}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function SearchRow({ hit, onPress }: { hit: SearchHit; onPress: () => void }) {
|
|
const styles = useThemedStyles(createHomeStyles);
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
|
>
|
|
<View style={styles.rowBody}>
|
|
<View style={styles.rowTop}>
|
|
<Text style={styles.name} numberOfLines={1}>
|
|
{hit.title}
|
|
</Text>
|
|
<Text style={styles.time}>{hit.kind}</Text>
|
|
</View>
|
|
<Text style={styles.preview} numberOfLines={2}>
|
|
{hit.groupName ?? hit.botName} · {hit.snippet}
|
|
</Text>
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ConversationRow
|
|
title={bot.name}
|
|
preview={preview}
|
|
time={time}
|
|
tag={tag}
|
|
unread={bot.unread}
|
|
accessibilityLabel={label}
|
|
accessibilityHint={
|
|
onLongPress ? t("Long press to pin, move, or silence notifications") : undefined
|
|
}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
avatar={
|
|
<BotAvatar
|
|
color={bot.color || FALLBACK_COLOR}
|
|
identity={bot.id}
|
|
status={bot.status}
|
|
muted={!bot.notifyOnFinish}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ConversationRow
|
|
title={group.name}
|
|
preview={preview}
|
|
time={time}
|
|
unread={group.unread}
|
|
accessibilityLabel={[group.name, group.unread ? t("unread") : null, time, preview]
|
|
.filter(Boolean)
|
|
.join(", ")}
|
|
accessibilityHint={onLongPress ? t("Long press to pin or move to a section") : undefined}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
avatar={<GroupAvatar members={group.members} size={54} />}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
},
|
|
projectTile: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 8,
|
|
alignItems: "center",
|
|
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",
|
|
},
|
|
});
|
|
}
|