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:
parent
aea604f7ca
commit
a62e09f36b
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user