Bots can offer an exact tool call as a card. Nothing runs until the user taps Accept; the call then runs as its own approved run of the bot it was meant for, and the card shows the outcome. Open suggestions also wait in the inbox and expire after a week. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
|
|
import { CARD_LIMITS } from "@rakazo/contracts";
|
|
import {
|
|
applyCardAct,
|
|
cardScale,
|
|
cardTimerRemaining,
|
|
checklistItemDisplay,
|
|
formatCardClock,
|
|
formatCardTimerLength,
|
|
suggestionStatus,
|
|
} from "@rakazo/core";
|
|
import { useState } from "react";
|
|
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
|
|
import { mobileTokens } from "../lib/appearance";
|
|
import { dateLocaleForUi, useI18n } from "../lib/i18n";
|
|
import { CookingMode, useNow } from "./cooking-mode";
|
|
import { NativeSymbol } from "./native-symbol";
|
|
|
|
type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
|
|
|
|
/** A bot's interactive card. Taps go to the server, which owns the card's state. */
|
|
export function ThreadCard({
|
|
block: stored,
|
|
createdAt,
|
|
onAct,
|
|
}: {
|
|
block: CardBlock;
|
|
/** When the card was posted; an open suggestion expires after a while. */
|
|
createdAt?: string;
|
|
onAct?: (act: CardAct) => Promise<void>;
|
|
}) {
|
|
const tokens = mobileTokens();
|
|
const { t, locale } = useI18n();
|
|
const [pending, setPending] = useState<CardAct | null>(null);
|
|
const [expanded, setExpanded] = useState<string[]>([]);
|
|
const [cooking, setCooking] = useState(false);
|
|
// Show the tap in flight on top of the stored card, so the card answers at once.
|
|
const preview = pending ? applyCardAct(stored, pending) : null;
|
|
const block = preview?.ok ? preview.block : stored;
|
|
const suggestion = block.suggestion
|
|
? suggestionStatus(block, createdAt ?? new Date())
|
|
: undefined;
|
|
// A suggestion takes one tap; once it moved on, only its outcome shows.
|
|
const answered = Boolean(block.answer) || (suggestion !== undefined && suggestion !== "proposed");
|
|
const busy = !onAct || pending !== null;
|
|
const factor = cardScale(block);
|
|
const header = block.sections?.find((part): part is Section<"header"> => part.type === "header");
|
|
const steps = block.sections?.find((part): part is Section<"steps"> => part.type === "steps");
|
|
const now = useNow(Boolean(steps?.steps.some((step) => step.timerEndsAt)));
|
|
|
|
async function act(next: CardAct) {
|
|
if (!onAct || pending) return;
|
|
setPending(next);
|
|
try {
|
|
await onAct(next);
|
|
} catch (error) {
|
|
Alert.alert(
|
|
t("Could not submit answer"),
|
|
error instanceof Error ? error.message : t("Please try again."),
|
|
);
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
function visible<T>(id: string, entries: T[]): { shown: T[]; hidden: number } {
|
|
if (expanded.includes(id) || entries.length <= CARD_LIMITS.collapsedEntries + 1) {
|
|
return { shown: entries, hidden: 0 };
|
|
}
|
|
return {
|
|
shown: entries.slice(0, CARD_LIMITS.collapsedEntries),
|
|
hidden: entries.length - CARD_LIMITS.collapsedEntries,
|
|
};
|
|
}
|
|
|
|
function showMore(id: string, hidden: number) {
|
|
if (!hidden) return null;
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
onPress={() => setExpanded((ids) => [...ids, id])}
|
|
style={{ paddingVertical: 6 }}
|
|
>
|
|
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
|
{t("Show {count} more", { count: hidden })}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const roundButton = (
|
|
label: string,
|
|
symbol: { ios: string; android: "remove" | "add" },
|
|
disabled: boolean,
|
|
onPress: () => void,
|
|
) => (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={label}
|
|
accessibilityState={{ disabled }}
|
|
disabled={disabled}
|
|
onPress={onPress}
|
|
style={[styles.round, { borderColor: tokens.border, opacity: disabled ? 0.4 : 1 }]}
|
|
>
|
|
<NativeSymbol ios={symbol.ios} android={symbol.android} size={16} />
|
|
</Pressable>
|
|
);
|
|
const select = block.sections?.find((part): part is Section<"select"> => part.type === "select");
|
|
const nothingChosen = Boolean(select?.multiple && !select.selected?.length);
|
|
|
|
return (
|
|
<View
|
|
testID="thread-card"
|
|
style={[styles.card, { borderColor: tokens.border, backgroundColor: tokens.card }]}
|
|
>
|
|
{block.sections?.map((part, index) => {
|
|
if (part.type === "header") {
|
|
return (
|
|
<View key={index} style={{ gap: 2 }}>
|
|
<Text style={[styles.title, { color: tokens.foreground }]}>{part.title}</Text>
|
|
{part.subtitle ? (
|
|
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
|
{part.subtitle}
|
|
</Text>
|
|
) : null}
|
|
{part.meta?.length ? (
|
|
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
|
{part.meta.join(" · ")}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "facts") {
|
|
return (
|
|
<View key={index} style={{ gap: 6 }}>
|
|
{part.rows.map((row) => (
|
|
<View key={row.label} style={styles.factRow}>
|
|
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{row.label}</Text>
|
|
<Text style={[styles.body, styles.factValue, { color: tokens.foreground }]}>
|
|
{row.value}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "stepper") {
|
|
const label = part.label;
|
|
return (
|
|
<View key={index} style={styles.stepperRow}>
|
|
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{label}</Text>
|
|
<View style={styles.stepperControls}>
|
|
{roundButton(
|
|
t("Fewer {label}", { label }),
|
|
{ ios: "minus", android: "remove" },
|
|
busy || part.value <= 1,
|
|
() => void act({ type: "stepper", sectionId: part.id, value: part.value - 1 }),
|
|
)}
|
|
<Text
|
|
testID="card-stepper-value"
|
|
style={[styles.body, styles.stepperValue, { color: tokens.foreground }]}
|
|
>
|
|
{part.value}
|
|
</Text>
|
|
{roundButton(
|
|
t("More {label}", { label }),
|
|
{ ios: "plus", android: "add" },
|
|
busy || part.value >= CARD_LIMITS.stepperMax,
|
|
() => void act({ type: "stepper", sectionId: part.id, value: part.value + 1 }),
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "steps") {
|
|
const { shown, hidden } = visible(part.id, part.steps);
|
|
return (
|
|
<View key={index} style={{ gap: 10 }}>
|
|
{shown.map((step, position) => {
|
|
const remaining = cardTimerRemaining(step.timerEndsAt, now);
|
|
return (
|
|
<View key={step.id} style={{ flexDirection: "row", gap: 10 }}>
|
|
<Text
|
|
style={[styles.body, styles.stepNumber, { color: tokens.mutedForeground }]}
|
|
>
|
|
{position + 1}
|
|
</Text>
|
|
<View style={{ flex: 1, gap: 2 }}>
|
|
<Text style={[styles.body, { color: tokens.foreground }]}>{step.text}</Text>
|
|
{step.timerSeconds ? (
|
|
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
|
{remaining !== null
|
|
? formatCardClock(remaining)
|
|
: formatCardTimerLength(step.timerSeconds, dateLocaleForUi(locale))}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
{showMore(part.id, hidden)}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "checklist") {
|
|
const { shown, hidden } = visible(part.id, part.items);
|
|
return (
|
|
<View key={index}>
|
|
{shown.map((item) => {
|
|
const checked = Boolean(item.checked);
|
|
const display = checklistItemDisplay(item, factor);
|
|
return (
|
|
<Pressable
|
|
key={item.id}
|
|
accessibilityRole="checkbox"
|
|
accessibilityState={{ checked, disabled: busy }}
|
|
disabled={busy}
|
|
onPress={() =>
|
|
void act({
|
|
type: "toggle",
|
|
sectionId: part.id,
|
|
itemId: item.id,
|
|
checked: !checked,
|
|
})
|
|
}
|
|
style={styles.checkRow}
|
|
>
|
|
<NativeSymbol
|
|
ios={checked ? "checkmark.circle.fill" : "circle"}
|
|
android={checked ? "checkmark-circle" : "ellipse-outline"}
|
|
size={20}
|
|
color={checked ? tokens.primary : tokens.mutedForeground}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.body,
|
|
{
|
|
flexShrink: 1,
|
|
color: checked ? tokens.mutedForeground : tokens.foreground,
|
|
},
|
|
checked && { textDecorationLine: "line-through" },
|
|
]}
|
|
>
|
|
{display.amount ? (
|
|
<Text style={{ fontWeight: "600" }}>{display.amount} </Text>
|
|
) : null}
|
|
{display.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
{showMore(part.id, hidden)}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "select") {
|
|
return (
|
|
<View key={index} style={part.multiple ? styles.chips : { gap: 6 }}>
|
|
{part.options.map((option) => {
|
|
const chosen = part.selected?.includes(option.id) ?? false;
|
|
return (
|
|
<Pressable
|
|
key={option.id}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: chosen, disabled: busy || answered }}
|
|
disabled={busy || answered}
|
|
onPress={() =>
|
|
void act({
|
|
type: "select",
|
|
sectionId: part.id,
|
|
optionId: option.id,
|
|
...(part.multiple ? { selected: !chosen } : {}),
|
|
})
|
|
}
|
|
style={[
|
|
styles.option,
|
|
part.multiple ? null : { alignSelf: "stretch" },
|
|
{
|
|
borderColor: chosen ? tokens.primary : tokens.border,
|
|
backgroundColor: chosen ? tokens.primary : "transparent",
|
|
opacity: answered && !chosen ? 0.5 : 1,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.body,
|
|
{ color: chosen ? tokens.primaryForeground : tokens.foreground },
|
|
]}
|
|
>
|
|
{option.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
const actions = part.actions.filter((action) => action.kind === "steps" || !answered);
|
|
if (!actions.length) return null;
|
|
return (
|
|
<View key={index} style={styles.chips}>
|
|
{actions.map((action, position) => {
|
|
const primary = position === 0;
|
|
const opensSteps = action.kind === "steps";
|
|
const disabled = opensSteps ? !steps : busy || nothingChosen;
|
|
return (
|
|
<Pressable
|
|
key={action.id}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ disabled }}
|
|
disabled={disabled}
|
|
onPress={() =>
|
|
opensSteps
|
|
? setCooking(true)
|
|
: void act({ type: "action", actionId: action.id })
|
|
}
|
|
style={[
|
|
styles.option,
|
|
{
|
|
borderColor: primary ? tokens.primary : tokens.border,
|
|
backgroundColor: primary ? tokens.primary : "transparent",
|
|
opacity: disabled ? 0.5 : 1,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.body,
|
|
{
|
|
fontWeight: "600",
|
|
color: primary ? tokens.primaryForeground : tokens.foreground,
|
|
},
|
|
]}
|
|
>
|
|
{action.kind === "accept"
|
|
? t("Accept")
|
|
: action.kind === "dismiss"
|
|
? t("Dismiss")
|
|
: action.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
})}
|
|
{suggestion && suggestion !== "proposed" ? (
|
|
<Text
|
|
style={[
|
|
styles.small,
|
|
{
|
|
color:
|
|
suggestion === "failed"
|
|
? tokens.destructive
|
|
: suggestion === "done"
|
|
? tokens.success
|
|
: tokens.mutedForeground,
|
|
},
|
|
]}
|
|
>
|
|
{suggestion === "failed"
|
|
? (block.suggestion?.error ?? t("Failed"))
|
|
: suggestion === "running"
|
|
? t("Running")
|
|
: suggestion === "done"
|
|
? t("Done")
|
|
: suggestion === "dismissed"
|
|
? t("Dismissed")
|
|
: t("Expired")}
|
|
</Text>
|
|
) : null}
|
|
{cooking && steps ? (
|
|
<CookingMode
|
|
title={header?.title ?? ""}
|
|
steps={steps}
|
|
now={now}
|
|
busy={busy}
|
|
onAct={act}
|
|
onClose={() => setCooking(false)}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
card: {
|
|
width: "90%",
|
|
gap: 14,
|
|
borderRadius: 18,
|
|
borderWidth: 1,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 14,
|
|
},
|
|
title: { fontSize: 16, fontWeight: "600" },
|
|
body: { fontSize: 15.5, lineHeight: 22 },
|
|
small: { fontSize: 13.5 },
|
|
factRow: { flexDirection: "row", gap: 12 },
|
|
factValue: { flex: 1 },
|
|
checkRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
|
|
chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
|
option: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 14, paddingVertical: 10 },
|
|
stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
|
stepperControls: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
stepperValue: { minWidth: 28, textAlign: "center", fontWeight: "600" },
|
|
round: {
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 17,
|
|
borderWidth: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
stepNumber: { width: 16, textAlign: "right" },
|
|
});
|