feat(mobile): pick a time for the autopilot and see the night on home (#147)

The attach sheet's autopilot now offers "Until a time" with the native
time picker from Expo UI: Android's clock dialog, an iOS wheel in a sheet
with Start. The home list opens with the autopilots that run, wait or
ended within a day, as Today does on web, and each row opens its thread.
The attach and until menus move to the shared action sheet, so Android
pages them instead of dropping buttons past three.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 05:35:47 +02:00 committed by GitHub
parent 54658ee689
commit ca57ebda85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 272 additions and 21 deletions

View File

@ -1,3 +1,4 @@
import type { AutopilotRecent } from "@rakazo/contracts";
import {
normalizeCreateBotProfile,
type RunActivityRow,
@ -46,6 +47,7 @@ import {
selectSpace,
} from "../lib/api";
import { 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";
@ -87,6 +89,7 @@ export default function Home() {
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);
@ -131,9 +134,10 @@ export default function Home() {
const requestId = ++inboxRequestId.current;
setError(null);
try {
const [navigation, nextMe] = await Promise.all([
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))) {
@ -145,6 +149,7 @@ export default function Home() {
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"));
@ -543,11 +548,15 @@ export default function Home() {
/>
}
ListHeaderComponent={
activityMode &&
!searching &&
!query.trim() &&
(activity.active.length > 0 || activity.recent.length > 0) ? (
<ActivitySection activity={activity} bots={bots} />
!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={
@ -744,6 +753,67 @@ function ActivitySection({
);
}
/** 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,

View File

@ -69,6 +69,7 @@ import { AppConnectCard } from "../components/AppConnectCard";
import { AskActions } from "../components/AskActions";
import type { AutopilotAction } from "../components/autopilot-line";
import { AutopilotLine } from "../components/autopilot-line";
import { AutopilotTimePicker } from "../components/autopilot-time-picker";
import { BotAvatar } from "../components/bot-avatar";
import {
MarkdownArtifactPreview,
@ -245,6 +246,7 @@ function Thread() {
const colorScheme = useResolvedAppearance();
const tokens = mobileTokens();
const [botActionsOpen, setBotActionsOpen] = useState(false);
const [autopilotTimeFor, setAutopilotTimeFor] = useState<((time: string) => void) | null>(null);
const { t } = useI18n();
const navigation = useNavigation();
const router = useRouter();
@ -1282,16 +1284,18 @@ function Thread() {
function showAttachMenu() {
// The autopilot takes the typed order, so it is offered once there is one.
const order = draft.trim();
Alert.alert(t("Attach"), undefined, [
{
text: t("Photo library"),
onPress: () => void addAttachments(pickFromLibrary),
},
{ text: t("Camera"), onPress: () => void addAttachments(takePhoto) },
{ text: t("File"), onPress: () => void addAttachments(pickDocuments) },
...(order ? [{ text: t("Autopilot"), onPress: () => askAutopilotUntil(order) }] : []),
{ text: t("Cancel"), style: "cancel" as const },
]);
presentMessageActionSheet({
title: t("Attach"),
actions: [
{ text: t("Photo library"), onPress: () => void addAttachments(pickFromLibrary) },
{ text: t("Camera"), onPress: () => void addAttachments(takePhoto) },
{ text: t("File"), onPress: () => void addAttachments(pickDocuments) },
...(order ? [{ text: t("Autopilot"), onPress: () => askAutopilotUntil(order) }] : []),
],
colorScheme,
cancel: t("Cancel"),
more: t("More"),
});
}
function askAutopilotUntil(order: string) {
@ -1314,11 +1318,20 @@ function Thread() {
Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")),
);
};
Alert.alert(t("Until when?"), order, [
{ text: t("Until done"), onPress: () => start({ kind: "done" }) },
{ text: t("Until tomorrow morning"), onPress: () => start({ kind: "morning" }) },
{ text: t("Cancel"), style: "cancel" },
]);
presentMessageActionSheet({
title: t("Until when?"),
actions: [
{ text: t("Until done"), onPress: () => start({ kind: "done" }) },
{ text: t("Until tomorrow morning"), onPress: () => start({ kind: "morning" }) },
{
text: t("Until a time"),
onPress: () => setAutopilotTimeFor(() => (time: string) => start({ kind: "time", time })),
},
],
colorScheme,
cancel: t("Cancel"),
more: t("More"),
});
}
async function addAttachments(
@ -2200,6 +2213,15 @@ function Thread() {
) : null}
</View>
</View>
{autopilotTimeFor ? (
<AutopilotTimePicker
onPick={(time) => {
setAutopilotTimeFor(null);
autopilotTimeFor(time);
}}
onClose={() => setAutopilotTimeFor(null)}
/>
) : null}
<Modal
visible={botActionsOpen}
transparent

View File

@ -0,0 +1,71 @@
import { DateTimePicker } from "@expo/ui/community/datetime-picker";
import { useState } from "react";
import { Modal, Platform, Pressable, Text, View } from "react-native";
import { autopilotTimeOfDay } from "../lib/autopilot";
import { useI18n } from "../lib/i18n";
import { useMobileTokens, useResolvedAppearance } from "../lib/native";
/**
* The native time picker for "Until a time". Android opens its clock dialog; iOS shows the wheel in
* a sheet with Start. Either way the caller gets "HH:MM" and unmounts the picker.
*/
export function AutopilotTimePicker({
onPick,
onClose,
}: {
onPick: (time: string) => void;
onClose: () => void;
}) {
const tokens = useMobileTokens();
const colorScheme = useResolvedAppearance();
const { t } = useI18n();
const [value, setValue] = useState(() => {
const morning = new Date();
morning.setHours(8, 0, 0, 0);
return morning;
});
if (Platform.OS === "android") {
return (
<DateTimePicker
mode="time"
value={value}
onValueChange={(_event, date) => onPick(autopilotTimeOfDay(date))}
onDismiss={onClose}
/>
);
}
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View style={{ flex: 1, backgroundColor: tokens.background, padding: 20, gap: 12 }}>
<View
style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}
>
<Pressable accessibilityRole="button" onPress={onClose} hitSlop={12}>
<Text style={{ color: tokens.mutedForeground, fontSize: 17 }}>{t("Cancel")}</Text>
</Pressable>
<Text style={{ color: tokens.foreground, fontSize: 17, fontWeight: "600" }}>
{t("Until when?")}
</Text>
<Pressable
accessibilityRole="button"
onPress={() => onPick(autopilotTimeOfDay(value))}
hitSlop={12}
>
<Text style={{ color: tokens.foreground, fontSize: 17, fontWeight: "600" }}>
{t("Start")}
</Text>
</Pressable>
</View>
<DateTimePicker
mode="time"
display="spinner"
value={value}
themeVariant={colorScheme}
onValueChange={(_event, date) => setValue(date)}
/>
</View>
</Modal>
);
}

View File

@ -0,0 +1,50 @@
import type { AutopilotRecent } from "@rakazo/contracts";
import { describe, expect, it, vi } from "vitest";
import { autopilotSummary, autopilotTimeOfDay } from "./autopilot";
vi.mock("./i18n", () => ({
t: (message: string, values?: Record<string, string | number>) =>
message.replace(/\{(\w+)\}/g, (_, key: string) => String(values?.[key] ?? "")),
}));
function autopilot(patch: Partial<AutopilotRecent> = {}): AutopilotRecent {
return {
id: "autopilot-1",
botId: "bot-1",
threadId: "thread-1",
spaceId: "space-1",
groupId: null,
goal: "Tidy the tickets",
focus: null,
until: null,
status: "active",
pauseReason: null,
lastCheckpoint: null,
nextStep: null,
result: null,
stepCount: 0,
startedAt: "2026-10-05T00:00:00.000Z",
endedAt: null,
...patch,
};
}
describe("autopilot on mobile", () => {
it("hands the picked time to autopilotUntil as HH:MM", () => {
expect(autopilotTimeOfDay(new Date(2026, 9, 5, 6, 5))).toBe("06:05");
expect(autopilotTimeOfDay(new Date(2026, 9, 5, 23, 30))).toBe("23:30");
});
it("sums up the night like Today on web", () => {
expect(
autopilotSummary(autopilot({ stepCount: 2, lastCheckpoint: "Ticket 2 tidied" })),
).toEqual({ detail: "last: Ticket 2 tidied", trail: "2 steps" });
expect(
autopilotSummary(autopilot({ status: "finished", stepCount: 1, result: "All tidy." })),
).toEqual({ detail: "All tidy.", trail: "1 step" });
expect(
autopilotSummary(autopilot({ status: "paused", stepCount: 3, pauseReason: "Need a key." })),
).toEqual({ detail: "Need a key.", trail: "Paused" });
expect(autopilotSummary(autopilot())).toEqual({ detail: "", trail: "0 steps" });
});
});

View File

@ -0,0 +1,30 @@
import type { AutopilotRecent } from "@rakazo/contracts";
import { t } from "./i18n";
/** "HH:MM" for autopilotUntil's time choice. */
export function autopilotTimeOfDay(date: Date): string {
const pad = (value: number) => String(value).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/**
* One autopilot in the home list, like Today on web: how it ended or where it stands, and its step
* count, or Paused when it waits on the user.
*/
export function autopilotSummary(autopilot: AutopilotRecent): { detail: string; trail: string } {
const detail =
autopilot.status === "finished"
? autopilot.result
: autopilot.status === "paused"
? autopilot.pauseReason
: autopilot.lastCheckpoint
? t("last: {latest}", { latest: autopilot.lastCheckpoint })
: null;
const trail =
autopilot.status === "paused"
? t("Paused")
: autopilot.stepCount === 1
? t("1 step")
: t("{count} steps", { count: autopilot.stepCount });
return { detail: detail ?? "", trail };
}

View File

@ -694,6 +694,8 @@ export const RU_MESSAGES: Record<string, string> = {
"Until when?": "До какого времени?",
"Until done": "До готовности",
"Until tomorrow morning": "До завтрашнего утра",
"Until a time": "До определённого времени",
Start: "Запустить",
"1 step": "1 шаг",
"{count} steps": "Шагов: {count}",
"last: {latest}": "последний: {latest}",

View File

@ -678,6 +678,8 @@ export const ZH_MESSAGES: Record<string, string> = {
"Until when?": "持续到什么时候?",
"Until done": "直到完成",
"Until tomorrow morning": "直到明天早上",
"Until a time": "直到指定时间",
Start: "开始",
"1 step": "1 步",
"{count} steps": "{count} 步",
"last: {latest}": "最近:{latest}",

View File

@ -18,6 +18,7 @@
},
"dependencies": {
"@expo/metro-runtime": "57.0.16",
"@expo/ui": "57.0.21",
"@rakazo/chat-ui": "workspace:*",
"@rakazo/contracts": "workspace:*",
"@rakazo/core": "workspace:*",

3
pnpm-lock.yaml generated
View File

@ -125,6 +125,9 @@ importers:
'@expo/metro-runtime':
specifier: 57.0.16
version: 57.0.16(@expo/log-box@57.0.4)(expo@57.0.26)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)
'@expo/ui':
specifier: 57.0.21
version: 57.0.21(@babel/core@7.29.7)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(expo@57.0.26)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)
'@rakazo/chat-ui':
specifier: workspace:*
version: link:../../packages/chat-ui