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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-04 04:17:32 +00:00
parent aea604f7ca
commit a62e09f36b
No known key found for this signature in database
13 changed files with 272 additions and 54 deletions

View File

@ -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,
};

View File

@ -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);

View File

@ -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.",
);
});
});

View File

@ -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<MessageBlock, { kind: "choice" }>;
type ChoiceOption = ChoiceBlock["options"][number];
type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
const QUESTIONS: Record<OnboardingStep, MessageDescriptor> = {
@ -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<Group, "preview" | "previewBlock">): 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;
}

View File

@ -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 (

View File

@ -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))}
</div>
</div>
</button>

View File

@ -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 (
<div className="flex justify-start">

View File

@ -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,
});

View File

@ -137,14 +137,7 @@ export const AskBlockSchema = z.object({
.optional(),
});
export const MessageBlock = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), text: z.string() }),
z.object({
kind: z.literal("card"),
lines: z.array(z.object({ k: z.string(), v: z.string() })),
}),
AskBlockSchema,
z.object({
export const ChoiceBlockSchema = z.object({
kind: z.literal("choice"),
question: z.string(),
subtitle: z.string().optional(),
@ -158,18 +151,36 @@ export const MessageBlock = z.discriminatedUnion("kind", [
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`.
*/
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<typeof IntakeBlockSchema>;
export const MessageBlock = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), text: z.string() }),
z.object({
kind: z.literal("card"),
lines: z.array(z.object({ k: z.string(), v: z.string() })),
}),
AskBlockSchema,
ChoiceBlockSchema,
IntakeNoteBlockSchema,
z.object({
/** Inline app authorization card (Composio-backed): logo, name, one-line
description, and an Authorize button that flips to connected. */

View File

@ -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<string, unknown[] | undefined> = {
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",

View File

@ -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(),
};

View File

@ -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<Group, "preview" | "previewBlock"> {
const intake = IntakeBlockSchema.safeParse(Array.isArray(blocks) ? blocks[0] : undefined);
return {
preview: previewFromBlocks(blocks),
...(intake.success ? { previewBlock: intake.data } : {}),
};
}

View File

@ -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<SpaceNavigation>(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(