diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 92225e6..35d5fa6 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -142,6 +142,7 @@ import { isProjectGoalStatus, issueMessagingLinkCode, listSpaceUserBotIds, + markRoutineNoticesRemoved, newestModelCredentialOrder, newestVoiceCredentialOrder, ownMainAgentsBlockingSpaceDeletion, @@ -2909,6 +2910,8 @@ export function createRouter(deps: RouterDeps) { if (!existing) throw new IsolationError(); await deps.prisma.routine.delete({ where: { id: existing.id } }); await deps.jobs.cancel(routineJobKey(existing.id)); + const marked = await markRoutineNoticesRemoved(deps.prisma, existing).catch(() => null); + if (marked) await deps.events.notify(marked.threadId, marked.seq).catch(() => undefined); return { ok: true as const }; }), testRun: authed.routines.testRun.handler(async ({ context, input }) => { diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index 7e146cc..708609b 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -19,6 +19,7 @@ import { cloudAgentHttpsUrl, deriveWorkspaceFocus, isApprovalAskBlock, + isRoutineEventMessage, isRunTerminalEvent, isSecretAskBlock, latestAnswerableAskMessageId, @@ -67,6 +68,7 @@ import { type MarkdownArtifactPreviewTarget, } from "../components/markdown-artifact-preview"; import { NativeSymbol } from "../components/native-symbol"; +import { RoutineEventLine } from "../components/routine-event-line"; import type { MobileBot, MobileGroup, @@ -2400,6 +2402,15 @@ const MessageBubble = memo(function MessageBubble({ ); } + if (isRoutineEventMessage(message.blocks)) { + return ( + + {message.blocks.map((block, index) => + block.kind === "event" ? : null, + )} + + ); + } const handoff = message.blocks.find((block) => block.kind === "handoff"); if (handoff) { const from = memberName(members, handoff.fromBotId) ?? t("bot"); diff --git a/apps/mobile/components/routine-event-line.tsx b/apps/mobile/components/routine-event-line.tsx new file mode 100644 index 0000000..e93ad4f --- /dev/null +++ b/apps/mobile/components/routine-event-line.tsx @@ -0,0 +1,105 @@ +import type { RoutineEventBlock } from "@rakazo/contracts"; +import { isOneShotRoutineCrons } from "@rakazo/core"; +import { useRouter } from "expo-router"; +import { useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { rpc } from "../lib/api"; +import { useI18n } from "../lib/i18n"; +import { useMobileTokens } from "../lib/native"; +import { routineLineSchedule } from "../lib/routine-line"; +import { NativeSymbol } from "./native-symbol"; + +/** A routine the bot set up from the conversation: one quiet line with Edit and Undo. */ +export function RoutineEventLine({ block }: { block: RoutineEventBlock }) { + const tokens = useMobileTokens(); + const { t } = useI18n(); + const router = useRouter(); + const [undoing, setUndoing] = useState(false); + const [failed, setFailed] = useState(false); + const oneShot = isOneShotRoutineCrons(block.crons); + const schedule = routineLineSchedule(block); + const muted = { color: tokens.mutedForeground, fontSize: 13.5 }; + + async function undo() { + setUndoing(true); + setFailed(false); + try { + await rpc("routines/remove", { routineId: block.routineId }); + } catch { + setFailed(true); + } finally { + setUndoing(false); + } + } + + return ( + + + + + + {block.name} + + {schedule ? · {schedule} : null} + {block.removed ? ( + · {t("Removed")} + ) : ( + <> + {block.event === "routine.updated" ? · {t("Updated")} : null} + · + + router.push({ + pathname: "/routine", + params: { botId: block.botId, routineId: block.routineId }, + }) + } + > + {t("Edit")} + + · + void undo()} + > + + {failed ? t("Try again") : t("Undo")} + + + + )} + + ); +} diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 50f3ccd..79c23a1 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -901,6 +901,7 @@ export function blockText(message: MobileMessage) { return answer ? `${block.question}\n${answer}` : block.question; } if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; + if (block.kind === "event") return block.name; if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`; if (block.kind === "file") { return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`; diff --git a/apps/mobile/lib/locales/ru.ts b/apps/mobile/lib/locales/ru.ts index 0155e1f..50d8864 100644 --- a/apps/mobile/lib/locales/ru.ts +++ b/apps/mobile/lib/locales/ru.ts @@ -600,4 +600,18 @@ export const RU_MESSAGES: Record = { "{name} (permission denied)": "{name} (в разрешении отказано)", "{name} (unsupported type)": "{name} (неподдерживаемый тип)", "Saved {name} locally": "{name} сохранен локально.", + Reminder: "Напоминание", + Removed: "Удалено", + Updated: "Изменено", + Edit: "Изменить", + "Try again": "Повторить", + Undo: "Отменить", + "Every hour": "Каждый час", + "Every {count} min": "Каждые {count} мин", + "Every {count} h": "Каждые {count} ч", + "Daily, {time}": "Ежедневно, {time}", + "Weekdays, {time}": "По будням, {time}", + "Every {day}, {time}": "Еженедельно: {day}, {time}", + "Monthly on day {day}, {time}": "Ежемесячно {day}-го числа, {time}", + "Next {date}": "Далее: {date}", }; diff --git a/apps/mobile/lib/locales/zh.ts b/apps/mobile/lib/locales/zh.ts index 4b17aa4..03f37fe 100644 --- a/apps/mobile/lib/locales/zh.ts +++ b/apps/mobile/lib/locales/zh.ts @@ -584,4 +584,18 @@ export const ZH_MESSAGES: Record = { "Extra high": "超高", Low: "低", High: "高", + Reminder: "提醒", + Removed: "已移除", + Updated: "已更新", + Edit: "编辑", + "Try again": "重试", + Undo: "撤销", + "Every hour": "每小时", + "Every {count} min": "每 {count} 分钟", + "Every {count} h": "每 {count} 小时", + "Daily, {time}": "每天 {time}", + "Weekdays, {time}": "工作日 {time}", + "Every {day}, {time}": "每{day} {time}", + "Monthly on day {day}, {time}": "每月 {day} 日 {time}", + "Next {date}": "下次:{date}", }; diff --git a/apps/mobile/lib/message-presentation.ts b/apps/mobile/lib/message-presentation.ts index 83a1369..5dae1c4 100644 --- a/apps/mobile/lib/message-presentation.ts +++ b/apps/mobile/lib/message-presentation.ts @@ -7,7 +7,8 @@ export function isCenteredAgentEvent(blocks: readonly MessageBlock[]): boolean { block.kind === "handoff" || block.kind === "bot_message_sent" || block.kind === "bot_message_received" || - block.kind === "channel_message", + block.kind === "channel_message" || + block.kind === "event", ); } diff --git a/apps/mobile/lib/routine-line.ts b/apps/mobile/lib/routine-line.ts new file mode 100644 index 0000000..ff47677 --- /dev/null +++ b/apps/mobile/lib/routine-line.ts @@ -0,0 +1,56 @@ +import type { RoutineEventBlock } from "@rakazo/contracts"; +import { routineScheduleShape } from "@rakazo/core"; +import { dateLocaleForUi, t } from "./i18n"; + +/** 2024-01-07 is a Sunday, so day n of that week has weekday n. */ +const weekdayName = (weekday: number, locale: string) => + new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format( + new Date(Date.UTC(2024, 0, 7 + weekday)), + ); + +const clock = (hour: number, minute: number, locale: string) => + new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format( + new Date(Date.UTC(2024, 0, 1, hour, minute)), + ); + +/** When a routine runs, in a few words of the active locale. */ +export function routineLineSchedule( + block: Pick, + locale = dateLocaleForUi(), +): string { + const shape = routineScheduleShape(block.crons); + switch (shape.kind) { + case "hourly": + return t("Every hour"); + case "minutes": + return t("Every {count} min", { count: shape.every }); + case "hours": + return t("Every {count} h", { count: shape.every }); + case "daily": + return t("Daily, {time}", { time: clock(shape.hour, shape.minute, locale) }); + case "weekdays": + return t("Weekdays, {time}", { time: clock(shape.hour, shape.minute, locale) }); + case "weekly": + return t("Every {day}, {time}", { + day: weekdayName(shape.weekday, locale), + time: clock(shape.hour, shape.minute, locale), + }); + case "monthly": + return t("Monthly on day {day}, {time}", { + day: shape.day, + time: clock(shape.hour, shape.minute, locale), + }); + default: { + if (!block.nextRunAt) return ""; + const date = new Intl.DateTimeFormat(locale, { + weekday: "short", + day: "numeric", + month: "short", + hour: "numeric", + minute: "2-digit", + timeZone: block.timezone, + }).format(new Date(block.nextRunAt)); + return shape.kind === "once" ? date : t("Next {date}", { date }); + } + } +} diff --git a/apps/web/e2e/routine-from-text.spec.ts b/apps/web/e2e/routine-from-text.spec.ts new file mode 100644 index 0000000..4ae0841 --- /dev/null +++ b/apps/web/e2e/routine-from-text.spec.ts @@ -0,0 +1,60 @@ +import { expect, type Page, test } from "@playwright/test"; +import type { Routine } from "@rakazo/contracts"; +import { + activeBotId, + captureScreenshot, + completeOnboarding, + openUserSettings, + rpc, + 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 routine said in passing becomes one quiet line with Edit and Undo", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `routine-line-${stamp}@rakazo.test`, "password12", "Routine Line"); + await completeOnboarding(page); + const botId = activeBotId(page); + + await page.getByPlaceholder(/Message/).fill("Every Sunday I plan the week, keep me on it."); + await page.keyboard.press("Enter"); + + const line = page.getByTestId("routine-event-line"); + await expect(line).toBeVisible({ timeout: 60_000 }); + await expect(line).toContainText("Weekly plan"); + await expect(line).toContainText("Every Sunday, 6:00 PM"); + await expect(page.getByText("i will remind you every sunday at 6 pm.")).toBeVisible({ + timeout: 30_000, + }); + await useAppearance(page, "light"); + await line.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "routine-line-light"); + await useAppearance(page, "dark"); + await line.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "routine-line-dark"); + + await line.getByRole("button", { name: "Edit" }).click(); + await expect(page.getByPlaceholder("Name this routine")).toHaveValue("Weekly plan", { + timeout: 15_000, + }); + await page.getByRole("button", { name: "Back" }).click(); + + await line.getByRole("button", { name: "Undo" }).click(); + await expect(line).toContainText("Removed", { timeout: 15_000 }); + await expect(line.getByRole("button", { name: "Undo" })).toHaveCount(0); + expect(await rpc(page, "routines/list", { botId })).toEqual([]); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("routine-event-line")).toContainText("Removed", { + timeout: 30_000, + }); +}); diff --git a/apps/web/scripts/translations-de.json b/apps/web/scripts/translations-de.json index ccf963c..79250b1 100644 --- a/apps/web/scripts/translations-de.json +++ b/apps/web/scripts/translations-de.json @@ -620,5 +620,15 @@ "Loading voices…": "Stimmen werden geladen…", "Voice is not available yet. You can turn it on later in settings.": "Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren.", "Voice set to {0}.": "Stimme auf {0} gesetzt.", - "Could not update voice": "Stimme konnte nicht aktualisiert werden" + "Could not update voice": "Stimme konnte nicht aktualisiert werden", + "Daily, {time}": "Täglich, {time}", + "Every {count} h": "Alle {count} Std.", + "Every {count} min": "Alle {count} Min.", + "Every {day}, {time}": "Jeden {day}, {time}", + "Monthly on day {day}, {time}": "Monatlich am {day}., {time}", + "Next {date}": "Nächstes Mal {date}", + "Reminder": "Erinnerung", + "Removed": "Entfernt", + "Undo": "Rückgängig", + "Weekdays, {time}": "Werktags, {time}" } diff --git a/apps/web/src/lib/routine-line.test.ts b/apps/web/src/lib/routine-line.test.ts new file mode 100644 index 0000000..28b496f --- /dev/null +++ b/apps/web/src/lib/routine-line.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { routineLineSchedule } from "./routine-line"; + +// The macros compile at build time; here the English source comes back with its values filled in. +vi.mock("@lingui/core/macro", () => ({ + t: (parts: TemplateStringsArray, ...values: unknown[]) => + parts.reduce((out, part, index) => `${out}${String(values[index - 1])}${part}`), +})); + +const at = (crons: string[], nextRunAt: string | null = null) => ({ + crons, + nextRunAt, + timezone: "Europe/Berlin", +}); + +describe("routineLineSchedule", () => { + it("words the common schedules with the locale's clock", () => { + expect(routineLineSchedule(at(["0 18 * * 0"]), "de-DE")).toBe("Every Sonntag, 18:00"); + expect(routineLineSchedule(at(["0 18 * * 0"]), "en-US")).toBe("Every Sunday, 6:00 PM"); + expect(routineLineSchedule(at(["30 7 * * *"]), "en-US")).toBe("Daily, 7:30 AM"); + expect(routineLineSchedule(at(["0 8 * * 1-5"]), "de-DE")).toBe("Weekdays, 8:00"); + expect(routineLineSchedule(at(["0 9 1 * *"]), "en-US")).toBe("Monthly on day 1, 9:00 AM"); + expect(routineLineSchedule(at(["*/30 * * * *"]), "en-US")).toBe("Every 30 min"); + }); + + it("shows a reminder's time in the routine's zone, and the next run for other schedules", () => { + expect(routineLineSchedule(at(["@once"], "2026-10-05T07:00:00.000Z"), "de-DE")).toBe( + "Mo., 5. Okt., 9:00", + ); + expect(routineLineSchedule(at(["0 9 * * 1,3"], "2026-10-05T07:00:00.000Z"), "en-US")).toBe( + "Next Mon, Oct 5, 9:00 AM", + ); + expect(routineLineSchedule(at(["0 9 * * 1,3"]), "en-US")).toBe(""); + }); +}); diff --git a/apps/web/src/lib/routine-line.ts b/apps/web/src/lib/routine-line.ts new file mode 100644 index 0000000..88a0054 --- /dev/null +++ b/apps/web/src/lib/routine-line.ts @@ -0,0 +1,67 @@ +import { t } from "@lingui/core/macro"; +import type { RoutineEventBlock } from "@rakazo/contracts"; +import { routineScheduleShape } from "@rakazo/core"; + +/** 2024-01-07 is a Sunday, so day n of that week has weekday n. */ +const weekdayName = (weekday: number, locale: string) => + new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format( + new Date(Date.UTC(2024, 0, 7 + weekday)), + ); + +const clock = (hour: number, minute: number, locale: string) => + new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format( + new Date(Date.UTC(2024, 0, 1, hour, minute)), + ); + +const nextRun = (iso: string, timeZone: string, locale: string) => + new Intl.DateTimeFormat(locale, { + weekday: "short", + day: "numeric", + month: "short", + hour: "numeric", + minute: "2-digit", + timeZone, + }).format(new Date(iso)); + +/** When a routine runs, in a few words of the active locale. */ +export function routineLineSchedule( + block: Pick, + locale: string, +): string { + const shape = routineScheduleShape(block.crons); + switch (shape.kind) { + case "hourly": + return t`Every hour`; + case "minutes": { + const count = shape.every; + return t`Every ${count} min`; + } + case "hours": { + const count = shape.every; + return t`Every ${count} h`; + } + case "daily": { + const time = clock(shape.hour, shape.minute, locale); + return t`Daily, ${time}`; + } + case "weekdays": { + const time = clock(shape.hour, shape.minute, locale); + return t`Weekdays, ${time}`; + } + case "weekly": { + const day = weekdayName(shape.weekday, locale); + const time = clock(shape.hour, shape.minute, locale); + return t`Every ${day}, ${time}`; + } + case "monthly": { + const day = shape.day; + const time = clock(shape.hour, shape.minute, locale); + return t`Monthly on day ${day}, ${time}`; + } + default: { + if (!block.nextRunAt) return ""; + const date = nextRun(block.nextRunAt, block.timezone, locale); + return shape.kind === "once" ? date : t`Next ${date}`; + } + } +} diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index e54f8d7..8b3b5e0 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -1861,6 +1861,10 @@ msgstr "Kundensupport" msgid "Customize bot avatar" msgstr "Bot-Avatar anpassen" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "Täglich, {time}" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "Dunkel" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "Frühere Nachricht" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "Bearbeiten" @@ -2194,6 +2199,18 @@ msgstr "Abend-Check-in" msgid "Every" msgstr "Alle" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "Alle {count} Std." + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "Alle {count} Min." + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "Jeden {day}, {time}" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "alle {intervalAmountSelect} {intervalUnitSelect}" @@ -2203,6 +2220,7 @@ msgstr "alle {intervalAmountSelect} {intervalUnitSelect}" msgid "Every day" msgstr "Jeden Tag" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "Modelle vom Server" msgid "Monthly" msgstr "Monatlich" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "Monatlich am {day}., {time}" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "Mehr" @@ -3175,6 +3197,10 @@ msgstr "Neuer Skill" msgid "New space" msgstr "Neuer Space" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "Nächstes Mal {date}" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "Nächster Look" @@ -3828,6 +3854,10 @@ msgstr "Unterbrochenen Computer freigeben?" msgid "Reload" msgstr "Neu laden" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "Erinnerung" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "Skill {0} entfernen" msgid "Remove webhook" msgstr "Webhook entfernen" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "Entfernt" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "Zusammen mit Chat, Computer und Erinnerungen entfernt." @@ -4005,6 +4039,7 @@ msgstr "Schlüssel erneuern" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "Routine" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "Triage läuft…" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Erneut versuchen" @@ -4773,6 +4809,10 @@ msgstr "Nicht zugewiesen" msgid "Unavailable" msgstr "Nicht verfügbar" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "Rückgängig" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "Deinstallieren" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "Update-Fortschritt" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "Aktualisiert" @@ -4962,6 +5003,10 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Wochentage" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "Werktags, {time}" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "Wöchentlich" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index 89f5775..e3f5100 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -1861,6 +1861,10 @@ msgstr "Customer support" msgid "Customize bot avatar" msgstr "Customize bot avatar" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "Daily, {time}" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "Dark" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "Earlier message" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "Edit" @@ -2194,6 +2199,18 @@ msgstr "Evening check-in" msgid "Every" msgstr "Every" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "Every {count} h" + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "Every {count} min" + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "Every {day}, {time}" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "every {intervalAmountSelect} {intervalUnitSelect}" @@ -2203,6 +2220,7 @@ msgstr "every {intervalAmountSelect} {intervalUnitSelect}" msgid "Every day" msgstr "Every day" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "Models from server" msgid "Monthly" msgstr "Monthly" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "Monthly on day {day}, {time}" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "More" @@ -3175,6 +3197,10 @@ msgstr "New skill" msgid "New space" msgstr "New space" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "Next {date}" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "Next look" @@ -3828,6 +3854,10 @@ msgstr "Release interrupted computer?" msgid "Reload" msgstr "Reload" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "Reminder" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "Remove skill {0}" msgid "Remove webhook" msgstr "Remove webhook" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "Removed" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "Removed with chat, computer, and memory." @@ -4005,6 +4039,7 @@ msgstr "Rotate key" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "Routine" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "Triage in progress…" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Try again" @@ -4773,6 +4809,10 @@ msgstr "Unassigned" msgid "Unavailable" msgstr "Unavailable" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "Undo" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "Uninstall" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "Update progress" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "Updated" @@ -4962,6 +5003,10 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Weekdays" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "Weekdays, {time}" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "Weekly" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 31ebe36..ab5d3b3 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -1861,6 +1861,10 @@ msgstr "Atención al cliente" msgid "Customize bot avatar" msgstr "Personalizar avatar del bot" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "Oscuro" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "Mensaje anterior" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "" @@ -2194,6 +2199,18 @@ msgstr "" msgid "Every" msgstr "Cada" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "cada {intervalAmountSelect} {intervalUnitSelect}" @@ -2203,6 +2220,7 @@ msgstr "cada {intervalAmountSelect} {intervalUnitSelect}" msgid "Every day" msgstr "Todos los días" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "Modelos del servidor" msgid "Monthly" msgstr "Mensual" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "" @@ -3175,6 +3197,10 @@ msgstr "Nueva habilidad" msgid "New space" msgstr "Nuevo space" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "" @@ -3828,6 +3854,10 @@ msgstr "" msgid "Reload" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "Quitar habilidad {0}" msgid "Remove webhook" msgstr "Quitar webhook" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "Eliminado junto con el chat, la computadora y la memoria." @@ -4005,6 +4039,7 @@ msgstr "Rotar clave" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "Rutina" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Intentarlo de nuevo" @@ -4773,6 +4809,10 @@ msgstr "Sin asignar" msgid "Unavailable" msgstr "No disponible" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "Actualizado" @@ -4962,6 +5003,10 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Días laborables" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 33e5203..33700b3 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -1861,6 +1861,10 @@ msgstr "" msgid "Customize bot avatar" msgstr "बॉट अवतार अनुकूलित करें" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "डार्क" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "पुराना संदेश" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "" @@ -2194,6 +2199,18 @@ msgstr "" msgid "Every" msgstr "हर" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "हर {intervalAmountSelect} {intervalUnitSelect}" @@ -2203,6 +2220,7 @@ msgstr "हर {intervalAmountSelect} {intervalUnitSelect}" msgid "Every day" msgstr "हर दिन" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "सर्वर से मॉडल" msgid "Monthly" msgstr "मासिक" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "" @@ -3175,6 +3197,10 @@ msgstr "नई स्किल" msgid "New space" msgstr "" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "" @@ -3828,6 +3854,10 @@ msgstr "" msgid "Reload" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "स्किल {0} हटाएं" msgid "Remove webhook" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "चैट, कंप्यूटर और मेमोरी सहित हटाया गया।" @@ -4005,6 +4039,7 @@ msgstr "" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "रूटीन" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "फिर से कोशिश करें" @@ -4773,6 +4809,10 @@ msgstr "असाइन नहीं किया गया" msgid "Unavailable" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "" @@ -4962,6 +5003,10 @@ msgstr "" msgid "Weekdays" msgstr "कार्यदिवस" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index 1bd9164..8d02744 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -1861,6 +1861,10 @@ msgstr "고객 지원" msgid "Customize bot avatar" msgstr "봇 아바타 맞춤설정" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "다크" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "이전 메시지" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "" @@ -2194,6 +2199,18 @@ msgstr "" msgid "Every" msgstr "매" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "{intervalAmountSelect} {intervalUnitSelect}마다" @@ -2203,6 +2220,7 @@ msgstr "{intervalAmountSelect} {intervalUnitSelect}마다" msgid "Every day" msgstr "매일" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "서버에서 찾은 모델" msgid "Monthly" msgstr "매월" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "" @@ -3175,6 +3197,10 @@ msgstr "새 스킬" msgid "New space" msgstr "새 스페이스" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "" @@ -3828,6 +3854,10 @@ msgstr "" msgid "Reload" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "작업 기술 {0} 삭제" msgid "Remove webhook" msgstr "웹훅 제거" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "대화, 컴퓨터, 기억과 함께 삭제되었습니다." @@ -4005,6 +4039,7 @@ msgstr "key 교체" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "자동 실행" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "다시 시도" @@ -4773,6 +4809,10 @@ msgstr "섹션 없음" msgid "Unavailable" msgstr "사용할 수 없음" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "업데이트됨" @@ -4962,6 +5003,10 @@ msgstr "웹훅" msgid "Weekdays" msgstr "주중" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index e96f31e..b48fefd 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -1861,6 +1861,10 @@ msgstr "" msgid "Customize bot avatar" msgstr "Personalizar avatar do bot" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "Escuro" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "" @@ -2194,6 +2199,18 @@ msgstr "" msgid "Every" msgstr "A cada" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "cada {intervalAmountSelect} {intervalUnitSelect}" @@ -2203,6 +2220,7 @@ msgstr "cada {intervalAmountSelect} {intervalUnitSelect}" msgid "Every day" msgstr "Todos os dias" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "Modelos do servidor" msgid "Monthly" msgstr "Mensalmente" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "" @@ -3175,6 +3197,10 @@ msgstr "Nova habilidade" msgid "New space" msgstr "" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "" @@ -3828,6 +3854,10 @@ msgstr "" msgid "Reload" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "Remover habilidade {0}" msgid "Remove webhook" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "Removido com chat, computador e memória." @@ -4005,6 +4039,7 @@ msgstr "" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "Rotina" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Tentar novamente" @@ -4773,6 +4809,10 @@ msgstr "Não atribuído" msgid "Unavailable" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "" @@ -4962,6 +5003,10 @@ msgstr "" msgid "Weekdays" msgstr "Dias da semana" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index 160e421..860d4b7 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -1861,6 +1861,10 @@ msgstr "Поддержка клиентов" msgid "Customize bot avatar" msgstr "Настроить аватар бота" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "Тёмная" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "Предыдущее сообщение" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "" @@ -2194,6 +2199,18 @@ msgstr "" msgid "Every" msgstr "Каждые" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "каждые {intervalAmountSelect} {intervalUnitSelect}" @@ -2203,6 +2220,7 @@ msgstr "каждые {intervalAmountSelect} {intervalUnitSelect}" msgid "Every day" msgstr "Каждый день" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "Модели с сервера" msgid "Monthly" msgstr "Ежемесячно" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "Ещё" @@ -3175,6 +3197,10 @@ msgstr "Новый навык" msgid "New space" msgstr "Новое пространство" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "" @@ -3828,6 +3854,10 @@ msgstr "Освободить прерванный компьютер?" msgid "Reload" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "Удалить навык {0}" msgid "Remove webhook" msgstr "Удалить вебхук" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "Удалено вместе с чатом, компьютером и памятью." @@ -4005,6 +4039,7 @@ msgstr "Сменить ключ" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "Задача" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "" @@ -4773,6 +4809,10 @@ msgstr "Без раздела" msgid "Unavailable" msgstr "Недоступно" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "Удалить" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "Ход обновления" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "Обновлено" @@ -4962,6 +5003,10 @@ msgstr "Вебхук" msgid "Weekdays" msgstr "Будни" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index a2da959..635d728 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -1861,6 +1861,10 @@ msgstr "" msgid "Customize bot avatar" msgstr "Bot avatarını özelleştir" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "Koyu" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "" @@ -2194,6 +2199,18 @@ msgstr "" msgid "Every" msgstr "Her" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "her {intervalAmountSelect} {intervalUnitSelect}" @@ -2203,6 +2220,7 @@ msgstr "her {intervalAmountSelect} {intervalUnitSelect}" msgid "Every day" msgstr "Her gün" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "Sunucudan gelen modeller" msgid "Monthly" msgstr "Aylık" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "" @@ -3175,6 +3197,10 @@ msgstr "Yeni beceri" msgid "New space" msgstr "" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "" @@ -3828,6 +3854,10 @@ msgstr "" msgid "Reload" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "{0} becerisini kaldır" msgid "Remove webhook" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "Sohbeti, bilgisayarı ve hafızasıyla birlikte kaldırıldı." @@ -4005,6 +4039,7 @@ msgstr "" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "Rutin" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "Tekrar dene" @@ -4773,6 +4809,10 @@ msgstr "Atanmamış" msgid "Unavailable" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "" @@ -4962,6 +5003,10 @@ msgstr "" msgid "Weekdays" msgstr "Hafta içi" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 6fbc473..4ab22f8 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -1861,6 +1861,10 @@ msgstr "客户支持" msgid "Customize bot avatar" msgstr "自定义机器人头像" +#: src/lib/routine-line.ts +msgid "Daily, {time}" +msgstr "" + #: src/pages/AccountSettingsOverlay.tsx msgid "Dark" msgstr "深色" @@ -2129,6 +2133,7 @@ msgid "Earlier message" msgstr "更早的消息" #: src/components/UserProfileSettings.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Edit" msgstr "" @@ -2194,6 +2199,18 @@ msgstr "" msgid "Every" msgstr "每" +#: src/lib/routine-line.ts +msgid "Every {count} h" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {count} min" +msgstr "" + +#: src/lib/routine-line.ts +msgid "Every {day}, {time}" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "每 {intervalAmountSelect} {intervalUnitSelect}" @@ -2203,6 +2220,7 @@ msgstr "每 {intervalAmountSelect} {intervalUnitSelect}" msgid "Every day" msgstr "每天" +#: src/lib/routine-line.ts #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every hour" @@ -3053,6 +3071,10 @@ msgstr "来自服务器的模型" msgid "Monthly" msgstr "每月" +#: src/lib/routine-line.ts +msgid "Monthly on day {day}, {time}" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "More" msgstr "" @@ -3175,6 +3197,10 @@ msgstr "新建技能" msgid "New space" msgstr "新建工作区" +#: src/lib/routine-line.ts +msgid "Next {date}" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Next look" msgstr "" @@ -3828,6 +3854,10 @@ msgstr "" msgid "Reload" msgstr "" +#: src/pages/shell/routine-event-line.tsx +msgid "Reminder" +msgstr "" + #: src/components/ApprovalRulesSettings.tsx #: src/components/MediaSettings.tsx #: src/pages/PluginsOverlay.tsx @@ -3866,6 +3896,10 @@ msgstr "移除技能 {0}" msgid "Remove webhook" msgstr "移除 Webhook" +#: src/pages/shell/routine-event-line.tsx +msgid "Removed" +msgstr "" + #: src/pages/shell/transcript.tsx msgid "Removed with chat, computer, and memory." msgstr "已连同对话、电脑和记忆一起移除。" @@ -4005,6 +4039,7 @@ msgstr "轮换密钥" #: src/pages/RoutineEditor.tsx #: src/pages/Shell.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Routine" msgstr "例行任务" @@ -4754,6 +4789,7 @@ msgid "Triage in progress…" msgstr "" #: src/pages/Onboarding.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Try again" msgstr "重试" @@ -4773,6 +4809,10 @@ msgstr "未分配" msgid "Unavailable" msgstr "不可用" +#: src/pages/shell/routine-event-line.tsx +msgid "Undo" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Uninstall" msgstr "" @@ -4829,6 +4869,7 @@ msgid "Update progress" msgstr "" #: src/components/SoftwareUpdateSection.tsx +#: src/pages/shell/routine-event-line.tsx msgid "Updated" msgstr "已更新" @@ -4962,6 +5003,10 @@ msgstr "Webhook" msgid "Weekdays" msgstr "工作日" +#: src/lib/routine-line.ts +msgid "Weekdays, {time}" +msgstr "" + #: src/components/heute/CoachRhythmControl.tsx msgid "Weekly" msgstr "" diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 8a68433..4e61dbe 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -1524,14 +1524,30 @@ export function ShellPage() { } if (!active) return; if (routineId && routinesBotId === active.id) { + const openRoutine = (routine: Routine | undefined) => { + if (routine) { + setRoutineDraft(draftFromRoutine(routine)); + setRoutineWebhookSecret(null); + setEditingRoutine(routine); + setPanel("routine"); + } else { + setPanel("computer"); + } + }; const routine = routines.find((item) => item.id === routineId); if (routine) { - setRoutineDraft(draftFromRoutine(routine)); - setRoutineWebhookSecret(null); - setEditingRoutine(routine); - setPanel("routine"); + openRoutine(routine); } else { - setPanel("computer"); + // The bot may have set it up after the list was loaded. + const botId = active.id; + void rpc.routines + .list({ botId }) + .then((fresh) => { + if (activeBotId.current !== botId) return; + setRoutines(fresh); + openRoutine(fresh.find((item) => item.id === routineId)); + }) + .catch(() => setPanel("computer")); } const next = new URLSearchParams(searchParams); next.delete("routine"); diff --git a/apps/web/src/pages/shell/routine-event-line.tsx b/apps/web/src/pages/shell/routine-event-line.tsx new file mode 100644 index 0000000..b45900e --- /dev/null +++ b/apps/web/src/pages/shell/routine-event-line.tsx @@ -0,0 +1,84 @@ +import { useLingui } from "@lingui/react/macro"; +import type { RoutineEventBlock } from "@rakazo/contracts"; +import { isOneShotRoutineCrons } from "@rakazo/core"; +import { Icon } from "@rakazo/ui-web"; +import { BellRing, Repeat } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { routineLineSchedule } from "../../lib/routine-line"; +import { rpc } from "../../lib/rpc"; + +const actionClass = + "rounded-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"; + +/** A routine the bot set up from the conversation: one quiet line with Edit and Undo. */ +export function RoutineEventLine({ block }: { block: RoutineEventBlock }) { + const { i18n, t } = useLingui(); + const navigate = useNavigate(); + const [undoing, setUndoing] = useState(false); + const [failed, setFailed] = useState(false); + const oneShot = isOneShotRoutineCrons(block.crons); + const schedule = routineLineSchedule(block, i18n.locale || "en"); + + async function undo() { + setUndoing(true); + setFailed(false); + try { + await rpc.routines.remove({ routineId: block.routineId }); + } catch { + setFailed(true); + } finally { + setUndoing(false); + } + } + + return ( +
+

+ + + {block.name} + + {schedule ? · : null} + {schedule ? {schedule} : null} + · + {block.removed ? ( + {t`Removed`} + ) : ( + <> + {block.event === "routine.updated" ? {t`Updated`} : null} + {block.event === "routine.updated" ? · : null} + + · + + + )} +

+
+ ); +} diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index 1e1c21c..dd25bdd 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -4,7 +4,12 @@ import { ChatMarkdown } from "@rakazo/chat-ui/web"; import type { MessageReaction, ThreadMessage } from "@rakazo/contracts"; import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts"; import type { WorkspaceArtifact } from "@rakazo/core"; -import { isPeerReceiptBlocks, isToolActivityBlock, projectMessageReactions } from "@rakazo/core"; +import { + isPeerReceiptBlocks, + isRoutineEventMessage, + isToolActivityBlock, + projectMessageReactions, +} from "@rakazo/core"; import type { GroupAvatarMember } from "@rakazo/ui-web"; import { BotAvatar, @@ -47,6 +52,7 @@ import { McpApprovalCard, } from "./message-cards"; import { previewMessageText } from "./message-preview"; +import { RoutineEventLine } from "./routine-event-line"; import { FALLBACK_BOT_COLOR } from "./shell-types"; export const Transcript = memo(function Transcript({ @@ -368,6 +374,15 @@ export const Transcript = memo(function Transcript({ {reactionView.visibleMessages.map((message) => { if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null; const peerReceipt = isPeerReceiptBlocks(message.blocks); + if (isRoutineEventMessage(message.blocks)) { + return ( +
+ {message.blocks.map((block, index) => + block.kind === "event" ? : null, + )} +
+ ); + } const messageReactions = reactionView.reactions.get(message.id); return (
{ ]), }, chatGroup: { findFirst: vi.fn(async () => null) }, - routine: { findMany: vi.fn(async () => []), create: routineCreate }, + routine: { + findMany: vi.fn(async () => []), + count: vi.fn(async () => 0), + create: routineCreate, + }, }, ); expect(results[0]).toMatchObject({ ok: true, routineId: "routine-1", bot: "Atlas" }); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 7a65cc8..1074430 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -2785,6 +2785,7 @@ export function createRunExecutor(deps: ExecutorDeps) { delayMinutes: args.delayMinutes, delaySeconds: args.delaySeconds, }, + notice: { threadId: thread.id, botId: bot.id }, }); return finish(created); } diff --git a/packages/adapters/src/schedule-notices.postgres.test.ts b/packages/adapters/src/schedule-notices.postgres.test.ts new file mode 100644 index 0000000..867662d --- /dev/null +++ b/packages/adapters/src/schedule-notices.postgres.test.ts @@ -0,0 +1,144 @@ +import { randomUUID } from "node:crypto"; +import type { MessageBlock } from "@rakazo/contracts"; +import type { PrismaClient, ThreadEvents } from "@rakazo/db"; +import { + bootstrapUserSpace, + createDb, + ensureMainConversation, + markRoutineNoticesRemoved, +} from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { cancelScheduleFromTool, createScheduleFromTool } from "./schedule-tools.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("routine lines in the conversation (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newConversation() { + const id = `sn${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + await prisma.userProfile.create({ data: { userId: id, timeZone: "Europe/Berlin" } }); + const conversation = await ensureMainConversation(prisma, id); + const notify = vi.fn(async () => undefined); + const deps = { + prisma, + events: { append: vi.fn(async () => undefined), notify } as unknown as ThreadEvents, + jobs: { + enqueue: vi.fn(async () => undefined), + cancel: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }, + }; + const owner = { + spaceId: conversation.spaceId, + botId: conversation.leadBotId, + userId: id, + threadId: conversation.threadId, + notice: { threadId: conversation.threadId, botId: conversation.leadBotId }, + }; + return { deps, owner, notify, conversation }; + } + + async function lines(threadId: string) { + const messages = await prisma.message.findMany({ + where: { threadId }, + orderBy: { seq: "asc" }, + select: { role: true, blocks: true }, + }); + return messages.flatMap((message) => + (message.blocks as MessageBlock[]).filter((block) => block.kind === "event"), + ); + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("writes a line, changes the routine of the same name, and marks the lines when it is cancelled", async () => { + const { deps, owner, notify, conversation } = await newConversation(); + + const created = await createScheduleFromTool(deps, { + ...owner, + name: "Wochenplan", + prompt: "Plan the week with me", + schedule: { cron: "0 18 * * 0" }, + }); + expect(created).toMatchObject({ ok: true, cron: "0 18 * * 0" }); + const changed = await createScheduleFromTool(deps, { + ...owner, + name: "wochenplan", + prompt: "Plan the week with me on Saturday", + schedule: { cron: "0 10 * * 6" }, + }); + expect(changed).toMatchObject({ ok: true, updated: true, cron: "0 10 * * 6" }); + + const routines = await prisma.routine.findMany({ where: { userId: owner.userId } }); + expect(routines.filter((routine) => routine.coachKey === null)).toHaveLength(1); + const routine = routines.find((row) => row.name === "Wochenplan")!; + expect(routine).toMatchObject({ crons: ["0 10 * * 6"], timezone: "Europe/Berlin" }); + expect(await lines(conversation.threadId)).toEqual([ + expect.objectContaining({ event: "routine.created", crons: ["0 18 * * 0"] }), + expect.objectContaining({ event: "routine.updated", crons: ["0 10 * * 6"] }), + ]); + expect(notify).toHaveBeenCalledTimes(2); + const thread = await prisma.thread.findUniqueOrThrow({ where: { id: conversation.threadId } }); + expect(thread.unread).toBe(false); + + const cancelled = await cancelScheduleFromTool(deps, { ...owner, name: "Wochenplan" }); + expect(cancelled).toMatchObject({ ok: true }); + expect((await lines(conversation.threadId)).map((line) => line.removed)).toEqual([true, true]); + }); + + it("keeps one-shot reminders of the same name apart", async () => { + const { deps, owner } = await newConversation(); + for (const delayMinutes of [10, 20]) { + await createScheduleFromTool(deps, { + ...owner, + name: "Call mum", + prompt: "Remind me to call mum", + schedule: { delayMinutes }, + }); + } + expect(await prisma.routine.count({ where: { userId: owner.userId, name: "Call mum" } })).toBe( + 2, + ); + }); + + it("does nothing for a routine without a line", async () => { + const { owner } = await newConversation(); + expect( + await markRoutineNoticesRemoved(prisma, { + id: "missing", + botId: owner.botId, + noticeMessageId: null, + }), + ).toBeNull(); + }); +}); diff --git a/packages/adapters/src/schedule-tools.test.ts b/packages/adapters/src/schedule-tools.test.ts index 469c770..dd678e0 100644 --- a/packages/adapters/src/schedule-tools.test.ts +++ b/packages/adapters/src/schedule-tools.test.ts @@ -7,6 +7,7 @@ import { filterBuiltinToolsForThread, isOneShotRoutineCron, listSchedulesFromTool, + MAX_ACTIVE_ROUTINES_PER_BOT, resolveScheduleOwner, resolveScheduleTiming, SCHEDULE_TOOL_NAMES, @@ -169,7 +170,10 @@ describe("schedule tool persistence", () => { nextRunAt: args.data.nextRunAt, })); const deps = { - prisma: { routine: { create }, userProfile: { findUnique: async () => null } }, + prisma: { + routine: { create, findFirst: async () => null, count: async () => 0 }, + userProfile: { findUnique: async () => null }, + }, events: { append }, jobs: { enqueue, cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; @@ -202,6 +206,36 @@ describe("schedule tool persistence", () => { expect(enqueue).toHaveBeenCalledOnce(); }); + it("refuses another schedule once the bot has the maximum active", async () => { + const create = vi.fn(); + const deps = { + prisma: { + routine: { + create, + findFirst: async () => null, + count: async () => MAX_ACTIVE_ROUTINES_PER_BOT, + }, + userProfile: { findUnique: async () => null }, + }, + events: { append: vi.fn(async () => undefined) }, + jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, + } as unknown as Parameters[0]; + + const result = await createScheduleFromTool(deps, { + spaceId: "ws-1", + botId: "bot-1", + userId: "user-1", + threadId: "thread-1", + name: "One more", + prompt: "Do it", + schedule: { cron: "0 9 * * *" }, + }); + expect(result).toEqual({ + error: `This bot already has ${MAX_ACTIVE_ROUTINES_PER_BOT} active schedules. Cancel one before adding another.`, + }); + expect(create).not.toHaveBeenCalled(); + }); + it("defaults the routine time zone to the user's and lets an explicit zone win", async () => { const create = vi.fn(async (args: { data: Record }) => ({ id: "routine-1", @@ -209,7 +243,10 @@ describe("schedule tool persistence", () => { })); const findUnique = vi.fn(async () => ({ timeZone: "America/New_York" })); const deps = { - prisma: { routine: { create }, userProfile: { findUnique } }, + prisma: { + routine: { create, findFirst: async () => null, count: async () => 0 }, + userProfile: { findUnique }, + }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; @@ -250,7 +287,7 @@ describe("schedule tool persistence", () => { })); const deps = { prisma: { - routine: { create }, + routine: { create, findFirst: async () => null, count: async () => 0 }, userProfile: { findUnique: vi.fn(async () => ({ timeZone: "Mars/Olympus" })) }, }, events: { append: vi.fn(async () => undefined) }, @@ -278,6 +315,8 @@ describe("schedule tool persistence", () => { prisma: { userProfile: { findUnique: async () => null }, routine: { + findFirst: async () => null, + count: async () => 0, create: vi.fn(async () => ({ id: "routine-1", name: "Morning joke", @@ -316,6 +355,8 @@ describe("schedule tool persistence", () => { prisma: { userProfile: { findUnique: async () => null }, routine: { + findFirst: async () => null, + count: async () => 0, create: vi.fn(async () => ({ id: "routine-1", name: "Morning joke", @@ -354,6 +395,8 @@ describe("schedule tool persistence", () => { prisma: { userProfile: { findUnique: async () => null }, routine: { + findFirst: async () => null, + count: async () => 0, create: vi.fn(async () => ({ id: "routine-1", name: "Morning joke", @@ -397,6 +440,8 @@ describe("schedule tool persistence", () => { prisma: { userProfile: { findUnique: async () => null }, routine: { + findFirst: async () => null, + count: async () => 0, create: vi.fn(async () => ({ id: "routine-1", name: "Morning joke", @@ -631,7 +676,10 @@ describe("scheduling on behalf of another bot", () => { const append = vi.fn(async () => undefined); const deps = { // No profile time zone: the routine falls back to UTC. - prisma: { routine: { create }, userProfile: { findUnique: async () => null } }, + prisma: { + routine: { create, findFirst: async () => null, count: async () => 0 }, + userProfile: { findUnique: async () => null }, + }, events: { append }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; diff --git a/packages/adapters/src/schedule-tools.ts b/packages/adapters/src/schedule-tools.ts index f267200..529ae7a 100644 --- a/packages/adapters/src/schedule-tools.ts +++ b/packages/adapters/src/schedule-tools.ts @@ -1,5 +1,6 @@ import type { ConnectorTool, JobPublisher } from "@rakazo/adapter-kit"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; +import type { RoutineEventBlock } from "@rakazo/contracts"; import { cronFromPreset, isOneShotRoutineCron, @@ -8,6 +9,8 @@ import { ONCE_ROUTINE_CRON, } from "@rakazo/core"; import type { PrismaClient, ThreadEvents } from "@rakazo/db"; +import { markRoutineNoticesRemoved, postRoutineNotice } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; import { loadUserTimeZone } from "./current-time.js"; export { isOneShotRoutineCron, ONCE_ROUTINE_CRON }; @@ -276,7 +279,20 @@ function pickScheduleTarget; + notice?: ScheduleNotice; }, ) { const name = input.name.trim(); @@ -298,6 +315,36 @@ export async function createScheduleFromTool( const resolved = resolveScheduleTiming(input.schedule, timezone); if (!resolved.ok) return { error: resolved.error }; + if (!resolved.oneShot) { + const existing = await deps.prisma.routine.findFirst({ + where: { + botId: input.botId, + userId: input.userId, + active: true, + coachKey: null, + name: { equals: name, mode: "insensitive" }, + NOT: { crons: { has: ONCE_ROUTINE_CRON } }, + }, + }); + if (existing) { + return updateScheduleFromTool(deps, input, existing.id, { + prompt, + timezone, + cron: resolved.cron, + nextRunAt: resolved.nextRunAt, + }); + } + } + + const activeCount = await deps.prisma.routine.count({ + where: { botId: input.botId, active: true }, + }); + if (activeCount >= MAX_ACTIVE_ROUTINES_PER_BOT) { + return { + error: `This bot already has ${MAX_ACTIVE_ROUTINES_PER_BOT} active schedules. Cancel one before adding another.`, + }; + } + const row = await deps.prisma.routine.create({ data: { spaceId: input.spaceId, @@ -341,6 +388,16 @@ export async function createScheduleFromTool( } catch { // Match routines.create: the reminder is live even if the thread signal fails. } + await postNotice(deps, input.notice, input.spaceId, { + kind: "event", + event: "routine.created", + routineId: row.id, + botId: input.botId, + name: row.name, + crons: row.crons, + timezone: row.timezone, + nextRunAt: row.nextRunAt?.toISOString() ?? null, + }); return { ok: true as const, @@ -353,6 +410,93 @@ export async function createScheduleFromTool( }; } +async function updateScheduleFromTool( + deps: ScheduleToolDeps, + input: ScheduleOwner & { notice?: ScheduleNotice }, + routineId: string, + change: { prompt: string; timezone: string; cron: string; nextRunAt: Date }, +) { + const row = await deps.prisma.routine.update({ + where: { id: routineId }, + data: { + prompt: change.prompt, + timezone: change.timezone, + crons: [change.cron], + nextRunAt: change.nextRunAt, + }, + }); + try { + await deps.jobs.enqueue(routineWakeupJob(row.id, change.nextRunAt)); + } catch { + // The job reconciler re-enqueues active routines whose wakeup is missing. + } + try { + await deps.events.append({ + spaceId: row.spaceId, + threadId: row.threadId ?? input.threadId, + botId: row.botId, + type: "routine.updated", + payload: { name: row.name }, + }); + } catch { + // The routine is updated even if the thread signal fails. + } + await postNotice(deps, input.notice, input.spaceId, { + kind: "event", + event: "routine.updated", + routineId: row.id, + botId: row.botId, + name: row.name, + crons: row.crons, + timezone: row.timezone, + nextRunAt: row.nextRunAt?.toISOString() ?? null, + }); + return { + ok: true as const, + updated: true as const, + routineId: row.id, + name: row.name, + cron: row.crons[0], + nextRunAt: row.nextRunAt?.toISOString() ?? null, + oneShot: false, + ...(input.createdByBotId ? { bot: input.botName } : {}), + }; +} + +/** The line is a courtesy: a routine that is live stays live when the line cannot be written. */ +async function postNotice( + deps: ScheduleToolDeps, + notice: ScheduleNotice | undefined, + spaceId: string, + block: RoutineEventBlock, +) { + if (!notice) return; + try { + const posted = await postRoutineNotice(deps.prisma, { + spaceId, + threadId: notice.threadId, + botId: notice.botId, + block, + }); + await deps.events.notify(notice.threadId, posted.seq); + } catch (error) { + getLogger().error("routine notice", error); + } +} + +/** Mark the timeline line of a removed routine; the removal itself has already happened. */ +export async function markRemovedRoutineNotice( + deps: Pick, + routine: { id: string; botId: string; noticeMessageId: string | null }, +) { + try { + const marked = await markRoutineNoticesRemoved(deps.prisma, routine); + if (marked) await deps.events.notify(marked.threadId, marked.seq); + } catch (error) { + getLogger().error("routine notice removal", error); + } +} + /** * Routines a bot manages: its own (in a group only the ones of that thread) and the ones it * scheduled for other bots, wherever they wake. @@ -429,5 +573,6 @@ export async function cancelScheduleFromTool( await deps.prisma.routine.delete({ where: { id: existing.id } }); await deps.jobs.cancel(routineJobKey(existing.id)); + await markRemovedRoutineNotice(deps, existing); return { ok: true as const, routineId: existing.id, name: existing.name }; } diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index bd849a5..b0197ee 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -260,6 +260,21 @@ export function inferScript( } const coach = inferCoachScript(prompt); if (coach) return coach; + // Said in passing: the bot sets the routine up without asking first. + if (lower.includes("every sunday i plan the week")) { + return [ + { + assistant: "i will remind you every sunday at 6 pm.", + toolCalls: [ + { + name: "schedule_create", + args: { name: "Weekly plan", prompt: "Plan the week with me.", cron: "0 18 * * 0" }, + }, + ], + complete: true, + }, + ]; + } if (lower.includes("delegate to the bot named")) { const name = namedBot(prompt) ?? "Researcher"; const brief = diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 8473593..f82b0ba 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -173,6 +173,24 @@ export const IntakeBlockSchema = z.discriminatedUnion("kind", [ ]); export type IntakeBlock = z.infer; +/** + * A quiet line in the timeline for a routine the bot set up from the conversation. `removed` is + * set once the routine is gone, wherever it was removed. + */ +export const RoutineEventBlockSchema = z.object({ + kind: z.literal("event"), + event: z.enum(["routine.created", "routine.updated"]), + routineId: Id, + /** The bot the routine belongs to; it may differ from the bot that wrote the line. */ + botId: Id, + name: z.string(), + crons: z.array(z.string()), + timezone: z.string(), + nextRunAt: z.string().nullable(), + removed: z.boolean().optional(), +}); +export type RoutineEventBlock = z.infer; + export const MessageBlock = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("text"), text: z.string() }), z.object({ @@ -182,6 +200,7 @@ export const MessageBlock = z.discriminatedUnion("kind", [ AskBlockSchema, ChoiceBlockSchema, IntakeNoteBlockSchema, + RoutineEventBlockSchema, z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line description, and an Authorize button that flips to connected. */ @@ -386,6 +405,6 @@ export function canReactToThreadMessage(message: Pick block.kind === "channel_message") + !message.blocks.some((block) => block.kind === "channel_message" || block.kind === "event") ); } diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts index c8ed92d..997b4ba 100644 --- a/packages/core/src/attachments.ts +++ b/packages/core/src/attachments.ts @@ -6,6 +6,7 @@ import { isAllowedAttachmentMimeType, isAttachmentImageMimeType, } from "@rakazo/contracts"; +import { routineEventHistoryText } from "./routine-events.js"; export class AttachmentValidationError extends Error { constructor(message: string) { @@ -137,6 +138,7 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string { if (block.kind === "task") { return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`; } + if (block.kind === "event") return routineEventHistoryText(block); if (block.kind === "handoff") { return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1a78747..e0f3a54 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export * from "./model-providers.js"; export * from "./oo-avatar.js"; export * from "./quiet-hours.js"; export * from "./response-bytes.js"; +export * from "./routine-events.js"; export * from "./run-state.js"; export * from "./sandbox-command.js"; export * from "./screen-lease.js"; diff --git a/packages/core/src/routine-events.test.ts b/packages/core/src/routine-events.test.ts new file mode 100644 index 0000000..038bce3 --- /dev/null +++ b/packages/core/src/routine-events.test.ts @@ -0,0 +1,82 @@ +import type { MessageBlock, RoutineEventBlock } from "@rakazo/contracts"; +import { describe, expect, it } from "vitest"; +import { blocksToAgentHistoryText } from "./attachments.js"; +import { + isRoutineEventMessage, + markRoutineEventsRemoved, + routineScheduleShape, +} from "./routine-events.js"; + +const line: RoutineEventBlock = { + kind: "event", + event: "routine.created", + routineId: "routine-1", + botId: "bot-1", + name: "Weekly plan", + crons: ["0 18 * * 1"], + timezone: "Europe/Berlin", + nextRunAt: "2026-10-05T16:00:00.000Z", +}; + +describe("routine lines", () => { + it("are messages of their own", () => { + expect(isRoutineEventMessage([line])).toBe(true); + expect(isRoutineEventMessage([line, { kind: "text", text: "Done." }])).toBe(false); + expect(isRoutineEventMessage([])).toBe(false); + }); + + it("are marked removed once and leave other routines alone", () => { + const other: MessageBlock = { ...line, routineId: "routine-2" }; + const marked = markRoutineEventsRemoved([line, other], "routine-1"); + expect(marked).toEqual([{ ...line, removed: true }, other]); + expect(markRoutineEventsRemoved(marked!, "routine-1")).toBeNull(); + expect(markRoutineEventsRemoved([other], "routine-1")).toBeNull(); + }); + + it("tell a later turn what was set up, changed or removed", () => { + expect(blocksToAgentHistoryText([line])).toBe( + "[routine set up: Weekly plan (Every Monday at 6:00 PM, Europe/Berlin)]", + ); + expect( + blocksToAgentHistoryText([ + { ...line, event: "routine.updated", crons: ["@once"], name: "Call mum" }, + ]), + ).toBe("[routine updated: Call mum (once at 2026-10-05T16:00:00.000Z)]"); + expect(blocksToAgentHistoryText([{ ...line, removed: true }])).toBe( + "[routine removed: Weekly plan]", + ); + }); +}); + +describe("routineScheduleShape", () => { + it("reads the schedules people say out loud", () => { + expect(routineScheduleShape(["@once"])).toEqual({ kind: "once" }); + expect(routineScheduleShape(["0 8 * * *"])).toEqual({ kind: "daily", hour: 8, minute: 0 }); + expect(routineScheduleShape(["30 7 * * 1-5"])).toEqual({ + kind: "weekdays", + hour: 7, + minute: 30, + }); + expect(routineScheduleShape(["0 18 * * 7"])).toEqual({ + kind: "weekly", + weekday: 0, + hour: 18, + minute: 0, + }); + expect(routineScheduleShape(["0 9 1 * *"])).toEqual({ + kind: "monthly", + day: 1, + hour: 9, + minute: 0, + }); + expect(routineScheduleShape(["*/15 * * * *"])).toEqual({ kind: "minutes", every: 15 }); + expect(routineScheduleShape(["0 */2 * * *"])).toEqual({ kind: "hours", every: 2 }); + expect(routineScheduleShape(["0 * * * *"])).toEqual({ kind: "hourly" }); + }); + + it("leaves everything else to the next run", () => { + for (const crons of [["0 9 * * 1,3"], ["0 9 * 1 *"], ["0 9 * * *", "0 18 * * *"], ["nope"]]) { + expect(routineScheduleShape(crons)).toEqual({ kind: "other" }); + } + }); +}); diff --git a/packages/core/src/routine-events.ts b/packages/core/src/routine-events.ts new file mode 100644 index 0000000..4a521e0 --- /dev/null +++ b/packages/core/src/routine-events.ts @@ -0,0 +1,72 @@ +import type { MessageBlock, RoutineEventBlock } from "@rakazo/contracts"; +import { formatCron, isOneShotRoutineCrons } from "./cron.js"; + +/** A message that is only timeline lines, shown without a speaker, bubble or reactions. */ +export function isRoutineEventMessage(blocks: readonly MessageBlock[]): boolean { + return blocks.length > 0 && blocks.every((block) => block.kind === "event"); +} + +/** The blocks with every line about `routineId` marked removed, or null when nothing changes. */ +export function markRoutineEventsRemoved( + blocks: readonly MessageBlock[], + routineId: string, +): MessageBlock[] | null { + let changed = false; + const next = blocks.map((block) => { + if (block.kind !== "event" || block.routineId !== routineId || block.removed) return block; + changed = true; + return { ...block, removed: true }; + }); + return changed ? next : null; +} + +/** How a later turn reads the line, so the bot knows what it set up and what is gone. */ +export function routineEventHistoryText(block: RoutineEventBlock): string { + if (block.removed) return `[routine removed: ${block.name}]`; + const verb = block.event === "routine.updated" ? "updated" : "set up"; + const schedule = isOneShotRoutineCrons(block.crons) + ? `once at ${block.nextRunAt ?? "an unknown time"}` + : `${block.crons.map(formatCron).join(", ")}, ${block.timezone}`; + return `[routine ${verb}: ${block.name} (${schedule})]`; +} + +/** The parts of a routine schedule a one-line summary needs; clients word it in their locale. */ +export type RoutineScheduleShape = + | { kind: "once" } + | { kind: "hourly" } + | { kind: "minutes" | "hours"; every: number } + | { kind: "daily" | "weekdays"; hour: number; minute: number } + | { kind: "weekly"; weekday: number; hour: number; minute: number } + | { kind: "monthly"; day: number; hour: number; minute: number } + | { kind: "other" }; + +const CLOCK_FIELD = /^\d{1,2}$/; + +export function routineScheduleShape(crons: readonly string[]): RoutineScheduleShape { + if (isOneShotRoutineCrons([...crons])) return { kind: "once" }; + if (crons.length !== 1) return { kind: "other" }; + const parts = crons[0]!.trim().split(/\s+/); + if (parts.length !== 5) return { kind: "other" }; + const [minute, hour, day, month, weekday] = parts as [string, string, string, string, string]; + if (month !== "*") return { kind: "other" }; + const step = (field: string) => /^\*\/(\d+)$/.exec(field)?.[1]; + if (day === "*" && weekday === "*") { + if (minute === "0" && hour === "*") return { kind: "hourly" }; + const minuteStep = step(minute); + if (minuteStep && hour === "*") return { kind: "minutes", every: Number(minuteStep) }; + const hourStep = step(hour); + if (minute === "0" && hourStep) return { kind: "hours", every: Number(hourStep) }; + } + if (!CLOCK_FIELD.test(minute) || !CLOCK_FIELD.test(hour)) return { kind: "other" }; + const clock = { hour: Number(hour), minute: Number(minute) }; + if (clock.hour > 23 || clock.minute > 59) return { kind: "other" }; + if (day === "*" && weekday === "*") return { kind: "daily", ...clock }; + if (day === "*" && weekday === "1-5") return { kind: "weekdays", ...clock }; + if (day === "*" && /^[0-7]$/.test(weekday)) { + return { kind: "weekly", weekday: Number(weekday) % 7, ...clock }; + } + if (weekday === "*" && /^([1-9]|[12]\d|3[01])$/.test(day)) { + return { kind: "monthly", day: Number(day), ...clock }; + } + return { kind: "other" }; +} diff --git a/packages/db/prisma/migrations/20261004160000_routine_notice_message/migration.sql b/packages/db/prisma/migrations/20261004160000_routine_notice_message/migration.sql new file mode 100644 index 0000000..2607007 --- /dev/null +++ b/packages/db/prisma/migrations/20261004160000_routine_notice_message/migration.sql @@ -0,0 +1,3 @@ +-- A routine the bot set up from the conversation remembers its timeline line, so removing the +-- routine anywhere can mark that line removed. +ALTER TABLE "routines" ADD COLUMN "noticeMessageId" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 1923740..8ed7fbd 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -779,6 +779,8 @@ model Routine { messageProvider String? /// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise. coachKey String? + /// The latest timeline line the bot wrote about this routine; marked removed with it. + noticeMessageId String? lastRunAt DateTime? nextRunAt DateTime? createdAt DateTime @default(now()) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d8e5de1..83c8165 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -18,6 +18,7 @@ export * from "./project-goals.js"; export * from "./project-workspace.js"; export * from "./push-subscriptions.js"; export * from "./repos.js"; +export * from "./routine-notices.js"; export * from "./scope.js"; export * from "./spaces.js"; export * from "./studio-agent.js"; diff --git a/packages/db/src/routine-notices.ts b/packages/db/src/routine-notices.ts new file mode 100644 index 0000000..a710c30 --- /dev/null +++ b/packages/db/src/routine-notices.ts @@ -0,0 +1,84 @@ +import type { RoutineEventBlock } from "@rakazo/contracts"; +import { markRoutineEventsRemoved } from "@rakazo/core"; +import type { Prisma, PrismaClient } from "./client.js"; +import { appendEventInTransaction } from "./events.js"; +import { createThreadMessageInTransaction } from "./messages.js"; + +export interface RoutineNoticeInput { + spaceId: string; + threadId: string; + /** The bot that set the routine up and writes the line. */ + botId: string; + block: RoutineEventBlock; +} + +/** Write the timeline line for a routine the bot set up and remember it on the routine. */ +export async function postRoutineNotice( + prisma: PrismaClient, + input: RoutineNoticeInput, +): Promise<{ messageId: string; seq: number }> { + return prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const message = await createThreadMessageInTransaction(tx, { + threadId: input.threadId, + role: "bot", + blocks: [input.block], + botId: input.botId, + markUnread: false, + }); + await tx.routine.updateMany({ + where: { id: input.block.routineId }, + data: { noticeMessageId: message.id }, + }); + const event = await appendEventInTransaction(tx, { + spaceId: input.spaceId, + threadId: input.threadId, + botId: input.botId, + type: "thread.message.created", + payload: { messageId: message.id, role: "bot", blocks: [input.block] }, + }); + return { messageId: message.id, seq: event.seq }; + }); +} + +/** + * Mark every line about a removed routine in the thread of its latest line. Returns the thread and + * event seq to notify, or null when the routine had no line or nothing changed. + */ +export async function markRoutineNoticesRemoved( + prisma: PrismaClient, + routine: { id: string; botId: string; noticeMessageId: string | null }, +): Promise<{ threadId: string; seq: number } | null> { + if (!routine.noticeMessageId) return null; + return prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const notice = await tx.message.findUnique({ + where: { id: routine.noticeMessageId! }, + select: { threadId: true, thread: { select: { spaceId: true } } }, + }); + if (!notice) return null; + const messages = await tx.message.findMany({ + where: { + threadId: notice.threadId, + blocks: { array_contains: [{ kind: "event", routineId: routine.id }] }, + }, + select: { id: true, role: true, botId: true, blocks: true }, + }); + let seq: number | null = null; + for (const message of messages) { + const blocks = markRoutineEventsRemoved(message.blocks as RoutineEventBlock[], routine.id); + if (!blocks) continue; + await tx.message.update({ + where: { id: message.id }, + data: { blocks: blocks as Prisma.InputJsonValue }, + }); + const event = await appendEventInTransaction(tx, { + spaceId: notice.thread.spaceId, + threadId: notice.threadId, + botId: message.botId ?? routine.botId, + type: "thread.message.updated", + payload: { messageId: message.id, role: message.role, blocks }, + }); + seq = event.seq; + } + return seq === null ? null : { threadId: notice.threadId, seq }; + }); +}