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