Merge the conversational onboarding intake

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:26:11 +00:00
commit e72b169ca4
No known key found for this signature in database
32 changed files with 1417 additions and 663 deletions

View File

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
closingLine,
focusCard,
intakeContext,
intakeStep,
nextIntakeStep,
readIntakeAnswer,
secondaryOption,
} from "./onboarding-intake.js";
// Intl may separate the time from AM/PM with a narrow no-break space.
const plain = (text: string) => text.replace(/\s/g, " ");
const off = { morning: null, evening: null, weekly: null };
describe("onboarding steps", () => {
it("asks in order and offers notifications only to a device that can still turn push on", () => {
expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal");
expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm");
expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours");
expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications");
expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull();
expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull();
});
it("gives every onboarding card a quiet way out, and another bot's focus card none", () => {
const context = intakeContext(null, new Date());
for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) {
const card = intakeStep(id).card(context);
expect(card.step).toBe(id);
expect(secondaryOption(card)).toBeDefined();
}
expect(focusCard().step).toBeUndefined();
expect(secondaryOption(focusCard())).toBeUndefined();
});
it("writes quiet hours in the user's locale", () => {
const labels = (locale: string | null) =>
intakeStep("quiet_hours")
.card(intakeContext({ locale, timeZone: null }, new Date()))
.options.map((option) => plain(option.label));
expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]);
expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]);
expect(labels("not a tag")).toEqual(labels("en"));
});
});
describe("readIntakeAnswer", () => {
const goal = intakeStep("goal").card(intakeContext(null, new Date()));
const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date()));
it("accepts the card's own options and words only where the card takes them", () => {
expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({
option: { id: "weekly", letter: "C", label: "Once a week" },
});
expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull();
expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull();
expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" });
expect(readIntakeAnswer(goal, "_text", " ")).toBeNull();
expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip");
});
});
describe("closingLine", () => {
const vienna = (iso: string, locale = "de-AT") =>
intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso));
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(
"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.",
);
});
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.",
);
});
it("falls back to English and UTC for an unusable profile", () => {
expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({
locale: "en",
timeZone: "UTC",
});
});
});

View File

@ -0,0 +1,288 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters";
import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { knownTimeZone, nextCronDateAcross } from "@rakazo/core";
import {
createGoal,
type Prisma,
type PrismaClient,
updateNotificationSettings,
updateUserProfile,
} from "@rakazo/db";
/**
* The questions the main bot asks a new user in the main conversation, one card at a time.
* Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order
* the user sees. Everything here is deterministic, so no model tokens are spent.
*/
export type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
type ChoiceOption = ChoiceBlock["options"][number];
/** A validated answer: an option of the card, or the user's own words. */
export type IntakeAnswer =
| { option: ChoiceOption; text?: undefined }
| { option?: undefined; text: string };
/** What a card may adapt to: times are written in the user's locale. */
export interface IntakeContext {
locale: string;
timeZone: string;
now: Date;
}
export interface IntakeEffects {
prisma: PrismaClient;
jobs: JobPublisher;
tx: Prisma.TransactionClient;
userId: string;
}
interface IntakeStep {
id: OnboardingStep;
/** Asked only when this holds; otherwise onboarding moves past the step. */
asks?: (device: { canEnablePush: boolean }) => boolean;
card: (context: IntakeContext) => ChoiceBlock;
/** Records a validated answer. Runs while the main conversation is locked. */
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
}
export interface FocusOption {
id: string;
letter: string;
label: string;
apps: string[];
/** Stored as a `goals` fact, so it stays a plain English statement. */
fact: string;
}
export const FOCUS_OPTIONS: FocusOption[] = [
{
id: "day",
letter: "A",
label: "Day-to-day work",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants help with day-to-day work first",
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
fact: "Wants help with inbox and email first",
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
fact: "Wants help with research and writing first",
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants a coordinated crew of specialist bots",
},
];
const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" };
/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */
export function focusCard(step?: "focus"): ChoiceBlock {
return {
kind: "choice",
question: "What do you want me on first?",
options: [
...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
...(step ? [SKIP] : []),
],
...(step ? { step } : {}),
};
}
const RHYTHMS: Record<string, CoachRhythm> = {
morning: { morning: "08:00", evening: null, weekly: null },
morning_evening: { morning: "08:00", evening: "18:00", weekly: null },
weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } },
none: { morning: null, evening: null, weekly: null },
};
const QUIET_HOURS: Record<string, QuietHours> = {
"22-07": { start: "22:00", end: "07:00" },
"23-08": { start: "23:00", end: "08:00" },
};
export const INTAKE_STEPS: IntakeStep[] = [
{
id: "focus",
card: () => focusCard("focus"),
save: async ({ prisma, tx, userId }, answer) => {
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
if (!option) return;
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
await updateUserProfile(prisma, userId, {
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
});
},
},
{
id: "goal",
card: () => ({
kind: "choice",
step: "goal",
question: "What’s one thing you want to get done in the next few weeks?",
textInput: { placeholder: "Your goal" },
options: [SKIP],
}),
save: async ({ prisma, userId }, answer) => {
if (answer.text)
await createGoal(prisma, userId, { title: answer.text, source: "onboarding" });
},
},
{
id: "rhythm",
card: () => ({
kind: "choice",
step: "rhythm",
question: "When should I check in with you?",
options: [
{ id: "morning", letter: "A", label: "Every morning" },
{ id: "morning_evening", letter: "B", label: "Morning and evening" },
{ id: "weekly", letter: "C", label: "Once a week" },
{ id: "none", letter: "", label: "Not now" },
],
}),
save: async ({ prisma, jobs, userId }, answer) => {
const rhythm = answer.option && RHYTHMS[answer.option.id];
if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm });
},
},
{
id: "quiet_hours",
card: (context) => ({
kind: "choice",
step: "quiet_hours",
question: "When should I stay quiet?",
options: [
...Object.entries(QUIET_HOURS).map(([id, range], index) => ({
id,
letter: String.fromCharCode(65 + index),
label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`,
})),
SKIP,
],
}),
save: async ({ prisma, userId }, answer) => {
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours });
},
},
{
// The browser subscribes when the user taps "Turn on"; the answer only moves on.
id: "notifications",
asks: ({ canEnablePush }) => canEnablePush,
card: () => ({
kind: "choice",
step: "notifications",
question: "Can I send you notifications?",
options: [
{ id: "on", letter: "A", label: "Turn on" },
{ id: "later", letter: "", label: "Not now" },
],
}),
save: async () => {},
},
];
export function intakeStep(id: OnboardingStep): IntakeStep {
const step = INTAKE_STEPS.find((entry) => entry.id === id);
if (!step) throw new Error(`Unknown onboarding step: ${id}`);
return step;
}
/** The step after `id` that applies to this device, or null when onboarding is done. */
export function nextIntakeStep(
id: OnboardingStep,
device: { canEnablePush: boolean },
): IntakeStep | null {
const index = INTAKE_STEPS.findIndex((entry) => entry.id === id);
return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null;
}
/** Checks an answer against the card it answers; null when the card does not offer it. */
export function readIntakeAnswer(
card: ChoiceBlock,
optionId: string,
text: string | undefined,
): IntakeAnswer | null {
if (optionId === CHOICE_TEXT_ANSWER_ID) {
const words = text?.trim();
return card.textInput && words ? { text: words } : null;
}
const option = card.options.find((entry) => entry.id === optionId);
return option ? { option } : null;
}
/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */
export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined {
return card.options.find((option) => !option.letter);
}
/** Card times and the closing line follow the user's profile, falling back to English and UTC. */
export function intakeContext(
profile: { locale: string | null; timeZone: string | null } | null,
now: Date,
): IntakeContext {
let locale = "en";
try {
if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en";
} catch {
// Not a language tag; keep English.
}
return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now };
}
function formatClock(time: string, locale: string): string {
const [hour = 0, minute = 0] = time.split(":").map(Number);
return new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
}
function localDay(date: Date, timeZone: string): number {
const [year, month, day] = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.format(date)
.split("-")
.map(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 {
const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []);
const next = nextCronDateAcross(crons, context.now, context.timeZone);
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 =
days === 0
? "today"
: days === 1
? "tomorrow"
: `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`;
const time = new Intl.DateTimeFormat(context.locale, {
hour: "numeric",
minute: "2-digit",
timeZone: context.timeZone,
}).format(next);
return `All set. I’ll check in ${day} at ${time}.`;
}

View File

@ -1,6 +1,7 @@
import type * as db from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { chooseFocus, markAppConnected } from "./onboarding.js";
import { focusCard } from "./onboarding-intake.js";
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
vi.mock("@rakazo/db", async (original) => ({
@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({
}));
function fixture(catalog: unknown[]) {
posted.length = 0;
// A specialist's focus card, waiting for an answer.
const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]);
const tx = {
$executeRaw: vi.fn(),
message: {
findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]),
update: vi.fn(),
},
message: { findMany: pending, update: vi.fn() },
};
const deps = {
prisma: {
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
message: { findMany: pending },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
},
events: { notify: vi.fn() },
@ -39,17 +40,19 @@ function fixture(catalog: unknown[]) {
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", "day");
await chooseFocus(deps, actor, "bot", { optionId: "day" });
expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
expect.objectContaining({ kind: "app_connect" }),
);
expect(posted.length).toBeGreaterThan(0);
expect(posted.flatMap((message) => message.blocks)).toEqual([
{ kind: "text", text: "Got it. What would you like to work on first?" },
]);
});
it("uses the available connector and omits unavailable apps", async () => {
const { deps, actor } = fixture([
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
]);
await chooseFocus(deps, actor, "bot", "day");
await chooseFocus(deps, actor, "bot", { optionId: "day" });
expect(
posted
.flatMap((message) => message.blocks)
@ -57,6 +60,9 @@ 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." },
]);
});
});

View File

@ -1,5 +1,8 @@
import type { ConnectorRegistry } from "@rakazo/adapters";
import type { Actor, MessageBlock } from "@rakazo/contracts";
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import { type ConnectorRegistry, getCoachRhythm } from "@rakazo/adapters";
import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { featuredConnectorProvidersMatch } from "@rakazo/core";
import {
appendEventInTransaction,
@ -10,58 +13,36 @@ import {
type PrismaClient,
type ThreadEvents,
} from "@rakazo/db";
import {
type ChoiceBlock,
closingLine,
FOCUS_OPTIONS,
type FocusOption,
focusCard,
type IntakeAnswer,
type IntakeContext,
intakeContext,
intakeStep,
nextIntakeStep,
readIntakeAnswer,
secondaryOption,
} from "./onboarding-intake.js";
/**
* First-run conversational onboarding, seeded deterministically into the bot's
* thread: greeting, a focus choice, and available app cards the user authorizes
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
* talks in the main conversation, where onboarding lands.
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
* conversation, one card after each answer. Any other bot gets one focus card that suggests
* apps the user authorizes inline. Focus never renames a bot.
*/
type OnboardingDeps = {
prisma: PrismaClient;
events: ThreadEvents;
connectors: ConnectorRegistry;
jobs: JobPublisher;
};
type FocusOption = {
id: string;
letter: string;
label: string;
summary: string;
apps: string[];
};
const FOCUS_OPTIONS: FocusOption[] = [
{
id: "day",
letter: "A",
label: "Day-to-day work",
summary: "Slack, calendar, and email",
apps: ["slack", "gmail", "googlecalendar"],
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
summary: "email and calendar",
apps: ["gmail", "googlecalendar", "slack"],
},
{
id: "research",
letter: "C",
label: "Research & writing",
summary: "the web, notes, and docs",
apps: ["hackernews", "notion", "googledocs"],
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
summary: "a coordinated team of focused Oos",
apps: ["slack", "gmail", "googlecalendar"],
},
];
type Target = { spaceId: string; botId: string; threadId: string };
const APP_DESCRIPTIONS: Record<string, string> = {
slack: "Search, read, and send messages.",
@ -85,38 +66,40 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
return { bot, thread: bot.thread };
}
async function post(
deps: OnboardingDeps,
target: { spaceId: string; botId: string; threadId: string },
/**
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows
* that reference the thread from another connection (the coach routines do).
*/
async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise<void> {
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`;
}
async function postInTransaction(
tx: Prisma.TransactionClient,
target: Target,
blocks: MessageBlock[],
): Promise<string> {
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
): Promise<{ seq: number }> {
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "bot",
blocks,
botId: target.botId,
});
const event = await appendEventInTransaction(tx, {
return appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "bot", blocks },
});
return { message, event };
});
await deps.events.notify(target.threadId, committed.event.seq);
return committed.message.id;
}
async function updateBlocks(
deps: OnboardingDeps,
target: { spaceId: string; botId: string; threadId: string },
async function updateBlocksInTransaction(
tx: Prisma.TransactionClient,
target: Target,
messageId: string,
blocks: MessageBlock[],
): Promise<void> {
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
): Promise<{ seq: number }> {
await tx.message.update({ where: { id: messageId }, data: { blocks } });
return appendEventInTransaction(tx, {
spaceId: target.spaceId,
@ -125,34 +108,60 @@ async function updateBlocks(
type: "thread.message.updated",
payload: { messageId, role: "bot", blocks },
});
});
}
async function updateBlocks(
deps: OnboardingDeps,
target: Target,
messageId: string,
blocks: MessageBlock[],
): Promise<void> {
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) =>
updateBlocksInTransaction(tx, target, messageId, blocks),
);
await deps.events.notify(target.threadId, event.seq);
}
/** Sentinel answerId for a focus card the user dismissed without choosing. */
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */
export async function startOnboarding(
deps: OnboardingDeps,
actor: Actor,
botId: string,
): Promise<void> {
const { thread } = await requireBotThread(deps, actor, botId);
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
if (existing > 0) return;
// Fresh chats start empty (same as web). The focus card is posted later via
// promptFocus so non-first bots can wait ~10s for free typing, or skip if the
// user already engaged.
await requireBotThread(deps, actor, botId);
}
function messageHasChoice(blocks: MessageBlock[]): boolean {
return blocks.some((block) => block.kind === "choice");
function isChoice(block: MessageBlock): block is ChoiceBlock {
return block.kind === "choice";
}
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
return blocks.some((block) => block.kind === "choice" && !block.answerId);
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId);
}
/** The newest card in the thread that still waits for an answer, however long ago it came. */
async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) {
const messages = await client.message.findMany({
where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } },
select: { id: true, blocks: true },
orderBy: { seq: "desc" },
take: 20,
});
for (const message of messages) {
const blocks = message.blocks as MessageBlock[];
const card = pendingChoice(blocks);
if (card) return { messageId: message.id, blocks, card };
}
return null;
}
/**
* Posts the first card once. The main bot starts the onboarding questions, one set per user;
* any other bot posts its focus card only while its thread is still idle.
*/
export async function promptFocus(
deps: OnboardingDeps,
actor: Actor,
@ -160,44 +169,31 @@ export async function promptFocus(
): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const blocks: MessageBlock[] = [
{
kind: "choice",
question: "What do you want me on first?",
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
},
];
// Check + insert + event in one transaction so concurrent promptFocus calls
// cannot duplicate cards, and a concurrent user send cannot publish first.
const main = bot.kind === "main";
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
// and a concurrent user send cannot publish first.
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
// Serialize concurrent promptFocus callers on this thread before the gate check.
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
await lockThread(tx, thread.id);
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
return null;
}
const recent = await tx.message.findMany({
where: { threadId: thread.id },
select: { role: true, blocks: true },
orderBy: { createdAt: "asc" },
});
if (recent.some((message) => message.role === "user")) return null;
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "bot",
blocks,
botId: target.botId,
if (!main && recent.some((message) => message.role === "user")) return null;
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
if (!main) return postInTransaction(tx, target, [focusCard()]);
const first = intakeStep("focus");
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
return postInTransaction(tx, target, [
first.card(await loadIntakeContext(tx, actor.userId, new Date())),
]);
});
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "bot", blocks },
});
return { message, event };
});
if (!committed) return;
await deps.events.notify(target.threadId, committed.event.seq);
if (committed) await deps.events.notify(target.threadId, committed.seq);
}
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
export async function dismissFocus(
deps: OnboardingDeps,
actor: Actor,
@ -205,81 +201,131 @@ export async function dismissFocus(
): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
const recent = await tx.message.findMany({
where: { threadId: thread.id },
orderBy: { createdAt: "asc" },
});
const pending = recent.find((message) =>
messageHasPendingChoice(message.blocks as MessageBlock[]),
const pending = await findPendingCard(deps.prisma, thread.id);
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined;
if (pending?.card.step && skip) {
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id });
return;
}
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await lockThread(tx, thread.id);
const current = await findPendingCard(tx, thread.id);
if (!current || current.card.step) return null;
const blocks = current.blocks.map((block) =>
block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block,
);
if (!pending) return null;
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
block.kind === "choice" && !block.answerId
? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID }
: block,
);
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId: pending.id, role: "bot", blocks },
return updateBlocksInTransaction(tx, target, current.messageId, blocks);
});
return { messageId: pending.id, blocks, event };
});
if (!claimed) return;
await deps.events.notify(target.threadId, claimed.event.seq);
if (event) await deps.events.notify(target.threadId, event.seq);
}
export interface ChooseInput {
step?: OnboardingStep;
optionId: string;
text?: string;
canEnablePush?: boolean;
}
/**
* Answers the pending card. The answer is checked against that card, a stale answer (another
* card is pending by now) is ignored, and everything that follows is committed together with
* it: the focus card's app suggestions, then the next onboarding question or the closing line.
*/
export async function chooseFocus(
deps: OnboardingDeps,
actor: Actor,
botId: string,
optionId: string,
input: ChooseInput,
): Promise<void> {
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
if (!option) throw new IsolationError();
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const pending = await findPendingCard(deps.prisma, thread.id);
if (!pending || pending.card.step !== input.step) return;
const answer = readIntakeAnswer(pending.card, input.optionId, input.text);
if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" });
const focus =
!pending.card.step || pending.card.step === "focus"
? 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 claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
const recent = await tx.message.findMany({
where: { threadId: thread.id },
orderBy: { createdAt: "asc" },
});
const pending = recent.find((message) =>
messageHasPendingChoice(message.blocks as MessageBlock[]),
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await lockThread(tx, thread.id);
const current = await findPendingCard(tx, thread.id);
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
const onboarding = current.card.step
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
: null;
if (current.card.step) {
if (onboarding?.step !== current.card.step) return null;
await intakeStep(current.card.step).save(
{ prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId },
answer,
);
if (!pending) return null;
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
block.kind === "choice" ? { ...block, answerId: option.id } : block,
);
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId: pending.id, role: "bot", blocks },
}
const answered = answeredCard(current.card, answer);
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
for (const message of followUp) last = await postInTransaction(tx, target, message);
if (current.card.step) {
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush);
}
return last;
});
return { messageId: pending.id, blocks, event };
if (event) await deps.events.notify(target.threadId, event.seq);
}
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock {
return answer.option
? { ...card, answerId: answer.option.id }
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text };
}
async function loadIntakeContext(
tx: Prisma.TransactionClient,
userId: string,
now: Date,
): Promise<IntakeContext> {
const profile = await tx.userProfile.findUnique({
where: { userId },
select: { locale: true, timeZone: true },
});
if (!claimed) return;
await deps.events.notify(target.threadId, claimed.event.seq);
return intakeContext(profile, now);
}
// Keep the name and title the user chose when creating the bot; the focus
// step only suggests apps, it must not rename the bot.
await post(deps, target, [
{
kind: "text",
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
},
]);
/** Posts the next onboarding question, or the closing line once every step is answered. */
async function askNext(
deps: OnboardingDeps,
tx: Prisma.TransactionClient,
target: Target,
userId: string,
answeredStep: OnboardingStep,
canEnablePush = false,
): Promise<{ seq: number }> {
const now = new Date();
const context = await loadIntakeContext(tx, userId, now);
const next = nextIntakeStep(answeredStep, { canEnablePush });
if (next) {
await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } });
return postInTransaction(tx, target, [next.card(context)]);
}
// 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) }]);
}
/** The messages after a focus answer: a short acknowledgement and the apps worth connecting. */
async function focusFollowUp(
deps: OnboardingDeps,
actor: Actor,
botId: string,
option: FocusOption,
onboarding: boolean,
): Promise<MessageBlock[][]> {
const providers = deps.connectors.managedProviders();
const catalog = (
await Promise.all(
@ -290,14 +336,14 @@ export async function chooseFocus(
traceId: "onboarding.choose",
spaceId: actor.spaceId,
userId: actor.userId,
botId: bot.id,
botId,
signal: AbortSignal.timeout(15_000),
})
.catch(() => []),
),
)
).flat();
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
const cards = option.apps.flatMap((slug) => {
const entry = catalog.find(
(item) =>
item.slug.toLowerCase() === slug.toLowerCase() ||
@ -317,26 +363,22 @@ export async function chooseFocus(
];
});
if (!cards.length) {
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
return;
// 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 cardNames = cards
.map((card) => (card.kind === "app_connect" ? card.name : ""))
.filter(Boolean);
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
await post(deps, target, [
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: `${named} are a good place to start. Connect them here and I’ll use what you already have.`,
text: `Got it. ${named} ${names.length > 1 ? "are" : "is"} a good place to start.`,
},
]);
await post(deps, target, cards);
await post(deps, target, [
{
kind: "text",
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`,
},
]);
],
cards,
];
}
export async function markAppConnected(
@ -376,7 +418,3 @@ export async function markAppConnected(
await updateBlocks(deps, target, message.id, next);
}
}
function capitalize(value: string): string {
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
}

View File

@ -530,7 +530,12 @@ export function createRouter(deps: RouterDeps) {
userAgent?: string;
}>();
const repos = createRepos(deps.prisma);
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors };
const onboardingDeps = {
prisma: deps.prisma,
events: deps.events,
connectors: deps.connectors,
jobs: deps.jobs,
};
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
const groupRepos = createGroupRepos(deps.prisma);
const projectGoalRepos = createProjectGoalRepos(deps.prisma);
@ -3494,7 +3499,7 @@ export function createRouter(deps: RouterDeps) {
return { ok: true as const };
}),
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId);
await chooseFocus(onboardingDeps, context.actor, input.botId, input);
return { ok: true as const };
}),
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {

View File

@ -1857,6 +1857,25 @@ describe("mobile thread event reduction", () => {
).toBe("Slack · Alex: Hello from the group");
});
it("reads a choice card as its question and answer", () => {
const card = {
kind: "choice" as const,
step: "goal" as const,
question: "What’s one thing you want to get done in the next few weeks?",
textInput: { placeholder: "Your goal" },
options: [{ id: "skip", letter: "", label: "Skip" }],
};
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
expect(
blockText(
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
),
).toBe(`${card.question}\nRun a 10K`);
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
`${card.question}\nSkip`,
);
});
it("deduplicates durable messages and replaces matching transient subagent state", () => {
const initial = snapshot([
mobileMessage("message-1", [{ kind: "text", text: "old" }]),

View File

@ -874,6 +874,12 @@ export function blockText(message: MobileMessage) {
if (block.kind === "child_bot") {
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
}
// Choice cards are answered on the web; here they read as the question and its answer.
if (block.kind === "choice") {
const answer =
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
return answer ? `${block.question}\n${answer}` : block.question;
}
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
if (block.kind === "file") {

View File

@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) {
}
/**
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
* is set, it then opens the main bot's own thread, where most specs start.
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
* set, it then opens the main bot's own thread, where most specs start.
*/
export async function completeOnboarding(
page: Page,
@ -101,13 +102,12 @@ export async function completeOnboarding(
if (page.url().includes("/onboarding")) {
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations|scan|bot)$/,
/^(emoji|language|voice|integrations|scan|bot)$/,
{ timeout: 30_000 },
);
}
const design = page.getByRole("heading", { name: /Design your Oo/i });
const preferences = page.getByRole("heading", { name: /Tell us about yourself/i });
const language = page.getByRole("heading", { name: /Choose your language/i });
const voice = page.getByRole("heading", { name: /Pick a voice/i });
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
@ -119,8 +119,6 @@ export async function completeOnboarding(
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) {
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(preferences).toBeVisible();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(language).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();
@ -133,8 +131,15 @@ export async function completeOnboarding(
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
// then lands in the main conversation.
const skipScan = page
.locator('[data-onboarding-step="scan"]')
.getByRole("button", { name: "Skip", exact: true });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
}).toPass({ timeout: 30_000 });
await expect(chief).toBeVisible({ timeout: 15_000 });
await waitForChatComposer(page);

View File

@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
await composer.fill(text);
const send = page.getByRole("button", { name: "Send", exact: true });
// An open onboarding card in the transcript has its own Send.
const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true });
await expect(send).toBeEnabled();
await send.click();
await expect(composer).toHaveValue("");
@ -72,13 +73,20 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
const transcript = page.getByTestId("transcript");
// Empty start: no Fresh-start greeting. Answer the focus card so a plain
// bot text bubble exists for hover layout checks.
// Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
// row's rail sits at the row's end, so the measured reply must wrap and fill its row.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
timeout: 20_000,
});
await page.getByTestId("focus-choice-day").click();
const botText = page.getByText(/Got it\./);
await expect(page.getByText(/Got it\./)).toBeVisible({ timeout: 20_000 });
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(
page,
composer,
`hover-bot-${stamp} ${"please look into this ".repeat(7)}`,
);
const botText = page.getByText(/i handled: hover-bot-/);
await expect(botText).toBeVisible({ timeout: 20_000 });
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
await expect(botRow).toBeVisible();
@ -121,7 +129,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
const parentText = `hover-parent-${stamp}`;
const replyText = `hover-reply-${stamp}`;
const composer = page.getByRole("combobox", { name: /^Message/ });
await expect(composer).toBeVisible();
await sendComposerMessage(page, composer, parentText);

View File

@ -21,6 +21,9 @@ test("focus choice suggests apps and preserves a completed connection", async ({
await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work");
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
// Onboarding questions offer a quiet way out instead of a dismiss button.
await expect(page.getByTestId("focus-choice-skip")).toBeVisible();
await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "01-focus-choice");
await captureScreenshot(page, testInfo, "choice-card-onboarding");
@ -33,6 +36,9 @@ 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();
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
const connectionCards = page.getByRole("group", { name: / connection$/ });
@ -45,10 +51,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({
);
expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8);
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
await page
.getByTestId("transcript")
.getByText("Hit those three and I’ll start pulling the picture.")
.scrollIntoViewIfNeeded();
// The next onboarding question follows the suggestions.
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
await slackCard(page).scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "02-app-suggestions");
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });

View File

@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => {
});
/**
* E2E test for the new emoji-first onboarding flow:
* E2E test for the emoji-first onboarding flow:
* 1. Sign up → lands on onboarding
* 2. Design Oo: pick shape + color → live preview
* 3. Tell us about yourself: name, timezone, hobbies, work style, communication
* 4. Choose language
* 5. Skip integrations → bot created → land in chat
* 3. Choose language, skip the voice
* 4. Skip integrations → bot created → land in chat, where the main bot asks the rest
*/
test("emoji-first onboarding: shape + color selection with live preview", async ({
@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
// Click Continue → goes to preferences step
// Click Continue → goes to the language step
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 2: Tell us about yourself
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
// Fill in name
await page.getByPlaceholder(/Your name/i).fill("Alice");
// Fill in timezone
await page.getByLabel(/Timezone/i).fill("Europe/Vienna");
// Select a hobby
const hobbyButtons = page.locator("button[aria-pressed]");
if (await hobbyButtons.count()) {
await hobbyButtons.first().click(); // toggle first hobby
}
// Select work style
const workStyleButtons = page.locator("button[aria-pressed]:visible");
if ((await workStyleButtons.count()) >= 2) {
await workStyleButtons.nth(0).click();
}
// Select communication style
const commButtons = page.locator("button[aria-pressed]:visible");
if ((await commButtons.count()) >= 2) {
await commButtons.nth(0).click();
}
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
// Click Continue → goes to language step
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 3: Language selection
// Step 2: Language selection
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
.first()
.click();
// Preferences step — just continue
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Language step — click Skip, then skip voice.
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();

View File

@ -0,0 +1,83 @@
/**
* E2E: after the wizard the main bot gets to know the user in the main conversation, one card
* after each answer, without ever blocking the composer.
*/
import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
function card(page: Page, step: string) {
return page.locator(`[data-testid="choice-card"][data-step="${step}"]`);
}
test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({
page,
}, testInfo) => {
await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester");
await completeOnboarding(page, undefined, { mainConversation: true });
await page.getByTestId("focus-choice-day").click();
const goal = card(page, "goal");
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
// Typing instead of answering goes to the main bot, and the card stays answerable.
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("Quick question first");
await composer.press("Enter");
await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, {
timeout: 60_000,
});
const goalInput = goal.getByRole("textbox", {
name: "What’s one thing you want to get done in the next few weeks?",
});
await goalInput.fill("Run a 10K");
await goal.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-goal");
await goalInput.press("Enter");
await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K");
const rhythm = card(page, "rhythm");
await expect(rhythm).toContainText("When should I check in with you?");
await rhythm.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-rhythm");
await rhythm.getByTestId("rhythm-choice-morning").click();
await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning");
const quiet = card(page, "quiet_hours");
await expect(quiet).toContainText("When should I stay quiet?");
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\.$/);
const notifications = card(page, "notifications");
await expect(notifications.or(closing).first()).toBeVisible();
if (await notifications.isVisible()) {
await notifications.getByTestId("notifications-choice-later").click();
}
await expect(closing).toBeVisible();
await expect(
page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'),
).toHaveCount(0);
await closing.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-done");
const settings = await rpc<{ quietHours: { start: string; end: string } | null }>(
page,
"notifications/settings/get",
null,
);
expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" });
const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>(
page,
"profile/get",
null,
);
expect(profile.facts).toContainEqual(
expect.objectContaining({
value: "Wants help with day-to-day work first",
source: "onboarding",
}),
);
});

View File

@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations)$/,
/^(emoji|language|voice|integrations)$/,
{ timeout: 30_000 },
);
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) {
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations)$/,
/^(emoji|language|voice|integrations)$/,
{ timeout: 30_000 },
);
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({

View File

@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
.getByRole("button", { name: /Continue/i })
.first()
.click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
});

View File

@ -1,6 +1,7 @@
/**
* E2E: onboarding answers land in the user profile, and Settings → About you shows every
* fact with its origin, confirms a pending one, and forgets one or all of them.
* E2E: onboarding fills the user profile without asking (time zone, locale and language from
* the browser) and records the focus answer as a fact; Settings → About you shows every fact
* with its origin, confirms a pending one, and forgets one or all of them.
*/
import { expect, test } from "@playwright/test";
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer }
test.use({ timezoneId: "Europe/Vienna" });
test.beforeEach(async ({ page }) => {
// Walk the full emoji-first flow, including the preferences step.
// Walk the full emoji-first flow.
await page.route("**/rpc/integrationSetup/get", (route) =>
route.fulfill({
json: {
@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
});
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna");
await page.getByPlaceholder("Your name").fill("Pat Example");
await page.getByRole("button", { name: "Music", exact: true }).click();
await page.getByRole("button", { name: "Deep-dive focused" }).click();
await page.getByRole("button", { name: "Direct & concise" }).click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: "English", exact: true }).click();
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 });
await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
await waitForChatComposer(page);
// The main bot asks the rest in the main conversation; the focus answer becomes a fact.
await page.getByTestId("focus-choice-day").click();
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible();
// The answers no longer overwrite a bot's instructions.
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
expect(bots.length).toBeGreaterThan(0);
for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example");
// The account name is used as is; onboarding no longer asks for it again.
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester");
// Sensitive facts from anywhere but the user wait for confirmation.
await rpc(page, "profile/update", {
@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
await expect(profile.getByTestId("profile-field-locale")).toContainText("United States");
await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText(
"Direct and concise",
);
const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" });
await expect(music).toContainText("Onboarding");
await expect(profile.getByTestId("user-facts-habits")).toContainText(
"Prefers focused deep dives",
const focus = profile
.getByTestId("user-fact")
.filter({ hasText: "Wants help with day-to-day work first" });
await expect(focus).toContainText("Onboarding");
await expect(profile.getByTestId("user-facts-goals")).toContainText(
"Wants help with day-to-day work first",
);
const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" });
@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await expect(allergy).toHaveAttribute("data-status", "active");
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
await music.getByRole("button", { name: "Delete" }).click();
await expect(music).toHaveCount(0);
await focus.getByRole("button", { name: "Delete" }).click();
await expect(focus).toHaveCount(0);
await captureScreenshot(page, testInfo, "user-profile-settings");
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
page,
@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
null,
);
expect(afterDelete.facts).toEqual(
expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]),
expect.not.arrayContaining([
expect.objectContaining({ value: "Wants help with day-to-day work first" }),
]),
);
expect(afterDelete.facts).toContainEqual(
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),

View File

@ -1,56 +1,25 @@
import { describe, expect, it } from "vitest";
import { onboardingProfileUpdate } from "./onboarding-profile";
const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" };
describe("onboardingProfileUpdate", () => {
it("turns answers into profile fields and onboarding facts", () => {
it("fills the time zone, locale and chosen language from the browser", () => {
expect(
onboardingProfileUpdate({
answers: {
name: " Alice ",
timezone: "Europe/Vienna",
hobbies: ["music", "tech", "none"],
workStyle: "deep-dive",
communication: "direct",
},
accountName: "Alice Example",
browserTimeZone: "UTC",
browserTimeZone: "Europe/Vienna",
locale: "de-AT",
language: "de",
}),
).toEqual({
name: "Alice",
timeZone: "Europe/Vienna",
locale: "de-AT",
language: "de",
communicationStyle: "Direct and concise",
addFacts: [
{ category: "preferences", value: "Interested in music", source: "onboarding" },
{ category: "preferences", value: "Interested in tech and gadgets", source: "onboarding" },
{ category: "habits", value: "Prefers focused deep dives", source: "onboarding" },
],
});
).toEqual({ timeZone: "Europe/Vienna", locale: "de-AT", language: "de" });
});
it("keeps the account name and falls back to the browser time zone", () => {
it("leaves out a language the user did not pick and malformed values", () => {
expect(
onboardingProfileUpdate({
answers: { ...blank, name: "Alice", timezone: "Not/AZone" },
accountName: "Alice",
browserTimeZone: "America/Toronto",
}),
).toEqual({ timeZone: "America/Toronto" });
});
it("leaves out a language the user did not pick and malformed tags", () => {
expect(
onboardingProfileUpdate({
answers: blank,
browserTimeZone: "UTC",
browserTimeZone: "Not/AZone",
locale: "not a tag",
language: null,
}),
).toEqual({ timeZone: "UTC" });
).toEqual({});
expect(onboardingProfileUpdate({ browserTimeZone: "UTC" })).toEqual({ timeZone: "UTC" });
});
});

View File

@ -1,73 +1,19 @@
import type { UserFactCategory } from "@rakazo/contracts";
import { isLanguageTag, isTimeZone } from "@rakazo/contracts";
export interface OnboardingAnswers {
name: string;
timezone: string;
hobbies: string[];
workStyle: string;
communication: string;
}
// Stored as data for the user's bots, so these stay plain English statements.
const HOBBY_FACTS: Record<string, string> = {
coding: "Interested in coding",
design: "Interested in design",
writing: "Interested in writing",
music: "Interested in music",
gaming: "Interested in gaming",
reading: "Interested in reading",
sports: "Interested in sports",
cooking: "Interested in cooking",
tech: "Interested in tech and gadgets",
};
const WORK_STYLE_FACTS: Record<string, string> = {
structured: "Works in a structured, planned way",
flexible: "Works flexibly and spontaneously",
"deep-dive": "Prefers focused deep dives",
quick: "Works quickly and pragmatically",
};
const COMMUNICATION_STYLES: Record<string, string> = {
direct: "Direct and concise",
detailed: "Detailed and explanatory",
balanced: "Balanced",
casual: "Casual and friendly",
};
/** Turns onboarding answers into one profile update; empty or unknown answers are left out. */
/**
* The profile fields onboarding fills in without asking: the browser's time zone and locale,
* and the language the user picked for their Oos. The name comes from the account.
*/
export function onboardingProfileUpdate(input: {
answers: OnboardingAnswers;
/** The account name, which only changes when the user typed a different one. */
accountName?: string;
/** Used when the typed time zone is empty or unknown. */
browserTimeZone: string;
/** Browser locale, e.g. "de-AT". */
locale?: string;
/** Language the user picked for their Oos, when they picked one. */
language?: string | null;
}) {
const { answers } = input;
const name = answers.name.trim();
const typedZone = answers.timezone.trim();
const workStyle = WORK_STYLE_FACTS[answers.workStyle];
const facts: Array<{ category: UserFactCategory; value: string }> = [
...answers.hobbies.flatMap((hobby) => {
const value = HOBBY_FACTS[hobby];
return value ? [{ category: "preferences" as const, value }] : [];
}),
...(workStyle ? [{ category: "habits" as const, value: workStyle }] : []),
];
const communicationStyle = COMMUNICATION_STYLES[answers.communication];
return {
...(name && name !== input.accountName?.trim() ? { name } : {}),
timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone,
...(isTimeZone(input.browserTimeZone) ? { timeZone: input.browserTimeZone } : {}),
...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}),
...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}),
...(communicationStyle ? { communicationStyle } : {}),
...(facts.length > 0
? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) }
: {}),
};
}

View File

@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
import { Button, Input } from "@rakazo/ui-web";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Button } from "@rakazo/ui-web";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
@ -24,7 +24,6 @@ import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-loo
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
import { rpc, selectSpace } from "../lib/rpc";
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
/** Selects the main conversation's space and returns its route. */
@ -53,57 +52,11 @@ function defaultEmojiConfig(): OnboardingEmojiConfig {
};
}
interface UserPreferences {
name: string;
timezone: string;
hobbies: string[];
workStyle: string;
communication: string;
}
const HOBBY_OPTIONS = [
{ key: "coding", label: "Coding" },
{ key: "design", label: "Design" },
{ key: "writing", label: "Writing" },
{ key: "music", label: "Music" },
{ key: "gaming", label: "Gaming" },
{ key: "reading", label: "Reading" },
{ key: "sports", label: "Sports" },
{ key: "cooking", label: "Cooking" },
{ key: "tech", label: "Tech & Gadgets" },
{ key: "none", label: <Trans>Nothing specific</Trans> },
];
const WORK_STYLE_OPTIONS = [
{ key: "structured", label: <Trans>Structured & planned</Trans> },
{ key: "flexible", label: <Trans>Flexible & spontaneous</Trans> },
{ key: "deep-dive", label: <Trans>Deep-dive focused</Trans> },
{ key: "quick", label: <Trans>Quick & pragmatic</Trans> },
];
const COMMUNICATION_OPTIONS = [
{ key: "direct", label: <Trans>Direct & concise</Trans> },
{ key: "detailed", label: <Trans>Detailed & explanatory</Trans> },
{ key: "balanced", label: <Trans>Balanced</Trans> },
{ key: "casual", label: <Trans>Casual & friendly</Trans> },
];
function defaultPreferences(): UserPreferences {
return {
name: "",
timezone: localTimezone(),
hobbies: [],
workStyle: "",
communication: "",
};
}
export function OnboardingPage() {
const { t } = useLingui();
const navigate = useNavigate();
const fieldId = useId();
const [step, setStep] = useState<
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot"
"loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot"
>("emoji");
const [selectedVoiceId, setSelectedVoiceId] = useState("");
const [voiceSkipped, setVoiceSkipped] = useState(false);
@ -114,10 +67,8 @@ export function OnboardingPage() {
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
const [profilePersisted, setProfilePersisted] = useState(false);
const [crewReady, setCrewReady] = useState(false);
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
const [currentLocale, setCurrentLocale] = useState<string>(() => {
try {
return localStorage.getItem("rakazo.uiLocale") ?? "en";
@ -139,7 +90,6 @@ export function OnboardingPage() {
.then(async ([me, integrations]) => {
if (cancelled) return;
setBootstrapMe(me);
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
setIntegrationSetup(integrations);
// The first step renders before this resolves; never pull a user back who moved on.
if (!me.needsModel && integrations?.needsSetup) {
@ -169,17 +119,10 @@ export function OnboardingPage() {
};
}, [navigate]);
function toggleHobby(key: string) {
const hobbies = prefs.hobbies.includes(key)
? prefs.hobbies.filter((h) => h !== key)
: [...prefs.hobbies, key];
setPrefs({ ...prefs, hobbies });
}
const discoveryPhase: OnboardingDiscoveryPhase =
step === "loading"
? "loading"
: step === "emoji" || step === "preferences" || step === "language" || step === "voice"
: step === "emoji" || step === "language" || step === "voice"
? "emoji"
: step === "integrations"
? "integrations"
@ -220,8 +163,6 @@ export function OnboardingPage() {
await rpc.profile
.update(
onboardingProfileUpdate({
answers: prefs,
accountName: bootstrapMe?.name,
browserTimeZone: localTimezone(),
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
language: languageChoice,
@ -243,22 +184,15 @@ export function OnboardingPage() {
const me = await rpc.me().catch(() => null);
if (me) setBootstrapMe(me);
const harvested = await persistUserProfileHarvest(
rpc,
collectLocalProfileSignals(me ?? undefined),
).catch(() => null);
if (harvested) setHelpVibe(harvested);
await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch(
() => null,
);
setProfilePersisted(true);
for (const serverId of integrationServers) {
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
}
const started = await rpc.onboarding
.start({ botId: bot.id })
.then(() => true)
.catch(() => false);
if (started) {
// The main bot asks the rest in the main conversation.
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
}
const main = await rpc.spaces
.list()
.then((navigation) => navigation.main)
@ -291,11 +225,7 @@ export function OnboardingPage() {
<OnboardingDiscoveryShell
items={discoveryItems}
compact={
step === "emoji" ||
step === "preferences" ||
step === "language" ||
step === "voice" ||
step === "integrations"
step === "emoji" || step === "language" || step === "voice" || step === "integrations"
}
>
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
@ -304,140 +234,10 @@ export function OnboardingPage() {
<OnboardingOoDesigner
config={emojiConfig}
onChange={setEmojiConfig}
onContinue={() => setStep("preferences")}
onContinue={() => setStep("language")}
/>
) : null}
{step === "preferences" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Tell us about yourself</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>A few quick preferences so your Oo can be better tailored to you.</Trans>
</p>
<div className="mt-6">
<label
htmlFor={`${fieldId}-name`}
className="block text-sm font-medium text-foreground"
>
<Trans>Name</Trans>
</label>
<Input
id={`${fieldId}-name`}
value={prefs.name}
onChange={(e) => setPrefs({ ...prefs, name: e.target.value })}
placeholder={t`Your name`}
className="mt-2"
/>
</div>
<div className="mt-6">
<label
htmlFor={`${fieldId}-timezone`}
className="block text-sm font-medium text-foreground"
>
<Trans>Timezone</Trans>
</label>
<Input
id={`${fieldId}-timezone`}
value={prefs.timezone}
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
placeholder={t`Auto-detected or enter manually`}
className="mt-2"
/>
</div>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Hobbies & interests</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>Select all that apply</Trans>
</p>
<div className="mt-3 flex flex-wrap gap-2">
{HOBBY_OPTIONS.map((hobby) => (
<button
key={hobby.key}
type="button"
onClick={() => toggleHobby(hobby.key)}
aria-pressed={prefs.hobbies.includes(hobby.key)}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
prefs.hobbies.includes(hobby.key)
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{hobby.label}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Work style</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How do you like to work?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{WORK_STYLE_OPTIONS.map((ws) => (
<button
key={ws.key}
type="button"
onClick={() => setPrefs({ ...prefs, workStyle: ws.key })}
aria-pressed={prefs.workStyle === ws.key}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
prefs.workStyle === ws.key
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{ws.label}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Communication style</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How should your Oo talk to you?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{COMMUNICATION_OPTIONS.map((comm) => (
<button
key={comm.key}
type="button"
onClick={() => setPrefs({ ...prefs, communication: comm.key })}
aria-pressed={prefs.communication === comm.key}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
prefs.communication === comm.key
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{comm.label}
</button>
))}
</div>
</fieldset>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("language")}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
) : null}
{step === "language" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
@ -468,7 +268,7 @@ export function OnboardingPage() {
))}
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("voice")}>

View File

@ -1,15 +1,34 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { MessageBlock } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { abortableDelay } from "@rakazo/core";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { X } from "lucide-react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTitle,
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@rakazo/ui-web";
import { ArrowUp, Check, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
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 { rpc } from "../../lib/rpc";
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
/**
* A question card. Lettered options are the answers; an option without a letter ("Skip",
* "Not now") is the quiet way out, and a card without one can be dismissed instead. Onboarding
* cards may also take an answer in the user's own words.
*/
export function ChoiceCard({
botId,
block,
@ -22,14 +41,31 @@ export function ChoiceCard({
const { t } = useLingui();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [text, setText] = useState("");
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);
const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`;
async function choose(optionId: string) {
async function choose(optionId: string, words?: string) {
setPending(true);
setError(null);
try {
await rpc.onboarding.choose({ botId, optionId });
// Tapping "Turn on" is the gesture the browser needs before it asks for permission.
const answer =
block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled"
? "later"
: optionId;
await rpc.onboarding.choose({
botId,
step: block.step,
optionId: answer,
...(words ? { text: words } : {}),
// The server offers the notifications card only to a browser that can still turn push on.
canEnablePush: webPushSupported() && !(await webPushEnabled()),
});
await onBotChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t`Could not save this choice`);
@ -52,10 +88,18 @@ export function ChoiceCard({
if (dismissed) return null;
const chosen = block.answerText
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText }
: block.options.find((option) => option.id === block.answerId);
return (
<div className="flex justify-start">
<div className="apple-glass-card relative w-[440px] max-w-full rounded-[24px] px-5 py-4.5 transition-all">
{!block.answerId ? (
<BuiCard
data-testid="choice-card"
data-step={block.step}
className="relative w-[440px] max-w-full px-5 py-4"
>
{!answered && waysOut.length === 0 ? (
<Button
variant="ghost"
size="icon-sm"
@ -73,46 +117,108 @@ export function ChoiceCard({
{block.subtitle ? (
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
) : null}
<div className="mt-3.5 space-y-2.5">
{block.options
.filter((option) => !block.answerId || option.id === block.answerId)
.map((option) => (
{answered ? (
chosen ? (
<div
data-testid="choice-answer"
className="mt-3 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3 text-ui-body font-medium text-foreground"
>
{chosen.letter ? <ChoiceLetter letter={chosen.letter} selected /> : null}
<span className="flex-1 leading-snug" dir="auto">
{chosen.label}
</span>
<Check aria-hidden strokeWidth={2.2} className="mt-0.5 size-icon-md text-primary" />
</div>
) : null
) : (
<>
{answers.length > 0 ? (
<div className="mt-3 space-y-2">
{answers.map((option) => (
<button
key={option.id}
type="button"
data-testid={`focus-choice-${option.id}`}
data-testid={testId(option)}
aria-label={option.label}
disabled={Boolean(block.answerId) || pending}
disabled={pending}
onClick={() => void choose(option.id)}
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-white/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
block.answerId === option.id
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
: "bg-white/[0.04] hover:bg-white/[0.08] hover:border-white/10"
}`}
className="tactile-press flex w-full items-start gap-3 rounded-xl border border-border bg-background p-3 text-start text-ui-body leading-snug text-foreground transition-colors hover:bg-muted disabled:opacity-60"
>
<span
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
block.answerId === option.id
? "bg-blue-500 text-white"
: "bg-white/10 text-foreground/80"
}`}
>
{option.letter}
</span>
<span
className={`flex-1 text-ui-body leading-snug ${block.answerId ? "text-foreground font-medium" : "text-foreground"}`}
>
{option.label}
</span>
{block.answerId === option.id ? (
<span className="mt-0.5 text-blue-400 font-bold">✓</span>
) : null}
<ChoiceLetter letter={option.letter} />
<span className="flex-1">{option.label}</span>
</button>
))}
</div>
) : null}
{block.textInput ? (
<form
className="mt-3"
onSubmit={(event) => {
event.preventDefault();
const words = text.trim();
if (words) void choose(CHOICE_TEXT_ANSWER_ID, words);
}}
>
<InputGroup className="h-control-lg rounded-xl bg-background">
<InputGroupInput
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={block.textInput.placeholder}
aria-label={block.question}
maxLength={200}
disabled={pending}
dir="auto"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
size="icon-sm"
variant="default"
aria-label={t`Send`}
disabled={pending || !text.trim()}
className="rounded-lg"
>
<ArrowUp aria-hidden />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
) : null}
{waysOut.length > 0 ? (
<div className="mt-2 flex justify-end gap-1">
{waysOut.map((option) => (
<Button
key={option.id}
variant="ghost"
size="sm"
data-testid={testId(option)}
disabled={pending}
onClick={() => void choose(option.id)}
className="text-muted-foreground hover:text-foreground"
>
{option.label}
</Button>
))}
</div>
) : null}
</>
)}
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
</BuiCard>
</div>
</div>
);
}
function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) {
return (
<span
aria-hidden
className={`mt-0.5 grid size-6 shrink-0 place-items-center rounded-lg text-ui-body-sm font-semibold ${
selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/80"
}`}
>
{letter}
</span>
);
}

View File

@ -24,6 +24,7 @@ interface FixtureOptions {
goals?: Array<Record<string, unknown>>;
habits?: Array<Record<string, unknown>>;
doneCheckIns?: Array<{ habitId: string; date: Date }>;
onboarding?: { completedAt: Date | null } | null;
peers?: Array<{
id: string;
name: string;
@ -116,7 +117,12 @@ async function runOnce(options: FixtureOptions = {}) {
agentSecret: { findMany: vi.fn(async () => []) },
agentSkill: { findMany: vi.fn(async () => []) },
scratchpadItem: { findMany: vi.fn(async () => []) },
user: { findUnique: vi.fn(async () => ({ name: "Ada Example" })) },
user: {
findUnique: vi.fn(async () => ({
name: "Ada Example",
onboarding: options.onboarding ?? null,
})),
},
userProfile: {
findUnique: vi.fn(async () =>
options.profile === undefined
@ -400,6 +406,19 @@ describe("run instructions", () => {
expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal");
});
it("leaves open onboarding questions to their cards, for the main bot only", async () => {
const main = { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" };
const open = { completedAt: null };
const note = "still answering onboarding cards";
expect((await runOnce({ bot: main, onboarding: open })).request.instructions).toContain(note);
expect(
(await runOnce({ bot: main, onboarding: { completedAt: new Date() } })).request.instructions,
).not.toContain(note);
expect((await runOnce({ bot: main })).request.instructions).not.toContain(note);
expect((await runOnce({ onboarding: open })).request.instructions).not.toContain(note);
});
it("shows the main bot every specialist it can delegate to, its own space first", async () => {
const { request, peersFindMany } = await runOnce({
bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" },

View File

@ -18,6 +18,7 @@ const user: UserContext = {
{ category: "work", value: "Owns a bakery", confidence: 1 },
{ category: "preferences", value: "Prefers morning meetings", confidence: 0.8 },
],
onboardingOpen: false,
};
describe("formatBotIdentity", () => {
@ -96,6 +97,7 @@ describe("formatUserContext", () => {
communicationStyle: "",
summary: " ",
facts: [{ category: "work", value: " ", confidence: 1 }],
onboardingOpen: false,
});
expect(block).toContain("<user>\nName: Ada\n</user>");
expect(block).not.toContain("Time zone");
@ -112,6 +114,7 @@ describe("formatUserContext", () => {
communicationStyle: "",
summary: "",
facts: [],
onboardingOpen: false,
}),
).toBeUndefined();
});

View File

@ -68,6 +68,13 @@ function formatCoachGuidance(toolNames: ReadonlySet<string>): Array<string | und
];
}
/**
* While the main bot's onboarding cards are still open, it leaves their questions to them, so
* a reply does not ask what the next card asks.
*/
export const ONBOARDING_OPEN_INSTRUCTION =
"The user is still answering onboarding cards in the main conversation: what to focus on, a goal, when to check in, quiet hours and notifications. Do not ask these questions yourself; answer what the user writes.";
/** One line of escaped user text, clipped to `max` characters. */
function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string {
const text = (value ?? "").replace(/\s+/g, " ").trim();

View File

@ -250,6 +250,7 @@ import {
formatMainAgentRole,
formatSubagentContext,
formatUserContext,
ONBOARDING_OPEN_INSTRUCTION,
} from "./executor-prompt.js";
import {
isProtectedComputerLifecycleCommandReadingScripts,
@ -3743,6 +3744,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
bot.kind === "main" ? formatMainAgentRole(exposedToolNames) : undefined,
timeInstruction,
userBlock,
bot.kind === "main" &&
userContext.onboardingOpen &&
!(messagingChannelRun || foreignAgentRun)
? ONBOARDING_OPEN_INSTRUCTION
: undefined,
groupContext?.instructions,
inboundHandoffInstruction,
delegatedTaskInstruction,

View File

@ -100,6 +100,20 @@ export const DELEGATED_TASK_STATUSES = [
export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES);
export type DelegatedTaskStatus = z.infer<typeof DelegatedTaskStatusSchema>;
/** Questions the main bot asks a new user, one card at a time, in the main conversation. */
export const ONBOARDING_STEPS = [
"focus",
"goal",
"rhythm",
"quiet_hours",
"notifications",
] as const;
export const OnboardingStepSchema = z.enum(ONBOARDING_STEPS);
export type OnboardingStep = z.infer<typeof OnboardingStepSchema>;
/** `optionId` of a choice answered in the user's own words. */
export const CHOICE_TEXT_ANSWER_ID = "_text";
/** A question or approval that pauses its run until the user answers. */
export const AskBlockSchema = z.object({
kind: z.literal("ask"),
@ -134,9 +148,16 @@ export const MessageBlock = z.discriminatedUnion("kind", [
kind: z.literal("choice"),
question: z.string(),
subtitle: z.string().optional(),
/** An option without a letter is a secondary answer such as "Skip". */
options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })),
/** The onboarding step this card asks; absent on a specialist's focus card. */
step: OnboardingStepSchema.optional(),
/** Lets the user answer in their own words. */
textInput: z.object({ placeholder: z.string() }).optional(),
/** Set once the user picks an option; renders the picker as answered. */
answerId: z.string().optional(),
/** The user's words when they answered through the text input. */
answerText: z.string().optional(),
}),
z.object({
/** Inline app authorization card (Composio-backed): logo, name, one-line

View File

@ -2,6 +2,7 @@ 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,
CoachRhythmSchema,
@ -90,7 +91,7 @@ import {
VoiceInfoSchema,
VoiceStatusSchema,
} from "./domain.js";
import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js";
import { DelegatedTaskStatusSchema, OnboardingStepSchema, ProductEventSchema } from "./events.js";
import { Id, IsoDate } from "./ids.js";
import { InboxListOutputSchema } from "./inbox.js";
import {
@ -713,15 +714,31 @@ export const appContract = {
},
},
onboarding: {
/** Seed the first-run greeting into the bot's thread (focus card is separate). */
/** Checks that the caller may onboard this bot's thread. Posts nothing. */
start: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
/** Post the focus choice card when the thread is still idle. */
/**
* Posts the first card: for the main bot the first onboarding question, once per user;
* for another bot its focus card while the thread is still idle.
*/
promptFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
/** Answer the focus choice; posts the app cards. Does not rename the bot. */
/**
* Answers the pending card and posts what follows it: the focus card's app suggestions,
* then the next onboarding question. A stale answer (another step is pending) is ignored.
*/
choose: oc
.input(z.object({ botId: Id, optionId: z.string() }))
.input(
z.object({
botId: Id,
step: OnboardingStepSchema.optional(),
optionId: z.string(),
/** The answer in the user's words, with `optionId` "_text"; only the goal card takes one. */
text: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(),
/** This device could still turn on push notifications. */
canEnablePush: z.boolean().optional(),
}),
)
.output(z.object({ ok: z.literal(true) })),
/** Dismiss the unanswered focus card without choosing an option. */
/** Dismisses the pending card; an onboarding question counts as skipped. */
dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
/** Flip an app_connect card to connected after authorization completes. */
appConnected: oc

View File

@ -109,3 +109,27 @@ describe("peer message history", () => {
).toBe("[to Analyst] chart it");
});
});
describe("choice card history", () => {
const card = {
kind: "choice" as const,
question: "When should I check in with you?",
options: [
{ id: "morning", letter: "A", label: "Every morning" },
{ id: "none", letter: "", label: "Not now" },
],
};
it("keeps the question and the answer the user picked or typed", () => {
expect(blocksToAgentHistoryText([card])).toBe("When should I check in with you?");
expect(blocksToAgentHistoryText([{ ...card, answerId: "morning" }])).toBe(
"When should I check in with you?\n[answered: Every morning]",
);
expect(
blocksToAgentHistoryText([{ ...card, answerId: "_text", answerText: "Before lunch" }]),
).toBe("When should I check in with you?\n[answered: Before lunch]");
expect(blocksToAgentHistoryText([{ ...card, answerId: "_dismissed" }])).toBe(
"When should I check in with you?",
);
});
});

View File

@ -105,6 +105,12 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
if (block.kind === "handoff") {
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
}
// A card's question and answer, so a later turn knows what the user already chose.
if (block.kind === "choice") {
const answer =
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
return answer ? `${block.question}\n[answered: ${answer}]` : block.question;
}
if ("text" in block && typeof block.text === "string") return block.text;
return "";
})

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "user_onboarding" (
"userId" TEXT NOT NULL,
"step" TEXT,
"focus" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_onboarding_pkey" PRIMARY KEY ("userId")
);
-- AddForeignKey
ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- The table is new and empty, so its constraint applies immediately.
ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_step_chk" CHECK ("step" IS NULL OR "step" IN ('focus', 'goal', 'rhythm', 'quiet_hours', 'notifications'));

View File

@ -46,6 +46,7 @@ model User {
botSecrets BotSecret[]
agentSecrets AgentSecret[]
profile UserProfile?
onboarding UserOnboarding?
facts UserFact[]
goals UserGoal[]
habits Habit[]
@ -1477,6 +1478,21 @@ model UserProfile {
@@map("user_profiles")
}
/// Where the user is in the onboarding questions the main bot asks in the main conversation.
model UserOnboarding {
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// The step whose card is waiting for an answer; null once onboarding is complete.
step String?
focus String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// CHECK constraint user_onboarding_step_chk is created in SQL.
@@map("user_onboarding")
}
/// One durable statement about the user, with its origin and consent state.
model UserFact {
id String @id @default(cuid())

View File

@ -42,6 +42,8 @@ export interface UserContext {
communicationStyle: string;
summary: string;
facts: Array<{ category: UserFactCategory; value: string; confidence: number }>;
/** The main bot is still asking its onboarding questions on cards in the main conversation. */
onboardingOpen: boolean;
}
function isUniqueViolation(error: unknown): boolean {
@ -258,7 +260,10 @@ export async function loadUserContext(
input: { userId: string; spaceId: string },
): Promise<UserContext> {
const [user, profile, facts] = await Promise.all([
prisma.user.findUnique({ where: { id: input.userId }, select: { name: true } }),
prisma.user.findUnique({
where: { id: input.userId },
select: { name: true, onboarding: { select: { completedAt: true } } },
}),
prisma.userProfile.findUnique({ where: { userId: input.userId } }),
prisma.userFact.findMany({
where: {
@ -283,5 +288,6 @@ export async function loadUserContext(
value: fact.value,
confidence: fact.confidence,
})),
onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt),
};
}

View File

@ -108,6 +108,7 @@ async function main() {
"packages/testkit/src/bot-secrets.test.ts",
"packages/testkit/src/delegation.postgres.test.ts",
"packages/testkit/src/user-profile.postgres.test.ts",
"packages/testkit/src/onboarding-intake.postgres.test.ts",
"packages/testkit/src/onboarding-discovery.postgres.test.ts",
"packages/testkit/src/notifications.postgres.test.ts",
"packages/db/src/space-membership.postgres.test.ts",

View File

@ -0,0 +1,276 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { ComposioEmulator, getCoachRhythm } from "@rakazo/adapters";
import type { MessageBlock } from "@rakazo/contracts";
import { getNotificationSettings, loadUserContext } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { createApp } from "../../../apps/api/src/app.ts";
import { sessionCookieHeader } from "./index.js";
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
type Choice = Extract<MessageBlock, { kind: "choice" }>;
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = "fake";
process.env.AGENT_RUNTIME = "scripted";
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
const describeWithDatabase = hasDb ? describe : describe.skip;
describeWithDatabase("onboarding questions in the main conversation", () => {
let handles: AppHandles;
let app: App;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-onboarding-"));
beforeAll(async () => {
const { createApp } = await import("../../../apps/api/src/app.ts");
handles = await createApp({
databaseUrl: process.env.DATABASE_URL!,
dataDir,
sandboxProvider: "fake",
agentRuntime: "scripted",
wakeupDriver: "memory",
signupsEnabled: "true",
composio: new ComposioEmulator(),
});
app = handles.app;
});
afterAll(async () => {
await handles?.stop();
rmSync(dataDir, { recursive: true, force: true });
});
/** A fresh user with their main bot, as the web wizard leaves them. */
async function newUser(name: string) {
const cookie = await signup(app, `${name.toLowerCase()}-${stamp}@rakazo.test`, name);
const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me");
await rpc(app, cookie, "profile/update", { timeZone: "Europe/Vienna", locale: "en-US" });
const main = await rpc<{ id: string }>(app, cookie, "bots/ensureMain");
const { main: conversation } = await rpc<{ main: { groupId: string } }>(
app,
cookie,
"spaces/list",
);
const { id: threadId } = await handles.prisma.thread.findUniqueOrThrow({
where: { groupId: conversation.groupId },
select: { id: true },
});
return { cookie, userId: me.userId, spaceId: me.spaceId, botId: main.id, threadId };
}
async function messages(threadId: string) {
const rows = await handles.prisma.message.findMany({
where: { threadId },
orderBy: { seq: "asc" },
select: { blocks: true },
});
return rows.flatMap((row) => row.blocks as MessageBlock[]);
}
async function cards(threadId: string): Promise<Choice[]> {
return (await messages(threadId)).filter((block): block is Choice => block.kind === "choice");
}
async function pending(threadId: string) {
return (await cards(threadId)).filter((card) => !card.answerId);
}
const onboardingRow = (userId: string) =>
handles.prisma.userOnboarding.findUniqueOrThrow({ where: { userId } });
it("asks one question after another and records every answer", async () => {
const ada = await newUser("Ada");
const choose = (body: Record<string, unknown>) =>
rpc(app, ada.cookie, "onboarding/choose", { botId: ada.botId, ...body });
await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId });
await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId });
expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["focus"]);
expect(await onboardingRow(ada.userId)).toMatchObject({ step: "focus", completedAt: null });
expect(
(await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId }))
.onboardingOpen,
).toBe(true);
await choose({ step: "focus", optionId: "day" });
expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" });
const facts = await handles.prisma.userFact.findMany({ where: { userId: ada.userId } });
expect(facts.map(({ category, value, source }) => ({ category, value, source }))).toEqual([
{ category: "goals", value: "Wants help with day-to-day work first", source: "onboarding" },
]);
const afterFocus = await messages(ada.threadId);
expect(afterFocus.filter((block) => block.kind === "app_connect").length).toBeGreaterThan(0);
// The app suggestions come before the next question.
expect(afterFocus.at(-1)).toMatchObject({ kind: "choice", step: "goal" });
// A stale answer to a card that is no longer pending changes nothing.
await choose({ step: "focus", optionId: "inbox" });
expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" });
await expect(choose({ step: "goal", optionId: "morning" })).rejects.toThrow(/400/);
await expect(choose({ step: "goal", optionId: "_text", text: " " })).rejects.toThrow(/400/);
// A double submit records the goal once.
await Promise.all([
choose({ step: "goal", optionId: "_text", text: "Run a 10K" }),
choose({ step: "goal", optionId: "_text", text: "Run a 10K" }),
]);
const goals = await handles.prisma.userGoal.findMany({ where: { userId: ada.userId } });
expect(goals.map(({ title, source }) => ({ title, source }))).toEqual([
{ title: "Run a 10K", source: "onboarding" },
]);
expect((await cards(ada.threadId)).find((card) => card.step === "goal")).toMatchObject({
answerId: "_text",
answerText: "Run a 10K",
});
await choose({ step: "rhythm", optionId: "morning" });
expect(await getCoachRhythm(handles.prisma, ada.userId)).toEqual({
morning: "08:00",
evening: null,
weekly: null,
});
expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["quiet_hours"]);
expect(
(await pending(ada.threadId))[0]?.options.map((option) => option.label.replace(/\s/g, " ")),
).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]);
// A browser that can still turn push on gets the notifications question.
await choose({ step: "quiet_hours", optionId: "22-07", canEnablePush: true });
expect((await getNotificationSettings(handles.prisma, ada.userId)).quietHours).toEqual({
start: "22:00",
end: "07:00",
});
expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["notifications"]);
await choose({ step: "notifications", optionId: "later" });
expect(await onboardingRow(ada.userId)).toMatchObject({
step: null,
completedAt: expect.any(Date),
});
expect(await pending(ada.threadId)).toEqual([]);
const closing = (await messages(ada.threadId)).at(-1);
expect(closing?.kind === "text" && closing.text.replace(/\s/g, " ")).toMatch(
/^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/,
);
expect(
(await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId }))
.onboardingOpen,
).toBe(false);
// Onboarding runs once.
const count = (await messages(ada.threadId)).length;
await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId });
expect((await messages(ada.threadId)).length).toBe(count);
});
it("moves on when a question is skipped or dismissed, and saves nothing for it", async () => {
const bo = await newUser("Bo");
const choose = (body: Record<string, unknown>) =>
rpc(app, bo.cookie, "onboarding/choose", { botId: bo.botId, ...body });
await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: bo.botId });
await rpc(app, bo.cookie, "onboarding/dismissFocus", { botId: bo.botId });
expect((await cards(bo.threadId))[0]).toMatchObject({ step: "focus", answerId: "skip" });
await choose({ step: "goal", optionId: "skip" });
await choose({ step: "rhythm", optionId: "none" });
await choose({ step: "quiet_hours", optionId: "skip" });
expect(await onboardingRow(bo.userId)).toMatchObject({
step: null,
focus: null,
completedAt: expect.any(Date),
});
expect(await handles.prisma.userFact.count({ where: { userId: bo.userId } })).toBe(0);
expect(await handles.prisma.userGoal.count({ where: { userId: bo.userId } })).toBe(0);
expect(await getCoachRhythm(handles.prisma, bo.userId)).toEqual({
morning: null,
evening: null,
weekly: null,
});
expect((await getNotificationSettings(handles.prisma, bo.userId)).quietHours).toBeNull();
expect((await messages(bo.threadId)).at(-1)).toEqual({
kind: "text",
text: "All set. I’m here whenever you need me.",
});
// Another bot still gets only its focus card, outside onboarding.
const helper = await rpc<{ id: string; threadId: string }>(app, bo.cookie, "bots/create", {
name: "Helper",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const { thread } = await handles.prisma.bot.findUniqueOrThrow({
where: { id: helper.id },
select: { thread: { select: { id: true } } },
});
await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: helper.id });
const [focus] = await pending(thread!.id);
expect(focus?.step).toBeUndefined();
expect(focus?.options.map((option) => option.id)).toEqual(["day", "inbox", "research", "crew"]);
await rpc(app, bo.cookie, "onboarding/choose", { botId: helper.id, optionId: "research" });
expect(await pending(thread!.id)).toEqual([]);
expect(await onboardingRow(bo.userId)).toMatchObject({ focus: null });
});
it("keeps each user's onboarding to themselves", async () => {
const owner = await newUser("Owner");
const other = await newUser("Other");
await rpc(app, owner.cookie, "onboarding/promptFocus", { botId: owner.botId });
for (const [procedure, body] of [
["onboarding/promptFocus", { botId: owner.botId }],
["onboarding/choose", { botId: owner.botId, step: "focus", optionId: "day" }],
["onboarding/dismissFocus", { botId: owner.botId }],
["onboarding/start", { botId: owner.botId }],
] as const) {
const response = await raw(app, other.cookie, procedure, body);
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
}
expect(await onboardingRow(owner.userId)).toMatchObject({ step: "focus", focus: null });
expect((await pending(owner.threadId)).map((card) => card.step)).toEqual(["focus"]);
expect(
await handles.prisma.userOnboarding.findUnique({ where: { userId: other.userId } }),
).toBe(null);
expect(await handles.prisma.userFact.count({ where: { userId: owner.userId } })).toBe(0);
});
});
async function signup(app: App, email: string, name: string) {
const response = await app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
body: JSON.stringify({ email, password: "password12", name }),
});
if (response.status >= 400) {
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
}
return sessionCookieHeader(response);
}
async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) {
return app.request(`/rpc/${procedure}`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie,
origin: "http://127.0.0.1:5173",
},
body: JSON.stringify({ json: body }),
});
}
async function rpc<T>(app: App, cookie: string, procedure: string, body: unknown = {}) {
const response = await raw(app, cookie, procedure, body);
const text = await response.text();
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
if (response.status >= 400 || payload.error) {
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
}
return payload.json as T;
}