Merge f/main-preview: localized main conversation preview

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:18:08 +00:00
commit 31dcdf5293
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,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<typeof IntakeBlockSchema>;
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. */

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(