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,
|
sectionId: group.sectionId,
|
||||||
members: group.members,
|
members: group.members,
|
||||||
preview: group.preview,
|
preview: group.preview,
|
||||||
|
...(group.previewBlock ? { previewBlock: group.previewBlock } : {}),
|
||||||
unread: group.unread,
|
unread: group.unread,
|
||||||
updatedAt: group.updatedAt,
|
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");
|
const rhythm = card(page, "rhythm");
|
||||||
await expect(rhythm).toContainText("When should I check in with you?");
|
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 rhythm.scrollIntoViewIfNeeded();
|
||||||
await page.mouse.move(1, 1);
|
await page.mouse.move(1, 1);
|
||||||
await captureScreenshot(page, testInfo, "onboarding-intake-rhythm");
|
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 notifications.getByTestId("notifications-choice-later").click();
|
||||||
}
|
}
|
||||||
await expect(closing).toBeVisible();
|
await expect(closing).toBeVisible();
|
||||||
|
await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./);
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'),
|
page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
|
|||||||
@ -1,21 +1,22 @@
|
|||||||
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
|
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { i18n } from "./i18n.js";
|
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", () => {
|
vi.mock("@lingui/core/macro", () => {
|
||||||
const text = (parts: TemplateStringsArray, ...values: unknown[]) =>
|
const descriptor = (parts: TemplateStringsArray, values: unknown[]) => {
|
||||||
parts.reduce(
|
const message = parts.reduce((out, part, index) => `${out}{${index - 1}}${part}`);
|
||||||
(out, part, index) => out + part + (index < values.length ? values[index] : ""),
|
return { id: message, message, values: { ...values } };
|
||||||
"",
|
};
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
t: text,
|
t: (parts: TemplateStringsArray, ...values: unknown[]) => i18n._(descriptor(parts, values)),
|
||||||
msg: (parts: TemplateStringsArray, ...values: unknown[]) => {
|
msg: (parts: TemplateStringsArray, ...values: unknown[]) => descriptor(parts, values),
|
||||||
const message = text(parts, ...values);
|
|
||||||
return { id: message, message };
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -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 type { MessageDescriptor } from "@lingui/core";
|
||||||
import { msg, t } from "@lingui/core/macro";
|
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";
|
import { i18n } from "./i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -10,6 +11,7 @@ import { i18n } from "./i18n";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
||||||
|
type ChoiceOption = ChoiceBlock["options"][number];
|
||||||
type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
|
type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
|
||||||
|
|
||||||
const QUESTIONS: Record<OnboardingStep, MessageDescriptor> = {
|
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 {
|
function sameDay(left: Date, right: Date): boolean {
|
||||||
return left.toDateString() === right.toDateString();
|
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,
|
formatLocalDate,
|
||||||
withHabitDone,
|
withHabitDone,
|
||||||
} from "../lib/heute-brief";
|
} from "../lib/heute-brief";
|
||||||
|
import { localizedPreview } from "../lib/onboarding-copy";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
type HeuteBriefProps = {
|
type HeuteBriefProps = {
|
||||||
@ -336,7 +337,7 @@ function NeedsYouRow({
|
|||||||
|
|
||||||
const { chatKind, chat } = item;
|
const { chatKind, chat } = item;
|
||||||
const title = chat.name;
|
const title = chat.name;
|
||||||
const preview = cleanBriefPreview(chat.preview) || t`Unread reply`;
|
const preview = cleanBriefPreview(localizedPreview(chat)) || t`Unread reply`;
|
||||||
const label =
|
const label =
|
||||||
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
|
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -216,7 +216,7 @@ import { lazyWithRecovery } from "../lib/lazy-recovery";
|
|||||||
import { localTimezone } from "../lib/local-timezone";
|
import { localTimezone } from "../lib/local-timezone";
|
||||||
import { copyableMessageText } from "../lib/message-text";
|
import { copyableMessageText } from "../lib/message-text";
|
||||||
import { messageProviderLabel } from "../lib/messaging";
|
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 { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
|
||||||
import { ooSound } from "../lib/oo-sound";
|
import { ooSound } from "../lib/oo-sound";
|
||||||
import { ooVocals } from "../lib/oo-vocals";
|
import { ooVocals } from "../lib/oo-vocals";
|
||||||
@ -3655,7 +3655,7 @@ export function ShellPage() {
|
|||||||
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
|
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
|
||||||
subtitle={rosterActivitySubtitle(
|
subtitle={rosterActivitySubtitle(
|
||||||
mainConversationEntry.bot.status,
|
mainConversationEntry.bot.status,
|
||||||
cleanRosterPreview(mainConversationEntry.group.preview),
|
cleanRosterPreview(localizedPreview(mainConversationEntry.group)),
|
||||||
)}
|
)}
|
||||||
active={inGroup && groupId === mainConversationEntry.groupId}
|
active={inGroup && groupId === mainConversationEntry.groupId}
|
||||||
onOpen={openMainConversation}
|
onOpen={openMainConversation}
|
||||||
@ -4169,7 +4169,7 @@ export function ShellPage() {
|
|||||||
item.chat.unread,
|
item.chat.unread,
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
{cleanRosterPreview(item.chat.preview)}
|
{cleanRosterPreview(localizedPreview(item.chat))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { BuiCard, SuccessPop } from "../../components/ai/primitives";
|
|||||||
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
|
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
|
||||||
import { chartViewport } from "../../lib/chart-viewport";
|
import { chartViewport } from "../../lib/chart-viewport";
|
||||||
import { connectMcpOauth } from "../../lib/mcp-connect";
|
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 { rpc } from "../../lib/rpc";
|
||||||
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
|
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
|
||||||
|
|
||||||
@ -91,9 +91,7 @@ export function ChoiceCard({
|
|||||||
|
|
||||||
if (dismissed) return null;
|
if (dismissed) return null;
|
||||||
|
|
||||||
const chosen = block.answerText
|
const chosen = chosenOption(card);
|
||||||
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText }
|
|
||||||
: card.options.find((option) => option.id === block.answerId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { CoachRoutineKeySchema } from "./coach.js";
|
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 { Id, MemoryScope, RunStatus, SandboxKind } from "./ids.js";
|
||||||
import { McpHeadersSchema, McpRemoteEndpointSchema, McpTransportSchema } from "./mcp.js";
|
import { McpHeadersSchema, McpRemoteEndpointSchema, McpTransportSchema } from "./mcp.js";
|
||||||
|
|
||||||
@ -130,6 +130,8 @@ export const GroupSchema = z.object({
|
|||||||
members: z.array(GroupMemberSchema),
|
members: z.array(GroupMemberSchema),
|
||||||
threadId: Id,
|
threadId: Id,
|
||||||
preview: z.string(),
|
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(),
|
unread: z.boolean(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
@ -202,6 +204,7 @@ export const SpaceGroupSchema = GroupSchema.pick({
|
|||||||
sectionId: true,
|
sectionId: true,
|
||||||
members: true,
|
members: true,
|
||||||
preview: true,
|
preview: true,
|
||||||
|
previewBlock: true,
|
||||||
unread: true,
|
unread: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -137,14 +137,7 @@ export const AskBlockSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MessageBlock = z.discriminatedUnion("kind", [
|
export const ChoiceBlockSchema = z.object({
|
||||||
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({
|
|
||||||
kind: z.literal("choice"),
|
kind: z.literal("choice"),
|
||||||
question: z.string(),
|
question: z.string(),
|
||||||
subtitle: z.string().optional(),
|
subtitle: z.string().optional(),
|
||||||
@ -158,18 +151,36 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
answerId: z.string().optional(),
|
answerId: z.string().optional(),
|
||||||
/** The user's words when they answered through the text input. */
|
/** The user's words when they answered through the text input. */
|
||||||
answerText: z.string().optional(),
|
answerText: z.string().optional(),
|
||||||
}),
|
});
|
||||||
z.object({
|
|
||||||
/**
|
/**
|
||||||
* A line onboarding writes. Clients that know `note` say it in the user's language and
|
* 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`.
|
* write the time where they are; everything else (mobile, agent history) uses `text`.
|
||||||
*/
|
*/
|
||||||
|
export const IntakeNoteBlockSchema = z.object({
|
||||||
kind: z.literal("intake_note"),
|
kind: z.literal("intake_note"),
|
||||||
/** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */
|
/** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */
|
||||||
note: z.enum(["done"]),
|
note: z.enum(["done"]),
|
||||||
nextCheckInAt: z.string().nullable(),
|
nextCheckInAt: z.string().nullable(),
|
||||||
text: z.string(),
|
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({
|
z.object({
|
||||||
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
||||||
description, and an Authorize button that flips to connected. */
|
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", () => {
|
describe("archiveGroup", () => {
|
||||||
const actor = {
|
const actor = {
|
||||||
spaceId: "workspace-1",
|
spaceId: "workspace-1",
|
||||||
|
|||||||
@ -15,7 +15,12 @@ import type { Prisma, PrismaClient } from "./client.js";
|
|||||||
import { expireComputerExecutionLeases } from "./computers.js";
|
import { expireComputerExecutionLeases } from "./computers.js";
|
||||||
import { IsolationError } from "./scope.js";
|
import { IsolationError } from "./scope.js";
|
||||||
import { lockSpaceForContentCreation } from "./spaces.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 = {
|
type GroupRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -65,7 +70,6 @@ function mapGroupMembers(members: GroupRecord["members"]): GroupMember[] {
|
|||||||
|
|
||||||
function mapGroup(group: GroupRecord): Group {
|
function mapGroup(group: GroupRecord): Group {
|
||||||
if (!group.thread) throw new IsolationError("Group is missing its thread");
|
if (!group.thread) throw new IsolationError("Group is missing its thread");
|
||||||
const preview = previewFromBlocks(group.thread.messages[0]?.blocks);
|
|
||||||
return {
|
return {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
spaceId: group.spaceId,
|
spaceId: group.spaceId,
|
||||||
@ -77,7 +81,7 @@ function mapGroup(group: GroupRecord): Group {
|
|||||||
archivedAt: group.archivedAt?.toISOString() ?? null,
|
archivedAt: group.archivedAt?.toISOString() ?? null,
|
||||||
members: mapGroupMembers(group.members),
|
members: mapGroupMembers(group.members),
|
||||||
threadId: group.thread.id,
|
threadId: group.thread.id,
|
||||||
preview,
|
...groupPreview(group.thread.messages[0]?.blocks),
|
||||||
unread: group.thread.unread,
|
unread: group.thread.unread,
|
||||||
updatedAt: group.updatedAt.toISOString(),
|
updatedAt: group.updatedAt.toISOString(),
|
||||||
createdAt: group.createdAt.toISOString(),
|
createdAt: group.createdAt.toISOString(),
|
||||||
@ -112,7 +116,7 @@ function mapSpaceGroup(group: SpaceGroupRecord): SpaceGroup {
|
|||||||
pinned: group.pinned,
|
pinned: group.pinned,
|
||||||
sectionId: group.sectionId,
|
sectionId: group.sectionId,
|
||||||
members: mapGroupMembers(group.members),
|
members: mapGroupMembers(group.members),
|
||||||
preview: previewFromBlocks(group.thread.messages[0]?.blocks),
|
...groupPreview(group.thread.messages[0]?.blocks),
|
||||||
unread: group.thread.unread,
|
unread: group.thread.unread,
|
||||||
updatedAt: group.updatedAt.toISOString(),
|
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";
|
import { ACTIVE_RUN_STATUSES } from "@rakazo/core";
|
||||||
|
|
||||||
export const activeRunStatuses = [...ACTIVE_RUN_STATUSES];
|
export const activeRunStatuses = [...ACTIVE_RUN_STATUSES];
|
||||||
@ -23,3 +25,15 @@ export function previewFromBlocks(blocks: unknown): string {
|
|||||||
}
|
}
|
||||||
return "";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ComposioEmulator, getCoachRhythm } from "@rakazo/adapters";
|
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 { getNotificationSettings, loadUserContext } from "@rakazo/db";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import type { createApp } from "../../../apps/api/src/app.ts";
|
import type { createApp } from "../../../apps/api/src/app.ts";
|
||||||
@ -59,7 +59,26 @@ describeWithDatabase("onboarding questions in the main conversation", () => {
|
|||||||
where: { groupId: conversation.groupId },
|
where: { groupId: conversation.groupId },
|
||||||
select: { id: true },
|
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) {
|
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 });
|
await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId });
|
||||||
expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["focus"]);
|
expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["focus"]);
|
||||||
expect(await onboardingRow(ada.userId)).toMatchObject({ step: "focus", completedAt: null });
|
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(
|
expect(
|
||||||
(await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId }))
|
(await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId }))
|
||||||
.onboardingOpen,
|
.onboardingOpen,
|
||||||
@ -161,6 +185,7 @@ describeWithDatabase("onboarding questions in the main conversation", () => {
|
|||||||
expect(closing.text.replace(/\s/g, " ")).toMatch(
|
expect(closing.text.replace(/\s/g, " ")).toMatch(
|
||||||
/^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/,
|
/^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 ?? "");
|
const firstCheckIn = new Date(closing.nextCheckInAt ?? "");
|
||||||
expect(firstCheckIn.getTime()).toBeGreaterThan(Date.now());
|
expect(firstCheckIn.getTime()).toBeGreaterThan(Date.now());
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user