From a62e09f36b2e87f27692a61061e9d39deb649b2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 04:17:32 +0000 Subject: [PATCH] fix(web): word the main conversation preview in the user's language during onboarding While onboarding wrote the main conversation, its sidebar row, project rows and Heute showed the server preview: nothing for a pending card (a card has no text of its own, so the row fell back to "Ready") and the English fallback of the closing note, even for a German user. Groups now carry `previewBlock` next to `preview`: the latest message when it is an onboarding card or note, validated with the same block schemas the messages use (now named exports). The web words it with the helpers that render the card and the note, so no copy is duplicated: the question while a card waits, the answer once given, and the closing note in the user's language and clock. A dismissed card keeps the plain preview, as the conversation no longer shows it. The field is left out for every other message, and mobile keeps using the plain `preview`. The chosen-answer lookup moves from the card into onboarding-copy so the card and the preview share it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/router.ts | 1 + apps/web/e2e/onboarding-intake.spec.ts | 4 + apps/web/src/lib/onboarding-copy.test.ts | 87 ++++++++++++++++--- apps/web/src/lib/onboarding-copy.ts | 25 +++++- apps/web/src/pages/HeuteBrief.tsx | 3 +- apps/web/src/pages/Shell.tsx | 6 +- apps/web/src/pages/shell/message-cards.tsx | 6 +- packages/contracts/src/domain.ts | 5 +- packages/contracts/src/events.ts | 63 ++++++++------ packages/db/src/groups.test.ts | 71 +++++++++++++++ packages/db/src/groups.ts | 12 ++- packages/db/src/thread-listing.ts | 14 +++ .../src/onboarding-intake.postgres.test.ts | 29 ++++++- 13 files changed, 272 insertions(+), 54 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index a196f22..150df33 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -5276,6 +5276,7 @@ function spaceGroupDto(group: SpaceGroup): SpaceGroup { sectionId: group.sectionId, members: group.members, preview: group.preview, + ...(group.previewBlock ? { previewBlock: group.previewBlock } : {}), unread: group.unread, updatedAt: group.updatedAt, }; diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts index ae8967a..7e223cf 100644 --- a/apps/web/e2e/onboarding-intake.spec.ts +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -39,6 +39,9 @@ test("the main bot asks focus, goal, check-ins and quiet hours, then says what i const rhythm = card(page, "rhythm"); await expect(rhythm).toContainText("When should I check in with you?"); + // The sidebar previews the main conversation as the web words it: the pending question. + const mainEntry = page.locator("aside").first().locator("[data-main-conversation]"); + await expect(mainEntry).toContainText("When should I check in with you?"); await rhythm.scrollIntoViewIfNeeded(); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "onboarding-intake-rhythm"); @@ -60,6 +63,7 @@ test("the main bot asks focus, goal, check-ins and quiet hours, then says what i await notifications.getByTestId("notifications-choice-later").click(); } await expect(closing).toBeVisible(); + await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./); await expect( page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'), ).toHaveCount(0); diff --git a/apps/web/src/lib/onboarding-copy.test.ts b/apps/web/src/lib/onboarding-copy.test.ts index eb8e1c2..b408585 100644 --- a/apps/web/src/lib/onboarding-copy.test.ts +++ b/apps/web/src/lib/onboarding-copy.test.ts @@ -1,21 +1,22 @@ import type { MessageBlock, ThreadMessage } from "@rakazo/contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "./i18n.js"; -import { intakeNoteText, localizedChoice, withIntakeNoteText } from "./onboarding-copy.js"; +import { + intakeNoteText, + localizedChoice, + localizedPreview, + withIntakeNoteText, +} from "./onboarding-copy.js"; -// The macros compile at build time; here they keep the English source. +// The macros compile at build time; here the English source is the id, placeholders numbered. vi.mock("@lingui/core/macro", () => { - const text = (parts: TemplateStringsArray, ...values: unknown[]) => - parts.reduce( - (out, part, index) => out + part + (index < values.length ? values[index] : ""), - "", - ); + const descriptor = (parts: TemplateStringsArray, values: unknown[]) => { + const message = parts.reduce((out, part, index) => `${out}{${index - 1}}${part}`); + return { id: message, message, values: { ...values } }; + }; return { - t: text, - msg: (parts: TemplateStringsArray, ...values: unknown[]) => { - const message = text(parts, ...values); - return { id: message, message }; - }, + t: (parts: TemplateStringsArray, ...values: unknown[]) => i18n._(descriptor(parts, values)), + msg: (parts: TemplateStringsArray, ...values: unknown[]) => descriptor(parts, values), }; }); @@ -138,3 +139,65 @@ describe("withIntakeNoteText", () => { ]); }); }); + +describe("localizedPreview", () => { + const now = new Date(2026, 9, 7, 10, 0); + const rhythm: Choice = { + kind: "choice", + step: "rhythm", + question: "server question", + options: [ + { id: "morning", letter: "A", label: "server morning" }, + { id: "none", letter: "", label: "server none" }, + ], + }; + const goal: Choice = { + kind: "choice", + step: "goal", + question: "server goal", + textInput: { placeholder: "server placeholder" }, + options: [{ id: "skip", letter: "", label: "server skip" }], + }; + const done: Note = { + kind: "intake_note", + note: "done", + nextCheckInAt: new Date(2026, 9, 8, 8, 0).toISOString(), + text: "server text", + }; + const preview = (previewBlock?: Choice | Note) => localizedPreview({ preview: "", previewBlock }); + + beforeEach(() => { + vi.useFakeTimers({ now }); + return () => vi.useRealTimers(); + }); + + it("keeps the server preview when onboarding did not write the latest message", () => { + expect(localizedPreview({ preview: "See you at 8" })).toBe("See you at 8"); + }); + + it("reads like the conversation: the question, the answer once given, the closing note", () => { + expect(preview(rhythm)).toBe("When should I check in with you?"); + expect(preview({ ...rhythm, answerId: "morning" })).toBe("Every morning"); + expect(preview({ ...goal, answerId: "_text", answerText: "Run a 10K" })).toBe("Run a 10K"); + expect(plain(preview(done))).toBe("All set. I’ll check in tomorrow at 8:00 AM."); + // A dismissed card is gone from the conversation, so the preview is too. + expect(preview({ ...rhythm, answerId: "_dismissed" })).toBe(""); + }); + + it("speaks the user's language", () => { + i18n.load("de", { + "When should I check in with you?": "Wann soll ich mich bei dir melden?", + "Every morning": "Jeden Morgen", + "All set. I’ll check in tomorrow at {0}.": "Alles klar. Ich melde mich morgen um {0}.", + "All set. I’m here whenever you need me.": + "Alles klar. Ich bin da, wann immer du mich brauchst.", + }); + i18n.activate("de"); + expect(preview(rhythm)).toBe("Wann soll ich mich bei dir melden?"); + expect(preview({ ...rhythm, answerId: "morning" })).toBe("Jeden Morgen"); + expect(preview(done)).toBe("Alles klar. Ich melde mich morgen um 8:00."); + expect(preview({ ...done, nextCheckInAt: null })).toBe( + "Alles klar. Ich bin da, wann immer du mich brauchst.", + ); + }); +}); diff --git a/apps/web/src/lib/onboarding-copy.ts b/apps/web/src/lib/onboarding-copy.ts index 1eb077d..839110f 100644 --- a/apps/web/src/lib/onboarding-copy.ts +++ b/apps/web/src/lib/onboarding-copy.ts @@ -1,6 +1,7 @@ import type { MessageDescriptor } from "@lingui/core"; import { msg, t } from "@lingui/core/macro"; -import type { MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts"; +import type { Group, MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { i18n } from "./i18n"; /** @@ -10,6 +11,7 @@ import { i18n } from "./i18n"; */ type ChoiceBlock = Extract; +type ChoiceOption = ChoiceBlock["options"][number]; type IntakeNote = Extract; const QUESTIONS: Record = { @@ -73,6 +75,13 @@ export function localizedChoice(card: ChoiceBlock): ChoiceBlock { }; } +/** What the user answered: the option they picked or their own words. */ +export function chosenOption(card: ChoiceBlock): ChoiceOption | undefined { + return card.answerText + ? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: card.answerText } + : card.options.find((option) => option.id === card.answerId); +} + function sameDay(left: Date, right: Date): boolean { return left.toDateString() === right.toDateString(); } @@ -105,3 +114,17 @@ export function withIntakeNoteText(message: ThreadMessage): ThreadMessage { ), }; } + +/** + * A conversation's preview in the user's language. An onboarding card reads as its question, or + * as the answer once given; the closing note as the note. A dismissed card shows nothing in the + * conversation, so it keeps the server preview. + */ +export function localizedPreview(chat: Pick): string { + const block = chat.previewBlock; + if (!block) return chat.preview; + if (block.kind === "intake_note") return intakeNoteText(block); + const card = localizedChoice(block); + if (!card.answerId) return card.question; + return chosenOption(card)?.label ?? chat.preview; +} diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index c23ed72..4bef9db 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -15,6 +15,7 @@ import { formatLocalDate, withHabitDone, } from "../lib/heute-brief"; +import { localizedPreview } from "../lib/onboarding-copy"; import { rpc } from "../lib/rpc"; type HeuteBriefProps = { @@ -336,7 +337,7 @@ function NeedsYouRow({ const { chatKind, chat } = item; const title = chat.name; - const preview = cleanBriefPreview(chat.preview) || t`Unread reply`; + const preview = cleanBriefPreview(localizedPreview(chat)) || t`Unread reply`; const label = chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`; return ( diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 6c64240..a07547f 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -216,7 +216,7 @@ import { lazyWithRecovery } from "../lib/lazy-recovery"; import { localTimezone } from "../lib/local-timezone"; import { copyableMessageText } from "../lib/message-text"; import { messageProviderLabel } from "../lib/messaging"; -import { withIntakeNoteText } from "../lib/onboarding-copy"; +import { localizedPreview, withIntakeNoteText } from "../lib/onboarding-copy"; import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire"; import { ooSound } from "../lib/oo-sound"; import { ooVocals } from "../lib/oo-vocals"; @@ -3655,7 +3655,7 @@ export function ShellPage() { time={formatRosterTime(mainConversationEntry.group.updatedAt)} subtitle={rosterActivitySubtitle( mainConversationEntry.bot.status, - cleanRosterPreview(mainConversationEntry.group.preview), + cleanRosterPreview(localizedPreview(mainConversationEntry.group)), )} active={inGroup && groupId === mainConversationEntry.groupId} onOpen={openMainConversation} @@ -4169,7 +4169,7 @@ export function ShellPage() { item.chat.unread, )}`} > - {cleanRosterPreview(item.chat.preview)} + {cleanRosterPreview(localizedPreview(item.chat))} diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index 8cbda50..a6e7bca 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -19,7 +19,7 @@ import { BuiCard, SuccessPop } from "../../components/ai/primitives"; import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open"; import { chartViewport } from "../../lib/chart-viewport"; import { connectMcpOauth } from "../../lib/mcp-connect"; -import { localizedChoice } from "../../lib/onboarding-copy"; +import { chosenOption, localizedChoice } from "../../lib/onboarding-copy"; import { rpc } from "../../lib/rpc"; import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push"; @@ -91,9 +91,7 @@ export function ChoiceCard({ if (dismissed) return null; - const chosen = block.answerText - ? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText } - : card.options.find((option) => option.id === block.answerId); + const chosen = chosenOption(card); return (
diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 5caa284..d7c2fc3 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -1,6 +1,6 @@ import * as z from "zod"; import { CoachRoutineKeySchema } from "./coach.js"; -import { DelegatedTaskStatusSchema, ThreadMessageSchema } from "./events.js"; +import { DelegatedTaskStatusSchema, IntakeBlockSchema, ThreadMessageSchema } from "./events.js"; import { Id, MemoryScope, RunStatus, SandboxKind } from "./ids.js"; import { McpHeadersSchema, McpRemoteEndpointSchema, McpTransportSchema } from "./mcp.js"; @@ -130,6 +130,8 @@ export const GroupSchema = z.object({ members: z.array(GroupMemberSchema), threadId: Id, preview: z.string(), + /** The latest message when it is an onboarding card or note; the web words it from this. */ + previewBlock: IntakeBlockSchema.optional(), unread: z.boolean(), updatedAt: z.string(), createdAt: z.string(), @@ -202,6 +204,7 @@ export const SpaceGroupSchema = GroupSchema.pick({ sectionId: true, members: true, preview: true, + previewBlock: true, unread: true, updatedAt: true, }); diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index ad7918b..5db6c34 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -137,6 +137,41 @@ export const AskBlockSchema = z.object({ .optional(), }); +export const ChoiceBlockSchema = z.object({ + kind: z.literal("choice"), + question: z.string(), + subtitle: z.string().optional(), + /** An option without a letter is a secondary answer such as "Skip". */ + options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })), + /** The onboarding step this card asks; absent on a specialist's focus card. */ + step: OnboardingStepSchema.optional(), + /** Lets the user answer in their own words. */ + textInput: z.object({ placeholder: z.string() }).optional(), + /** Set once the user picks an option; renders the picker as answered. */ + answerId: z.string().optional(), + /** The user's words when they answered through the text input. */ + answerText: z.string().optional(), +}); + +/** + * A line onboarding writes. Clients that know `note` say it in the user's language and + * write the time where they are; everything else (mobile, agent history) uses `text`. + */ +export const IntakeNoteBlockSchema = z.object({ + kind: z.literal("intake_note"), + /** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */ + note: z.enum(["done"]), + nextCheckInAt: z.string().nullable(), + text: z.string(), +}); + +/** What onboarding writes into a conversation: its cards and its closing note. */ +export const IntakeBlockSchema = z.discriminatedUnion("kind", [ + ChoiceBlockSchema, + IntakeNoteBlockSchema, +]); +export type IntakeBlock = z.infer; + export const MessageBlock = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("text"), text: z.string() }), z.object({ @@ -144,32 +179,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [ lines: z.array(z.object({ k: z.string(), v: z.string() })), }), AskBlockSchema, - z.object({ - kind: z.literal("choice"), - question: z.string(), - subtitle: z.string().optional(), - /** An option without a letter is a secondary answer such as "Skip". */ - options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })), - /** The onboarding step this card asks; absent on a specialist's focus card. */ - step: OnboardingStepSchema.optional(), - /** Lets the user answer in their own words. */ - textInput: z.object({ placeholder: z.string() }).optional(), - /** Set once the user picks an option; renders the picker as answered. */ - answerId: z.string().optional(), - /** The user's words when they answered through the text input. */ - answerText: z.string().optional(), - }), - z.object({ - /** - * A line onboarding writes. Clients that know `note` say it in the user's language and - * write the time where they are; everything else (mobile, agent history) uses `text`. - */ - kind: z.literal("intake_note"), - /** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */ - note: z.enum(["done"]), - nextCheckInAt: z.string().nullable(), - text: z.string(), - }), + ChoiceBlockSchema, + IntakeNoteBlockSchema, z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line description, and an Authorize button that flips to connected. */ diff --git a/packages/db/src/groups.test.ts b/packages/db/src/groups.test.ts index d8c42e2..7b1ff8c 100644 --- a/packages/db/src/groups.test.ts +++ b/packages/db/src/groups.test.ts @@ -63,6 +63,77 @@ describe("listSpaceGroupsForSpaces", () => { }); }); +describe("group previews", () => { + const actor = { + spaceId: "workspace-1", + userId: "user-1", + email: "user@example.test", + isDeploymentOwner: false, + }; + const card = { + kind: "choice", + step: "rhythm", + question: "When should I check in with you?", + options: [{ id: "morning", letter: "A", label: "Every morning" }], + }; + const note = { + kind: "intake_note", + note: "done", + nextCheckInAt: "2026-10-05T06:00:00.000Z", + text: "All set. I’ll check in tomorrow at 8:00 AM.", + }; + const latest: Record = { + card: [card], + note: [note], + text: [{ kind: "text", text: "Hello" }], + malformed: [{ kind: "choice", question: 1 }], + empty: undefined, + }; + const records = Object.entries(latest).map(([id, blocks]) => ({ + id, + spaceId: actor.spaceId, + userId: actor.userId, + kind: "main", + name: "Chief", + leadBotId: "bot-1", + pinned: false, + sectionId: null, + archivedAt: null, + createdAt: new Date("2026-10-04T00:00:00.000Z"), + updatedAt: new Date("2026-10-04T00:00:00.000Z"), + thread: { id: `thread-${id}`, unread: false, messages: blocks ? [{ blocks }] : [] }, + members: [{ bot: { id: "bot-1", name: "Chief", color: "#111", runs: [] } }], + })); + + it.each(["listGroups", "listSpaceGroupsForSpaces"] as const)( + "%s keeps the latest onboarding card or note next to the preview", + async (list) => { + const repos = createGroupRepos({ + chatGroup: { findMany: vi.fn(async () => records) }, + } as unknown as PrismaClient); + const groups = + list === "listGroups" + ? await repos.listGroups(actor) + : await repos.listSpaceGroupsForSpaces(actor, [actor.spaceId]); + + expect( + groups.map(({ id, preview, previewBlock }) => ({ id, preview, previewBlock })), + ).toEqual([ + { id: "card", preview: "", previewBlock: card }, + { id: "note", preview: note.text, previewBlock: note }, + { id: "text", preview: "Hello", previewBlock: undefined }, + { id: "malformed", preview: "", previewBlock: undefined }, + { id: "empty", preview: "", previewBlock: undefined }, + ]); + // Everything else leaves the field out, so the wire carries nothing extra. + expect(groups.filter((group) => "previewBlock" in group).map(({ id }) => id)).toEqual([ + "card", + "note", + ]); + }, + ); +}); + describe("archiveGroup", () => { const actor = { spaceId: "workspace-1", diff --git a/packages/db/src/groups.ts b/packages/db/src/groups.ts index 36f0332..9f99e33 100644 --- a/packages/db/src/groups.ts +++ b/packages/db/src/groups.ts @@ -15,7 +15,12 @@ import type { Prisma, PrismaClient } from "./client.js"; import { expireComputerExecutionLeases } from "./computers.js"; import { IsolationError } from "./scope.js"; import { lockSpaceForContentCreation } from "./spaces.js"; -import { activeRunSelection, activeRunStatuses, previewFromBlocks } from "./thread-listing.js"; +import { + activeRunSelection, + activeRunStatuses, + groupPreview, + previewFromBlocks, +} from "./thread-listing.js"; type GroupRecord = { id: string; @@ -65,7 +70,6 @@ function mapGroupMembers(members: GroupRecord["members"]): GroupMember[] { function mapGroup(group: GroupRecord): Group { if (!group.thread) throw new IsolationError("Group is missing its thread"); - const preview = previewFromBlocks(group.thread.messages[0]?.blocks); return { id: group.id, spaceId: group.spaceId, @@ -77,7 +81,7 @@ function mapGroup(group: GroupRecord): Group { archivedAt: group.archivedAt?.toISOString() ?? null, members: mapGroupMembers(group.members), threadId: group.thread.id, - preview, + ...groupPreview(group.thread.messages[0]?.blocks), unread: group.thread.unread, updatedAt: group.updatedAt.toISOString(), createdAt: group.createdAt.toISOString(), @@ -112,7 +116,7 @@ function mapSpaceGroup(group: SpaceGroupRecord): SpaceGroup { pinned: group.pinned, sectionId: group.sectionId, members: mapGroupMembers(group.members), - preview: previewFromBlocks(group.thread.messages[0]?.blocks), + ...groupPreview(group.thread.messages[0]?.blocks), unread: group.thread.unread, updatedAt: group.updatedAt.toISOString(), }; diff --git a/packages/db/src/thread-listing.ts b/packages/db/src/thread-listing.ts index 853eb6d..726456a 100644 --- a/packages/db/src/thread-listing.ts +++ b/packages/db/src/thread-listing.ts @@ -1,3 +1,5 @@ +import type { Group } from "@rakazo/contracts"; +import { IntakeBlockSchema } from "@rakazo/contracts"; import { ACTIVE_RUN_STATUSES } from "@rakazo/core"; export const activeRunStatuses = [...ACTIVE_RUN_STATUSES]; @@ -23,3 +25,15 @@ export function previewFromBlocks(blocks: unknown): string { } return ""; } + +/** + * A group's preview of its latest message. An onboarding card or note comes along as + * `previewBlock`, so the web can word the preview in the user's language. + */ +export function groupPreview(blocks: unknown): Pick { + const intake = IntakeBlockSchema.safeParse(Array.isArray(blocks) ? blocks[0] : undefined); + return { + preview: previewFromBlocks(blocks), + ...(intake.success ? { previewBlock: intake.data } : {}), + }; +} diff --git a/packages/testkit/src/onboarding-intake.postgres.test.ts b/packages/testkit/src/onboarding-intake.postgres.test.ts index b65f6d2..6825fc2 100644 --- a/packages/testkit/src/onboarding-intake.postgres.test.ts +++ b/packages/testkit/src/onboarding-intake.postgres.test.ts @@ -2,7 +2,7 @@ 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 type { MessageBlock, SpaceNavigation } 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"; @@ -59,7 +59,26 @@ describeWithDatabase("onboarding questions in the main conversation", () => { where: { groupId: conversation.groupId }, select: { id: true }, }); - return { cookie, userId: me.userId, spaceId: me.spaceId, botId: main.id, threadId }; + return { + cookie, + userId: me.userId, + spaceId: me.spaceId, + botId: main.id, + groupId: conversation.groupId, + threadId, + }; + } + + /** The main conversation's preview as the sidebar lists it, in its space and across spaces. */ + async function listedPreview(user: { cookie: string; groupId: string }) { + const { current, spaces } = await rpc(app, user.cookie, "spaces/list"); + const previews = [current.groups, ...spaces.map((space) => space.groups)] + .flat() + .filter((group) => group.id === user.groupId) + .map(({ preview, previewBlock }) => ({ preview, previewBlock })); + expect(previews).toHaveLength(2); + expect(previews[1]).toEqual(previews[0]); + return previews[0]; } async function messages(threadId: string) { @@ -91,6 +110,11 @@ describeWithDatabase("onboarding questions in the main conversation", () => { 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 }); + // The card has no text of its own; the web words the preview from the card. + expect(await listedPreview(ada)).toEqual({ + preview: "", + previewBlock: (await pending(ada.threadId))[0], + }); expect( (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) .onboardingOpen, @@ -161,6 +185,7 @@ describeWithDatabase("onboarding questions in the main conversation", () => { expect(closing.text.replace(/\s/g, " ")).toMatch( /^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/, ); + expect(await listedPreview(ada)).toEqual({ preview: closing.text, previewBlock: closing }); const firstCheckIn = new Date(closing.nextCheckInAt ?? ""); expect(firstCheckIn.getTime()).toBeGreaterThan(Date.now()); expect(