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:
commit
31dcdf5293
@ -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