feat: recipe cards that scale servings and cook step by step (#140)
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
fb5165bb26
commit
5f3aa8f3fe
173
apps/mobile/components/cooking-mode.tsx
Normal file
173
apps/mobile/components/cooking-mode.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import type { CardAct, CardSection } from "@rakazo/contracts";
|
||||
import { cardTimerRemaining, formatCardClock } from "@rakazo/core";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from "react-native";
|
||||
import { mobileTokens } from "../lib/appearance";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
type Steps = Extract<CardSection, { type: "steps" }>;
|
||||
|
||||
/** The current time, ticking every second while `active`. */
|
||||
export function useNow(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(Date.now());
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's steps one at a time in full screen, large enough to read from the stove, with the
|
||||
* screen kept on. The step and any timer live on the card, so the web and a reload agree.
|
||||
*/
|
||||
export function CookingMode({
|
||||
title,
|
||||
steps,
|
||||
now,
|
||||
busy,
|
||||
onAct,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
steps: Steps;
|
||||
now: number;
|
||||
busy: boolean;
|
||||
onAct: (act: CardAct) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useKeepAwake();
|
||||
const tokens = mobileTokens();
|
||||
const { t } = useI18n();
|
||||
const index = Math.min(steps.current ?? 0, steps.steps.length - 1);
|
||||
const step = steps.steps[index];
|
||||
const last = index === steps.steps.length - 1;
|
||||
const remaining = cardTimerRemaining(step?.timerEndsAt, now);
|
||||
const running = remaining !== null && remaining > 0;
|
||||
const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next });
|
||||
|
||||
return (
|
||||
<Modal animationType="slide" presentationStyle="fullScreen" onRequestClose={onClose}>
|
||||
<SafeAreaView style={[styles.screen, { backgroundColor: tokens.background }]}>
|
||||
<View style={styles.top}>
|
||||
<Text numberOfLines={1} style={[styles.title, { color: tokens.mutedForeground }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("Close")}
|
||||
onPress={onClose}
|
||||
hitSlop={12}
|
||||
>
|
||||
<NativeSymbol ios="xmark" android="close" size={20} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.middle}>
|
||||
<Text style={[styles.counter, { color: tokens.mutedForeground }]}>
|
||||
{t("Step {position} of {total}", {
|
||||
position: index + 1,
|
||||
total: steps.steps.length,
|
||||
})}
|
||||
</Text>
|
||||
<Text style={[styles.step, { color: tokens.foreground }]}>{step?.text}</Text>
|
||||
{step?.timerSeconds ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={running ? t("Stop timer") : t("Start timer")}
|
||||
disabled={busy}
|
||||
onPress={() =>
|
||||
void onAct({
|
||||
type: "timer",
|
||||
sectionId: steps.id,
|
||||
stepId: step.id,
|
||||
running: !running,
|
||||
})
|
||||
}
|
||||
style={[
|
||||
styles.timer,
|
||||
{
|
||||
borderColor: running ? tokens.primary : tokens.border,
|
||||
backgroundColor: running ? tokens.primary : "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<NativeSymbol
|
||||
ios={running ? "pause.fill" : "play.fill"}
|
||||
android={running ? "pause" : "play"}
|
||||
size={20}
|
||||
color={running ? tokens.primaryForeground : tokens.foreground}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.clock,
|
||||
{ color: running ? tokens.primaryForeground : tokens.foreground },
|
||||
]}
|
||||
>
|
||||
{formatCardClock(remaining ?? step.timerSeconds)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.bottom}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={busy || index === 0}
|
||||
onPress={() => go(index - 1)}
|
||||
style={[styles.nav, { borderColor: tokens.border, opacity: index === 0 ? 0.4 : 1 }]}
|
||||
>
|
||||
<Text style={[styles.navText, { color: tokens.foreground }]}>{t("Back")}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={busy && !last}
|
||||
onPress={() => (last ? onClose() : go(index + 1))}
|
||||
style={[styles.nav, { borderColor: tokens.primary, backgroundColor: tokens.primary }]}
|
||||
>
|
||||
<Text style={[styles.navText, { color: tokens.primaryForeground }]}>
|
||||
{last ? t("Done") : t("Next")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, paddingHorizontal: 24 },
|
||||
top: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
paddingTop: 16,
|
||||
},
|
||||
title: { flex: 1, fontSize: 15, fontWeight: "600" },
|
||||
middle: { flex: 1, justifyContent: "center", gap: 28 },
|
||||
counter: { fontSize: 15 },
|
||||
step: { fontSize: 30, lineHeight: 40, fontWeight: "600" },
|
||||
timer: {
|
||||
alignSelf: "flex-start",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
clock: { fontSize: 26, fontWeight: "600", fontVariant: ["tabular-nums"] },
|
||||
bottom: { flexDirection: "row", justifyContent: "space-between", gap: 12, paddingBottom: 16 },
|
||||
nav: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
navText: { fontSize: 16, fontWeight: "600" },
|
||||
});
|
||||
@ -1,25 +1,44 @@
|
||||
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
|
||||
import { CARD_LIMITS } from "@rakazo/contracts";
|
||||
import {
|
||||
applyCardAct,
|
||||
cardScale,
|
||||
cardTimerRemaining,
|
||||
checklistItemDisplay,
|
||||
formatCardClock,
|
||||
formatCardTimerLength,
|
||||
} from "@rakazo/core";
|
||||
import { useState } from "react";
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { mobileTokens } from "../lib/appearance";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
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,
|
||||
block: stored,
|
||||
onAct,
|
||||
}: {
|
||||
block: CardBlock;
|
||||
onAct?: (act: CardAct) => Promise<void>;
|
||||
}) {
|
||||
const tokens = mobileTokens();
|
||||
const { t } = useI18n();
|
||||
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 answered = Boolean(block.answer);
|
||||
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;
|
||||
@ -36,14 +55,48 @@ export function ThreadCard({
|
||||
}
|
||||
}
|
||||
|
||||
const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) =>
|
||||
pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId
|
||||
? pending.checked
|
||||
: stored;
|
||||
const selectedNow = (select: Section<"select">, optionId: string) =>
|
||||
pending?.type === "select" && pending.sectionId === select.id && pending.optionId === optionId
|
||||
? (pending.selected ?? true)
|
||||
: (select.selected?.includes(optionId) ?? false);
|
||||
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);
|
||||
|
||||
@ -84,11 +137,71 @@ export function ThreadCard({
|
||||
</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}>
|
||||
{part.items.map((item) => {
|
||||
const checked = checkedNow(part, item.id, Boolean(item.checked));
|
||||
{shown.map((item) => {
|
||||
const checked = Boolean(item.checked);
|
||||
const display = checklistItemDisplay(item, factor);
|
||||
return (
|
||||
<Pressable
|
||||
key={item.id}
|
||||
@ -121,11 +234,15 @@ export function ThreadCard({
|
||||
checked && { textDecorationLine: "line-through" },
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
{display.amount ? (
|
||||
<Text style={{ fontWeight: "600" }}>{display.amount} </Text>
|
||||
) : null}
|
||||
{display.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{showMore(part.id, hidden)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -133,7 +250,7 @@ export function ThreadCard({
|
||||
return (
|
||||
<View key={index} style={part.multiple ? styles.chips : { gap: 6 }}>
|
||||
{part.options.map((option) => {
|
||||
const chosen = selectedNow(part, option.id);
|
||||
const chosen = part.selected?.includes(option.id) ?? false;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
@ -172,19 +289,25 @@ export function ThreadCard({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (answered) return null;
|
||||
const actions = part.actions.filter((action) => action.kind === "steps" || !answered);
|
||||
if (!actions.length) return null;
|
||||
return (
|
||||
<View key={index} style={styles.chips}>
|
||||
{part.actions.map((action, position) => {
|
||||
{actions.map((action, position) => {
|
||||
const primary = position === 0;
|
||||
const disabled = busy || nothingChosen;
|
||||
const opensSteps = action.kind === "steps";
|
||||
const disabled = opensSteps ? !steps : busy || nothingChosen;
|
||||
return (
|
||||
<Pressable
|
||||
key={action.id}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled }}
|
||||
disabled={disabled}
|
||||
onPress={() => void act({ type: "action", actionId: action.id })}
|
||||
onPress={() =>
|
||||
opensSteps
|
||||
? setCooking(true)
|
||||
: void act({ type: "action", actionId: action.id })
|
||||
}
|
||||
style={[
|
||||
styles.option,
|
||||
{
|
||||
@ -211,6 +334,16 @@ export function ThreadCard({
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{cooking && steps ? (
|
||||
<CookingMode
|
||||
title={header?.title ?? ""}
|
||||
steps={steps}
|
||||
now={now}
|
||||
busy={busy}
|
||||
onAct={act}
|
||||
onClose={() => setCooking(false)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -232,4 +365,16 @@ const styles = StyleSheet.create({
|
||||
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" },
|
||||
});
|
||||
|
||||
@ -608,4 +608,12 @@ export const RU_MESSAGES: Record<string, string> = {
|
||||
"Every {day}, {time}": "Еженедельно: {day}, {time}",
|
||||
"Monthly on day {day}, {time}": "Ежемесячно {day}-го числа, {time}",
|
||||
"Next {date}": "Далее: {date}",
|
||||
Close: "Закрыть",
|
||||
"Fewer {label}": "Меньше: {label}",
|
||||
"More {label}": "Больше: {label}",
|
||||
Next: "Далее",
|
||||
"Show {count} more": "Показать ещё {count}",
|
||||
"Start timer": "Запустить таймер",
|
||||
"Step {position} of {total}": "Шаг {position} из {total}",
|
||||
"Stop timer": "Остановить таймер",
|
||||
};
|
||||
|
||||
@ -592,4 +592,12 @@ export const ZH_MESSAGES: Record<string, string> = {
|
||||
"Every {day}, {time}": "每{day} {time}",
|
||||
"Monthly on day {day}, {time}": "每月 {day} 日 {time}",
|
||||
"Next {date}": "下次:{date}",
|
||||
Close: "关闭",
|
||||
"Fewer {label}": "减少{label}",
|
||||
"More {label}": "增加{label}",
|
||||
Next: "下一步",
|
||||
"Show {count} more": "再显示 {count} 项",
|
||||
"Start timer": "开始计时",
|
||||
"Step {position} of {total}": "第 {position} 步,共 {total} 步",
|
||||
"Stop timer": "停止计时",
|
||||
};
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
"expo-file-system": "57.0.7",
|
||||
"expo-font": "57.0.4",
|
||||
"expo-image-picker": "57.0.20",
|
||||
"expo-keep-awake": "57.0.2",
|
||||
"expo-linking": "57.0.11",
|
||||
"expo-localization": "57.0.2",
|
||||
"expo-modules-core": "57.0.20",
|
||||
|
||||
71
apps/web/e2e/recipe-card.spec.ts
Normal file
71
apps/web/e2e/recipe-card.spec.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
|
||||
|
||||
async function useAppearance(page: Page, appearance: "light" | "dark") {
|
||||
const settings = await openUserSettings(page);
|
||||
await settings.getByTestId(`ui-appearance-${appearance}`).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", appearance);
|
||||
await settings.getByRole("button", { name: "Close user settings" }).click();
|
||||
await expect(settings).toBeHidden();
|
||||
}
|
||||
|
||||
test("a recipe card scales its ingredients and cooks step by step", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `recipe-card-${stamp}@rakazo.test`, "password12", "Recipe Card");
|
||||
await completeOnboarding(page);
|
||||
|
||||
await page.getByPlaceholder(/Message/).fill("Give me a pancake recipe.");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const card = page.getByTestId("thread-card");
|
||||
await expect(card).toBeVisible({ timeout: 60_000 });
|
||||
await expect(card).toContainText("200 g");
|
||||
await expect(card).toContainText("1 Egg");
|
||||
|
||||
await card.getByRole("button", { name: "More Servings" }).click();
|
||||
await expect(card.getByTestId("card-stepper-value")).toHaveText("3");
|
||||
await expect(card).toContainText("300 g");
|
||||
await expect(card).toContainText("1½ Eggs");
|
||||
await expect(card).toContainText("450 ml");
|
||||
|
||||
await useAppearance(page, "light");
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await captureScreenshot(page, testInfo, "recipe-card-light");
|
||||
await useAppearance(page, "dark");
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await captureScreenshot(page, testInfo, "recipe-card-dark");
|
||||
|
||||
await card.getByRole("button", { name: "Cook" }).click();
|
||||
const cooking = page.getByTestId("cooking-mode");
|
||||
await expect(cooking).toContainText("Step 1 of 3");
|
||||
await cooking.getByRole("button", { name: "Next" }).click();
|
||||
await expect(cooking).toContainText("Step 2 of 3");
|
||||
await expect(cooking).toContainText("Let the batter rest.");
|
||||
await cooking.getByRole("button", { name: "Start timer" }).click();
|
||||
await expect(cooking.getByRole("button", { name: "Stop timer" })).toContainText(/9:5\d/, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await captureScreenshot(page, testInfo, "cooking-mode-dark");
|
||||
await closeCookingMode(page);
|
||||
await useAppearance(page, "light");
|
||||
await card.getByRole("button", { name: "Cook" }).click();
|
||||
await expect(cooking).toContainText("Step 2 of 3");
|
||||
await captureScreenshot(page, testInfo, "cooking-mode-light");
|
||||
await closeCookingMode(page);
|
||||
|
||||
// Servings, step and timer are stored on the card, so a reload keeps them.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
const reloaded = page.getByTestId("thread-card");
|
||||
await expect(reloaded.getByTestId("card-stepper-value")).toHaveText("3", { timeout: 30_000 });
|
||||
await expect(reloaded).toContainText(/9:[45]\d/);
|
||||
await reloaded.getByRole("button", { name: "Cook" }).click();
|
||||
await expect(page.getByTestId("cooking-mode")).toContainText("Step 2 of 3");
|
||||
await expect(
|
||||
page.getByTestId("cooking-mode").getByRole("button", { name: "Stop timer" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
async function closeCookingMode(page: Page) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("cooking-mode")).toBeHidden();
|
||||
}
|
||||
@ -630,5 +630,11 @@
|
||||
"Reminder": "Erinnerung",
|
||||
"Removed": "Entfernt",
|
||||
"Undo": "Rückgängig",
|
||||
"Weekdays, {time}": "Werktags, {time}"
|
||||
"Weekdays, {time}": "Werktags, {time}",
|
||||
"Fewer {label}": "Weniger {label}",
|
||||
"More {label}": "Mehr {label}",
|
||||
"Show {hidden} more": "{hidden} weitere zeigen",
|
||||
"Start timer": "Timer starten",
|
||||
"Step {position} of {total}": "Schritt {position} von {total}",
|
||||
"Stop timer": "Timer stoppen"
|
||||
}
|
||||
|
||||
137
apps/web/src/components/CookingMode.tsx
Normal file
137
apps/web/src/components/CookingMode.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { CardAct, CardSection } from "@rakazo/contracts";
|
||||
import { cardTimerRemaining, formatCardClock } from "@rakazo/core";
|
||||
import { Button, Dialog, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
||||
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type Steps = Extract<CardSection, { type: "steps" }>;
|
||||
|
||||
type WakeLockSentinelLike = { release: () => Promise<void> };
|
||||
type WakeLockNavigator = Navigator & {
|
||||
wakeLock?: { request: (type: "screen") => Promise<WakeLockSentinelLike> };
|
||||
};
|
||||
|
||||
/** Keep the screen on while mounted; the browser drops the lock when the tab hides. */
|
||||
function useScreenWakeLock() {
|
||||
useEffect(() => {
|
||||
const wakeLock = (navigator as WakeLockNavigator).wakeLock;
|
||||
if (!wakeLock) return;
|
||||
let lock: WakeLockSentinelLike | null = null;
|
||||
let released = false;
|
||||
const acquire = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
wakeLock
|
||||
.request("screen")
|
||||
.then((next) => {
|
||||
if (released) void next.release();
|
||||
else lock = next;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
acquire();
|
||||
document.addEventListener("visibilitychange", acquire);
|
||||
return () => {
|
||||
released = true;
|
||||
document.removeEventListener("visibilitychange", acquire);
|
||||
void lock?.release().catch(() => undefined);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's steps one at a time in full screen, large enough to read from the stove. The step and
|
||||
* any timer live on the card, so another device or a reload picks up where the cook is.
|
||||
*/
|
||||
export function CookingMode({
|
||||
title,
|
||||
steps,
|
||||
now,
|
||||
busy,
|
||||
onAct,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
steps: Steps;
|
||||
now: number;
|
||||
busy: boolean;
|
||||
onAct: (act: CardAct) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
useScreenWakeLock();
|
||||
const index = Math.min(steps.current ?? 0, steps.steps.length - 1);
|
||||
const step = steps.steps[index];
|
||||
const last = index === steps.steps.length - 1;
|
||||
const position = index + 1;
|
||||
const total = steps.steps.length;
|
||||
const remaining = cardTimerRemaining(step?.timerEndsAt, now);
|
||||
const running = remaining !== null && remaining > 0;
|
||||
const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next });
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
data-testid="cooking-mode"
|
||||
className="top-0 left-0 flex h-dvh w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none p-6 ring-0 sm:max-w-none sm:p-10"
|
||||
>
|
||||
{/* A plain line, not the display face the app gives headings. */}
|
||||
<DialogTitle
|
||||
render={<div />}
|
||||
className="pe-10 text-ui-body font-medium text-muted-foreground"
|
||||
>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<div className="flex flex-1 flex-col justify-center gap-8 py-8">
|
||||
<div className="text-ui-body text-muted-foreground tabular-nums">
|
||||
{t`Step ${position} of ${total}`}
|
||||
</div>
|
||||
<p className="max-w-3xl text-3xl leading-snug font-medium text-foreground sm:text-5xl sm:leading-tight">
|
||||
{step?.text}
|
||||
</p>
|
||||
{step?.timerSeconds ? (
|
||||
<Button
|
||||
variant={running ? "default" : "outline"}
|
||||
size="lg"
|
||||
className="self-start text-2xl tabular-nums"
|
||||
aria-label={running ? t`Stop timer` : t`Start timer`}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void onAct({
|
||||
type: "timer",
|
||||
sectionId: steps.id,
|
||||
stepId: step.id,
|
||||
running: !running,
|
||||
})
|
||||
}
|
||||
>
|
||||
{running ? <Pause /> : <Play />}
|
||||
{formatCardClock(remaining ?? step.timerSeconds)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => go(index - 1)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
{t`Back`}
|
||||
</Button>
|
||||
{last ? (
|
||||
<Button size="lg" onClick={onClose}>
|
||||
{t`Done`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" disabled={busy} onClick={() => go(index + 1)}>
|
||||
{t`Next`}
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,19 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
|
||||
import { CARD_LIMITS } from "@rakazo/contracts";
|
||||
import {
|
||||
applyCardAct,
|
||||
cardScale,
|
||||
cardTimerRemaining,
|
||||
checklistItemDisplay,
|
||||
formatCardClock,
|
||||
formatCardTimerLength,
|
||||
} from "@rakazo/core";
|
||||
import { Button, Checkbox, cn } from "@rakazo/ui-web";
|
||||
import { Minus, Plus, Timer } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { CookingMode } from "./CookingMode";
|
||||
import { useNow } from "./card-timer";
|
||||
|
||||
type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
|
||||
|
||||
@ -9,7 +22,7 @@ type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
|
||||
* card re-renders from the updated message, so only the tap in flight is held locally.
|
||||
*/
|
||||
export function ThreadCard({
|
||||
block,
|
||||
block: stored,
|
||||
onAct,
|
||||
className,
|
||||
}: {
|
||||
@ -17,10 +30,21 @@ export function ThreadCard({
|
||||
onAct?: (act: CardAct) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t, i18n } = useLingui();
|
||||
const idPrefix = useId();
|
||||
const [pending, setPending] = useState<CardAct | null>(null);
|
||||
const [error, setError] = useState<string | 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 answered = Boolean(block.answer);
|
||||
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;
|
||||
@ -35,19 +59,29 @@ export function ThreadCard({
|
||||
}
|
||||
}
|
||||
|
||||
const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) =>
|
||||
pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId
|
||||
? pending.checked
|
||||
: stored;
|
||||
|
||||
const selectedNow = (select: Section<"select">, optionId: string) => {
|
||||
const stored = select.selected?.includes(optionId) ?? false;
|
||||
return pending?.type === "select" &&
|
||||
pending.sectionId === select.id &&
|
||||
pending.optionId === optionId
|
||||
? (pending.selected ?? true)
|
||||
: stored;
|
||||
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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start px-0 text-muted-foreground"
|
||||
onClick={() => setExpanded((ids) => [...ids, id])}
|
||||
>
|
||||
{t`Show ${hidden} more`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const select = block.sections?.find((part): part is Section<"select"> => part.type === "select");
|
||||
const nothingChosen = Boolean(select?.multiple && !select.selected?.length);
|
||||
@ -87,11 +121,52 @@ export function ThreadCard({
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
if (part.type === "checklist") {
|
||||
if (part.type === "stepper") {
|
||||
const label = part.label;
|
||||
return (
|
||||
<ul key={index} className="flex flex-col gap-2">
|
||||
{part.items.map((item) => {
|
||||
const checked = checkedNow(part, item.id, Boolean(item.checked));
|
||||
<div key={index} className="flex items-center justify-between gap-3 text-ui-body">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t`Fewer ${label}`}
|
||||
disabled={busy || part.value <= 1}
|
||||
onClick={() =>
|
||||
void act({ type: "stepper", sectionId: part.id, value: part.value - 1 })
|
||||
}
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
<span
|
||||
data-testid="card-stepper-value"
|
||||
className="min-w-8 text-center font-medium tabular-nums text-foreground"
|
||||
>
|
||||
{part.value}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t`More ${label}`}
|
||||
disabled={busy || part.value >= CARD_LIMITS.stepperMax}
|
||||
onClick={() =>
|
||||
void act({ type: "stepper", sectionId: part.id, value: part.value + 1 })
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (part.type === "checklist") {
|
||||
const { shown, hidden } = visible(part.id, part.items);
|
||||
return (
|
||||
<div key={index} className="flex flex-col gap-2">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{shown.map((item) => {
|
||||
const checked = Boolean(item.checked);
|
||||
const display = checklistItemDisplay(item, factor);
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<label
|
||||
@ -101,7 +176,7 @@ export function ThreadCard({
|
||||
<Checkbox
|
||||
id={`${idPrefix}-${item.id}`}
|
||||
checked={checked}
|
||||
disabled={!onAct || pending !== null}
|
||||
disabled={busy}
|
||||
onCheckedChange={(next) =>
|
||||
void act({
|
||||
type: "toggle",
|
||||
@ -117,13 +192,49 @@ export function ThreadCard({
|
||||
checked && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{display.amount ? (
|
||||
<span className="font-medium tabular-nums">{display.amount} </span>
|
||||
) : null}
|
||||
{display.label}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{showMore(part.id, hidden)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (part.type === "steps") {
|
||||
const { shown, hidden } = visible(part.id, part.steps);
|
||||
return (
|
||||
<div key={index} className="flex flex-col gap-2">
|
||||
<ol className="flex flex-col gap-2.5">
|
||||
{shown.map((step, position) => {
|
||||
const remaining = cardTimerRemaining(step.timerEndsAt, now);
|
||||
return (
|
||||
<li key={step.id} className="flex gap-2.5 text-ui-body">
|
||||
<span className="w-4 shrink-0 text-end tabular-nums text-muted-foreground">
|
||||
{position + 1}
|
||||
</span>
|
||||
<span className="flex flex-col gap-1 text-foreground">
|
||||
{step.text}
|
||||
{step.timerSeconds ? (
|
||||
<span className="inline-flex items-center gap-1 text-ui-body-sm text-muted-foreground tabular-nums">
|
||||
<Timer className="size-3.5" />
|
||||
{remaining !== null
|
||||
? formatCardClock(remaining)
|
||||
: formatCardTimerLength(step.timerSeconds, i18n.locale)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{showMore(part.id, hidden)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (part.type === "select") {
|
||||
@ -133,7 +244,7 @@ export function ThreadCard({
|
||||
className={cn("flex gap-1.5", part.multiple ? "flex-wrap" : "flex-col")}
|
||||
>
|
||||
{part.options.map((option) => {
|
||||
const chosen = selectedNow(part, option.id);
|
||||
const chosen = part.selected?.includes(option.id) ?? false;
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
@ -143,7 +254,7 @@ export function ThreadCard({
|
||||
"h-auto whitespace-normal px-3.5 py-2.5 font-normal",
|
||||
!part.multiple && "w-full justify-start text-start",
|
||||
)}
|
||||
disabled={!onAct || answered || pending !== null}
|
||||
disabled={busy || answered}
|
||||
onClick={() =>
|
||||
void act({
|
||||
type: "select",
|
||||
@ -160,23 +271,47 @@ export function ThreadCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (answered) return null;
|
||||
const visibleActions = part.actions.filter(
|
||||
(action) => action.kind === "steps" || !answered,
|
||||
);
|
||||
if (!visibleActions.length) return null;
|
||||
return (
|
||||
<div key={index} className="flex flex-wrap gap-2">
|
||||
{part.actions.map((action, position) => (
|
||||
{visibleActions.map((action, position) =>
|
||||
action.kind === "steps" ? (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={position === 0 ? "default" : "outline"}
|
||||
disabled={!onAct || pending !== null || nothingChosen}
|
||||
disabled={!steps}
|
||||
onClick={() => setCooking(true)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={position === 0 ? "default" : "outline"}
|
||||
disabled={busy || nothingChosen}
|
||||
onClick={() => void act({ type: "action", actionId: action.id })}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error ? <p className="text-ui-body-sm text-destructive">{error}</p> : null}
|
||||
{cooking && steps ? (
|
||||
<CookingMode
|
||||
title={header?.title ?? ""}
|
||||
steps={steps}
|
||||
now={now}
|
||||
busy={busy}
|
||||
onAct={act}
|
||||
onClose={() => setCooking(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
13
apps/web/src/components/card-timer.ts
Normal file
13
apps/web/src/components/card-timer.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** The current time, ticking every second while `active`. */
|
||||
export function useNow(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(Date.now());
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "Avatar-Studio"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr "Kein Stress — wir sind jetzt da."
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "Fehlerbehandlung"
|
||||
msgid "Fast"
|
||||
msgstr "Schnell"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr "Weniger {label}"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "Modelle suchen"
|
||||
@ -3071,6 +3077,10 @@ msgstr "Monatlich am {day}., {time}"
|
||||
msgid "More"
|
||||
msgstr "Mehr"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr "Mehr {label}"
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr "Weitere Computeraktionen"
|
||||
@ -3189,6 +3199,10 @@ msgstr "Neuer Skill"
|
||||
msgid "New space"
|
||||
msgstr "Neuer Space"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr "Weiter"
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr "Nächstes Mal {date}"
|
||||
@ -4289,6 +4303,10 @@ msgstr "Geteilte Dokumente"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr "Teile den Entwurf…"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr "{hidden} weitere zeigen"
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr "Starte die Unterhaltung — {activeName} steht oben auf der Bühne."
|
||||
msgid "Start the conversation."
|
||||
msgstr "Starte die Unterhaltung."
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr "Timer starten"
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr "Sprachunterhaltung starten"
|
||||
@ -4498,6 +4520,10 @@ msgstr "Startet…"
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr "Bleibe in meinem Fachgebiet…"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr "Schritt {position} von {total}"
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "Schritte"
|
||||
@ -4517,6 +4543,10 @@ msgstr "Vorlesen stoppen"
|
||||
msgid "Stop teaching"
|
||||
msgstr "Anlernen beenden"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr "Timer stoppen"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "Avatar Studio"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr "Don't stress — we're here now."
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "Failure handling"
|
||||
msgid "Fast"
|
||||
msgstr "Fast"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr "Fewer {label}"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "Find models"
|
||||
@ -3071,6 +3077,10 @@ msgstr "Monthly on day {day}, {time}"
|
||||
msgid "More"
|
||||
msgstr "More"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr "More {label}"
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr "More computer actions"
|
||||
@ -3189,6 +3199,10 @@ msgstr "New skill"
|
||||
msgid "New space"
|
||||
msgstr "New space"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr "Next"
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr "Next {date}"
|
||||
@ -4289,6 +4303,10 @@ msgstr "Shared documents"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr "Sharing the draft…"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr "Show {hidden} more"
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr "Start the conversation — {activeName} is on stage above."
|
||||
msgid "Start the conversation."
|
||||
msgstr "Start the conversation."
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr "Start timer"
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr "Start voice conversation"
|
||||
@ -4498,6 +4520,10 @@ msgstr "Starting…"
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr "Staying in my specialty…"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr "Step {position} of {total}"
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "Steps"
|
||||
@ -4517,6 +4543,10 @@ msgstr "Stop speaking"
|
||||
msgid "Stop teaching"
|
||||
msgstr "Stop teaching"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr "Stop timer"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "Estudio de avatares"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "Manejo de fallos"
|
||||
msgid "Fast"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "Buscar modelos"
|
||||
@ -3071,6 +3077,10 @@ msgstr ""
|
||||
msgid "More"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr "Más acciones de computadora"
|
||||
@ -3189,6 +3199,10 @@ msgstr "Nueva habilidad"
|
||||
msgid "New space"
|
||||
msgstr "Nuevo space"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr ""
|
||||
@ -4289,6 +4303,10 @@ msgstr "Documentos compartidos"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr ""
|
||||
msgid "Start the conversation."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr ""
|
||||
@ -4498,6 +4520,10 @@ msgstr ""
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "Pasos"
|
||||
@ -4517,6 +4543,10 @@ msgstr "Dejar de hablar"
|
||||
msgid "Stop teaching"
|
||||
msgstr "Detener enseñanza"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "अवतार स्टूडियो"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "विफलता प्रबंधन"
|
||||
msgid "Fast"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "मॉडल खोजें"
|
||||
@ -3071,6 +3077,10 @@ msgstr ""
|
||||
msgid "More"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr ""
|
||||
@ -3189,6 +3199,10 @@ msgstr "नई स्किल"
|
||||
msgid "New space"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr ""
|
||||
@ -4289,6 +4303,10 @@ msgstr "साझा दस्तावेज़"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr ""
|
||||
msgid "Start the conversation."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr ""
|
||||
@ -4498,6 +4520,10 @@ msgstr ""
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "चरण"
|
||||
@ -4517,6 +4543,10 @@ msgstr "बोलना रोकें"
|
||||
msgid "Stop teaching"
|
||||
msgstr "सिखाना रोकें"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "아바타 스튜디오"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "실패했을 때 처리"
|
||||
msgid "Fast"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "모델 찾기"
|
||||
@ -3071,6 +3077,10 @@ msgstr ""
|
||||
msgid "More"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr "컴퓨터 작업 더보기"
|
||||
@ -3189,6 +3199,10 @@ msgstr "새 스킬"
|
||||
msgid "New space"
|
||||
msgstr "새 스페이스"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr ""
|
||||
@ -4289,6 +4303,10 @@ msgstr "공유 문서"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr ""
|
||||
msgid "Start the conversation."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr ""
|
||||
@ -4498,6 +4520,10 @@ msgstr ""
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "실행 단계"
|
||||
@ -4517,6 +4543,10 @@ msgstr "읽기 중지"
|
||||
msgid "Stop teaching"
|
||||
msgstr "가르치기 종료"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "Estúdio de avatar"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "Tratamento de falhas"
|
||||
msgid "Fast"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "Encontrar modelos"
|
||||
@ -3071,6 +3077,10 @@ msgstr ""
|
||||
msgid "More"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr ""
|
||||
@ -3189,6 +3199,10 @@ msgstr "Nova habilidade"
|
||||
msgid "New space"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr ""
|
||||
@ -4289,6 +4303,10 @@ msgstr "Documentos compartilhados"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr ""
|
||||
msgid "Start the conversation."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr ""
|
||||
@ -4498,6 +4520,10 @@ msgstr ""
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "Passos"
|
||||
@ -4517,6 +4543,10 @@ msgstr "Pare de Falar"
|
||||
msgid "Stop teaching"
|
||||
msgstr "Parar de ensinar"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "Студия аватаров"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "Обработка сбоев"
|
||||
msgid "Fast"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "Найти модели"
|
||||
@ -3071,6 +3077,10 @@ msgstr ""
|
||||
msgid "More"
|
||||
msgstr "Ещё"
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr "Больше действий с компьютером"
|
||||
@ -3189,6 +3199,10 @@ msgstr "Новый навык"
|
||||
msgid "New space"
|
||||
msgstr "Новое пространство"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr ""
|
||||
@ -4289,6 +4303,10 @@ msgstr "Общие документы"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr ""
|
||||
msgid "Start the conversation."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr ""
|
||||
@ -4498,6 +4520,10 @@ msgstr ""
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "Шаги"
|
||||
@ -4517,6 +4543,10 @@ msgstr "Остановить озвучивание"
|
||||
msgid "Stop teaching"
|
||||
msgstr "Остановить обучение"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "Avatar Stüdyosu"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "Hata yönetimi"
|
||||
msgid "Fast"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "Model bul"
|
||||
@ -3071,6 +3077,10 @@ msgstr ""
|
||||
msgid "More"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr ""
|
||||
@ -3189,6 +3199,10 @@ msgstr "Yeni beceri"
|
||||
msgid "New space"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr ""
|
||||
@ -4289,6 +4303,10 @@ msgstr "Paylaşılan belgeler"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr ""
|
||||
msgid "Start the conversation."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr ""
|
||||
@ -4498,6 +4520,10 @@ msgstr ""
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "Adımlar"
|
||||
@ -4517,6 +4543,10 @@ msgstr "Seslendirmeyi durdur"
|
||||
msgid "Stop teaching"
|
||||
msgstr "Öğretmeyi durdur"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -757,6 +757,7 @@ msgid "Avatar Studio"
|
||||
msgstr "头像工作室"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ai/DelegatedTaskCard.tsx
|
||||
#: src/components/CookingMode.tsx
|
||||
#: src/components/media/MediaCard.tsx
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/ProjectSessionTimeline.tsx
|
||||
@ -2312,6 +2314,10 @@ msgstr "失败处理"
|
||||
msgid "Fast"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Fewer {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Find models"
|
||||
msgstr "查找模型"
|
||||
@ -3071,6 +3077,10 @@ msgstr ""
|
||||
msgid "More"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "More {label}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ComputerMaintenanceActions.tsx
|
||||
msgid "More computer actions"
|
||||
msgstr ""
|
||||
@ -3189,6 +3199,10 @@ msgstr "新建技能"
|
||||
msgid "New space"
|
||||
msgstr "新建工作区"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/routine-line.ts
|
||||
msgid "Next {date}"
|
||||
msgstr ""
|
||||
@ -4289,6 +4303,10 @@ msgstr "共享文档"
|
||||
msgid "Sharing the draft…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/ThreadCard.tsx
|
||||
msgid "Show {hidden} more"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: group.bots.length
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Show all {0}"
|
||||
@ -4476,6 +4494,10 @@ msgstr ""
|
||||
msgid "Start the conversation."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Start timer"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/composer.tsx
|
||||
msgid "Start voice conversation"
|
||||
msgstr ""
|
||||
@ -4498,6 +4520,10 @@ msgstr ""
|
||||
msgid "Staying in my specialty…"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Step {position} of {total}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/teach/SkillDraftCard.tsx
|
||||
msgid "Steps"
|
||||
msgstr "步骤"
|
||||
@ -4517,6 +4543,10 @@ msgstr "停止朗读"
|
||||
msgid "Stop teaching"
|
||||
msgstr "停止示范"
|
||||
|
||||
#: src/components/CookingMode.tsx
|
||||
msgid "Stop timer"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/lib/browser-notifications.ts
|
||||
msgid "Stopped."
|
||||
|
||||
@ -247,7 +247,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
{
|
||||
name: "show_card",
|
||||
description:
|
||||
"Show the user a card instead of a list in prose: a title, short facts, a checklist they can tick off, options they tap, or up to three buttons. Use it for everyday things worth keeping in view, such as a shopping list, a plan for the day or a choice between options. A tap on an option or button arrives as the user's next message. Keep text short; the card is the answer, so do not repeat it in your reply.",
|
||||
'Show the user a card instead of a list in prose: a title, short facts, a servings stepper, a checklist they tick off, numbered steps with timers, options they tap, or up to three buttons. Use it for everyday things worth keeping in view. Templates: a recipe is title + meta ["30 min"] + stepper {label "Servings", value} + checklist of ingredients with amount and unit + steps with timer_minutes + start_label "Cook" + actions ["Add to shopping list", "Every week"]; a shopping list is a checklist; a day plan is steps starting with the time; a decision is facts plus options. A tap on an option or button arrives as the user\'s next message. Keep text short; the card is the answer, so do not repeat it in your reply. At most five parts including the title.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -271,11 +271,60 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
},
|
||||
maxItems: 8,
|
||||
},
|
||||
stepper: {
|
||||
type: "object",
|
||||
description:
|
||||
"A number the user turns up or down, such as servings. Checklist amounts are written for this value and scale with it.",
|
||||
properties: {
|
||||
label: { type: "string", maxLength: 80 },
|
||||
value: { type: "integer", minimum: 1, maximum: 99 },
|
||||
},
|
||||
required: ["label", "value"],
|
||||
},
|
||||
checklist: {
|
||||
type: "array",
|
||||
items: { type: "string", minLength: 1, maxLength: 80 },
|
||||
description:
|
||||
'Entries to tick off. An ingredient is { label: "Flour", amount: 200, unit: "g" }; a counted one is { label: "Egg", plural: "Eggs", amount: 1 }.',
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: "string", minLength: 1, maxLength: 80 },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string", minLength: 1, maxLength: 80 },
|
||||
amount: { type: "number", exclusiveMinimum: 0 },
|
||||
unit: { type: "string", maxLength: 20 },
|
||||
plural: { type: "string", maxLength: 80 },
|
||||
},
|
||||
required: ["label"],
|
||||
},
|
||||
],
|
||||
},
|
||||
maxItems: 30,
|
||||
},
|
||||
steps: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: "string", minLength: 1, maxLength: 300 },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string", minLength: 1, maxLength: 300 },
|
||||
timer_minutes: { type: "number", exclusiveMinimum: 0, maximum: 1440 },
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
maxItems: 20,
|
||||
},
|
||||
start_label: {
|
||||
type: "string",
|
||||
maxLength: 80,
|
||||
description:
|
||||
'A button that walks through the steps one at a time in full screen, such as "Cook".',
|
||||
},
|
||||
options: {
|
||||
type: "array",
|
||||
items: { type: "string", minLength: 1, maxLength: 80 },
|
||||
|
||||
@ -295,6 +295,36 @@ export function inferScript(
|
||||
},
|
||||
];
|
||||
}
|
||||
if (lower.includes("pancake recipe")) {
|
||||
return [
|
||||
{
|
||||
assistant: "",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "show_card",
|
||||
args: {
|
||||
title: "Pancakes",
|
||||
meta: ["25 min"],
|
||||
stepper: { label: "Servings", value: 2 },
|
||||
checklist: [
|
||||
{ label: "Flour", amount: 200, unit: "g" },
|
||||
{ label: "Egg", plural: "Eggs", amount: 1 },
|
||||
{ label: "Milk", amount: 300, unit: "ml" },
|
||||
],
|
||||
steps: [
|
||||
"Whisk flour, egg and milk until smooth.",
|
||||
{ text: "Let the batter rest.", timer_minutes: 10 },
|
||||
"Fry thin pancakes in a hot pan.",
|
||||
],
|
||||
start_label: "Cook",
|
||||
actions: ["Every week"],
|
||||
},
|
||||
},
|
||||
],
|
||||
complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (lower.includes("delegate to the bot named")) {
|
||||
const name = namedBot(prompt) ?? "Researcher";
|
||||
const brief =
|
||||
|
||||
@ -6,8 +6,15 @@ import * as z from "zod";
|
||||
*/
|
||||
|
||||
export const CARD_LIMITS = {
|
||||
sections: 5,
|
||||
facts: 8,
|
||||
checklistItems: 30,
|
||||
steps: 20,
|
||||
stepText: 300,
|
||||
/** Longer lists show this many entries until the user opens the rest. */
|
||||
collapsedEntries: 8,
|
||||
stepperMax: 99,
|
||||
timerSeconds: 24 * 60 * 60,
|
||||
selectOptions: 6,
|
||||
actions: 3,
|
||||
label: 80,
|
||||
@ -34,15 +41,63 @@ export const CardFactsSchema = z.object({
|
||||
.max(CARD_LIMITS.facts),
|
||||
});
|
||||
|
||||
/** "200 g" of something, scaled by the card's stepper; `plural` names more than one ("Eggs"). */
|
||||
export const CardQuantitySchema = z.object({
|
||||
amount: z.number().positive().max(100_000),
|
||||
unit: z.string().trim().max(20).optional(),
|
||||
plural: Label.optional(),
|
||||
});
|
||||
export type CardQuantity = z.infer<typeof CardQuantitySchema>;
|
||||
|
||||
export const CardChecklistSchema = z.object({
|
||||
type: z.literal("checklist"),
|
||||
id: SectionId,
|
||||
items: z
|
||||
.array(z.object({ id: SectionId, label: Label, checked: z.boolean().optional() }))
|
||||
.array(
|
||||
z.object({
|
||||
id: SectionId,
|
||||
label: Label,
|
||||
quantity: CardQuantitySchema.optional(),
|
||||
checked: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(CARD_LIMITS.checklistItems),
|
||||
});
|
||||
|
||||
/** A number the user turns up or down, such as servings; quantities scale from `base`. */
|
||||
export const CardStepperSchema = z.object({
|
||||
type: z.literal("stepper"),
|
||||
id: SectionId,
|
||||
label: Label,
|
||||
base: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
|
||||
value: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
|
||||
});
|
||||
|
||||
/** Numbered steps, optionally timed; the card remembers the current step and running timers. */
|
||||
export const CardStepsSchema = z.object({
|
||||
type: z.literal("steps"),
|
||||
id: SectionId,
|
||||
steps: z
|
||||
.array(
|
||||
z.object({
|
||||
id: SectionId,
|
||||
text: z.string().trim().min(1).max(CARD_LIMITS.stepText),
|
||||
timerSeconds: z.number().int().min(1).max(CARD_LIMITS.timerSeconds).optional(),
|
||||
/** When a started timer runs out; stored so it survives a reload or another device. */
|
||||
timerEndsAt: z.string().datetime().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(CARD_LIMITS.steps),
|
||||
current: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(CARD_LIMITS.steps - 1)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const CardSelectSchema = z.object({
|
||||
type: z.literal("select"),
|
||||
id: SectionId,
|
||||
@ -55,11 +110,14 @@ export const CardSelectSchema = z.object({
|
||||
selected: z.array(SectionId).optional(),
|
||||
});
|
||||
|
||||
/** "reply" sends the label, with any selection, to the bot as the user's answer. */
|
||||
/**
|
||||
* "reply" sends the label, with any selection, to the bot as the user's answer. "steps" opens the
|
||||
* card's steps one at a time in full screen (cooking mode) and never reaches the bot.
|
||||
*/
|
||||
export const CardActionSchema = z.object({
|
||||
id: SectionId,
|
||||
label: Label,
|
||||
kind: z.literal("reply"),
|
||||
kind: z.enum(["reply", "steps"]),
|
||||
});
|
||||
|
||||
export const CardActionsSchema = z.object({
|
||||
@ -70,7 +128,9 @@ export const CardActionsSchema = z.object({
|
||||
export const CardSectionSchema = z.discriminatedUnion("type", [
|
||||
CardHeaderSchema,
|
||||
CardFactsSchema,
|
||||
CardStepperSchema,
|
||||
CardChecklistSchema,
|
||||
CardStepsSchema,
|
||||
CardSelectSchema,
|
||||
CardActionsSchema,
|
||||
]);
|
||||
@ -87,7 +147,7 @@ export const CardBlockSchema = z.object({
|
||||
kind: z.literal("card"),
|
||||
id: SectionId.optional(),
|
||||
lines: z.array(z.object({ k: z.string(), v: z.string() })).optional(),
|
||||
sections: z.array(CardSectionSchema).max(5).optional(),
|
||||
sections: z.array(CardSectionSchema).max(CARD_LIMITS.sections).optional(),
|
||||
answer: CardAnswerSchema.optional(),
|
||||
});
|
||||
export type CardBlock = z.infer<typeof CardBlockSchema>;
|
||||
@ -108,5 +168,25 @@ export const CardActSchema = z.discriminatedUnion("type", [
|
||||
selected: z.boolean().optional(),
|
||||
}),
|
||||
z.object({ type: z.literal("action"), actionId: SectionId }),
|
||||
z.object({
|
||||
type: z.literal("stepper"),
|
||||
sectionId: SectionId,
|
||||
value: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("step"),
|
||||
sectionId: SectionId,
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(CARD_LIMITS.steps - 1),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("timer"),
|
||||
sectionId: SectionId,
|
||||
stepId: SectionId,
|
||||
running: z.boolean(),
|
||||
}),
|
||||
]);
|
||||
export type CardAct = z.infer<typeof CardActSchema>;
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import type { CardBlock } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCardAct, buildCardFromTool, cardHistoryText } from "./cards.js";
|
||||
import {
|
||||
applyCardAct,
|
||||
buildCardFromTool,
|
||||
cardHistoryText,
|
||||
cardScale,
|
||||
checklistItemDisplay,
|
||||
formatCardAmount,
|
||||
} from "./cards.js";
|
||||
|
||||
function card(args: Parameters<typeof buildCardFromTool>[0]): CardBlock {
|
||||
const built = buildCardFromTool(args, "c1");
|
||||
@ -104,3 +111,128 @@ describe("applyCardAct", () => {
|
||||
expect(cardHistoryText({ kind: "card", lines: [{ k: "Mail", v: "sent" }] })).toBe("Mail: sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe cards", () => {
|
||||
const recipe = () =>
|
||||
card({
|
||||
title: "Pancakes",
|
||||
stepper: { label: "Servings", value: 2 },
|
||||
checklist: [
|
||||
{ label: "Flour", amount: 200, unit: "g" },
|
||||
{ label: "Egg", plural: "Eggs", amount: 1 },
|
||||
"Salt",
|
||||
],
|
||||
steps: ["Mix everything.", { text: "Rest the batter.", timer_minutes: 10 }],
|
||||
start_label: "Cook",
|
||||
actions: ["Every week"],
|
||||
});
|
||||
|
||||
it("lays a recipe out as header, servings, ingredients, steps and actions", () => {
|
||||
const block = recipe();
|
||||
expect(block.sections?.map((part) => part.type)).toEqual([
|
||||
"header",
|
||||
"stepper",
|
||||
"checklist",
|
||||
"steps",
|
||||
"actions",
|
||||
]);
|
||||
expect(block.sections?.[3]).toMatchObject({ steps: [{ id: "s1" }, { timerSeconds: 600 }] });
|
||||
expect(block.sections?.[4]).toMatchObject({
|
||||
actions: [
|
||||
{ id: "start", kind: "steps" },
|
||||
{ id: "a1", kind: "reply" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("scales 200 g to 300 g and one egg to 1½ eggs for three servings", () => {
|
||||
const scaled = applyCardAct(recipe(), { type: "stepper", sectionId: "stepper", value: 3 });
|
||||
if (!scaled.ok) throw new Error(scaled.error);
|
||||
const factor = cardScale(scaled.block);
|
||||
const list = scaled.block.sections?.[2];
|
||||
if (list?.type !== "checklist") throw new Error("no checklist");
|
||||
expect(list.items.map((item) => checklistItemDisplay(item, factor))).toEqual([
|
||||
{ amount: "300 g", label: "Flour" },
|
||||
{ amount: "1½", label: "Eggs" },
|
||||
{ label: "Salt" },
|
||||
]);
|
||||
expect(cardHistoryText(scaled.block)).toContain("Servings: 3");
|
||||
expect(cardHistoryText(scaled.block)).toContain("open: 300 g Flour, 1½ Eggs, Salt");
|
||||
});
|
||||
|
||||
it("writes amounts the way a cook would", () => {
|
||||
expect(formatCardAmount(1)).toBe("1");
|
||||
expect(formatCardAmount(0.5)).toBe("½");
|
||||
expect(formatCardAmount(2.34)).toBe("2⅓");
|
||||
expect(formatCardAmount(2.9)).toBe("3");
|
||||
expect(formatCardAmount(12.6)).toBe("13");
|
||||
expect(formatCardAmount(299.99)).toBe("300");
|
||||
expect(formatCardAmount(0.05)).toBe("0.1");
|
||||
expect(
|
||||
checklistItemDisplay({ label: "Egg", quantity: { amount: 1, plural: "Eggs" } }, 1),
|
||||
).toEqual({
|
||||
amount: "1",
|
||||
label: "Egg",
|
||||
});
|
||||
});
|
||||
|
||||
it("remembers the step and a timer's end, idempotently", () => {
|
||||
const now = new Date("2026-10-04T12:00:00Z");
|
||||
const moved = applyCardAct(recipe(), { type: "step", sectionId: "steps", index: 1 });
|
||||
if (!moved.ok) throw new Error(moved.error);
|
||||
const started = applyCardAct(
|
||||
moved.block,
|
||||
{ type: "timer", sectionId: "steps", stepId: "s2", running: true },
|
||||
now,
|
||||
);
|
||||
if (!started.ok) throw new Error(started.error);
|
||||
expect(started.block.sections?.[3]).toMatchObject({
|
||||
current: 1,
|
||||
steps: [{}, { timerEndsAt: "2026-10-04T12:10:00.000Z" }],
|
||||
});
|
||||
const again = applyCardAct(
|
||||
started.block,
|
||||
{ type: "timer", sectionId: "steps", stepId: "s2", running: true },
|
||||
new Date("2026-10-04T12:05:00Z"),
|
||||
);
|
||||
expect(again).toMatchObject({ ok: true, changed: false });
|
||||
const stopped = applyCardAct(
|
||||
started.block,
|
||||
{
|
||||
type: "timer",
|
||||
sectionId: "steps",
|
||||
stepId: "s2",
|
||||
running: false,
|
||||
},
|
||||
new Date("2026-10-04T12:05:00Z"),
|
||||
);
|
||||
const stoppedSteps = stopped.ok ? stopped.block.sections?.[3] : undefined;
|
||||
expect(stoppedSteps?.type === "steps" && stoppedSteps.steps[1]?.timerEndsAt).toBeFalsy();
|
||||
expect(stopped).toMatchObject({ ok: true, changed: true });
|
||||
expect(
|
||||
applyCardAct(moved.block, { type: "timer", sectionId: "steps", stepId: "s1", running: true }),
|
||||
).toMatchObject({ ok: false });
|
||||
expect(applyCardAct(recipe(), { type: "action", actionId: "start" })).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses more than five parts and a start without steps", () => {
|
||||
expect(
|
||||
buildCardFromTool(
|
||||
{
|
||||
title: "Too much",
|
||||
facts: [{ label: "a", value: "b" }],
|
||||
stepper: { label: "Servings", value: 2 },
|
||||
checklist: ["x"],
|
||||
steps: ["y"],
|
||||
actions: ["z"],
|
||||
},
|
||||
"c1",
|
||||
),
|
||||
).toMatchObject({ error: expect.stringContaining("at most 5") });
|
||||
expect(
|
||||
buildCardFromTool({ title: "No steps", checklist: ["x"], start_label: "Cook" }, "c1"),
|
||||
).toHaveProperty("error");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
|
||||
import { CardBlockSchema } from "@rakazo/contracts";
|
||||
import type { CardAct, CardBlock, CardQuantity, CardSection } from "@rakazo/contracts";
|
||||
import { CARD_LIMITS, CardBlockSchema } from "@rakazo/contracts";
|
||||
|
||||
type CardChecklistItem = Extract<CardSection, { type: "checklist" }>["items"][number];
|
||||
type CardStep = Extract<CardSection, { type: "steps" }>["steps"][number];
|
||||
type CardAction = Extract<CardSection, { type: "actions" }>["actions"][number];
|
||||
|
||||
/** What `show_card` accepts: plain content in a fixed order, never layout. */
|
||||
export interface ShowCardArgs {
|
||||
@ -7,7 +11,10 @@ export interface ShowCardArgs {
|
||||
subtitle?: unknown;
|
||||
meta?: unknown;
|
||||
facts?: unknown;
|
||||
stepper?: unknown;
|
||||
checklist?: unknown;
|
||||
steps?: unknown;
|
||||
start_label?: unknown;
|
||||
options?: unknown;
|
||||
multiple?: unknown;
|
||||
actions?: unknown;
|
||||
@ -18,6 +25,48 @@ const strings = (value: unknown): string[] =>
|
||||
|
||||
const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
const field = (value: unknown, key: string): unknown =>
|
||||
value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
|
||||
const positive = (value: unknown) =>
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
|
||||
function checklistItems(value: unknown): CardChecklistItem[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const items: CardChecklistItem[] = [];
|
||||
for (const raw of value) {
|
||||
const label = typeof raw === "string" ? raw.trim() : text(field(raw, "label"));
|
||||
if (!label) continue;
|
||||
const amount = positive(field(raw, "amount"));
|
||||
const unit = text(field(raw, "unit"));
|
||||
const plural = text(field(raw, "plural"));
|
||||
items.push({
|
||||
id: `i${items.length + 1}`,
|
||||
label,
|
||||
...(amount
|
||||
? { quantity: { amount, ...(unit ? { unit } : {}), ...(plural ? { plural } : {}) } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function stepItems(value: unknown): CardStep[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const steps: CardStep[] = [];
|
||||
for (const raw of value) {
|
||||
const stepText = typeof raw === "string" ? raw.trim() : text(field(raw, "text"));
|
||||
if (!stepText) continue;
|
||||
const minutes = positive(field(raw, "timer_minutes"));
|
||||
steps.push({
|
||||
id: `s${steps.length + 1}`,
|
||||
text: stepText,
|
||||
...(minutes ? { timerSeconds: Math.round(minutes * 60) } : {}),
|
||||
});
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Build a validated card from tool arguments; the error is phrased for the model. */
|
||||
export function buildCardFromTool(
|
||||
args: ShowCardArgs,
|
||||
@ -27,25 +76,31 @@ export function buildCardFromTool(
|
||||
if (!title) return { error: "title is required." };
|
||||
const facts = Array.isArray(args.facts)
|
||||
? args.facts
|
||||
.map((row) => ({
|
||||
label: text((row as { label?: unknown })?.label),
|
||||
value: text((row as { value?: unknown })?.value),
|
||||
}))
|
||||
.map((row) => ({ label: text(field(row, "label")), value: text(field(row, "value")) }))
|
||||
.filter((row) => row.label && row.value)
|
||||
: [];
|
||||
const checklist = strings(args.checklist);
|
||||
const stepperLabel = text(field(args.stepper, "label"));
|
||||
const stepperValue = positive(field(args.stepper, "value"));
|
||||
const checklist = checklistItems(args.checklist);
|
||||
const steps = stepItems(args.steps);
|
||||
const startLabel = text(args.start_label);
|
||||
const options = strings(args.options);
|
||||
const actions = strings(args.actions);
|
||||
const multiple = args.multiple === true;
|
||||
if (!facts.length && !checklist.length && !options.length && !actions.length) {
|
||||
if (!facts.length && !checklist.length && !steps.length && !options.length && !actions.length) {
|
||||
return {
|
||||
error: "Add facts, checklist, options or actions; a card with only a title says nothing.",
|
||||
error:
|
||||
"Add facts, checklist, steps, options or actions; a card with only a title says nothing.",
|
||||
};
|
||||
}
|
||||
if (options.length === 1) return { error: "options needs at least two choices." };
|
||||
if (multiple && options.length && !actions.length) {
|
||||
return { error: 'A multiple choice needs an action to send it, such as "Send".' };
|
||||
}
|
||||
if (args.stepper !== undefined && (!stepperLabel || !stepperValue)) {
|
||||
return { error: 'stepper needs a label and a whole number value, such as "Servings", 2.' };
|
||||
}
|
||||
if (startLabel && !steps.length) return { error: "start_label needs steps to walk through." };
|
||||
const subtitle = text(args.subtitle);
|
||||
const meta = strings(args.meta);
|
||||
const sections: CardSection[] = [
|
||||
@ -57,13 +112,12 @@ export function buildCardFromTool(
|
||||
},
|
||||
];
|
||||
if (facts.length) sections.push({ type: "facts", rows: facts });
|
||||
if (checklist.length) {
|
||||
sections.push({
|
||||
type: "checklist",
|
||||
id: "checklist",
|
||||
items: checklist.map((label, index) => ({ id: `i${index + 1}`, label })),
|
||||
});
|
||||
if (stepperLabel && stepperValue) {
|
||||
const value = Math.round(stepperValue);
|
||||
sections.push({ type: "stepper", id: "stepper", label: stepperLabel, base: value, value });
|
||||
}
|
||||
if (checklist.length) sections.push({ type: "checklist", id: "checklist", items: checklist });
|
||||
if (steps.length) sections.push({ type: "steps", id: "steps", steps });
|
||||
if (options.length) {
|
||||
sections.push({
|
||||
type: "select",
|
||||
@ -72,11 +126,15 @@ export function buildCardFromTool(
|
||||
options: options.map((label, index) => ({ id: `o${index + 1}`, label })),
|
||||
});
|
||||
}
|
||||
if (actions.length) {
|
||||
sections.push({
|
||||
type: "actions",
|
||||
actions: actions.map((label, index) => ({ id: `a${index + 1}`, label, kind: "reply" })),
|
||||
});
|
||||
const allActions: CardAction[] = [
|
||||
...(startLabel ? [{ id: "start", label: startLabel, kind: "steps" as const }] : []),
|
||||
...actions.map((label, index) => ({ id: `a${index + 1}`, label, kind: "reply" as const })),
|
||||
];
|
||||
if (allActions.length) sections.push({ type: "actions", actions: allActions });
|
||||
if (sections.length > CARD_LIMITS.sections) {
|
||||
return {
|
||||
error: `A card holds at most ${CARD_LIMITS.sections} parts including the title; leave out facts, options or actions.`,
|
||||
};
|
||||
}
|
||||
const parsed = CardBlockSchema.safeParse({ kind: "card", id, sections });
|
||||
if (!parsed.success) {
|
||||
@ -107,18 +165,69 @@ function replaceSection(block: CardBlock, next: CardSection, index: number): Car
|
||||
return { ...block, sections };
|
||||
}
|
||||
|
||||
function sectionIndex(block: CardBlock, type: CardSection["type"], id: string) {
|
||||
return (block.sections ?? []).findIndex(
|
||||
(candidate) => candidate.type === type && "id" in candidate && candidate.id === id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one tap. Checklists stay usable after the card is answered (a shopping list is still a
|
||||
* shopping list); a choice or an action answers the card once.
|
||||
* Apply one tap. Checklists, servings, steps and timers stay usable after the card is answered
|
||||
* (a shopping list is still a shopping list); a choice or an action answers the card once.
|
||||
* Every act names the state it wants, so a retried tap changes nothing.
|
||||
*/
|
||||
export function applyCardAct(block: CardBlock, act: CardAct): CardActResult {
|
||||
export function applyCardAct(
|
||||
block: CardBlock,
|
||||
act: CardAct,
|
||||
now: Date = new Date(),
|
||||
): CardActResult {
|
||||
const sections = block.sections ?? [];
|
||||
if (act.type === "stepper") {
|
||||
const index = sectionIndex(block, "stepper", act.sectionId);
|
||||
const stepper = sections[index];
|
||||
if (stepper?.type !== "stepper") return { ok: false, error: "No such stepper." };
|
||||
if (stepper.value === act.value) return { ok: true, changed: false, block };
|
||||
return {
|
||||
ok: true,
|
||||
changed: true,
|
||||
block: replaceSection(block, { ...stepper, value: act.value }, index),
|
||||
};
|
||||
}
|
||||
if (act.type === "step" || act.type === "timer") {
|
||||
const index = sectionIndex(block, "steps", act.sectionId);
|
||||
const list = sections[index];
|
||||
if (list?.type !== "steps") return { ok: false, error: "No such steps." };
|
||||
if (act.type === "step") {
|
||||
if (act.index >= list.steps.length) return { ok: false, error: "No such step." };
|
||||
if ((list.current ?? 0) === act.index) return { ok: true, changed: false, block };
|
||||
return {
|
||||
ok: true,
|
||||
changed: true,
|
||||
block: replaceSection(block, { ...list, current: act.index }, index),
|
||||
};
|
||||
}
|
||||
const step = list.steps.find((candidate) => candidate.id === act.stepId);
|
||||
if (!step?.timerSeconds) return { ok: false, error: "This step has no timer." };
|
||||
const running = Boolean(step.timerEndsAt && Date.parse(step.timerEndsAt) > now.getTime());
|
||||
if (running === act.running) return { ok: true, changed: false, block };
|
||||
const steps = list.steps.map((candidate) => {
|
||||
if (candidate.id !== step.id) return candidate;
|
||||
const { timerEndsAt: _ended, ...rest } = candidate;
|
||||
return act.running
|
||||
? {
|
||||
...rest,
|
||||
timerEndsAt: new Date(now.getTime() + (step.timerSeconds ?? 0) * 1000).toISOString(),
|
||||
}
|
||||
: rest;
|
||||
});
|
||||
return { ok: true, changed: true, block: replaceSection(block, { ...list, steps }, index) };
|
||||
}
|
||||
if (act.type === "toggle") {
|
||||
const index = sections.findIndex(
|
||||
(candidate) => candidate.type === "checklist" && candidate.id === act.sectionId,
|
||||
);
|
||||
const list = sections[index];
|
||||
if (!list || list.type !== "checklist") return { ok: false, error: "No such checklist." };
|
||||
if (list?.type !== "checklist") return { ok: false, error: "No such checklist." };
|
||||
if (!list.items.some((item) => item.id === act.itemId)) {
|
||||
return { ok: false, error: "No such item." };
|
||||
}
|
||||
@ -135,7 +244,7 @@ export function applyCardAct(block: CardBlock, act: CardAct): CardActResult {
|
||||
(candidate) => candidate.type === "select" && candidate.id === act.sectionId,
|
||||
);
|
||||
const select = sections[index];
|
||||
if (!select || select.type !== "select") return { ok: false, error: "No such choice." };
|
||||
if (select?.type !== "select") return { ok: false, error: "No such choice." };
|
||||
const option = select.options.find((candidate) => candidate.id === act.optionId);
|
||||
if (!option) return { ok: false, error: "No such option." };
|
||||
if (select.multiple) {
|
||||
@ -163,6 +272,7 @@ export function applyCardAct(block: CardBlock, act: CardAct): CardActResult {
|
||||
const actions = section(block, "actions");
|
||||
const action = actions?.actions.find((candidate) => candidate.id === act.actionId);
|
||||
if (!action) return { ok: false, error: "No such action." };
|
||||
if (action.kind !== "reply") return { ok: false, error: "This action opens on the device." };
|
||||
const select = section(block, "select");
|
||||
const chosen = select?.options
|
||||
.filter((option) => select.selected?.includes(option.id))
|
||||
@ -188,9 +298,16 @@ export function cardHistoryText(block: CardBlock): string {
|
||||
parts.push([part.title, part.subtitle, ...(part.meta ?? [])].filter(Boolean).join(" · "));
|
||||
} else if (part.type === "facts") {
|
||||
parts.push(part.rows.map((row) => `${row.label}: ${row.value}`).join("; "));
|
||||
} else if (part.type === "stepper") {
|
||||
parts.push(`${part.label}: ${part.value}`);
|
||||
} else if (part.type === "steps") {
|
||||
parts.push(`step ${(part.current ?? 0) + 1}/${part.steps.length}`);
|
||||
} else if (part.type === "checklist") {
|
||||
const factor = cardScale(block);
|
||||
const done = part.items.filter((item) => item.checked).length;
|
||||
const open = part.items.filter((item) => !item.checked).map((item) => item.label);
|
||||
const open = part.items
|
||||
.filter((item) => !item.checked)
|
||||
.map((item) => checklistItemText(item, factor));
|
||||
parts.push(
|
||||
`checklist ${done}/${part.items.length} done${open.length ? `, open: ${open.join(", ")}` : ""}`,
|
||||
);
|
||||
@ -208,3 +325,83 @@ export function cardHistoryText(block: CardBlock): string {
|
||||
if (block.answer) parts.push(`answered: ${block.answer.text}`);
|
||||
return `[card: ${parts.join(" — ")}]`;
|
||||
}
|
||||
|
||||
/** How much the card's quantities scale: the stepper's value over the amount they were written for. */
|
||||
export function cardScale(block: CardBlock): number {
|
||||
const stepper = block.sections?.find((part) => part.type === "stepper");
|
||||
return stepper?.type === "stepper" ? stepper.value / stepper.base : 1;
|
||||
}
|
||||
|
||||
const FRACTIONS: Array<[number, string]> = [
|
||||
[0, ""],
|
||||
[1 / 4, "¼"],
|
||||
[1 / 3, "⅓"],
|
||||
[1 / 2, "½"],
|
||||
[2 / 3, "⅔"],
|
||||
[3 / 4, "¾"],
|
||||
[1, ""],
|
||||
];
|
||||
|
||||
/**
|
||||
* A scaled amount as a cook would write it: kitchen fractions below ten ("1½"), whole numbers
|
||||
* above, and steps of five from a hundred on ("300 g", never "299.99 g").
|
||||
*/
|
||||
export function formatCardAmount(amount: number): string {
|
||||
if (amount >= 100) return String(Math.round(amount / 5) * 5);
|
||||
if (amount >= 10) return String(Math.round(amount));
|
||||
let whole = Math.floor(amount);
|
||||
const rest = amount - whole;
|
||||
const [value, glyph] = FRACTIONS.reduce((best, candidate) =>
|
||||
Math.abs(candidate[0] - rest) < Math.abs(best[0] - rest) ? candidate : best,
|
||||
);
|
||||
if (value === 1) whole += 1;
|
||||
if (whole === 0) return glyph || formatSmallAmount(amount);
|
||||
return `${whole}${glyph}`;
|
||||
}
|
||||
|
||||
/** Below an eighth, kitchen fractions would round a pinch away; keep one decimal instead. */
|
||||
function formatSmallAmount(amount: number): string {
|
||||
return String(Math.max(0.1, Math.round(amount * 10) / 10));
|
||||
}
|
||||
|
||||
/** A checklist entry's amount and name at the card's current scale. */
|
||||
export function checklistItemDisplay(
|
||||
item: { label: string; quantity?: CardQuantity },
|
||||
factor: number,
|
||||
): { amount?: string; label: string } {
|
||||
if (!item.quantity) return { label: item.label };
|
||||
const formatted = formatCardAmount(item.quantity.amount * factor);
|
||||
const many = !item.quantity.unit && formatted !== "1" && Number.parseFloat(formatted) >= 1;
|
||||
return {
|
||||
amount: [formatted, item.quantity.unit].filter(Boolean).join(" "),
|
||||
label: many && item.quantity.plural ? item.quantity.plural : item.label,
|
||||
};
|
||||
}
|
||||
|
||||
function checklistItemText(item: { label: string; quantity?: CardQuantity }, factor: number) {
|
||||
const shown = checklistItemDisplay(item, factor);
|
||||
return shown.amount ? `${shown.amount} ${shown.label}` : shown.label;
|
||||
}
|
||||
|
||||
/** Seconds left on a started step timer, or null when it is not running. */
|
||||
export function cardTimerRemaining(timerEndsAt: string | undefined, now: number): number | null {
|
||||
if (!timerEndsAt) return null;
|
||||
return Math.max(0, Math.ceil((Date.parse(timerEndsAt) - now) / 1000));
|
||||
}
|
||||
|
||||
/** A timer as a clock: "9:58" or "1:05:00". */
|
||||
export function formatCardClock(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = String(seconds % 60).padStart(2, "0");
|
||||
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`;
|
||||
}
|
||||
|
||||
/** A timer's length in the user's language, such as "10 min". */
|
||||
export function formatCardTimerLength(seconds: number, locale: string): string {
|
||||
const unit = (value: number, name: "second" | "minute" | "hour") =>
|
||||
new Intl.NumberFormat(locale, { style: "unit", unit: name }).format(value);
|
||||
if (seconds < 60) return unit(seconds, "second");
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return minutes % 60 === 0 ? unit(minutes / 60, "hour") : unit(minutes, "minute");
|
||||
}
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -170,6 +170,9 @@ importers:
|
||||
expo-image-picker:
|
||||
specifier: 57.0.20
|
||||
version: 57.0.20(expo@57.0.26)
|
||||
expo-keep-awake:
|
||||
specifier: 57.0.2
|
||||
version: 57.0.2(expo@57.0.26)(react@19.2.3)
|
||||
expo-linking:
|
||||
specifier: 57.0.11
|
||||
version: 57.0.11(expo@57.0.26)(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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user