From d3689b88fcebf9a77de1116608507b09e51967d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:51:59 +0000 Subject: [PATCH] feat(onboarding): show the onboarding cards and check-in names in the user's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding cards, the closing line and the coach routine names were English server text for every user. The server keeps the state and an English fallback; the web now words them through Lingui, so there is one translation source. - Choice cards: the web says the question, the option labels and the placeholder from the step and option ids, and keeps the server text for anything it does not know (quiet-hour ranges are already written in the user's locale). Another bot's focus card is recognized by its option ids. Mobile and agent history keep the server text. - The "Got it. … are a good place to start." line is gone: the app cards speak for themselves, and with no cards nothing is said. - The closing line becomes an intake_note block (note "done", nextCheckInAt, English text). The web words it with the first check-in in the user's clock; mobile blockText and blocksToAgentHistoryText read its text through their generic fallback. - Routines carry coachKey. The routine list and editor show a check-in under its label in the UI language until the user renames it; saving an untouched label keeps the stored name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/onboarding-intake.test.ts | 40 +++-- apps/api/src/onboarding-intake.ts | 18 ++- apps/api/src/onboarding.test.ts | 14 +- apps/api/src/onboarding.ts | 29 +--- apps/api/src/router.ts | 3 + apps/mobile/lib/api.test.ts | 7 + apps/web/e2e/heute-brief.spec.ts | 19 ++- apps/web/e2e/message-hover-actions.spec.ts | 9 +- apps/web/e2e/onboarding-conversation.spec.ts | 5 +- apps/web/e2e/onboarding-intake.spec.ts | 5 +- apps/web/src/lib/onboarding-copy.test.ts | 140 ++++++++++++++++++ apps/web/src/lib/onboarding-copy.ts | 107 +++++++++++++ apps/web/src/lib/routine-name.test.ts | 28 ++++ apps/web/src/lib/routine-name.ts | 27 ++++ apps/web/src/pages/RoutineEditor.tsx | 5 +- apps/web/src/pages/Shell.tsx | 10 +- apps/web/src/pages/shell/message-cards.tsx | 17 ++- packages/adapters/src/coach-rhythm.ts | 20 ++- packages/contracts/src/coach.ts | 12 ++ packages/contracts/src/domain.ts | 3 + packages/contracts/src/events.ts | 11 ++ packages/contracts/src/rpc.ts | 2 +- packages/core/src/attachments.test.ts | 13 ++ .../src/onboarding-intake.postgres.test.ts | 20 ++- 24 files changed, 482 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/lib/onboarding-copy.test.ts create mode 100644 apps/web/src/lib/onboarding-copy.ts create mode 100644 apps/web/src/lib/routine-name.test.ts create mode 100644 apps/web/src/lib/routine-name.ts diff --git a/apps/api/src/onboarding-intake.test.ts b/apps/api/src/onboarding-intake.test.ts index 0c8ccbd..42102f9 100644 --- a/apps/api/src/onboarding-intake.test.ts +++ b/apps/api/src/onboarding-intake.test.ts @@ -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[0], context: ReturnType) => { + 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", () => { diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts index 8a5828a..d67381a 100644 --- a/apps/api/src/onboarding-intake.ts +++ b/apps/api/src/onboarding-intake.ts @@ -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; + +/** + * 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 = diff --git a/apps/api/src/onboarding.test.ts b/apps/api/src/onboarding.test.ts index 66f8328..4d68859 100644 --- a/apps/api/src/onboarding.test.ts +++ b/apps/api/src/onboarding.test.ts @@ -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); }); }); diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index aa570e7..868acb4 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -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 { 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( diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 1c8f5eb..2fa49c5 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -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, }; } diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 053a60c..53ae1e8 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -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", () => { diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index c473c98..1019a6d 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -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 "Heute" or "Heute, 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(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(); }); diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index d35ade3..c511059 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -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") diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts index e028c66..7e78720 100644 --- a/apps/web/e2e/onboarding-conversation.spec.ts +++ b/apps/web/e2e/onboarding-conversation.spec.ts @@ -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$/ }); diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts index 3b7903a..fdcac51 100644 --- a/apps/web/e2e/onboarding-intake.spec.ts +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -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()) { diff --git a/apps/web/src/lib/onboarding-copy.test.ts b/apps/web/src/lib/onboarding-copy.test.ts new file mode 100644 index 0000000..eb8e1c2 --- /dev/null +++ b/apps/web/src/lib/onboarding-copy.test.ts @@ -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; +type Note = Extract; + +// 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." }, + ]); + }); +}); diff --git a/apps/web/src/lib/onboarding-copy.ts b/apps/web/src/lib/onboarding-copy.ts new file mode 100644 index 0000000..1eb077d --- /dev/null +++ b/apps/web/src/lib/onboarding-copy.ts @@ -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; +type IntakeNote = Extract; + +const QUESTIONS: Record = { + 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> = { + 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> = { + 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, + ), + }; +} diff --git a/apps/web/src/lib/routine-name.test.ts b/apps/web/src/lib/routine-name.test.ts new file mode 100644 index 0000000..b7a4d1f --- /dev/null +++ b/apps/web/src/lib/routine-name.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/routine-name.ts b/apps/web/src/lib/routine-name.ts new file mode 100644 index 0000000..434b1c9 --- /dev/null +++ b/apps/web/src/lib/routine-name.ts @@ -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 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): 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 | null, + draftName: string, +): string { + return routine && draftName === routineName(routine) ? routine.name : draftName; +} diff --git a/apps/web/src/pages/RoutineEditor.tsx b/apps/web/src/pages/RoutineEditor.tsx index 5f55284..b79f2db 100644 --- a/apps/web/src/pages/RoutineEditor.tsx +++ b/apps/web/src/pages/RoutineEditor.tsx @@ -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({ - {routine.name} + {routineName(routine)} {routineTriggerSummary(routine)} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 4656244..40e9931 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -207,6 +207,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"; @@ -220,6 +221,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"; @@ -5140,7 +5142,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, @@ -5891,7 +5893,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 = diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index 5c12492..8cbda50 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -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 (
@@ -112,7 +115,7 @@ export function ChoiceCard({ ) : null}
- {block.question} + {card.question}
{block.subtitle ? (
{block.subtitle}
@@ -150,7 +153,7 @@ export function ChoiceCard({ ))}
) : null} - {block.textInput ? ( + {card.textInput ? (
{ @@ -163,8 +166,8 @@ export function ChoiceCard({ 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" diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index 6606092..06ae758 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -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 = { 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.", }, diff --git a/packages/contracts/src/coach.ts b/packages/contracts/src/coach.ts index 17f777e..d2d5fdd 100644 --- a/packages/contracts/src/coach.ts +++ b/packages/contracts/src/coach.ts @@ -51,6 +51,18 @@ export type CoachRhythm = z.infer; 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; + +/** Stored names of the check-in routines; the web shows them in the user's language. */ +export const COACH_ROUTINE_NAMES: Record = { + morning: "Morning brief", + evening: "Evening check-in", + weekly: "Weekly review", +}; + export const GoalSchema = z.object({ id: Id, title: z.string(), diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 18b4bcc..fe532b8 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -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; diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 98babd6..9b27c13 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -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. */ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 960b36f..289eea4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -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, diff --git a/packages/core/src/attachments.test.ts b/packages/core/src/attachments.test.ts index 45653a0..cd17d8f 100644 --- a/packages/core/src/attachments.test.ts +++ b/packages/core/src/attachments.test.ts @@ -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."); + }); }); diff --git a/packages/testkit/src/onboarding-intake.postgres.test.ts b/packages/testkit/src/onboarding-intake.postgres.test.ts index 57aceea..b65f6d2 100644 --- a/packages/testkit/src/onboarding-intake.postgres.test.ts +++ b/packages/testkit/src/onboarding-intake.postgres.test.ts @@ -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.", });