From af70e30160262e070ad390d15579b057de68f94a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:53:32 +0000 Subject: [PATCH] feat(mobile): pick a time for the autopilot and see the night on home 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. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 --- apps/mobile/app/index.tsx | 82 +++++++++++++++++-- apps/mobile/app/thread.tsx | 52 ++++++++---- .../components/autopilot-time-picker.tsx | 71 ++++++++++++++++ apps/mobile/lib/autopilot.test.ts | 50 +++++++++++ apps/mobile/lib/autopilot.ts | 30 +++++++ apps/mobile/lib/locales/ru.ts | 2 + apps/mobile/lib/locales/zh.ts | 2 + apps/mobile/package.json | 1 + pnpm-lock.yaml | 3 + 9 files changed, 272 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/components/autopilot-time-picker.tsx create mode 100644 apps/mobile/lib/autopilot.test.ts create mode 100644 apps/mobile/lib/autopilot.ts diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 538c553..9af0370 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -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([]); 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); @@ -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("spaces/list"), rpc("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) ? ( - + !searching && !query.trim() ? ( + <> + {autopilots.length > 0 ? ( + + ) : null} + {activityMode && (activity.active.length > 0 || activity.recent.length > 0) ? ( + + ) : 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 ( + + {t("Autopilot")} + {autopilots.map((autopilot) => { + const { detail, trail } = autopilotSummary(autopilot); + return ( + open(autopilot)} + avatar={ + + } + /> + ); + })} + + ); +} + function ActivityRow({ run, bot, diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index 6274252..086c3ae 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -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} + {autopilotTimeFor ? ( + { + setAutopilotTimeFor(null); + autopilotTimeFor(time); + }} + onClose={() => setAutopilotTimeFor(null)} + /> + ) : null} 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 ( + onPick(autopilotTimeOfDay(date))} + onDismiss={onClose} + /> + ); + } + + return ( + + + + + {t("Cancel")} + + + {t("Until when?")} + + onPick(autopilotTimeOfDay(value))} + hitSlop={12} + > + + {t("Start")} + + + + setValue(date)} + /> + + + ); +} diff --git a/apps/mobile/lib/autopilot.test.ts b/apps/mobile/lib/autopilot.test.ts new file mode 100644 index 0000000..10a102c --- /dev/null +++ b/apps/mobile/lib/autopilot.test.ts @@ -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) => + message.replace(/\{(\w+)\}/g, (_, key: string) => String(values?.[key] ?? "")), +})); + +function autopilot(patch: Partial = {}): 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" }); + }); +}); diff --git a/apps/mobile/lib/autopilot.ts b/apps/mobile/lib/autopilot.ts new file mode 100644 index 0000000..1669ee9 --- /dev/null +++ b/apps/mobile/lib/autopilot.ts @@ -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 }; +} diff --git a/apps/mobile/lib/locales/ru.ts b/apps/mobile/lib/locales/ru.ts index ed39e27..e248a3a 100644 --- a/apps/mobile/lib/locales/ru.ts +++ b/apps/mobile/lib/locales/ru.ts @@ -694,6 +694,8 @@ export const RU_MESSAGES: Record = { "Until when?": "До какого времени?", "Until done": "До готовности", "Until tomorrow morning": "До завтрашнего утра", + "Until a time": "До определённого времени", + Start: "Запустить", "1 step": "1 шаг", "{count} steps": "Шагов: {count}", "last: {latest}": "последний: {latest}", diff --git a/apps/mobile/lib/locales/zh.ts b/apps/mobile/lib/locales/zh.ts index 09e38b9..6507602 100644 --- a/apps/mobile/lib/locales/zh.ts +++ b/apps/mobile/lib/locales/zh.ts @@ -678,6 +678,8 @@ export const ZH_MESSAGES: Record = { "Until when?": "持续到什么时候?", "Until done": "直到完成", "Until tomorrow morning": "直到明天早上", + "Until a time": "直到指定时间", + Start: "开始", "1 step": "1 步", "{count} steps": "{count} 步", "last: {latest}": "最近:{latest}", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index afdfc51..49350a5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 416f8d8..74db6ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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