Merge web-localized onboarding cards, closing note and check-in names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:52:40 +00:00
commit 056cabd4ae
No known key found for this signature in database
24 changed files with 482 additions and 82 deletions

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
closingLine,
closingNote,
focusCard,
intakeContext,
intakeStep,
@ -61,32 +61,42 @@ describe("readIntakeAnswer", () => {
});
});
describe("closingLine", () => {
describe("closingNote", () => {
const vienna = (iso: string, locale = "de-AT") =>
intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso));
const note = (rhythm: Parameters<typeof closingNote>[0], context: ReturnType<typeof vienna>) => {
const { nextCheckInAt, text } = closingNote(rhythm, context);
return { nextCheckInAt, text: plain(text) };
};
it("names the first check-in in the user's time zone", () => {
const morning = { ...off, morning: "08:00" };
expect(closingLine(morning, vienna("2026-10-07T04:00:00Z"))).toBe(
"All set. I’ll check in today at 8:00.",
);
expect(closingLine(morning, vienna("2026-10-07T09:00:00Z"))).toBe(
"All set. I’ll check in tomorrow at 8:00.",
);
expect(closingLine({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z"))).toBe(
expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({
nextCheckInAt: "2026-10-07T06:00:00.000Z",
text: "All set. I’ll check in today at 8:00.",
});
expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({
nextCheckInAt: "2026-10-08T06:00:00.000Z",
text: "All set. I’ll check in tomorrow at 8:00.",
});
expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe(
"All set. I’ll check in today at 18:00.",
);
// Wednesday in Vienna; the weekly review is on Sunday.
const weekly = { ...off, weekly: { day: 0, time: "18:00" } };
expect(plain(closingLine(weekly, vienna("2026-10-07T09:00:00Z", "en-US")))).toBe(
"All set. I’ll check in on Sunday at 6:00 PM.",
);
expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({
nextCheckInAt: "2026-10-11T16:00:00.000Z",
text: "All set. I’ll check in on Sunday at 6:00 PM.",
});
});
it("says the user can just write when no check-in is set", () => {
expect(closingLine(off, vienna("2026-10-07T09:00:00Z"))).toBe(
"All set. I’m here whenever you need me.",
);
expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({
kind: "intake_note",
note: "done",
nextCheckInAt: null,
text: "All set. I’m here whenever you need me.",
});
});
it("falls back to English and UTC for an unusable profile", () => {

View File

@ -267,10 +267,24 @@ function localDay(date: Date, timeZone: string): number {
return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000;
}
/** One line that says what happens next: the first check-in, when there is one. */
export function closingLine(rhythm: CoachRhythm, context: IntakeContext): string {
export type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
/**
* The closing note: what happens next, i.e. the first check-in when there is one. The web says
* it in the user's language from `nextCheckInAt`; `text` is the English line for everything else.
*/
export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote {
const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []);
const next = nextCronDateAcross(crons, context.now, context.timeZone);
return {
kind: "intake_note",
note: "done",
nextCheckInAt: next?.toISOString() ?? null,
text: closingText(next, context),
};
}
function closingText(next: Date | null, context: IntakeContext): string {
if (!next) return "All set. I’m here whenever you need me.";
const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone);
const day =

View File

@ -41,12 +41,9 @@ describe("onboarding connection suggestions", () => {
it("does not invent authorization cards when no connector has an app catalog", async () => {
const { deps, actor } = fixture([]);
await chooseFocus(deps, actor, "bot", { optionId: "day" });
expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
expect.objectContaining({ kind: "app_connect" }),
);
expect(posted.flatMap((message) => message.blocks)).toEqual([
{ kind: "text", text: "Got it. What would you like to work on first?" },
]);
// The answered card is all that changes; nothing is said around it.
expect(posted).toEqual([]);
expect(deps.events.notify).toHaveBeenCalled();
});
it("uses the available connector and omits unavailable apps", async () => {
const { deps, actor } = fixture([
@ -60,9 +57,8 @@ describe("onboarding connection suggestions", () => {
).toEqual([
expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }),
]);
expect(posted[0]?.blocks).toEqual([
{ kind: "text", text: "Got it. Slack is a good place to start." },
]);
// The cards speak for themselves.
expect(posted).toHaveLength(1);
});
});

View File

@ -15,7 +15,7 @@ import {
} from "@rakazo/db";
import {
type ChoiceBlock,
closingLine,
closingNote,
FOCUS_OPTIONS,
type FocusOption,
focusCard,
@ -248,9 +248,7 @@ export async function chooseFocus(
? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id)
: undefined;
// Fetched before the transaction: the connector catalog can take seconds.
const followUp = focus
? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step))
: [];
const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : [];
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await lockThread(tx, thread.id);
@ -315,16 +313,15 @@ async function askNext(
// The rhythm step saved its answer outside this transaction, so the routines are readable.
const rhythm = await getCoachRhythm(deps.prisma, userId);
await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } });
return postInTransaction(tx, target, [{ kind: "text", text: closingLine(rhythm, context) }]);
return postInTransaction(tx, target, [closingNote(rhythm, context)]);
}
/** The messages after a focus answer: a short acknowledgement and the apps worth connecting. */
/** The apps worth connecting for a focus, as cards; they need no words around them. */
async function focusFollowUp(
deps: OnboardingDeps,
actor: Actor,
botId: string,
option: FocusOption,
onboarding: boolean,
): Promise<MessageBlock[][]> {
const providers = deps.connectors.managedProviders();
const catalog = (
@ -362,23 +359,7 @@ async function focusFollowUp(
},
];
});
if (!cards.length) {
// In onboarding the next question follows; another bot asks what to start with.
const text = onboarding ? "Got it." : "Got it. What would you like to work on first?";
return [[{ kind: "text", text }]];
}
const names = cards.map((card) => card.name);
const named =
names.length > 1 ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}` : names[0];
return [
[
{
kind: "text",
text: `Got it. ${named} ${names.length > 1 ? "are" : "is"} a good place to start.`,
},
],
cards,
];
return cards.length > 0 ? [cards] : [];
}
export async function markAppConnected(

View File

@ -92,6 +92,7 @@ import type { Auth } from "@rakazo/auth";
import type { Actor, ComputerStatus, McpServer, Me, SpaceNavigation } from "@rakazo/contracts";
import {
appContract,
CoachRoutineKeySchema,
IntegrationProviderIdSchema,
OPENAI_COMPATIBLE_PROVIDER_ID,
} from "@rakazo/contracts";
@ -5563,6 +5564,7 @@ function mapRoutine(row: {
lastRunAt: Date | null;
nextRunAt: Date | null;
createdAt: Date;
coachKey: string | null;
}) {
return {
id: row.id,
@ -5579,6 +5581,7 @@ function mapRoutine(row: {
lastRunAt: row.lastRunAt?.toISOString() ?? null,
nextRunAt: row.nextRunAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
coachKey: CoachRoutineKeySchema.safeParse(row.coachKey).data ?? null,
};
}

View File

@ -1874,6 +1874,13 @@ describe("mobile thread event reduction", () => {
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
`${card.question}\nSkip`,
);
const note = {
kind: "intake_note" as const,
note: "done" as const,
nextCheckInAt: null,
text: "All set. I’m here whenever you need me.",
};
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
});
it("deduplicates durable messages and replaces matching transient subagent state", () => {

View File

@ -1,7 +1,7 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts";
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers";
/** The nav button reads "Today" or "Today, N need you". */
function openHeute(page: Page) {
@ -120,8 +120,17 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set
});
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const routines = await rpc<Routine[]>(page, "routines/list", { botId: main.id });
expect(routines.map((routine) => routine.name).sort()).toEqual([
"Morning brief",
"Weekly review",
]);
expect(routines.map(({ name, coachKey }) => ({ name, coachKey }))).toEqual(
expect.arrayContaining([
{ name: "Morning brief", coachKey: "morning" },
{ name: "Weekly review", coachKey: "weekly" },
]),
);
expect(routines).toHaveLength(2);
// The main bot's routine list names the check-ins by their key, in the UI language.
await openMainBotThread(page);
await page.getByTitle("Agent computer").click();
await expect(page.getByText("Morning brief", { exact: true })).toBeVisible();
await expect(page.getByText("Weekly review", { exact: true })).toBeVisible();
});

View File

@ -79,7 +79,9 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
timeout: 20_000,
});
await page.getByTestId("focus-choice-day").click();
await expect(page.getByText(/Got it\./)).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({
timeout: 20_000,
});
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(
page,
@ -398,7 +400,10 @@ test.describe("touch message actions", () => {
timeout: 20_000,
});
await page.getByTestId("focus-choice-day").click();
const botText = page.getByText(/Got it\./);
// Onboarding writes no text of its own, so ask for a plain reply to act on.
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`);
const botText = page.getByText(/i handled: touch-bot-/);
await expect(botText).toBeVisible({ timeout: 20_000 });
const row = page
.getByTestId("transcript")

View File

@ -36,9 +36,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({
).toBeVisible();
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(
page.getByText("Got it. Slack, Gmail, and Google Calendar are a good place to start."),
).toBeVisible();
// The app cards speak for themselves; onboarding adds no text around them.
await expect(page.getByText(/^Got it/)).toHaveCount(0);
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
const connectionCards = page.getByRole("group", { name: / connection$/ });

View File

@ -49,7 +49,10 @@ test("the main bot asks focus, goal, check-ins and quiet hours, then says what i
await quiet.getByTestId("quiet_hours-choice-22-07").click();
// Only a browser that can still turn push on is asked about notifications.
const closing = page.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/);
// The web words the closing note itself, from the time of the first check-in.
const closing = page
.getByTestId("transcript")
.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/);
const notifications = card(page, "notifications");
await expect(notifications.or(closing).first()).toBeVisible();
if (await notifications.isVisible()) {

View File

@ -0,0 +1,140 @@
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "./i18n.js";
import { intakeNoteText, localizedChoice, withIntakeNoteText } from "./onboarding-copy.js";
// The macros compile at build time; here they keep the English source.
vi.mock("@lingui/core/macro", () => {
const text = (parts: TemplateStringsArray, ...values: unknown[]) =>
parts.reduce(
(out, part, index) => out + part + (index < values.length ? values[index] : ""),
"",
);
return {
t: text,
msg: (parts: TemplateStringsArray, ...values: unknown[]) => {
const message = text(parts, ...values);
return { id: message, message };
},
};
});
type Choice = Extract<MessageBlock, { kind: "choice" }>;
type Note = Extract<MessageBlock, { kind: "intake_note" }>;
// Intl may separate the time from AM/PM with a narrow no-break space.
const plain = (text: string) => text.replace(/\s/g, " ");
beforeEach(() => {
i18n.load("en", {});
i18n.activate("en");
});
describe("localizedChoice", () => {
it("says a known card from its step and option ids, whatever the server wrote", () => {
const card: Choice = {
kind: "choice",
step: "rhythm",
question: "server question",
options: [
{ id: "morning", letter: "A", label: "server morning" },
{ id: "fortnightly", letter: "B", label: "Every other week" },
{ id: "none", letter: "", label: "server none" },
],
};
expect(localizedChoice(card)).toEqual({
...card,
question: "When should I check in with you?",
options: [
{ id: "morning", letter: "A", label: "Every morning" },
// An option the web does not know keeps the server text.
{ id: "fortnightly", letter: "B", label: "Every other week" },
{ id: "none", letter: "", label: "Not now" },
],
});
});
it("words the goal placeholder and keeps the server's quiet-hour ranges", () => {
const goal = localizedChoice({
kind: "choice",
step: "goal",
question: "?",
textInput: { placeholder: "server placeholder" },
options: [{ id: "skip", letter: "", label: "server skip" }],
});
expect(goal.textInput).toEqual({ placeholder: "Your goal" });
expect(goal.options[0]?.label).toBe("Skip");
const quiet = localizedChoice({
kind: "choice",
step: "quiet_hours",
question: "?",
options: [{ id: "22-07", letter: "A", label: "22:00 – 7:00" }],
});
expect(quiet.options[0]?.label).toBe("22:00 – 7:00");
});
it("recognizes another bot's focus card and leaves other cards alone", () => {
const focus: Choice = {
kind: "choice",
question: "server question",
options: [
{ id: "day", letter: "A", label: "x" },
{ id: "crew", letter: "D", label: "y" },
],
};
expect(localizedChoice(focus).question).toBe("What do you want me on first?");
expect(localizedChoice(focus).options.map((option) => option.label)).toEqual([
"Day-to-day work",
"Specialist crew",
]);
const other: Choice = {
kind: "choice",
question: "Pick one",
options: [{ id: "a", letter: "A", label: "Apples" }],
};
expect(localizedChoice(other)).toBe(other);
});
});
describe("intakeNoteText", () => {
const now = new Date(2026, 9, 7, 10, 0);
const note = (nextCheckInAt: Date | null): Note => ({
kind: "intake_note",
note: "done",
nextCheckInAt: nextCheckInAt?.toISOString() ?? null,
text: "server text",
});
it("names the first check-in relative to today, in the user's clock", () => {
expect(plain(intakeNoteText(note(new Date(2026, 9, 7, 18, 0)), now))).toBe(
"All set. I’ll check in today at 6:00 PM.",
);
expect(plain(intakeNoteText(note(new Date(2026, 9, 8, 8, 0)), now))).toBe(
"All set. I’ll check in tomorrow at 8:00 AM.",
);
expect(plain(intakeNoteText(note(new Date(2026, 9, 11, 18, 0)), now))).toBe(
"All set. I’ll check in on Sunday at 6:00 PM.",
);
});
it("has a line for no check-in and falls back to the server text when unreadable", () => {
expect(intakeNoteText(note(null), now)).toBe("All set. I’m here whenever you need me.");
expect(intakeNoteText({ ...note(null), nextCheckInAt: "soon" }, now)).toBe("server text");
});
});
describe("withIntakeNoteText", () => {
const message = (blocks: MessageBlock[]) =>
({ id: "m", role: "bot", blocks, createdAt: "2026-10-07T08:00:00.000Z" }) as ThreadMessage;
it("turns notes into text and leaves other messages untouched", () => {
const plainMessage = message([{ kind: "text", text: "hi" }]);
expect(withIntakeNoteText(plainMessage)).toBe(plainMessage);
const withNote = message([
{ kind: "intake_note", note: "done", nextCheckInAt: null, text: "server text" },
]);
expect(withIntakeNoteText(withNote).blocks).toEqual([
{ kind: "text", text: "All set. I’m here whenever you need me." },
]);
});
});

View File

@ -0,0 +1,107 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg, t } from "@lingui/core/macro";
import type { MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts";
import { i18n } from "./i18n";
/**
* The onboarding cards in the user's language. The server stores each card with its step and
* option ids plus English text; the web says what it knows from the ids and keeps the server
* text for anything new (mobile and agent history always use the server text).
*/
type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
const QUESTIONS: Record<OnboardingStep, MessageDescriptor> = {
focus: msg`What do you want me on first?`,
goal: msg`What’s one thing you want to get done in the next few weeks?`,
rhythm: msg`When should I check in with you?`,
quiet_hours: msg`When should I stay quiet?`,
notifications: msg`Can I send you notifications?`,
};
const SKIP = msg`Skip`;
const NOT_NOW = msg`Not now`;
// Quiet-hour ranges are left out: the server already writes them in the user's locale.
const OPTIONS: Record<OnboardingStep, Record<string, MessageDescriptor>> = {
focus: {
day: msg`Day-to-day work`,
inbox: msg`Inbox & email`,
research: msg`Research & writing`,
crew: msg`Specialist crew`,
skip: SKIP,
},
goal: { skip: SKIP },
rhythm: {
morning: msg`Every morning`,
morning_evening: msg`Morning and evening`,
weekly: msg`Once a week`,
none: NOT_NOW,
},
quiet_hours: { skip: SKIP },
notifications: { on: msg`Turn on`, later: NOT_NOW },
};
const PLACEHOLDERS: Partial<Record<OnboardingStep, MessageDescriptor>> = {
goal: msg`Your goal`,
};
/** Another bot's focus card carries no step; its option ids give it away. */
function cardStep(card: ChoiceBlock): OnboardingStep | undefined {
if (card.step) return card.step;
const focus = OPTIONS.focus;
return card.options.length > 0 && card.options.every((option) => focus[option.id])
? "focus"
: undefined;
}
/** The card's question, options and placeholder in the user's language. */
export function localizedChoice(card: ChoiceBlock): ChoiceBlock {
const step = cardStep(card);
if (!step) return card;
const options = OPTIONS[step];
const placeholder = PLACEHOLDERS[step];
return {
...card,
question: i18n._(QUESTIONS[step]),
options: card.options.map((option) => {
const label = options[option.id];
return label ? { ...option, label: i18n._(label) } : option;
}),
...(card.textInput && placeholder ? { textInput: { placeholder: i18n._(placeholder) } } : {}),
};
}
function sameDay(left: Date, right: Date): boolean {
return left.toDateString() === right.toDateString();
}
/** The closing note in the user's language, with the first check-in in their time zone. */
export function intakeNoteText(note: IntakeNote, now = new Date()): string {
if (note.note !== "done") return note.text;
if (!note.nextCheckInAt) return t`All set. I’m here whenever you need me.`;
const at = new Date(note.nextCheckInAt);
if (!Number.isFinite(at.getTime())) return note.text;
const time = new Intl.DateTimeFormat(i18n.locale || "en", {
hour: "numeric",
minute: "2-digit",
}).format(at);
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
if (sameDay(at, now)) return t`All set. I’ll check in today at ${time}.`;
if (sameDay(at, tomorrow)) return t`All set. I’ll check in tomorrow at ${time}.`;
const weekday = new Intl.DateTimeFormat(i18n.locale || "en", { weekday: "long" }).format(at);
return t`All set. I’ll check in on ${weekday} at ${time}.`;
}
/** The message with onboarding notes as plain text, so bubbles, copy and speech read the same. */
export function withIntakeNoteText(message: ThreadMessage): ThreadMessage {
if (!message.blocks.some((block) => block.kind === "intake_note")) return message;
return {
...message,
blocks: message.blocks.map((block) =>
block.kind === "intake_note" ? { kind: "text", text: intakeNoteText(block) } : block,
),
};
}

View File

@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { routineName, routineSaveName } from "./routine-name.js";
const labels = vi.hoisted(() => ({ locale: "en" }));
vi.mock("@lingui/core/macro", () => ({
// Stands in for a German catalog when the test switches the locale.
t: (parts: TemplateStringsArray) =>
labels.locale === "de" && parts.join("") === "Evening check-in"
? "Abend-Check-in"
: parts.join(""),
}));
describe("check-in routine names", () => {
it("shows a check-in under its own label until the user renames it", () => {
labels.locale = "de";
expect(routineName({ name: "Evening check-in", coachKey: "evening" })).toBe("Abend-Check-in");
expect(routineName({ name: "Wind down", coachKey: "evening" })).toBe("Wind down");
expect(routineName({ name: "Evening check-in", coachKey: null })).toBe("Evening check-in");
});
it("saves the stored name when the shown one was left as it is", () => {
labels.locale = "de";
const routine = { name: "Evening check-in", coachKey: "evening" as const };
expect(routineSaveName(routine, "Abend-Check-in")).toBe("Evening check-in");
expect(routineSaveName(routine, "Wind down")).toBe("Wind down");
expect(routineSaveName(null, "New")).toBe("New");
});
});

View File

@ -0,0 +1,27 @@
import { t } from "@lingui/core/macro";
import type { CoachRoutineKey, Routine } from "@rakazo/contracts";
import { COACH_ROUTINE_NAMES } from "@rakazo/contracts";
const COACH_ROUTINE_LABELS: Record<CoachRoutineKey, () => string> = {
morning: () => t`Morning brief`,
evening: () => t`Evening check-in`,
weekly: () => t`Weekly review`,
};
/**
* A check-in routine is stored under its English name; the user sees it in their language
* until they rename it.
*/
export function routineName(routine: Pick<Routine, "name" | "coachKey">): string {
return routine.coachKey && routine.name === COACH_ROUTINE_NAMES[routine.coachKey]
? COACH_ROUTINE_LABELS[routine.coachKey]()
: routine.name;
}
/** The name to save: a check-in name the user left as shown keeps its stored form. */
export function routineSaveName(
routine: Pick<Routine, "name" | "coachKey"> | null,
draftName: string,
): string {
return routine && draftName === routineName(routine) ? routine.name : draftName;
}

View File

@ -24,6 +24,7 @@ import {
} from "@rakazo/ui-web";
import { ChevronLeft, Clock, GitBranch, Globe, MessageSquare, Pause, Plus, X } from "lucide-react";
import { useId } from "react";
import { routineName } from "../lib/routine-name";
import { RoutineSchedule } from "./RoutineSchedule";
function toDatetimeLocalValue(date: Date): string {
@ -85,7 +86,7 @@ export function emptyRoutineDraft(): RoutineDraftState {
export function draftFromRoutine(routine: Routine): RoutineDraftState {
return {
name: routine.name,
name: routineName(routine),
prompt: routine.prompt,
schedules: routine.crons.map(presetFromCron),
webhookEnabled: routine.webhookEnabled,
@ -156,7 +157,7 @@ export function RoutineListRow({
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14.5px] font-medium text-foreground" dir="auto">
{routine.name}
{routineName(routine)}
</span>
<span className="block truncate text-ui-body-sm text-muted-foreground/80">
{routineTriggerSummary(routine)}

View File

@ -204,6 +204,7 @@ import { lazyWithRecovery } from "../lib/lazy-recovery";
import { localTimezone } from "../lib/local-timezone";
import { copyableMessageText } from "../lib/message-text";
import { messageProviderLabel } from "../lib/messaging";
import { withIntakeNoteText } from "../lib/onboarding-copy";
import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
import { ooSound } from "../lib/oo-sound";
import { ooVocals } from "../lib/oo-vocals";
@ -217,6 +218,7 @@ import { markAfterPaint, markOnce } from "../lib/performance";
import { isEmptyProjectSpace } from "../lib/project-empty-state";
import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order";
import { quoteDraftForSelection } from "../lib/quote-selection";
import { routineSaveName } from "../lib/routine-name";
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
import { sharedInflight } from "../lib/shared-inflight";
@ -5125,7 +5127,7 @@ export function ShellPage() {
}
saved = await rpc.routines.update({
routineId: targetRoutine.id,
name: routineDraft.name || t`Routine`,
name: routineSaveName(targetRoutine, routineDraft.name) || t`Routine`,
prompt: routineDraft.prompt || t`Check in.`,
crons,
active: armOneShot ? true : routineDraft.active,
@ -5873,7 +5875,11 @@ const Transcript = memo(function Transcript({
() => new Map(messages.map((message) => [message.id, message])),
[messages],
);
const reactionView = useMemo(() => projectMessageReactions(messages), [messages]);
const reactionView = useMemo(
() => projectMessageReactions(messages.map(withIntakeNoteText)),
// Onboarding notes are worded in the UI language.
[messages, i18n.locale],
);
const transcriptSubordinate = encounterMode && voiceEncounterActive;
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
const workingLabel =

View File

@ -19,6 +19,7 @@ import { BuiCard, SuccessPop } from "../../components/ai/primitives";
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
import { chartViewport } from "../../lib/chart-viewport";
import { connectMcpOauth } from "../../lib/mcp-connect";
import { localizedChoice } from "../../lib/onboarding-copy";
import { rpc } from "../../lib/rpc";
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
@ -45,8 +46,10 @@ export function ChoiceCard({
const [locallyDismissed, setLocallyDismissed] = useState(false);
const dismissed = locallyDismissed || block.answerId === "_dismissed";
const answered = Boolean(block.answerId);
const answers = block.options.filter((option) => option.letter);
const waysOut = block.options.filter((option) => !option.letter);
// What the card says, in the user's language; answers still go out by step and option id.
const card = localizedChoice(block);
const answers = card.options.filter((option) => option.letter);
const waysOut = card.options.filter((option) => !option.letter);
const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`;
async function choose(optionId: string, words?: string) {
@ -90,7 +93,7 @@ export function ChoiceCard({
const chosen = block.answerText
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText }
: block.options.find((option) => option.id === block.answerId);
: card.options.find((option) => option.id === block.answerId);
return (
<div className="flex justify-start">
@ -112,7 +115,7 @@ export function ChoiceCard({
</Button>
) : null}
<div className="pe-8 text-ui-body font-medium text-foreground tracking-tight">
{block.question}
{card.question}
</div>
{block.subtitle ? (
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
@ -150,7 +153,7 @@ export function ChoiceCard({
))}
</div>
) : null}
{block.textInput ? (
{card.textInput ? (
<form
className="mt-3"
onSubmit={(event) => {
@ -163,8 +166,8 @@ export function ChoiceCard({
<InputGroupInput
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={block.textInput.placeholder}
aria-label={block.question}
placeholder={card.textInput.placeholder}
aria-label={card.question}
maxLength={200}
disabled={pending}
dir="auto"

View File

@ -1,13 +1,19 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
import type { CoachRhythm } from "@rakazo/contracts";
import { COACH_RHYTHM_OFF, clockTimeToMinutes, minutesToClockTime } from "@rakazo/contracts";
import type { CoachRhythm, CoachRoutineKey } from "@rakazo/contracts";
import {
COACH_RHYTHM_OFF,
COACH_ROUTINE_KEYS,
COACH_ROUTINE_NAMES,
clockTimeToMinutes,
minutesToClockTime,
} from "@rakazo/contracts";
import { nextCronDate } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
export const COACH_KEYS = ["morning", "evening", "weekly"] as const;
export type CoachKey = (typeof COACH_KEYS)[number];
export const COACH_KEYS = COACH_ROUTINE_KEYS;
export type CoachKey = CoachRoutineKey;
/**
* The main bot's check-ins. Names show in the main bot's routine list; prompts are its task when
@ -16,17 +22,17 @@ export type CoachKey = (typeof COACH_KEYS)[number];
*/
export const COACH_ROUTINES: Record<CoachKey, { name: string; prompt: string }> = {
morning: {
name: "Morning brief",
name: COACH_ROUTINE_NAMES.morning,
prompt:
"Morning check-in. Review the user's active goals and habits, and today's calendar if a calendar app is connected. Write today's brief: two to five short lines on what matters most today, and save it. Then post one short, friendly message with the gist. Leave out what is not relevant today.",
},
evening: {
name: "Evening check-in",
name: COACH_ROUTINE_NAMES.evening,
prompt:
"Evening check-in. Log as check-ins what the user already told you about today. Then ask in one or two sentences how the day went and about habits not yet checked off. Keep it light, and do not ask when everything is already logged.",
},
weekly: {
name: "Weekly review",
name: COACH_ROUTINE_NAMES.weekly,
prompt:
"Weekly review. Look at the past week's check-ins and the progress on the user's active goals. Post a short summary: what moved, what stalled, and one concrete suggestion for next week. Offer to adjust, complete or drop goals, and change them only when the user agrees.",
},

View File

@ -51,6 +51,18 @@ export type CoachRhythm = z.infer<typeof CoachRhythmSchema>;
export const COACH_RHYTHM_OFF: CoachRhythm = { morning: null, evening: null, weekly: null };
/** The main bot's check-in routines, one per key; a routine carries its key as `coachKey`. */
export const COACH_ROUTINE_KEYS = ["morning", "evening", "weekly"] as const;
export const CoachRoutineKeySchema = z.enum(COACH_ROUTINE_KEYS);
export type CoachRoutineKey = z.infer<typeof CoachRoutineKeySchema>;
/** Stored names of the check-in routines; the web shows them in the user's language. */
export const COACH_ROUTINE_NAMES: Record<CoachRoutineKey, string> = {
morning: "Morning brief",
evening: "Evening check-in",
weekly: "Weekly review",
};
export const GoalSchema = z.object({
id: Id,
title: z.string(),

View File

@ -1,4 +1,5 @@
import * as z from "zod";
import { CoachRoutineKeySchema } from "./coach.js";
import { DelegatedTaskStatusSchema, ThreadMessageSchema } from "./events.js";
import { Id, MemoryScope, RunStatus, SandboxKind } from "./ids.js";
import { McpHeadersSchema, McpRemoteEndpointSchema, McpTransportSchema } from "./mcp.js";
@ -458,6 +459,8 @@ export const RoutineSchema = z.object({
lastRunAt: z.string().nullable(),
nextRunAt: z.string().nullable(),
createdAt: z.string(),
/** Set on the main bot's check-in routines. */
coachKey: CoachRoutineKeySchema.nullable(),
});
export type Routine = z.infer<typeof RoutineSchema>;

View File

@ -159,6 +159,17 @@ export const MessageBlock = z.discriminatedUnion("kind", [
/** The user's words when they answered through the text input. */
answerText: z.string().optional(),
}),
z.object({
/**
* A line onboarding writes. Clients that know `note` say it in the user's language and
* write the time where they are; everything else (mobile, agent history) uses `text`.
*/
kind: z.literal("intake_note"),
/** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */
note: z.enum(["done"]),
nextCheckInAt: z.string().nullable(),
text: z.string(),
}),
z.object({
/** Inline app authorization card (Composio-backed): logo, name, one-line
description, and an Authorize button that flips to connected. */

View File

@ -2,9 +2,9 @@ import { eventIterator, oc } from "@orpc/contract";
import * as z from "zod";
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
import { COACH_TITLE_MAX_LENGTH } from "./coach.js";
import {
CheckInSchema,
COACH_TITLE_MAX_LENGTH,
CoachRhythmSchema,
CoachTodaySchema,
CreateGoalInput,

View File

@ -132,4 +132,17 @@ describe("choice card history", () => {
"When should I check in with you?",
);
});
it("reads the onboarding closing note as its English line", () => {
expect(
blocksToAgentHistoryText([
{
kind: "intake_note",
note: "done",
nextCheckInAt: "2026-10-08T06:00:00.000Z",
text: "All set. I’ll check in tomorrow at 8:00 AM.",
},
]),
).toBe("All set. I’ll check in tomorrow at 8:00 AM.");
});
});

View File

@ -104,6 +104,8 @@ describeWithDatabase("onboarding questions in the main conversation", () => {
]);
const afterFocus = await messages(ada.threadId);
expect(afterFocus.filter((block) => block.kind === "app_connect").length).toBeGreaterThan(0);
// The cards speak for themselves: onboarding writes no text of its own.
expect(afterFocus.filter((block) => block.kind === "text")).toEqual([]);
// The app suggestions come before the next question.
expect(afterFocus.at(-1)).toMatchObject({ kind: "choice", step: "goal" });
@ -152,10 +154,22 @@ describeWithDatabase("onboarding questions in the main conversation", () => {
completedAt: expect.any(Date),
});
expect(await pending(ada.threadId)).toEqual([]);
// The closing note carries the first check-in for the web and an English line for others.
const closing = (await messages(ada.threadId)).at(-1);
expect(closing?.kind === "text" && closing.text.replace(/\s/g, " ")).toMatch(
expect(closing).toMatchObject({ kind: "intake_note", note: "done" });
if (closing?.kind !== "intake_note") throw new Error("no closing note");
expect(closing.text.replace(/\s/g, " ")).toMatch(
/^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/,
);
const firstCheckIn = new Date(closing.nextCheckInAt ?? "");
expect(firstCheckIn.getTime()).toBeGreaterThan(Date.now());
expect(
new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Vienna",
hour: "2-digit",
minute: "2-digit",
}).format(firstCheckIn),
).toBe("08:00");
expect(
(await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId }))
.onboardingOpen,
@ -193,7 +207,9 @@ describeWithDatabase("onboarding questions in the main conversation", () => {
});
expect((await getNotificationSettings(handles.prisma, bo.userId)).quietHours).toBeNull();
expect((await messages(bo.threadId)).at(-1)).toEqual({
kind: "text",
kind: "intake_note",
note: "done",
nextCheckInAt: null,
text: "All set. I’m here whenever you need me.",
});