The autopilot's start line counts its steps and folds open to the latest checkpoints, and its result reads as a card. The composer's plus menu starts an autopilot with the draft as the order and a choice of until done, tomorrow morning or a time. Today lists the autopilots that run, wait or ended within a day, and their step runs no longer show one by one. Autopilot runs send no completion or failure push; only a pause notifies. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
184 lines
6.1 KiB
TypeScript
184 lines
6.1 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. The start line counts the
|
|
* steps and folds open to the latest checkpoints; a finished autopilot's result reads as a card.
|
|
*/
|
|
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 [expanded, setExpanded] = 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 checkpoints = block.event === "autopilot.started" ? (block.checkpoints ?? []) : [];
|
|
const latest = checkpoints.at(-1)?.text;
|
|
const time = new Intl.DateTimeFormat(dateLocaleForUi(locale), {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
});
|
|
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>
|
|
))}
|
|
{block.steps && block.event === "autopilot.started" ? (
|
|
<>
|
|
<Text style={muted}>·</Text>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={{ expanded }}
|
|
hitSlop={10}
|
|
onPress={() => setExpanded((value) => !value)}
|
|
>
|
|
<Text style={[muted, { fontWeight: "600" }]}>
|
|
{block.steps === 1 ? t("1 step") : t("{count} steps", { count: block.steps })}
|
|
</Text>
|
|
</Pressable>
|
|
{latest && !expanded ? (
|
|
<Text style={muted} numberOfLines={1}>
|
|
· {t("last: {latest}", { latest })}
|
|
</Text>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{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>
|
|
{expanded && checkpoints.length > 0 ? (
|
|
<View style={{ width: "100%", maxWidth: 420, gap: 4, paddingHorizontal: 16 }}>
|
|
{checkpoints.map((checkpoint, index) => (
|
|
<View key={`${checkpoint.at}-${index}`} style={{ flexDirection: "row", gap: 8 }}>
|
|
<Text
|
|
style={[muted, { width: 64, textAlign: "right", fontVariant: ["tabular-nums"] }]}
|
|
>
|
|
{time.format(new Date(checkpoint.at))}
|
|
</Text>
|
|
<Text style={{ flex: 1, color: tokens.foreground, fontSize: 13.5 }}>
|
|
{checkpoint.text}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
{block.event === "autopilot.finished" && block.text ? (
|
|
<View
|
|
style={{
|
|
width: "100%",
|
|
maxWidth: 520,
|
|
marginTop: 4,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: tokens.border,
|
|
backgroundColor: tokens.card,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 10,
|
|
}}
|
|
>
|
|
<Text style={{ color: tokens.foreground, fontSize: 14 }}>{block.text}</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|