1282 lines
38 KiB
TypeScript
1282 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) return;
|
|
const targetSpace = item.space;
|
|
const main = !targetSpace.isDefault
|
|
? targetSpace.groups.find((group) => group.kind === "main")
|
|
: undefined;
|
|
if (main) {
|
|
void openMobileSpace(targetSpace.id, () =>
|
|
router.push({
|
|
pathname: "/group-thread",
|
|
params: { groupId: main.id, name: targetSpace.name },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
void chooseInboxSpace(targetSpace.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",
|
|
},
|
|
});
|
|
}
|