evOo-local/apps/mobile/components/autopilot-line.tsx
cynarAI 460eede226
feat: autopilot that keeps working on an order until it is done (#144)
An autopilot per thread continues the user's order run after run, with
no time limit. Each run reports a checkpoint; after three runs without
progress or three failed runs it pauses with Continue and Stop. Start,
pause, stop and finish show as quiet lines in the conversation.
Approvals during an autopilot run are parked as suggestions, and the
temporary approval rules end with the autopilot.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 03:56:45 +02:00

126 lines
4.0 KiB
TypeScript

import type { AutopilotBlock } from "@rakazo/contracts";
import { formatAutopilotDuration } from "@rakazo/core";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import { dateLocaleForUi, useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { NativeSymbol } from "./native-symbol";
export type AutopilotAction = "stop" | "resume";
const ICONS = {
"autopilot.started": { ios: "play.fill", android: "play" },
"autopilot.paused": { ios: "pause.fill", android: "pause" },
"autopilot.stopped": { ios: "stop.fill", android: "stop" },
"autopilot.finished": { ios: "checkmark.circle", android: "checkmark-circle-outline" },
} as const;
/** One quiet line when the autopilot starts, pauses, stops or finishes. */
export function AutopilotLine({
block,
onAction,
}: {
block: AutopilotBlock;
onAction?: (action: AutopilotAction) => Promise<void>;
}) {
const tokens = useMobileTokens();
const { t, locale } = useI18n();
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const open = !block.ended && Boolean(onAction);
const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
async function act(action: AutopilotAction) {
if (!onAction) return;
setBusy(true);
setFailed(false);
try {
await onAction(action);
} catch {
setFailed(true);
} finally {
setBusy(false);
}
}
const parts: string[] = [];
let label = t("Autopilot");
if (block.event === "autopilot.started") {
parts.push(block.goal);
if (block.until) {
const until = new Intl.DateTimeFormat(dateLocaleForUi(locale), {
hour: "numeric",
minute: "2-digit",
}).format(new Date(block.until));
parts.push(t("until {until}", { until }));
}
if (block.focus) parts.push(block.focus);
if (block.allowed?.length) {
parts.push(t("allowed: {allowed}", { allowed: block.allowed.join(", ") }));
}
} else if (block.event === "autopilot.paused") {
label = t("Autopilot paused");
if (block.text) parts.push(block.text);
} else {
label = block.event === "autopilot.stopped" ? t("Autopilot stopped") : t("Autopilot finished");
if (block.durationMs !== undefined) parts.push(formatAutopilotDuration(block.durationMs));
}
const icon = ICONS[block.event];
const link = (text: string, action: AutopilotAction) => (
<Pressable
accessibilityRole="button"
hitSlop={10}
disabled={busy}
onPress={() => void act(action)}
style={{ opacity: busy ? 0.5 : 1 }}
>
<Text style={[muted, { fontWeight: "600" }]}>{text}</Text>
</Pressable>
);
return (
<View style={{ width: "100%", paddingVertical: 4, alignItems: "center", gap: 4 }}>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
columnGap: 6,
rowGap: 2,
}}
>
<NativeSymbol
ios={icon.ios}
android={icon.android}
size={13}
color={tokens.mutedForeground}
/>
<Text style={[muted, { color: tokens.foreground, fontWeight: "500" }]}>{label}</Text>
{parts.map((part, index) => (
<Text key={index} style={muted}>
· {part}
</Text>
))}
{open && block.event === "autopilot.paused" ? (
<>
<Text style={muted}>·</Text>
{link(t("Continue"), "resume")}
</>
) : null}
{open && (block.event === "autopilot.started" || block.event === "autopilot.paused") ? (
<>
<Text style={muted}>·</Text>
{link(failed ? t("Try again") : t("Stop"), "stop")}
</>
) : null}
</View>
{block.event === "autopilot.finished" && block.text ? (
<Text style={{ color: tokens.foreground, fontSize: 14, textAlign: "center" }}>
{block.text}
</Text>
) : null}
</View>
);
}