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:
commit
e72b169ca4
98
apps/api/src/onboarding-intake.test.ts
Normal file
98
apps/api/src/onboarding-intake.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
288
apps/api/src/onboarding-intake.ts
Normal file
288
apps/api/src/onboarding-intake.ts
Normal 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}.`;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type * as db from "@rakazo/db";
|
import type * as db from "@rakazo/db";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { chooseFocus, markAppConnected } from "./onboarding.js";
|
import { chooseFocus, markAppConnected } from "./onboarding.js";
|
||||||
|
import { focusCard } from "./onboarding-intake.js";
|
||||||
|
|
||||||
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
|
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
|
||||||
vi.mock("@rakazo/db", async (original) => ({
|
vi.mock("@rakazo/db", async (original) => ({
|
||||||
@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({
|
|||||||
}));
|
}));
|
||||||
function fixture(catalog: unknown[]) {
|
function fixture(catalog: unknown[]) {
|
||||||
posted.length = 0;
|
posted.length = 0;
|
||||||
|
// A specialist's focus card, waiting for an answer.
|
||||||
|
const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]);
|
||||||
const tx = {
|
const tx = {
|
||||||
$executeRaw: vi.fn(),
|
$executeRaw: vi.fn(),
|
||||||
message: {
|
message: { findMany: pending, update: vi.fn() },
|
||||||
findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const deps = {
|
const deps = {
|
||||||
prisma: {
|
prisma: {
|
||||||
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
|
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
|
||||||
|
message: { findMany: pending },
|
||||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
|
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
|
||||||
},
|
},
|
||||||
events: { notify: vi.fn() },
|
events: { notify: vi.fn() },
|
||||||
@ -39,17 +40,19 @@ function fixture(catalog: unknown[]) {
|
|||||||
describe("onboarding connection suggestions", () => {
|
describe("onboarding connection suggestions", () => {
|
||||||
it("does not invent authorization cards when no connector has an app catalog", async () => {
|
it("does not invent authorization cards when no connector has an app catalog", async () => {
|
||||||
const { deps, actor } = fixture([]);
|
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(posted.flatMap((message) => message.blocks)).not.toContainEqual(
|
||||||
expect.objectContaining({ kind: "app_connect" }),
|
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 () => {
|
it("uses the available connector and omits unavailable apps", async () => {
|
||||||
const { deps, actor } = fixture([
|
const { deps, actor } = fixture([
|
||||||
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
|
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
|
||||||
]);
|
]);
|
||||||
await chooseFocus(deps, actor, "bot", "day");
|
await chooseFocus(deps, actor, "bot", { optionId: "day" });
|
||||||
expect(
|
expect(
|
||||||
posted
|
posted
|
||||||
.flatMap((message) => message.blocks)
|
.flatMap((message) => message.blocks)
|
||||||
@ -57,6 +60,9 @@ describe("onboarding connection suggestions", () => {
|
|||||||
).toEqual([
|
).toEqual([
|
||||||
expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }),
|
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." },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import type { ConnectorRegistry } from "@rakazo/adapters";
|
import { ORPCError } from "@orpc/server";
|
||||||
import type { Actor, MessageBlock } from "@rakazo/contracts";
|
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 { featuredConnectorProvidersMatch } from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
appendEventInTransaction,
|
appendEventInTransaction,
|
||||||
@ -10,58 +13,36 @@ import {
|
|||||||
type PrismaClient,
|
type PrismaClient,
|
||||||
type ThreadEvents,
|
type ThreadEvents,
|
||||||
} from "@rakazo/db";
|
} 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
|
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
|
||||||
* thread: greeting, a focus choice, and available app cards the user authorizes
|
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
|
||||||
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
|
* conversation, one card after each answer. Any other bot gets one focus card that suggests
|
||||||
* talks in the main conversation, where onboarding lands.
|
* apps the user authorizes inline. Focus never renames a bot.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type OnboardingDeps = {
|
type OnboardingDeps = {
|
||||||
prisma: PrismaClient;
|
prisma: PrismaClient;
|
||||||
events: ThreadEvents;
|
events: ThreadEvents;
|
||||||
connectors: ConnectorRegistry;
|
connectors: ConnectorRegistry;
|
||||||
|
jobs: JobPublisher;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FocusOption = {
|
type Target = { spaceId: string; botId: string; threadId: string };
|
||||||
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"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const APP_DESCRIPTIONS: Record<string, string> = {
|
const APP_DESCRIPTIONS: Record<string, string> = {
|
||||||
slack: "Search, read, and send messages.",
|
slack: "Search, read, and send messages.",
|
||||||
@ -85,74 +66,102 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
|
|||||||
return { bot, thread: bot.thread };
|
return { bot, thread: bot.thread };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(
|
/**
|
||||||
deps: OnboardingDeps,
|
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows
|
||||||
target: { spaceId: string; botId: string; threadId: string },
|
* 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[],
|
blocks: MessageBlock[],
|
||||||
): Promise<string> {
|
): Promise<{ seq: number }> {
|
||||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
threadId: target.threadId,
|
||||||
threadId: target.threadId,
|
role: "bot",
|
||||||
role: "bot",
|
blocks,
|
||||||
blocks,
|
botId: target.botId,
|
||||||
botId: target.botId,
|
});
|
||||||
});
|
return appendEventInTransaction(tx, {
|
||||||
const event = await appendEventInTransaction(tx, {
|
spaceId: target.spaceId,
|
||||||
spaceId: target.spaceId,
|
threadId: target.threadId,
|
||||||
threadId: target.threadId,
|
botId: target.botId,
|
||||||
botId: target.botId,
|
type: "thread.message.created",
|
||||||
type: "thread.message.created",
|
payload: { messageId: message.id, role: "bot", blocks },
|
||||||
payload: { messageId: message.id, role: "bot", blocks },
|
});
|
||||||
});
|
}
|
||||||
return { message, event };
|
|
||||||
|
async function updateBlocksInTransaction(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
target: Target,
|
||||||
|
messageId: string,
|
||||||
|
blocks: MessageBlock[],
|
||||||
|
): Promise<{ seq: number }> {
|
||||||
|
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
||||||
|
return appendEventInTransaction(tx, {
|
||||||
|
spaceId: target.spaceId,
|
||||||
|
threadId: target.threadId,
|
||||||
|
botId: target.botId,
|
||||||
|
type: "thread.message.updated",
|
||||||
|
payload: { messageId, role: "bot", blocks },
|
||||||
});
|
});
|
||||||
await deps.events.notify(target.threadId, committed.event.seq);
|
|
||||||
return committed.message.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateBlocks(
|
async function updateBlocks(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
target: { spaceId: string; botId: string; threadId: string },
|
target: Target,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
blocks: MessageBlock[],
|
blocks: MessageBlock[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) =>
|
||||||
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
updateBlocksInTransaction(tx, target, messageId, blocks),
|
||||||
return appendEventInTransaction(tx, {
|
);
|
||||||
spaceId: target.spaceId,
|
|
||||||
threadId: target.threadId,
|
|
||||||
botId: target.botId,
|
|
||||||
type: "thread.message.updated",
|
|
||||||
payload: { messageId, role: "bot", blocks },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await deps.events.notify(target.threadId, event.seq);
|
await deps.events.notify(target.threadId, event.seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sentinel answerId for a focus card the user dismissed without choosing. */
|
/** Sentinel answerId for a focus card the user dismissed without choosing. */
|
||||||
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
|
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(
|
export async function startOnboarding(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
botId: string,
|
botId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { thread } = await requireBotThread(deps, actor, botId);
|
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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageHasChoice(blocks: MessageBlock[]): boolean {
|
function isChoice(block: MessageBlock): block is ChoiceBlock {
|
||||||
return blocks.some((block) => block.kind === "choice");
|
return block.kind === "choice";
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
|
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
|
||||||
return blocks.some((block) => block.kind === "choice" && !block.answerId);
|
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(
|
export async function promptFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@ -160,44 +169,31 @@ export async function promptFocus(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||||
const blocks: MessageBlock[] = [
|
const main = bot.kind === "main";
|
||||||
{
|
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
|
||||||
kind: "choice",
|
// and a concurrent user send cannot publish first.
|
||||||
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 committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Serialize concurrent promptFocus callers on this thread before the gate check.
|
await lockThread(tx, thread.id);
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const recent = await tx.message.findMany({
|
const recent = await tx.message.findMany({
|
||||||
where: { threadId: thread.id },
|
where: { threadId: thread.id },
|
||||||
select: { role: true, blocks: true },
|
select: { role: true, blocks: true },
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
});
|
||||||
if (recent.some((message) => message.role === "user")) return null;
|
if (!main && recent.some((message) => message.role === "user")) return null;
|
||||||
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
|
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
if (!main) return postInTransaction(tx, target, [focusCard()]);
|
||||||
threadId: target.threadId,
|
const first = intakeStep("focus");
|
||||||
role: "bot",
|
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
|
||||||
blocks,
|
return postInTransaction(tx, target, [
|
||||||
botId: target.botId,
|
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;
|
if (committed) await deps.events.notify(target.threadId, committed.seq);
|
||||||
await deps.events.notify(target.threadId, committed.event.seq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
|
||||||
export async function dismissFocus(
|
export async function dismissFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@ -205,81 +201,131 @@ export async function dismissFocus(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||||
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const pending = await findPendingCard(deps.prisma, thread.id);
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined;
|
||||||
const recent = await tx.message.findMany({
|
if (pending?.card.step && skip) {
|
||||||
where: { threadId: thread.id },
|
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id });
|
||||||
orderBy: { createdAt: "asc" },
|
return;
|
||||||
});
|
}
|
||||||
const pending = recent.find((message) =>
|
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
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;
|
return updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||||
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 { messageId: pending.id, blocks, event };
|
|
||||||
});
|
});
|
||||||
if (!claimed) return;
|
if (event) await deps.events.notify(target.threadId, event.seq);
|
||||||
await deps.events.notify(target.threadId, claimed.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(
|
export async function chooseFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
botId: string,
|
botId: string,
|
||||||
optionId: string,
|
input: ChooseInput,
|
||||||
): Promise<void> {
|
): 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 { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
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) => {
|
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
await lockThread(tx, thread.id);
|
||||||
const recent = await tx.message.findMany({
|
const current = await findPendingCard(tx, thread.id);
|
||||||
where: { threadId: thread.id },
|
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
|
||||||
orderBy: { createdAt: "asc" },
|
const onboarding = current.card.step
|
||||||
});
|
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
|
||||||
const pending = recent.find((message) =>
|
: null;
|
||||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
if (current.card.step) {
|
||||||
);
|
if (onboarding?.step !== current.card.step) return null;
|
||||||
if (!pending) return null;
|
await intakeStep(current.card.step).save(
|
||||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
{ prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId },
|
||||||
block.kind === "choice" ? { ...block, answerId: option.id } : block,
|
answer,
|
||||||
);
|
);
|
||||||
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
}
|
||||||
const event = await appendEventInTransaction(tx, {
|
const answered = answeredCard(current.card, answer);
|
||||||
spaceId: target.spaceId,
|
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
|
||||||
threadId: target.threadId,
|
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||||
botId: target.botId,
|
for (const message of followUp) last = await postInTransaction(tx, target, message);
|
||||||
type: "thread.message.updated",
|
if (current.card.step) {
|
||||||
payload: { messageId: pending.id, role: "bot", blocks },
|
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush);
|
||||||
});
|
}
|
||||||
return { messageId: pending.id, blocks, event };
|
return last;
|
||||||
});
|
});
|
||||||
if (!claimed) return;
|
if (event) await deps.events.notify(target.threadId, event.seq);
|
||||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
}
|
||||||
|
|
||||||
// Keep the name and title the user chose when creating the bot; the focus
|
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock {
|
||||||
// step only suggests apps, it must not rename the bot.
|
return answer.option
|
||||||
await post(deps, target, [
|
? { ...card, answerId: answer.option.id }
|
||||||
{
|
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text };
|
||||||
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.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
return intakeContext(profile, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 providers = deps.connectors.managedProviders();
|
||||||
const catalog = (
|
const catalog = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@ -290,14 +336,14 @@ export async function chooseFocus(
|
|||||||
traceId: "onboarding.choose",
|
traceId: "onboarding.choose",
|
||||||
spaceId: actor.spaceId,
|
spaceId: actor.spaceId,
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
botId: bot.id,
|
botId,
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
})
|
})
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
).flat();
|
).flat();
|
||||||
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
|
const cards = option.apps.flatMap((slug) => {
|
||||||
const entry = catalog.find(
|
const entry = catalog.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.slug.toLowerCase() === slug.toLowerCase() ||
|
item.slug.toLowerCase() === slug.toLowerCase() ||
|
||||||
@ -317,26 +363,22 @@ export async function chooseFocus(
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
|
// In onboarding the next question follows; another bot asks what to start with.
|
||||||
return;
|
const text = onboarding ? "Got it." : "Got it. What would you like to work on first?";
|
||||||
|
return [[{ kind: "text", text }]];
|
||||||
}
|
}
|
||||||
const cardNames = cards
|
const names = cards.map((card) => card.name);
|
||||||
.map((card) => (card.kind === "app_connect" ? card.name : ""))
|
const named =
|
||||||
.filter(Boolean);
|
names.length > 1 ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}` : names[0];
|
||||||
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
|
return [
|
||||||
await post(deps, target, [
|
[
|
||||||
{
|
{
|
||||||
kind: "text",
|
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);
|
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.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markAppConnected(
|
export async function markAppConnected(
|
||||||
@ -376,7 +418,3 @@ export async function markAppConnected(
|
|||||||
await updateBlocks(deps, target, message.id, next);
|
await updateBlocks(deps, target, message.id, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalize(value: string): string {
|
|
||||||
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -530,7 +530,12 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}>();
|
}>();
|
||||||
const repos = createRepos(deps.prisma);
|
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 mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
|
||||||
const groupRepos = createGroupRepos(deps.prisma);
|
const groupRepos = createGroupRepos(deps.prisma);
|
||||||
const projectGoalRepos = createProjectGoalRepos(deps.prisma);
|
const projectGoalRepos = createProjectGoalRepos(deps.prisma);
|
||||||
@ -3494,7 +3499,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
|
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 };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
|
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
|
||||||
|
|||||||
@ -1857,6 +1857,25 @@ describe("mobile thread event reduction", () => {
|
|||||||
).toBe("Slack · Alex: Hello from the group");
|
).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", () => {
|
it("deduplicates durable messages and replaces matching transient subagent state", () => {
|
||||||
const initial = snapshot([
|
const initial = snapshot([
|
||||||
mobileMessage("message-1", [{ kind: "text", text: "old" }]),
|
mobileMessage("message-1", [{ kind: "text", text: "old" }]),
|
||||||
|
|||||||
@ -874,6 +874,12 @@ export function blockText(message: MobileMessage) {
|
|||||||
if (block.kind === "child_bot") {
|
if (block.kind === "child_bot") {
|
||||||
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
|
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 === "chart") return `[chart: ${block.name ?? "chart"}]`;
|
||||||
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
||||||
if (block.kind === "file") {
|
if (block.kind === "file") {
|
||||||
|
|||||||
@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
|
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
|
||||||
* is set, it then opens the main bot's own thread, where most specs start.
|
* 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(
|
export async function completeOnboarding(
|
||||||
page: Page,
|
page: Page,
|
||||||
@ -101,13 +102,12 @@ export async function completeOnboarding(
|
|||||||
if (page.url().includes("/onboarding")) {
|
if (page.url().includes("/onboarding")) {
|
||||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||||
"data-onboarding-step",
|
"data-onboarding-step",
|
||||||
/^(emoji|preferences|language|voice|integrations|scan|bot)$/,
|
/^(emoji|language|voice|integrations|scan|bot)$/,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const design = page.getByRole("heading", { name: /Design your Oo/i });
|
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 language = page.getByRole("heading", { name: /Choose your language/i });
|
||||||
const voice = page.getByRole("heading", { name: /Pick a voice/i });
|
const voice = page.getByRole("heading", { name: /Pick a voice/i });
|
||||||
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
|
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 });
|
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
if (await design.isVisible()) {
|
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 page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
await expect(language).toBeVisible();
|
await expect(language).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Skip/i }).click();
|
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();
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
|
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
|
||||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
// 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 expect(chief).toBeVisible({ timeout: 15_000 });
|
||||||
await waitForChatComposer(page);
|
await waitForChatComposer(page);
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr
|
|||||||
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
|
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
|
||||||
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
|
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
|
||||||
await composer.fill(text);
|
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 expect(send).toBeEnabled();
|
||||||
await send.click();
|
await send.click();
|
||||||
await expect(composer).toHaveValue("");
|
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");
|
const transcript = page.getByTestId("transcript");
|
||||||
|
|
||||||
// Empty start: no Fresh-start greeting. Answer the focus card so a plain
|
// Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
|
||||||
// bot text bubble exists for hover layout checks.
|
// 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({
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
await page.getByTestId("focus-choice-day").click();
|
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 });
|
await expect(botText).toBeVisible({ timeout: 20_000 });
|
||||||
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
|
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
|
||||||
await expect(botRow).toBeVisible();
|
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 parentText = `hover-parent-${stamp}`;
|
||||||
const replyText = `hover-reply-${stamp}`;
|
const replyText = `hover-reply-${stamp}`;
|
||||||
const composer = page.getByRole("combobox", { name: /^Message/ });
|
|
||||||
await expect(composer).toBeVisible();
|
await expect(composer).toBeVisible();
|
||||||
await sendComposerMessage(page, composer, parentText);
|
await sendComposerMessage(page, composer, parentText);
|
||||||
|
|
||||||
|
|||||||
@ -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-day")).toContainText("Day-to-day work");
|
||||||
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
|
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
|
||||||
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
|
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 page.mouse.move(1, 1);
|
||||||
await captureScreenshot(page, testInfo, "01-focus-choice");
|
await captureScreenshot(page, testInfo, "01-focus-choice");
|
||||||
await captureScreenshot(page, testInfo, "choice-card-onboarding");
|
await captureScreenshot(page, testInfo, "choice-card-onboarding");
|
||||||
@ -33,6 +36,9 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
|
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
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("Slack", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
|
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
|
||||||
const connectionCards = page.getByRole("group", { name: / connection$/ });
|
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[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8);
|
||||||
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
|
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
|
||||||
await page
|
// The next onboarding question follows the suggestions.
|
||||||
.getByTestId("transcript")
|
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
|
||||||
.getByText("Hit those three and I’ll start pulling the picture.")
|
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
|
||||||
.scrollIntoViewIfNeeded();
|
await slackCard(page).scrollIntoViewIfNeeded();
|
||||||
await page.mouse.move(1, 1);
|
await page.mouse.move(1, 1);
|
||||||
await captureScreenshot(page, testInfo, "02-app-suggestions");
|
await captureScreenshot(page, testInfo, "02-app-suggestions");
|
||||||
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });
|
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });
|
||||||
|
|||||||
@ -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
|
* 1. Sign up → lands on onboarding
|
||||||
* 2. Design Oo: pick shape + color → live preview
|
* 2. Design Oo: pick shape + color → live preview
|
||||||
* 3. Tell us about yourself: name, timezone, hobbies, work style, communication
|
* 3. Choose language, skip the voice
|
||||||
* 4. Choose language
|
* 4. Skip integrations → bot created → land in chat, where the main bot asks the rest
|
||||||
* 5. Skip integrations → bot created → land in chat
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
test("emoji-first onboarding: shape + color selection with live preview", async ({
|
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");
|
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
|
await page
|
||||||
.getByRole("button", { name: /Continue/i })
|
.getByRole("button", { name: /Continue/i })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Step 2: Tell us about yourself
|
// Step 2: Language selection
|
||||||
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
|
|
||||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
||||||
@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
|
|||||||
.first()
|
.first()
|
||||||
.click();
|
.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.
|
// Language step — click Skip, then skip voice.
|
||||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Skip/i }).click();
|
await page.getByRole("button", { name: /Skip/i }).click();
|
||||||
|
|||||||
83
apps/web/e2e/onboarding-intake.spec.ts
Normal file
83
apps/web/e2e/onboarding-intake.spec.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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 page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||||
"data-onboarding-step",
|
"data-onboarding-step",
|
||||||
/^(emoji|preferences|language|voice|integrations)$/,
|
/^(emoji|language|voice|integrations)$/,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
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 page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||||
"data-onboarding-step",
|
"data-onboarding-step",
|
||||||
/^(emoji|preferences|language|voice|integrations)$/,
|
/^(emoji|language|voice|integrations)$/,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||||
|
|||||||
@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
|
|||||||
.getByRole("button", { name: /Continue/i })
|
.getByRole("button", { name: /Continue/i })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* E2E: onboarding answers land in the user profile, and Settings → About you shows every
|
* E2E: onboarding fills the user profile without asking (time zone, locale and language from
|
||||||
* fact with its origin, confirms a pending one, and forgets one or all of them.
|
* 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 { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
|
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
|
||||||
@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer }
|
|||||||
test.use({ timezoneId: "Europe/Vienna" });
|
test.use({ timezoneId: "Europe/Vienna" });
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
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) =>
|
await page.route("**/rpc/integrationSetup/get", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
json: {
|
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 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 expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "English", exact: true }).click();
|
await page.getByRole("button", { name: "English", exact: true }).click();
|
||||||
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
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);
|
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.
|
// The answers no longer overwrite a bot's instructions.
|
||||||
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
|
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
|
||||||
expect(bots.length).toBeGreaterThan(0);
|
expect(bots.length).toBeGreaterThan(0);
|
||||||
for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
|
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.
|
// Sensitive facts from anywhere but the user wait for confirmation.
|
||||||
await rpc(page, "profile/update", {
|
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-timeZone")).toContainText("Europe/Vienna");
|
||||||
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
|
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-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" });
|
const focus = profile
|
||||||
await expect(music).toContainText("Onboarding");
|
.getByTestId("user-fact")
|
||||||
await expect(profile.getByTestId("user-facts-habits")).toContainText(
|
.filter({ hasText: "Wants help with day-to-day work first" });
|
||||||
"Prefers focused deep dives",
|
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" });
|
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).toHaveAttribute("data-status", "active");
|
||||||
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
|
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
|
||||||
|
|
||||||
await music.getByRole("button", { name: "Delete" }).click();
|
await focus.getByRole("button", { name: "Delete" }).click();
|
||||||
await expect(music).toHaveCount(0);
|
await expect(focus).toHaveCount(0);
|
||||||
await captureScreenshot(page, testInfo, "user-profile-settings");
|
await captureScreenshot(page, testInfo, "user-profile-settings");
|
||||||
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
|
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
|
||||||
page,
|
page,
|
||||||
@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
expect(afterDelete.facts).toEqual(
|
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(afterDelete.facts).toContainEqual(
|
||||||
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),
|
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),
|
||||||
|
|||||||
@ -1,56 +1,25 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { onboardingProfileUpdate } from "./onboarding-profile";
|
import { onboardingProfileUpdate } from "./onboarding-profile";
|
||||||
|
|
||||||
const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" };
|
|
||||||
|
|
||||||
describe("onboardingProfileUpdate", () => {
|
describe("onboardingProfileUpdate", () => {
|
||||||
it("turns answers into profile fields and onboarding facts", () => {
|
it("fills the time zone, locale and chosen language from the browser", () => {
|
||||||
expect(
|
expect(
|
||||||
onboardingProfileUpdate({
|
onboardingProfileUpdate({
|
||||||
answers: {
|
browserTimeZone: "Europe/Vienna",
|
||||||
name: " Alice ",
|
|
||||||
timezone: "Europe/Vienna",
|
|
||||||
hobbies: ["music", "tech", "none"],
|
|
||||||
workStyle: "deep-dive",
|
|
||||||
communication: "direct",
|
|
||||||
},
|
|
||||||
accountName: "Alice Example",
|
|
||||||
browserTimeZone: "UTC",
|
|
||||||
locale: "de-AT",
|
locale: "de-AT",
|
||||||
language: "de",
|
language: "de",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({ timeZone: "Europe/Vienna", locale: "de-AT", language: "de" });
|
||||||
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" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
expect(
|
||||||
onboardingProfileUpdate({
|
onboardingProfileUpdate({
|
||||||
answers: { ...blank, name: "Alice", timezone: "Not/AZone" },
|
browserTimeZone: "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",
|
|
||||||
locale: "not a tag",
|
locale: "not a tag",
|
||||||
language: null,
|
language: null,
|
||||||
}),
|
}),
|
||||||
).toEqual({ timeZone: "UTC" });
|
).toEqual({});
|
||||||
|
expect(onboardingProfileUpdate({ browserTimeZone: "UTC" })).toEqual({ timeZone: "UTC" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,73 +1,19 @@
|
|||||||
import type { UserFactCategory } from "@rakazo/contracts";
|
|
||||||
import { isLanguageTag, isTimeZone } from "@rakazo/contracts";
|
import { isLanguageTag, isTimeZone } from "@rakazo/contracts";
|
||||||
|
|
||||||
export interface OnboardingAnswers {
|
/**
|
||||||
name: string;
|
* The profile fields onboarding fills in without asking: the browser's time zone and locale,
|
||||||
timezone: string;
|
* and the language the user picked for their Oos. The name comes from the account.
|
||||||
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. */
|
|
||||||
export function onboardingProfileUpdate(input: {
|
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;
|
browserTimeZone: string;
|
||||||
/** Browser locale, e.g. "de-AT". */
|
/** Browser locale, e.g. "de-AT". */
|
||||||
locale?: string;
|
locale?: string;
|
||||||
/** Language the user picked for their Oos, when they picked one. */
|
/** Language the user picked for their Oos, when they picked one. */
|
||||||
language?: string | null;
|
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 {
|
return {
|
||||||
...(name && name !== input.accountName?.trim() ? { name } : {}),
|
...(isTimeZone(input.browserTimeZone) ? { timeZone: input.browserTimeZone } : {}),
|
||||||
timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone,
|
|
||||||
...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}),
|
...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}),
|
||||||
...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}),
|
...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}),
|
||||||
...(communicationStyle ? { communicationStyle } : {}),
|
|
||||||
...(facts.length > 0
|
|
||||||
? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
|
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
|
||||||
import { Button, Input } from "@rakazo/ui-web";
|
import { Button } from "@rakazo/ui-web";
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
||||||
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
|
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
|
||||||
@ -24,7 +24,6 @@ import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-loo
|
|||||||
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
||||||
import { rpc, selectSpace } from "../lib/rpc";
|
import { rpc, selectSpace } from "../lib/rpc";
|
||||||
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
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";
|
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
|
||||||
|
|
||||||
/** Selects the main conversation's space and returns its route. */
|
/** 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() {
|
export function OnboardingPage() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const fieldId = useId();
|
|
||||||
const [step, setStep] = useState<
|
const [step, setStep] = useState<
|
||||||
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot"
|
"loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot"
|
||||||
>("emoji");
|
>("emoji");
|
||||||
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
||||||
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
||||||
@ -114,10 +67,8 @@ export function OnboardingPage() {
|
|||||||
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
||||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||||
const [crewReady, setCrewReady] = useState(false);
|
const [crewReady, setCrewReady] = useState(false);
|
||||||
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
|
||||||
|
|
||||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
|
||||||
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem("rakazo.uiLocale") ?? "en";
|
return localStorage.getItem("rakazo.uiLocale") ?? "en";
|
||||||
@ -139,7 +90,6 @@ export function OnboardingPage() {
|
|||||||
.then(async ([me, integrations]) => {
|
.then(async ([me, integrations]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBootstrapMe(me);
|
setBootstrapMe(me);
|
||||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
|
||||||
setIntegrationSetup(integrations);
|
setIntegrationSetup(integrations);
|
||||||
// The first step renders before this resolves; never pull a user back who moved on.
|
// The first step renders before this resolves; never pull a user back who moved on.
|
||||||
if (!me.needsModel && integrations?.needsSetup) {
|
if (!me.needsModel && integrations?.needsSetup) {
|
||||||
@ -169,17 +119,10 @@ export function OnboardingPage() {
|
|||||||
};
|
};
|
||||||
}, [navigate]);
|
}, [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 =
|
const discoveryPhase: OnboardingDiscoveryPhase =
|
||||||
step === "loading"
|
step === "loading"
|
||||||
? "loading"
|
? "loading"
|
||||||
: step === "emoji" || step === "preferences" || step === "language" || step === "voice"
|
: step === "emoji" || step === "language" || step === "voice"
|
||||||
? "emoji"
|
? "emoji"
|
||||||
: step === "integrations"
|
: step === "integrations"
|
||||||
? "integrations"
|
? "integrations"
|
||||||
@ -220,8 +163,6 @@ export function OnboardingPage() {
|
|||||||
await rpc.profile
|
await rpc.profile
|
||||||
.update(
|
.update(
|
||||||
onboardingProfileUpdate({
|
onboardingProfileUpdate({
|
||||||
answers: prefs,
|
|
||||||
accountName: bootstrapMe?.name,
|
|
||||||
browserTimeZone: localTimezone(),
|
browserTimeZone: localTimezone(),
|
||||||
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
|
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
|
||||||
language: languageChoice,
|
language: languageChoice,
|
||||||
@ -243,22 +184,15 @@ export function OnboardingPage() {
|
|||||||
|
|
||||||
const me = await rpc.me().catch(() => null);
|
const me = await rpc.me().catch(() => null);
|
||||||
if (me) setBootstrapMe(me);
|
if (me) setBootstrapMe(me);
|
||||||
const harvested = await persistUserProfileHarvest(
|
await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch(
|
||||||
rpc,
|
() => null,
|
||||||
collectLocalProfileSignals(me ?? undefined),
|
);
|
||||||
).catch(() => null);
|
|
||||||
if (harvested) setHelpVibe(harvested);
|
|
||||||
setProfilePersisted(true);
|
setProfilePersisted(true);
|
||||||
for (const serverId of integrationServers) {
|
for (const serverId of integrationServers) {
|
||||||
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
|
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
|
||||||
}
|
}
|
||||||
const started = await rpc.onboarding
|
// The main bot asks the rest in the main conversation.
|
||||||
.start({ botId: bot.id })
|
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
if (started) {
|
|
||||||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
|
||||||
}
|
|
||||||
const main = await rpc.spaces
|
const main = await rpc.spaces
|
||||||
.list()
|
.list()
|
||||||
.then((navigation) => navigation.main)
|
.then((navigation) => navigation.main)
|
||||||
@ -291,11 +225,7 @@ export function OnboardingPage() {
|
|||||||
<OnboardingDiscoveryShell
|
<OnboardingDiscoveryShell
|
||||||
items={discoveryItems}
|
items={discoveryItems}
|
||||||
compact={
|
compact={
|
||||||
step === "emoji" ||
|
step === "emoji" || step === "language" || step === "voice" || step === "integrations"
|
||||||
step === "preferences" ||
|
|
||||||
step === "language" ||
|
|
||||||
step === "voice" ||
|
|
||||||
step === "integrations"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||||
@ -304,140 +234,10 @@ export function OnboardingPage() {
|
|||||||
<OnboardingOoDesigner
|
<OnboardingOoDesigner
|
||||||
config={emojiConfig}
|
config={emojiConfig}
|
||||||
onChange={setEmojiConfig}
|
onChange={setEmojiConfig}
|
||||||
onContinue={() => setStep("preferences")}
|
onContinue={() => setStep("language")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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" ? (
|
{step === "language" ? (
|
||||||
<div className="mx-auto max-w-xl">
|
<div className="mx-auto max-w-xl">
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
@ -468,7 +268,7 @@ export function OnboardingPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<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>
|
<Trans>Back</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setStep("voice")}>
|
<Button onClick={() => setStep("voice")}>
|
||||||
|
|||||||
@ -1,15 +1,34 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { MessageBlock } from "@rakazo/contracts";
|
import type { MessageBlock } from "@rakazo/contracts";
|
||||||
|
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||||
import { abortableDelay } from "@rakazo/core";
|
import { abortableDelay } from "@rakazo/core";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
import {
|
||||||
import { X } from "lucide-react";
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
|
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
|
||||||
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
|
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
|
||||||
import { chartViewport } from "../../lib/chart-viewport";
|
import { chartViewport } from "../../lib/chart-viewport";
|
||||||
import { connectMcpOauth } from "../../lib/mcp-connect";
|
import { connectMcpOauth } from "../../lib/mcp-connect";
|
||||||
import { rpc } from "../../lib/rpc";
|
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({
|
export function ChoiceCard({
|
||||||
botId,
|
botId,
|
||||||
block,
|
block,
|
||||||
@ -22,14 +41,31 @@ export function ChoiceCard({
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
||||||
const dismissed = locallyDismissed || block.answerId === "_dismissed";
|
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);
|
setPending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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();
|
await onBotChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t`Could not save this choice`);
|
setError(err instanceof Error ? err.message : t`Could not save this choice`);
|
||||||
@ -52,10 +88,18 @@ export function ChoiceCard({
|
|||||||
|
|
||||||
if (dismissed) return null;
|
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 (
|
return (
|
||||||
<div className="flex justify-start">
|
<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">
|
<BuiCard
|
||||||
{!block.answerId ? (
|
data-testid="choice-card"
|
||||||
|
data-step={block.step}
|
||||||
|
className="relative w-[440px] max-w-full px-5 py-4"
|
||||||
|
>
|
||||||
|
{!answered && waysOut.length === 0 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@ -73,49 +117,111 @@ export function ChoiceCard({
|
|||||||
{block.subtitle ? (
|
{block.subtitle ? (
|
||||||
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
|
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-3.5 space-y-2.5">
|
{answered ? (
|
||||||
{block.options
|
chosen ? (
|
||||||
.filter((option) => !block.answerId || option.id === block.answerId)
|
<div
|
||||||
.map((option) => (
|
data-testid="choice-answer"
|
||||||
<button
|
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"
|
||||||
key={option.id}
|
>
|
||||||
type="button"
|
{chosen.letter ? <ChoiceLetter letter={chosen.letter} selected /> : null}
|
||||||
data-testid={`focus-choice-${option.id}`}
|
<span className="flex-1 leading-snug" dir="auto">
|
||||||
aria-label={option.label}
|
{chosen.label}
|
||||||
disabled={Boolean(block.answerId) || pending}
|
</span>
|
||||||
onClick={() => void choose(option.id)}
|
<Check aria-hidden strokeWidth={2.2} className="mt-0.5 size-icon-md text-primary" />
|
||||||
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 ${
|
</div>
|
||||||
block.answerId === option.id
|
) : null
|
||||||
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
|
) : (
|
||||||
: "bg-white/[0.04] hover:bg-white/[0.08] hover:border-white/10"
|
<>
|
||||||
}`}
|
{answers.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{answers.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
data-testid={testId(option)}
|
||||||
|
aria-label={option.label}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void choose(option.id)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<InputGroup className="h-control-lg rounded-xl bg-background">
|
||||||
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
|
<InputGroupInput
|
||||||
block.answerId === option.id
|
value={text}
|
||||||
? "bg-blue-500 text-white"
|
onChange={(event) => setText(event.target.value)}
|
||||||
: "bg-white/10 text-foreground/80"
|
placeholder={block.textInput.placeholder}
|
||||||
}`}
|
aria-label={block.question}
|
||||||
>
|
maxLength={200}
|
||||||
{option.letter}
|
disabled={pending}
|
||||||
</span>
|
dir="auto"
|
||||||
<span
|
/>
|
||||||
className={`flex-1 text-ui-body leading-snug ${block.answerId ? "text-foreground font-medium" : "text-foreground"}`}
|
<InputGroupAddon align="inline-end">
|
||||||
>
|
<InputGroupButton
|
||||||
{option.label}
|
type="submit"
|
||||||
</span>
|
size="icon-sm"
|
||||||
{block.answerId === option.id ? (
|
variant="default"
|
||||||
<span className="mt-0.5 text-blue-400 font-bold">✓</span>
|
aria-label={t`Send`}
|
||||||
) : null}
|
disabled={pending || !text.trim()}
|
||||||
</button>
|
className="rounded-lg"
|
||||||
))}
|
>
|
||||||
</div>
|
<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}
|
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppConnectCard({
|
export function AppConnectCard({
|
||||||
botId,
|
botId,
|
||||||
block,
|
block,
|
||||||
|
|||||||
@ -24,6 +24,7 @@ interface FixtureOptions {
|
|||||||
goals?: Array<Record<string, unknown>>;
|
goals?: Array<Record<string, unknown>>;
|
||||||
habits?: Array<Record<string, unknown>>;
|
habits?: Array<Record<string, unknown>>;
|
||||||
doneCheckIns?: Array<{ habitId: string; date: Date }>;
|
doneCheckIns?: Array<{ habitId: string; date: Date }>;
|
||||||
|
onboarding?: { completedAt: Date | null } | null;
|
||||||
peers?: Array<{
|
peers?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -116,7 +117,12 @@ async function runOnce(options: FixtureOptions = {}) {
|
|||||||
agentSecret: { findMany: vi.fn(async () => []) },
|
agentSecret: { findMany: vi.fn(async () => []) },
|
||||||
agentSkill: { findMany: vi.fn(async () => []) },
|
agentSkill: { findMany: vi.fn(async () => []) },
|
||||||
scratchpadItem: { 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: {
|
userProfile: {
|
||||||
findUnique: vi.fn(async () =>
|
findUnique: vi.fn(async () =>
|
||||||
options.profile === undefined
|
options.profile === undefined
|
||||||
@ -400,6 +406,19 @@ describe("run instructions", () => {
|
|||||||
expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal");
|
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 () => {
|
it("shows the main bot every specialist it can delegate to, its own space first", async () => {
|
||||||
const { request, peersFindMany } = await runOnce({
|
const { request, peersFindMany } = await runOnce({
|
||||||
bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" },
|
bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" },
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const user: UserContext = {
|
|||||||
{ category: "work", value: "Owns a bakery", confidence: 1 },
|
{ category: "work", value: "Owns a bakery", confidence: 1 },
|
||||||
{ category: "preferences", value: "Prefers morning meetings", confidence: 0.8 },
|
{ category: "preferences", value: "Prefers morning meetings", confidence: 0.8 },
|
||||||
],
|
],
|
||||||
|
onboardingOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("formatBotIdentity", () => {
|
describe("formatBotIdentity", () => {
|
||||||
@ -96,6 +97,7 @@ describe("formatUserContext", () => {
|
|||||||
communicationStyle: "",
|
communicationStyle: "",
|
||||||
summary: " ",
|
summary: " ",
|
||||||
facts: [{ category: "work", value: " ", confidence: 1 }],
|
facts: [{ category: "work", value: " ", confidence: 1 }],
|
||||||
|
onboardingOpen: false,
|
||||||
});
|
});
|
||||||
expect(block).toContain("<user>\nName: Ada\n</user>");
|
expect(block).toContain("<user>\nName: Ada\n</user>");
|
||||||
expect(block).not.toContain("Time zone");
|
expect(block).not.toContain("Time zone");
|
||||||
@ -112,6 +114,7 @@ describe("formatUserContext", () => {
|
|||||||
communicationStyle: "",
|
communicationStyle: "",
|
||||||
summary: "",
|
summary: "",
|
||||||
facts: [],
|
facts: [],
|
||||||
|
onboardingOpen: false,
|
||||||
}),
|
}),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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. */
|
/** One line of escaped user text, clipped to `max` characters. */
|
||||||
function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string {
|
function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string {
|
||||||
const text = (value ?? "").replace(/\s+/g, " ").trim();
|
const text = (value ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
|||||||
@ -250,6 +250,7 @@ import {
|
|||||||
formatMainAgentRole,
|
formatMainAgentRole,
|
||||||
formatSubagentContext,
|
formatSubagentContext,
|
||||||
formatUserContext,
|
formatUserContext,
|
||||||
|
ONBOARDING_OPEN_INSTRUCTION,
|
||||||
} from "./executor-prompt.js";
|
} from "./executor-prompt.js";
|
||||||
import {
|
import {
|
||||||
isProtectedComputerLifecycleCommandReadingScripts,
|
isProtectedComputerLifecycleCommandReadingScripts,
|
||||||
@ -3743,6 +3744,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
bot.kind === "main" ? formatMainAgentRole(exposedToolNames) : undefined,
|
bot.kind === "main" ? formatMainAgentRole(exposedToolNames) : undefined,
|
||||||
timeInstruction,
|
timeInstruction,
|
||||||
userBlock,
|
userBlock,
|
||||||
|
bot.kind === "main" &&
|
||||||
|
userContext.onboardingOpen &&
|
||||||
|
!(messagingChannelRun || foreignAgentRun)
|
||||||
|
? ONBOARDING_OPEN_INSTRUCTION
|
||||||
|
: undefined,
|
||||||
groupContext?.instructions,
|
groupContext?.instructions,
|
||||||
inboundHandoffInstruction,
|
inboundHandoffInstruction,
|
||||||
delegatedTaskInstruction,
|
delegatedTaskInstruction,
|
||||||
|
|||||||
@ -100,6 +100,20 @@ export const DELEGATED_TASK_STATUSES = [
|
|||||||
export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES);
|
export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES);
|
||||||
export type DelegatedTaskStatus = z.infer<typeof DelegatedTaskStatusSchema>;
|
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. */
|
/** A question or approval that pauses its run until the user answers. */
|
||||||
export const AskBlockSchema = z.object({
|
export const AskBlockSchema = z.object({
|
||||||
kind: z.literal("ask"),
|
kind: z.literal("ask"),
|
||||||
@ -134,9 +148,16 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
kind: z.literal("choice"),
|
kind: z.literal("choice"),
|
||||||
question: z.string(),
|
question: z.string(),
|
||||||
subtitle: z.string().optional(),
|
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() })),
|
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. */
|
/** Set once the user picks an option; renders the picker as answered. */
|
||||||
answerId: z.string().optional(),
|
answerId: z.string().optional(),
|
||||||
|
/** The user's words when they answered through the text input. */
|
||||||
|
answerText: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
|
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
|
||||||
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
|
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
|
||||||
|
import { COACH_TITLE_MAX_LENGTH } from "./coach.js";
|
||||||
import {
|
import {
|
||||||
CheckInSchema,
|
CheckInSchema,
|
||||||
CoachRhythmSchema,
|
CoachRhythmSchema,
|
||||||
@ -90,7 +91,7 @@ import {
|
|||||||
VoiceInfoSchema,
|
VoiceInfoSchema,
|
||||||
VoiceStatusSchema,
|
VoiceStatusSchema,
|
||||||
} from "./domain.js";
|
} from "./domain.js";
|
||||||
import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js";
|
import { DelegatedTaskStatusSchema, OnboardingStepSchema, ProductEventSchema } from "./events.js";
|
||||||
import { Id, IsoDate } from "./ids.js";
|
import { Id, IsoDate } from "./ids.js";
|
||||||
import { InboxListOutputSchema } from "./inbox.js";
|
import { InboxListOutputSchema } from "./inbox.js";
|
||||||
import {
|
import {
|
||||||
@ -713,15 +714,31 @@ export const appContract = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
onboarding: {
|
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) })),
|
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) })),
|
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
|
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) })),
|
.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) })),
|
dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||||
/** Flip an app_connect card to connected after authorization completes. */
|
/** Flip an app_connect card to connected after authorization completes. */
|
||||||
appConnected: oc
|
appConnected: oc
|
||||||
|
|||||||
@ -109,3 +109,27 @@ describe("peer message history", () => {
|
|||||||
).toBe("[to Analyst] chart it");
|
).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?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -105,6 +105,12 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
|
|||||||
if (block.kind === "handoff") {
|
if (block.kind === "handoff") {
|
||||||
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
|
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;
|
if ("text" in block && typeof block.text === "string") return block.text;
|
||||||
return "";
|
return "";
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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'));
|
||||||
@ -46,6 +46,7 @@ model User {
|
|||||||
botSecrets BotSecret[]
|
botSecrets BotSecret[]
|
||||||
agentSecrets AgentSecret[]
|
agentSecrets AgentSecret[]
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
|
onboarding UserOnboarding?
|
||||||
facts UserFact[]
|
facts UserFact[]
|
||||||
goals UserGoal[]
|
goals UserGoal[]
|
||||||
habits Habit[]
|
habits Habit[]
|
||||||
@ -1477,6 +1478,21 @@ model UserProfile {
|
|||||||
@@map("user_profiles")
|
@@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.
|
/// One durable statement about the user, with its origin and consent state.
|
||||||
model UserFact {
|
model UserFact {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@ -42,6 +42,8 @@ export interface UserContext {
|
|||||||
communicationStyle: string;
|
communicationStyle: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
facts: Array<{ category: UserFactCategory; value: string; confidence: number }>;
|
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 {
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
@ -258,7 +260,10 @@ export async function loadUserContext(
|
|||||||
input: { userId: string; spaceId: string },
|
input: { userId: string; spaceId: string },
|
||||||
): Promise<UserContext> {
|
): Promise<UserContext> {
|
||||||
const [user, profile, facts] = await Promise.all([
|
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.userProfile.findUnique({ where: { userId: input.userId } }),
|
||||||
prisma.userFact.findMany({
|
prisma.userFact.findMany({
|
||||||
where: {
|
where: {
|
||||||
@ -283,5 +288,6 @@ export async function loadUserContext(
|
|||||||
value: fact.value,
|
value: fact.value,
|
||||||
confidence: fact.confidence,
|
confidence: fact.confidence,
|
||||||
})),
|
})),
|
||||||
|
onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,6 +108,7 @@ async function main() {
|
|||||||
"packages/testkit/src/bot-secrets.test.ts",
|
"packages/testkit/src/bot-secrets.test.ts",
|
||||||
"packages/testkit/src/delegation.postgres.test.ts",
|
"packages/testkit/src/delegation.postgres.test.ts",
|
||||||
"packages/testkit/src/user-profile.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/onboarding-discovery.postgres.test.ts",
|
||||||
"packages/testkit/src/notifications.postgres.test.ts",
|
"packages/testkit/src/notifications.postgres.test.ts",
|
||||||
"packages/db/src/space-membership.postgres.test.ts",
|
"packages/db/src/space-membership.postgres.test.ts",
|
||||||
|
|||||||
276
packages/testkit/src/onboarding-intake.postgres.test.ts
Normal file
276
packages/testkit/src/onboarding-intake.postgres.test.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user