feat: land in the main conversation, led by the main bot

After onboarding the user ended up in a 1:1 chat with "Chief", and the
"Hauptthread" was a group the web client created and found by name. The
main bot had no conversation it actually led, and addressing another bot
there did nothing unless the user had added it as a member first.

Now the main conversation is a real place:

- bots.ensureMain creates or returns the main bot. Bootstrap and space
  navigation return `main` ({ botId, groupId, spaceId }) once the main
  bot exists; reading navigation never creates a bot.
- The main bot leads the main conversation and answers when nobody is
  addressed: message routing, follow-ups, reactions and clearing the
  thread fall back to the group's lead instead of its first member.
- Addressing a same-space bot that is not a member yet, by @Name or a
  mention chip, makes it join before the message is routed, so it
  answers in the conversation. Specialists from other spaces do not join.
- The main bot cannot be archived or deleted, the main conversation
  cannot be archived or deleted or have its members edited, and child
  bots never dissolve it. Renaming the main bot renames the conversation.
- Web: onboarding uses bots.ensureMain and finishes in the main
  conversation. The main conversation is the first sidebar entry in every
  space, shown with the main bot's avatar and name; from another space it
  switches back to the home space. The main bot is not listed again among
  the bots, its 1:1 thread stays reachable by URL. The client no longer
  creates or detects the "Hauptthread" by name, refetches a group when it
  changes, and shows the speaker of any of the user's bots in a group.
- Onboarding no longer resets the step when its bootstrap resolves after
  the user has already moved on, and new spaces open in the app instead
  of walking onboarding again.

Tests: group mention routing with a default responder, lead follow-ups,
child-bot guards, a journey for the main conversation (lead answers,
addressed bot joins, guards, rename), authorization for bots.ensureMain,
and an E2E spec that lands in the main conversation, gets an answer from
the lead, sees an addressed bot join, and reaches the conversation from
another space. Existing E2E specs start in the main bot's thread through
a helper or use the main conversation where that is what they test.

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-02 22:59:04 +00:00
parent 86723f0f6f
commit 311551da04
No known key found for this signature in database
36 changed files with 949 additions and 286 deletions

View File

@ -0,0 +1,75 @@
import { ORPCError } from "@orpc/server";
import type { Actor, MainConversationRef } from "@rakazo/contracts";
import { hasMentionToken } from "@rakazo/core";
import {
ensureMainConversation,
findMainBotId,
joinMainConversation,
type PrismaClient,
} from "@rakazo/db";
/**
* Where the main conversation lives, repaired or created on the way. Null until onboarding has
* created the main bot: reading navigation must never create a bot by itself.
*/
export async function loadMainConversationRef(
prisma: PrismaClient,
userId: string,
): Promise<MainConversationRef | null> {
if (!(await findMainBotId(prisma, userId))) return null;
const conversation = await ensureMainConversation(prisma, userId);
return {
botId: conversation.leadBotId,
groupId: conversation.groupId,
spaceId: conversation.spaceId,
};
}
export function assertNotMainBot(bot: { kind: string }, message: string) {
if (bot.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
}
/** Unknown or foreign groups fall through so the caller's own lookup reports them as before. */
export async function assertNotMainConversation(
prisma: PrismaClient,
actor: Pick<Actor, "spaceId" | "userId">,
groupId: string,
message: string,
) {
const group = await prisma.chatGroup.findFirst({
where: { id: groupId, spaceId: actor.spaceId, userId: actor.userId },
select: { kind: true },
});
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
}
/** The main conversation carries the main bot's name. */
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) {
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } });
}
/**
* Same-space bots the user addresses in the main conversation, by `@Name` or a mention chip,
* join it first, so the member mention rules route the message to them.
*/
export async function joinAddressedBots(
prisma: PrismaClient,
actor: Pick<Actor, "spaceId" | "userId">,
input: { memberBotIds: string[]; text: string; botMentionIds: string[] },
) {
const candidates = await prisma.bot.findMany({
where: {
userId: actor.userId,
spaceId: actor.spaceId,
archivedAt: null,
id: { notIn: input.memberBotIds },
},
select: { id: true, name: true },
});
const addressed = candidates
.filter((bot) => input.botMentionIds.includes(bot.id) || hasMentionToken(input.text, bot.name))
.map((bot) => bot.id);
if (addressed.length === 0) return;
// Subscribers pick up the group.updated event with the send that follows.
await joinMainConversation(prisma, { userId: actor.userId, botIds: addressed });
}

View File

@ -4,6 +4,7 @@ import { featuredConnectorProvidersMatch } from "@rakazo/core";
import { import {
appendEventInTransaction, appendEventInTransaction,
createThreadMessageInTransaction, createThreadMessageInTransaction,
ensureMainConversation,
IsolationError, IsolationError,
type Prisma, type Prisma,
type PrismaClient, type PrismaClient,
@ -13,7 +14,8 @@ import {
/** /**
* First-run conversational onboarding, seeded deterministically into the bot's * First-run conversational onboarding, seeded deterministically into the bot's
* thread: greeting, a focus choice, and available app cards the user authorizes * thread: greeting, a focus choice, and available app cards the user authorizes
* inline. Focus must not rename the bot. No model tokens are spent. * inline. Focus must not rename the bot. No model tokens are spent. The main bot
* talks in the main conversation, where onboarding lands.
*/ */
type OnboardingDeps = { type OnboardingDeps = {
@ -76,6 +78,10 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
include: { thread: true }, include: { thread: true },
}); });
if (!bot?.thread) throw new IsolationError(); if (!bot?.thread) throw new IsolationError();
if (bot.kind === "main") {
const conversation = await ensureMainConversation(deps.prisma, actor.userId);
return { bot, thread: { id: conversation.threadId } };
}
return { bot, thread: bot.thread }; return { bot, thread: bot.thread };
} }
@ -89,6 +95,7 @@ async function post(
threadId: target.threadId, threadId: target.threadId,
role: "bot", role: "bot",
blocks, blocks,
botId: target.botId,
}); });
const event = await appendEventInTransaction(tx, { const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId, spaceId: target.spaceId,
@ -176,6 +183,7 @@ export async function promptFocus(
threadId: target.threadId, threadId: target.threadId,
role: "bot", role: "bot",
blocks, blocks,
botId: target.botId,
}); });
const event = await appendEventInTransaction(tx, { const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId, spaceId: target.spaceId,

View File

@ -115,6 +115,7 @@ import {
createSpaceForMember, createSpaceForMember,
deleteEmptySpaceForMember, deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret, deleteUnreferencedCredentialSecret,
ensureMainConversation,
findDefaultModelCredential, findDefaultModelCredential,
findDefaultVoiceCredential, findDefaultVoiceCredential,
findModelCredential, findModelCredential,
@ -132,6 +133,7 @@ import {
projectWorkspaceDto, projectWorkspaceDto,
releaseSpaceDeletionClaim, releaseSpaceDeletionClaim,
renewSpaceDeletionClaim, renewSpaceDeletionClaim,
requireMembership,
SPACE_DELETION_CLAIM_TIMEOUT_MS, SPACE_DELETION_CLAIM_TIMEOUT_MS,
SpaceDeletionInProgressError, SpaceDeletionInProgressError,
SpaceLimitError, SpaceLimitError,
@ -153,6 +155,12 @@ import {
toComputerStatus, toComputerStatus,
} from "./computer-status.js"; } from "./computer-status.js";
import { searchIntegrationCatalog } from "./integration-catalog.js"; import { searchIntegrationCatalog } from "./integration-catalog.js";
import {
assertNotMainBot,
assertNotMainConversation,
loadMainConversationRef,
renameMainConversation,
} from "./main-conversation.js";
import { buildMcpUpdateMaterial } from "./mcp-material.js"; import { buildMcpUpdateMaterial } from "./mcp-material.js";
import { import {
disconnectMemoryProvider, disconnectMemoryProvider,
@ -192,6 +200,7 @@ import {
} from "./thread-message-pages.js"; } from "./thread-message-pages.js";
import { import {
followUpGroupThreadMessage, followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage, reactToThreadMessage,
resolveThreadTarget, resolveThreadTarget,
sendThreadMessage, sendThreadMessage,
@ -737,6 +746,7 @@ export function createRouter(deps: RouterDeps) {
thread, thread,
routines, routines,
spaces: navigation.spaces, spaces: navigation.spaces,
main: navigation.main,
}; };
try { try {
@ -987,6 +997,23 @@ export function createRouter(deps: RouterDeps) {
throw mapSpaceLifecycleError(error); throw mapSpaceLifecycleError(error);
} }
}), }),
ensureMain: authed.bots.ensureMain.handler(async ({ context }) => {
const conversation = await ensureMainConversation(deps.prisma, context.actor.userId).catch(
(error: unknown) => {
throw mapSpaceLifecycleError(error);
},
);
// The main bot lives in the home space, which may not be the space of this request.
const actor =
conversation.spaceId === context.actor.spaceId
? context.actor
: await requireMembership(deps.prisma, context.actor.userId, conversation.spaceId);
const bot = (await repos.listBots(actor)).find(
(candidate) => candidate.id === conversation.leadBotId,
);
if (!bot) throw new IsolationError();
return bot;
}),
duplicate: authed.bots.duplicate.handler(async ({ context, input }) => { duplicate: authed.bots.duplicate.handler(async ({ context, input }) => {
const source = await repos.getBot(context.actor, input.botId); const source = await repos.getBot(context.actor, input.botId);
const duplicate = await repos const duplicate = await repos
@ -1135,6 +1162,9 @@ export function createRouter(deps: RouterDeps) {
...(input.teamChatRules !== undefined ? { teamChatRules: input.teamChatRules } : {}), ...(input.teamChatRules !== undefined ? { teamChatRules: input.teamChatRules } : {}),
}, },
}); });
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) {
await renameMainConversation(deps.prisma, context.actor.userId, input.name);
}
const bots = await repos.listBots(context.actor); const bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId); const bot = bots.find((b) => b.id === input.botId);
if (!bot) throw new IsolationError(); if (!bot) throw new IsolationError();
@ -1201,6 +1231,7 @@ export function createRouter(deps: RouterDeps) {
}), }),
archive: authed.bots.archive.handler(async ({ context, input }) => { archive: authed.bots.archive.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true }); const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true });
assertNotMainBot(bot, "The main bot can't be archived.");
await archiveBot( await archiveBot(
{ {
prisma: deps.prisma, prisma: deps.prisma,
@ -1223,6 +1254,7 @@ export function createRouter(deps: RouterDeps) {
}), }),
remove: authed.bots.remove.handler(async ({ context, input }) => { remove: authed.bots.remove.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true }); const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true });
assertNotMainBot(bot, "The main bot can't be deleted.");
await destroyBot( await destroyBot(
{ {
prisma: deps.prisma, prisma: deps.prisma,
@ -1339,6 +1371,14 @@ export function createRouter(deps: RouterDeps) {
}); });
if (!section) throw new IsolationError(); if (!section) throw new IsolationError();
} }
if (input.botIds) {
await assertNotMainConversation(
deps.prisma,
context.actor,
input.groupId,
"Bots join the main conversation when you address them.",
);
}
const updated = await groupRepos.updateGroup(context.actor, input); const updated = await groupRepos.updateGroup(context.actor, input);
await Promise.all( await Promise.all(
updated.cancelledRunIds.map((runId) => updated.cancelledRunIds.map((runId) =>
@ -1348,6 +1388,12 @@ export function createRouter(deps: RouterDeps) {
return updated.group; return updated.group;
}), }),
archive: authed.groups.archive.handler(async ({ context, input }) => { archive: authed.groups.archive.handler(async ({ context, input }) => {
await assertNotMainConversation(
deps.prisma,
context.actor,
input.groupId,
"The main conversation can't be archived.",
);
const archived = await groupRepos.archiveGroup(context.actor, input.groupId); const archived = await groupRepos.archiveGroup(context.actor, input.groupId);
await Promise.all( await Promise.all(
archived.cancelledRunIds.map((runId) => archived.cancelledRunIds.map((runId) =>
@ -1391,6 +1437,12 @@ export function createRouter(deps: RouterDeps) {
return { ok: true as const }; return { ok: true as const };
}), }),
remove: authed.groups.remove.handler(async ({ context, input }) => { remove: authed.groups.remove.handler(async ({ context, input }) => {
await assertNotMainConversation(
deps.prisma,
context.actor,
input.groupId,
"The main conversation can't be deleted.",
);
const removed = await groupRepos.removeGroup(context.actor, input.groupId); const removed = await groupRepos.removeGroup(context.actor, input.groupId);
const cleanup = await Promise.allSettled( const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) => removed.artifactStorageKeys.map((storageKey) =>
@ -1504,7 +1556,7 @@ export function createRouter(deps: RouterDeps) {
}), }),
clear: authed.threads.clear.handler(async ({ context, input }) => { clear: authed.threads.clear.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input); const target = await resolveThreadTarget(deps.prisma, context.actor, input);
const contextBotId = target.kind === "bot" ? target.botId : target.memberBotIds[0]; const contextBotId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
if (!contextBotId) throw new IsolationError(); if (!contextBotId) throw new IsolationError();
const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({ const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({
spaceId: context.actor.spaceId, spaceId: context.actor.spaceId,
@ -4786,6 +4838,8 @@ async function spaceNavigationDto(
repos: ReturnType<typeof createRepos>, repos: ReturnType<typeof createRepos>,
groupRepos: ReturnType<typeof createGroupRepos>, groupRepos: ReturnType<typeof createGroupRepos>,
): Promise<SpaceNavigation> { ): Promise<SpaceNavigation> {
// First, so a main conversation created here is part of the lists below.
const main = await loadMainConversationRef(deps.prisma, actor.userId);
const currentSpace = await deps.prisma.space.findUnique({ const currentSpace = await deps.prisma.space.findUnique({
where: { id: actor.spaceId }, where: { id: actor.spaceId },
select: { organizationId: true }, select: { organizationId: true },
@ -4915,6 +4969,7 @@ async function spaceNavigationDto(
botSections: sectionsFor(membership.spaceId), botSections: sectionsFor(membership.spaceId),
}; };
}), }),
main,
}; };
} }

View File

@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
cancelSupersededQueuedRuns, cancelSupersededQueuedRuns,
followUpGroupThreadMessage, followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage, reactToThreadMessage,
resolveGroupFollowUpBotId, resolveGroupFollowUpBotId,
sendThreadMessage, sendThreadMessage,
@ -1660,6 +1661,44 @@ describe("resolveGroupFollowUpBotId", () => {
}), }),
).toBe("bot-b"); ).toBe("bot-b");
}); });
it("sends an unaddressed follow-up to the lead instead of the first member", () => {
expect(
resolveGroupFollowUpBotId({
text: "continue",
members,
activeRuns: [],
defaultBotId: "bot-b",
}),
).toBe("bot-b");
});
});
describe("groupDefaultBotId", () => {
const group = {
kind: "group",
groupId: "group-1",
threadId: "thread-1",
groupName: "Group",
groupKind: "main",
members: [],
memberBotIds: ["bot-a", "bot-b"],
} as const;
it("prefers the lead while it is a member", () => {
expect(
groupDefaultBotId({ ...group, memberBotIds: [...group.memberBotIds], leadBotId: "bot-b" }),
).toBe("bot-b");
});
it("falls back to the first member without a lead or after the lead left", () => {
expect(
groupDefaultBotId({ ...group, memberBotIds: [...group.memberBotIds], leadBotId: null }),
).toBe("bot-a");
expect(
groupDefaultBotId({ ...group, memberBotIds: [...group.memberBotIds], leadBotId: "gone" }),
).toBe("bot-a");
});
}); });
describe("followUpGroupThreadMessage", () => { describe("followUpGroupThreadMessage", () => {

View File

@ -4,6 +4,7 @@ import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@raka
import { import {
type Actor, type Actor,
GROUP_MEMBER_MIN, GROUP_MEMBER_MIN,
type GroupKind,
type GroupMember, type GroupMember,
type MessageBlock, type MessageBlock,
type MessageReaction, type MessageReaction,
@ -40,6 +41,7 @@ import {
resolveSendAttachments, resolveSendAttachments,
} from "./artifacts.js"; } from "./artifacts.js";
import { resolveBusyBotName, toComputerStatus } from "./computer-status.js"; import { resolveBusyBotName, toComputerStatus } from "./computer-status.js";
import { joinAddressedBots } from "./main-conversation.js";
import { withSerializableRetry } from "./serializable-retry.js"; import { withSerializableRetry } from "./serializable-retry.js";
import { loadMessagePage } from "./thread-message-pages.js"; import { loadMessagePage } from "./thread-message-pages.js";
@ -55,10 +57,20 @@ export type ThreadTarget =
groupId: string; groupId: string;
threadId: string; threadId: string;
groupName: string; groupName: string;
groupKind: GroupKind;
/** Answers when nobody is addressed; null for ordinary groups. */
leadBotId: string | null;
members: GroupMember[]; members: GroupMember[];
memberBotIds: string[]; memberBotIds: string[];
}; };
/** The member that answers when nobody is addressed: the group's lead, else its first member. */
export function groupDefaultBotId(target: Extract<ThreadTarget, { kind: "group" }>) {
return target.leadBotId && target.memberBotIds.includes(target.leadBotId)
? target.leadBotId
: target.memberBotIds[0];
}
/** /**
* Flatten text for excerpt comparison. The parent blocks hold markdown * Flatten text for excerpt comparison. The parent blocks hold markdown
* source while the selection captures rendered text, so structural syntax * source while the selection captures rendered text, so structural syntax
@ -344,7 +356,9 @@ async function lockAndLoadGroupMembers(
}, },
}, },
}); });
if (!group || group.members.length < GROUP_MEMBER_MIN) throw new IsolationError(); // The main conversation starts with only its lead.
const minimum = group?.kind === "main" ? 1 : GROUP_MEMBER_MIN;
if (!group || group.members.length < minimum) throw new IsolationError();
return group.members.map((member) => ({ return group.members.map((member) => ({
botId: member.bot.id, botId: member.bot.id,
name: member.bot.name, name: member.bot.name,
@ -383,6 +397,8 @@ export async function resolveThreadTarget(
groupId: group.id, groupId: group.id,
threadId: group.thread.id, threadId: group.thread.id,
groupName: group.name, groupName: group.name,
groupKind: group.kind === "main" ? "main" : "group",
leadBotId: group.leadBotId,
members, members,
memberBotIds: members.map((member) => member.botId), memberBotIds: members.map((member) => member.botId),
}; };
@ -563,6 +579,7 @@ export async function threadSnapshot(
groupId: target.groupId, groupId: target.groupId,
groupName: target.groupName, groupName: target.groupName,
members: target.members, members: target.members,
...(target.leadBotId ? { leadBotId: target.leadBotId } : {}),
threadId: target.threadId, threadId: target.threadId,
cursor: core.last?.seq ?? -1, cursor: core.last?.seq ?? -1,
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents), messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
@ -683,6 +700,13 @@ export async function sendThreadMessage(
) { ) {
const existing = await replayExistingSend(deps, target.threadId, input.clientNonce); const existing = await replayExistingSend(deps, target.threadId, input.clientNonce);
if (existing) return existing; if (existing) return existing;
if (target.kind === "group" && target.groupKind === "main") {
await joinAddressedBots(deps.prisma, actor, {
memberBotIds: target.memberBotIds,
text: input.text ?? "",
botMentionIds: splitMentionTargets(input.mentions).botMentionIds,
});
}
// The excerpt is rendered text while blocks hold markdown source, so it // The excerpt is rendered text while blocks hold markdown source, so it
// can't be substring-verified verbatim — enforce the cap here, then check // can't be substring-verified verbatim — enforce the cap here, then check
// the flattened form against the parent inside the transaction. // the flattened form against the parent inside the transaction.
@ -828,6 +852,7 @@ export async function sendThreadMessage(
text: input.text ?? "", text: input.text ?? "",
members: members.map((member) => ({ id: member.botId, name: member.name })), members: members.map((member) => ({ id: member.botId, name: member.name })),
explicitMentions: mentionTargets.botMentionIds, explicitMentions: mentionTargets.botMentionIds,
defaultBotId: target.leadBotId,
}); });
const { blocks: attachmentBlocks, artifacts } = await resolveGroupSendAttachments( const { blocks: attachmentBlocks, artifacts } = await resolveGroupSendAttachments(
{ prisma: tx }, { prisma: tx },
@ -953,12 +978,13 @@ export async function sendThreadMessage(
* Pick the group member bot that should receive a follow-up. Prefer a waiting * Pick the group member bot that should receive a follow-up. Prefer a waiting
* ask/takeover on any member over the first member's merely-busy run — the * ask/takeover on any member over the first member's merely-busy run — the
* router's old members[0] shortcut let a fresh run on bot A bury an unanswered * router's old members[0] shortcut let a fresh run on bot A bury an unanswered
* ask on bot B. * ask on bot B. Unaddressed follow-ups go to the lead when the group has one.
*/ */
export function resolveGroupFollowUpBotId(input: { export function resolveGroupFollowUpBotId(input: {
text: string; text: string;
members: Array<{ botId: string; name: string }>; members: Array<{ botId: string; name: string }>;
activeRuns: Array<{ botId: string; status: string; createdAt: Date }>; activeRuns: Array<{ botId: string; status: string; createdAt: Date }>;
defaultBotId?: string | null;
}): string { }): string {
const memberBotIds = new Set(input.members.map((member) => member.botId)); const memberBotIds = new Set(input.members.map((member) => member.botId));
const memberRuns = input.activeRuns const memberRuns = input.activeRuns
@ -974,6 +1000,7 @@ export function resolveGroupFollowUpBotId(input: {
text: input.text, text: input.text,
members: input.members.map((member) => ({ id: member.botId, name: member.name })), members: input.members.map((member) => ({ id: member.botId, name: member.name })),
explicitMentions: [], explicitMentions: [],
defaultBotId: input.defaultBotId,
})[0]; })[0];
if (mentioned) return mentioned; if (mentioned) return mentioned;
const fallback = input.members[0]?.botId; const fallback = input.members[0]?.botId;
@ -999,7 +1026,12 @@ export async function followUpGroupThreadMessage(
select: { id: true, botId: true, status: true, createdAt: true }, select: { id: true, botId: true, status: true, createdAt: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
const botId = resolveGroupFollowUpBotId({ text: input.text, members, activeRuns }); const botId = resolveGroupFollowUpBotId({
text: input.text,
members,
activeRuns,
defaultBotId: target.leadBotId,
});
const blocks = [{ kind: "text" as const, text: input.text }]; const blocks = [{ kind: "text" as const, text: input.text }];
const message = await createThreadMessageInTransaction(tx, { const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId, threadId: target.threadId,
@ -1085,7 +1117,7 @@ export async function reactToThreadMessage(
select: { id: true }, select: { id: true },
}); });
if (existing) return { eventSeq: null }; if (existing) return { eventSeq: null };
const botId = target.kind === "bot" ? target.botId : target.memberBotIds[0]; const botId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
if (!botId) throw new IsolationError(); if (!botId) throw new IsolationError();
const blocks: MessageBlock[] = [{ kind: "text", text: input.reaction }]; const blocks: MessageBlock[] = [{ kind: "text", text: input.reaction }];
const message = await createThreadMessageInTransaction(tx, { const message = await createThreadMessageInTransaction(tx, {

View File

@ -2,9 +2,10 @@ import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers"; import { captureScreenshot, completeOnboarding, signup } from "./helpers";
/** Activity rows sit above `[data-sidebar-group]` bots; match their aria-label. */ /** Activity rows sit above `[data-sidebar-group]` bots; match their aria-label. */
/** Activity rows only, not the sidebar's conversation entries for the same bot. */
function activityRow(page: Page, botName: string) { function activityRow(page: Page, botName: string) {
return page.locator("aside").getByRole("button", { return page.locator("aside").getByRole("button", {
name: new RegExp(`^${botName}, (?!1:1 conversation)`), name: new RegExp(`^${botName}, (?!1:1 conversation|main conversation)`),
}); });
} }

View File

@ -1,19 +1,26 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; import {
activeBotId,
captureScreenshot,
completeOnboarding,
createNamedBot,
rpc,
signup,
} from "./helpers";
test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => { test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `bot-organize-${stamp}@rakazo.test`, "password12", "Test User"); await signup(page, `bot-organize-${stamp}@rakazo.test`, "password12", "Test User");
await completeOnboarding(page); await completeOnboarding(page);
await page.goto("/app"); // The main bot is reached through the main conversation; specialists are organized here.
await page.waitForURL(/\/app\/[^/]+$/); await createNamedBot(page, "Scout");
const sidebar = page.locator("aside").first(); const sidebar = page.locator("aside").first();
const bot = sidebar.getByRole("button", { name: /^Chief/ }); const bot = sidebar.getByRole("button", { name: /^Scout/ });
await bot.click({ button: "right" }); await bot.click({ button: "right" });
await page.getByRole("menuitem", { name: "Pin", exact: true }).click(); await page.getByRole("menuitem", { name: "Pin", exact: true }).click();
await expect(sidebar.locator('[data-sidebar-group="pinned"]')).toContainText("Chief"); await expect(sidebar.locator('[data-sidebar-group="pinned"]')).toContainText("Scout");
await captureScreenshot(page, testInfo, "pinned-bots"); await captureScreenshot(page, testInfo, "pinned-bots");
await bot.click({ button: "right" }); await bot.click({ button: "right" });
@ -32,12 +39,12 @@ test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
const projects = sidebar.locator('[data-sidebar-group^="section:"]'); const projects = sidebar.locator('[data-sidebar-group^="section:"]');
await expect(projects).toContainText("Projects"); await expect(projects).toContainText("Projects");
await expect(projects).toContainText("Chief"); await expect(projects).toContainText("Scout");
await captureScreenshot(page, testInfo, "bot-sections"); await captureScreenshot(page, testInfo, "bot-sections");
await page.reload(); await page.reload();
await expect(projects).toContainText("Projects"); await expect(projects).toContainText("Projects");
await expect(projects).toContainText("Chief"); await expect(projects).toContainText("Scout");
await bot.click({ button: "right" }); await bot.click({ button: "right" });
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover(); await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
@ -58,17 +65,24 @@ test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
.getByRole("menu", { name: "Move to folder", exact: true }) .getByRole("menu", { name: "Move to folder", exact: true })
.getByRole("menuitem", { name: "Unassigned", exact: true }) .getByRole("menuitem", { name: "Unassigned", exact: true })
.click(); .click();
await expect(sidebar.locator('[data-sidebar-group="unassigned"]')).toContainText("Chief"); await expect(sidebar.locator('[data-sidebar-group="unassigned"]')).toContainText("Scout");
}); });
test("bots can be reordered by drag or keyboard and keep that order", async ({ page }) => { test("bots can be reordered by drag or keyboard and keep that order", async ({ page }) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `bot-reorder-${stamp}@rakazo.test`, "password12", "Bot Order"); await signup(page, `bot-reorder-${stamp}@rakazo.test`, "password12", "Bot Order");
await completeOnboarding(page); await completeOnboarding(page);
await page.goto("/app"); // The roster lists specialists; the main bot is reached through the main conversation.
await page.waitForURL(/\/app\/[^/]+$/); const leadId = (
await rpc<{ id: string }>(page, "bots/create", {
const chiefId = activeBotId(page); name: "Lead",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
computerMode: "team",
})
).id;
const alpha = await rpc<{ id: string }>(page, "bots/create", { const alpha = await rpc<{ id: string }>(page, "bots/create", {
name: "Alpha", name: "Alpha",
title: "", title: "",
@ -91,7 +105,7 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p
const rows = sidebar.locator("[data-roster-bot-id]"); const rows = sidebar.locator("[data-roster-bot-id]");
const order = () => const order = () =>
rows.evaluateAll((items) => items.map((item) => item.getAttribute("data-roster-bot-id"))); rows.evaluateAll((items) => items.map((item) => item.getAttribute("data-roster-bot-id")));
await expect.poll(order).toEqual([chiefId, alpha.id, beta.id]); await expect.poll(order).toEqual([leadId, alpha.id, beta.id]);
let releaseStaleList!: () => void; let releaseStaleList!: () => void;
let markStaleListReady!: () => void; let markStaleListReady!: () => void;
@ -126,20 +140,20 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p
); );
await sidebar await sidebar
.locator(`[data-roster-bot-id="${beta.id}"]`) .locator(`[data-roster-bot-id="${beta.id}"]`)
.dragTo(sidebar.locator(`[data-roster-bot-id="${chiefId}"]`)); .dragTo(sidebar.locator(`[data-roster-bot-id="${leadId}"]`));
await reorderSaved; await reorderSaved;
releaseStaleList(); releaseStaleList();
await staleListDelivered; await staleListDelivered;
await expect.poll(order).toEqual([beta.id, chiefId, alpha.id]); await expect.poll(order).toEqual([beta.id, leadId, alpha.id]);
await page.reload(); await page.reload();
await expect.poll(order).toEqual([beta.id, chiefId, alpha.id]); await expect.poll(order).toEqual([beta.id, leadId, alpha.id]);
const betaRow = sidebar.locator(`[data-roster-bot-id="${beta.id}"]`); const betaRow = sidebar.locator(`[data-roster-bot-id="${beta.id}"]`);
await betaRow.focus(); await betaRow.focus();
await page.keyboard.press("Alt+ArrowDown"); await page.keyboard.press("Alt+ArrowDown");
await expect.poll(order).toEqual([chiefId, beta.id, alpha.id]); await expect.poll(order).toEqual([leadId, beta.id, alpha.id]);
await page.reload(); await page.reload();
await expect.poll(order).toEqual([chiefId, beta.id, alpha.id]); await expect.poll(order).toEqual([leadId, beta.id, alpha.id]);
let releaseRejectedReorder!: () => void; let releaseRejectedReorder!: () => void;
let markRejectedReorderStarted!: () => void; let markRejectedReorderStarted!: () => void;
@ -160,7 +174,7 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p
); );
await sidebar await sidebar
.locator(`[data-roster-bot-id="${alpha.id}"]`) .locator(`[data-roster-bot-id="${alpha.id}"]`)
.dragTo(sidebar.locator(`[data-roster-bot-id="${chiefId}"]`)); .dragTo(sidebar.locator(`[data-roster-bot-id="${leadId}"]`));
await rejectedReorderStarted; await rejectedReorderStarted;
const queuedReorderSaved = page.waitForResponse( const queuedReorderSaved = page.waitForResponse(
@ -171,9 +185,9 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p
.dragTo(sidebar.locator(`[data-roster-bot-id="${alpha.id}"]`)); .dragTo(sidebar.locator(`[data-roster-bot-id="${alpha.id}"]`));
releaseRejectedReorder(); releaseRejectedReorder();
await queuedReorderSaved; await queuedReorderSaved;
await expect.poll(order).toEqual([beta.id, alpha.id, chiefId]); await expect.poll(order).toEqual([beta.id, alpha.id, leadId]);
await page.reload(); await page.reload();
await expect.poll(order).toEqual([beta.id, alpha.id, chiefId]); await expect.poll(order).toEqual([beta.id, alpha.id, leadId]);
}); });
test("chat composer controls are vertically centered", async ({ page }) => { test("chat composer controls are vertically centered", async ({ page }) => {

View File

@ -1,15 +1,15 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers"; import { captureScreenshot, completeOnboarding, createNamedBot, signup } from "./helpers";
test("titled sidebar section expands and collapses", async ({ page }, testInfo) => { test("titled sidebar section expands and collapses", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `sidebar-collapse-${stamp}@rakazo.test`, "password12", "Test User"); await signup(page, `sidebar-collapse-${stamp}@rakazo.test`, "password12", "Test User");
await completeOnboarding(page); await completeOnboarding(page);
await page.goto("/app"); // The main bot is reached through the main conversation; specialists live in sections.
await page.waitForURL(/\/app\/[^/]+$/); await createNamedBot(page, "Scout");
const sidebar = page.locator("aside").first(); const sidebar = page.locator("aside").first();
const bot = sidebar.getByRole("button", { name: /^Chief/ }); const bot = sidebar.getByRole("button", { name: /^Scout/ });
await bot.click({ button: "right" }); await bot.click({ button: "right" });
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover(); await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
@ -23,7 +23,7 @@ test("titled sidebar section expands and collapses", async ({ page }, testInfo)
const projects = sidebar.locator('[data-sidebar-group^="section:"]'); const projects = sidebar.locator('[data-sidebar-group^="section:"]');
await expect(projects).toContainText("Projects"); await expect(projects).toContainText("Projects");
await expect(projects).toContainText("Chief"); await expect(projects).toContainText("Scout");
const toggle = projects.getByRole("button", { name: /Collapse Projects|Expand Projects/ }); const toggle = projects.getByRole("button", { name: /Collapse Projects|Expand Projects/ });
await expect(toggle).toHaveAttribute("aria-expanded", "true"); await expect(toggle).toHaveAttribute("aria-expanded", "true");
@ -32,7 +32,7 @@ test("titled sidebar section expands and collapses", async ({ page }, testInfo)
await sidebar.getByPlaceholder("Search").hover(); await sidebar.getByPlaceholder("Search").hover();
await captureScreenshot(page, testInfo, "sidebar-section-expanded"); await captureScreenshot(page, testInfo, "sidebar-section-expanded");
// Hover header with Chief selected underneath — outer edges must match. // Hover header with Scout selected underneath — outer edges must match.
await bot.click(); await bot.click();
await toggle.hover(); await toggle.hover();
const headerBox = await toggle.boundingBox(); const headerBox = await toggle.boundingBox();
@ -45,11 +45,11 @@ test("titled sidebar section expands and collapses", async ({ page }, testInfo)
await toggle.click(); await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false"); await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(projects.getByRole("button", { name: /^Chief/ })).toHaveCount(0); await expect(projects.getByRole("button", { name: /^Scout/ })).toHaveCount(0);
await sidebar.getByPlaceholder("Search").hover(); await sidebar.getByPlaceholder("Search").hover();
await captureScreenshot(page, testInfo, "sidebar-section-collapsed"); await captureScreenshot(page, testInfo, "sidebar-section-collapsed");
await toggle.click(); await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true"); await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(projects.getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(projects.getByRole("button", { name: /^Scout/ })).toHaveCount(1);
}); });

View File

@ -49,8 +49,6 @@ test("Starten in an onboarded project opens the group create flow", async ({ pag
await page.waitForURL(/\/app(?:\/)?$/); await page.waitForURL(/\/app(?:\/)?$/);
await selectProjectSpace(projekte, "Website"); await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/); await page.waitForURL(/\/app(?:\/)?$/);
await page.goto("/onboarding");
await completeOnboarding(page);
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000, timeout: 60_000,
@ -83,12 +81,15 @@ test("archiving the only bot keeps Starten visible in the project sidebar", asyn
await dialog.getByLabel("Name").fill("Side"); await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create project", exact: true }).click(); await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/); await page.waitForURL(/\/app(?:\/)?$/);
await page.goto("/onboarding");
await completeOnboarding(page);
const [chief] = await rpc<Array<{ id: string; name: string }>>(page, "bots/list", {}); const sideBot = await rpc<{ id: string }>(page, "bots/create", {
expect(chief?.name).toMatch(/^Chief/); name: "Side bot",
await rpc(page, "bots/archive", { botId: chief!.id }); title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/archive", { botId: sideBot.id });
await page.goto("/app"); await page.goto("/app");
await page.waitForURL(/\/app(?:\/)?$/); await page.waitForURL(/\/app(?:\/)?$/);

View File

@ -3,6 +3,7 @@ import {
activeBotId, activeBotId,
captureScreenshot, captureScreenshot,
completeOnboarding, completeOnboarding,
createNamedBot,
openUserSettings, openUserSettings,
realSandboxTimeout, realSandboxTimeout,
rpc, rpc,
@ -298,12 +299,14 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
await captureScreenshot(page, testInfo, "11d-provider-emulators"); await captureScreenshot(page, testInfo, "11d-provider-emulators");
await page.getByRole("button", { name: "Close integrations settings" }).click(); await page.getByRole("button", { name: "Close integrations settings" }).click();
await expect(page.getByRole("button", { name: "Close integrations settings" })).toHaveCount(0);
await page.getByText("Chief").first().click(); // Agent computer toggles the panel, so only open it when it is closed.
const gear = page.getByRole("button", { name: "Show settings" }); if ((await sidePanel.getAttribute("data-panel")) !== "computer") {
if (!(await gear.isVisible().catch(() => false))) {
await page.getByTitle("Agent computer").click(); await page.getByTitle("Agent computer").click();
} }
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
const gear = page.getByRole("button", { name: "Show settings" });
await gear.click(); await gear.click();
const downloadPromise = page.waitForEvent("download"); const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export" }).click(); await page.getByRole("button", { name: "Export" }).click();
@ -314,10 +317,17 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
await expect(settings.getByRole("button", { name: "Delete bot" })).toHaveCount(0); await expect(settings.getByRole("button", { name: "Delete bot" })).toHaveCount(0);
await page.getByRole("button", { name: "Close panel" }).click(); await page.getByRole("button", { name: "Close panel" }).click();
await page.locator("aside").first().getByRole("button", { name: /Chief/ }).first().click({ // The main bot cannot be archived or deleted; a specialist's menu offers both.
button: "right", await createNamedBot(page, "Scout");
}); await page
const botMenu = page.getByRole("menu", { name: "Actions for Chief" }); .locator("aside")
.first()
.getByRole("button", { name: /^Scout/ })
.first()
.click({
button: "right",
});
const botMenu = page.getByRole("menu", { name: "Actions for Scout" });
await expect(botMenu.getByRole("menuitem", { name: "Archive" })).toBeVisible(); await expect(botMenu.getByRole("menuitem", { name: "Archive" })).toBeVisible();
await botMenu.getByRole("menuitem", { name: "Delete" }).click(); await botMenu.getByRole("menuitem", { name: "Delete" }).click();
await expect(page.getByRole("radio", { name: /Keep memories/ })).toBeChecked(); await expect(page.getByRole("radio", { name: /Keep memories/ })).toBeChecked();
@ -358,10 +368,6 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
}); });
await captureScreenshot(page, testInfo, "13-spawned-bot"); await captureScreenshot(page, testInfo, "13-spawned-bot");
await page
.locator("[data-sidebar-group]")
.getByRole("button", { name: /^Chief/ })
.click();
await composer.fill("keep working until I stop you"); await composer.fill("keep working until I stop you");
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible({ await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible({
@ -422,7 +428,7 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
await page.getByPlaceholder("Password").fill("password12"); await page.getByPlaceholder("Password").fill("password12");
await page.getByRole("button", { name: "Continue with email" }).click(); await page.getByRole("button", { name: "Continue with email" }).click();
await page.waitForURL(/\/app/, { timeout: 20_000 }); await page.waitForURL(/\/app/, { timeout: 20_000 });
await expect(sidebarBotButton(page, /^Chief/)).toBeVisible(); await expect(page.getByRole("button", { name: "Chief, main conversation" })).toBeVisible();
await expect(sidebarBotButton(page, /Scout/)).toBeVisible(); await expect(sidebarBotButton(page, /Scout/)).toBeVisible();
await captureScreenshot(page, testInfo, "15-restored-session"); await captureScreenshot(page, testInfo, "15-restored-session");
}); });
@ -433,41 +439,47 @@ test("bot context menu pins, duplicates, edits, and confirms deletion", async ({
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `menu-${stamp}@rakazo.test`, "password12", "Menu"); await signup(page, `menu-${stamp}@rakazo.test`, "password12", "Menu");
await completeOnboarding(page); await completeOnboarding(page);
// The main bot is reached through the main conversation; specialists carry the full menu.
await createNamedBot(page, "Scout");
const chief = page.getByRole("button", { name: /Chief/ }).first(); const scout = page
await chief.click({ button: "right" }); .locator("aside")
await expect(page.getByRole("menu", { name: "Actions for Chief" })).toBeVisible(); .first()
.getByRole("button", { name: /^Scout/ })
.first();
await scout.click({ button: "right" });
await expect(page.getByRole("menu", { name: "Actions for Scout" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Edit Profile" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Edit Profile" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Duplicate" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Duplicate" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible();
await captureScreenshot(page, testInfo, "16-bot-context-menu"); await captureScreenshot(page, testInfo, "16-bot-context-menu");
await page.getByRole("menuitem", { name: "Mark as Unread" }).click(); await page.getByRole("menuitem", { name: "Mark as Unread" }).click();
// Chief is the open bot, so the auto-read on window focus must not undo the manual mark. // Scout is the open bot, so the auto-read on window focus must not undo the manual mark.
await page.evaluate(() => window.dispatchEvent(new Event("focus"))); await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await chief.click({ button: "right" }); await scout.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Mark as Read" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Mark as Read" })).toBeVisible();
await page.getByRole("menuitem", { name: "Mark as Read" }).click(); await page.getByRole("menuitem", { name: "Mark as Read" }).click();
await chief.click({ button: "right" }); await scout.click({ button: "right" });
await page.getByRole("menuitem", { name: "Pin", exact: true }).click(); await page.getByRole("menuitem", { name: "Pin", exact: true }).click();
await chief.click({ button: "right" }); await scout.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Unpin", exact: true })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Unpin", exact: true })).toBeVisible();
await page.getByRole("menuitem", { name: "Duplicate" }).click(); await page.getByRole("menuitem", { name: "Duplicate" }).click();
await expect(page.getByText("Chief copy").first()).toBeVisible(); await expect(page.getByText("Scout copy").first()).toBeVisible();
await captureScreenshot(page, testInfo, "17-pinned-and-duplicated-bot"); await captureScreenshot(page, testInfo, "17-pinned-and-duplicated-bot");
const copy = page.getByRole("button", { name: /Chief copy/ }).first(); const copy = page.getByRole("button", { name: /Scout copy/ }).first();
await copy.click({ button: "right" }); await copy.click({ button: "right" });
await page.getByRole("menuitem", { name: "Delete" }).click(); await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(page.getByRole("alertdialog", { name: "Delete Chief copy?" })).toBeVisible(); await expect(page.getByRole("alertdialog", { name: "Delete Scout copy?" })).toBeVisible();
await captureScreenshot(page, testInfo, "18-delete-confirmation"); await captureScreenshot(page, testInfo, "18-delete-confirmation");
await page.getByRole("button", { name: "Cancel" }).click(); await page.getByRole("button", { name: "Cancel" }).click();
await chief.click({ button: "right" }); await scout.click({ button: "right" });
await page.getByRole("menuitem", { name: "Edit Profile" }).click(); await page.getByRole("menuitem", { name: "Edit Profile" }).click();
await expect(page.locator("label:has-text('Name') input")).toHaveValue("Chief"); await expect(page.locator("label:has-text('Name') input")).toHaveValue("Scout");
await captureScreenshot(page, testInfo, "19-edit-profile"); await captureScreenshot(page, testInfo, "19-edit-profile");
}); });

View File

@ -87,7 +87,15 @@ async function visible(locator: Locator, timeout: number) {
return locator.isVisible({ timeout }).catch(() => false); return locator.isVisible({ timeout }).catch(() => false);
} }
export async function completeOnboarding(page: Page, testInfo?: TestInfo) { /**
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
* is set, it then opens the main bot's own thread, where most specs start.
*/
export async function completeOnboarding(
page: Page,
testInfo?: TestInfo,
options: { mainConversation?: boolean } = {},
) {
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 }); await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
if (page.url().includes("/onboarding")) { if (page.url().includes("/onboarding")) {
@ -125,8 +133,8 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
await page.getByRole("button", { name: "Skip", exact: true }).click(); await page.getByRole("button", { name: "Skip", exact: true }).click();
} }
// Scan step auto-advances to first bot creation before landing in /app. // Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 }); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(chief).toBeVisible({ timeout: 15_000 }); await expect(chief).toBeVisible({ timeout: 15_000 });
await waitForChatComposer(page); await waitForChatComposer(page);
@ -134,6 +142,16 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
await captureScreenshot(page, testInfo, "03-create-first-bot"); await captureScreenshot(page, testInfo, "03-create-first-bot");
await captureScreenshot(page, testInfo, "06-onboarding-complete"); await captureScreenshot(page, testInfo, "06-onboarding-complete");
} }
if (!options.mainConversation) await openMainBotThread(page);
}
/** The main bot's own thread stays reachable by URL; the sidebar lists the main conversation. */
export async function openMainBotThread(page: Page) {
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
await page.goto(`/app/${main.id}`, { waitUntil: "domcontentloaded" });
await page.waitForURL(new RegExp(`/app/${main.id}$`), { timeout: 20_000 });
await waitForChatComposer(page);
return main.id;
} }
export async function signup( export async function signup(

View File

@ -107,7 +107,8 @@ test("direct MCP connects a catalog result without asking for a URL and assigns
const createGate = new Promise<void>((resolve) => { const createGate = new Promise<void>((resolve) => {
releaseCreate = resolve; releaseCreate = resolve;
}); });
await page.route("**/rpc/bots/create", async (route) => { // Onboarding creates the main bot, which then gets the connected server.
await page.route("**/rpc/bots/ensureMain", async (route) => {
createCalls += 1; createCalls += 1;
await createGate; await createGate;
await route.continue(); await route.continue();

View File

@ -0,0 +1,94 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
type Snapshot = {
leadBotId?: string;
members?: Array<{ botId: string }>;
messages: Array<{ role: string; botId?: string }>;
};
test("onboarding lands in the main conversation, its lead answers, and addressed bots join", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `main-conversation-${stamp}@rakazo.test`, "password12", "Main Conversation");
await completeOnboarding(page, testInfo, { mainConversation: true });
const mainUrl = page.url();
const groupId = new URL(mainUrl).pathname.split("/").at(-1)!;
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const sidebar = page.locator("aside").first();
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" });
await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again.
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
await expect(page.getByTestId("oo-group-stage")).toBeVisible();
await captureScreenshot(page, testInfo, "main-conversation-landing");
const snapshot = () => rpc<Snapshot>(page, "threads/get", { groupId });
const botMessages = async (botId: string) =>
(await snapshot()).messages.filter(
(message) => message.role === "bot" && message.botId === botId,
).length;
expect((await snapshot()).leadBotId).toBe(chief.id);
// Nobody addressed: the main bot answers.
const chiefBefore = await botMessages(chief.id);
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("What should we look at first?");
await composer.press("Enter");
await expect.poll(() => botMessages(chief.id), { timeout: 60_000 }).toBeGreaterThan(chiefBefore);
const transcript = page.getByTestId("transcript");
await expect(transcript).toContainText(/on it|handled/i);
// A same-space bot that is not a member yet joins when addressed and answers here.
const researcher = await rpc<{ id: string }>(page, "bots/create", {
name: "Researcher",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await page.reload({ waitUntil: "domcontentloaded" });
await waitForChatComposer(page);
await expect(sidebar.locator(`[data-roster-bot-id="${researcher.id}"]`)).toBeVisible();
await composer.fill("@Res");
await page.getByRole("option", { name: "@Researcher", exact: true }).click();
await expect(page.getByTestId("mention-chip").filter({ hasText: "Researcher" })).toBeVisible();
await composer.fill(`${await composer.inputValue()}find three sources`);
await composer.press("Enter");
await expect
.poll(async () => (await snapshot()).members?.map((member) => member.botId).sort(), {
timeout: 30_000,
})
.toEqual([chief.id, researcher.id].sort());
await expect.poll(() => botMessages(researcher.id), { timeout: 60_000 }).toBeGreaterThan(0);
const researcherReply = transcript
.locator("[data-message-id]")
.filter({ hasText: /on it|handled/i })
.filter({ has: page.getByText("Researcher", { exact: true }) })
.first();
await expect(researcherReply).toBeVisible();
await captureScreenshot(page, testInfo, "main-conversation-specialist-joined");
// From another space the entry is still first and leads back to the home space.
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId);
await expect(mainEntry).toBeVisible({ timeout: 30_000 });
await captureScreenshot(page, testInfo, "main-conversation-from-another-space");
await mainEntry.click();
await page.waitForURL(mainUrl);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(homeSpaceId);
await waitForChatComposer(page);
await expect(researcherReply).toBeVisible();
});

View File

@ -67,7 +67,7 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
}, testInfo) => { }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `hover-actions-${stamp}@rakazo.test`, "password12", "Hover Actions"); await signup(page, `hover-actions-${stamp}@rakazo.test`, "password12", "Hover Actions");
await completeOnboarding(page); await completeOnboarding(page, undefined, { mainConversation: true });
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
const transcript = page.getByTestId("transcript"); const transcript = page.getByTestId("transcript");
@ -383,7 +383,7 @@ test.describe("touch message actions", () => {
test("More exposes actions without simulated hover", async ({ page }, testInfo) => { test("More exposes actions without simulated hover", async ({ page }, testInfo) => {
await signup(page, `touch-actions-${Date.now()}@rakazo.test`, "password12", "Touch Actions"); await signup(page, `touch-actions-${Date.now()}@rakazo.test`, "password12", "Touch Actions");
await completeOnboarding(page); await completeOnboarding(page, undefined, { mainConversation: true });
expect( expect(
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches), await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
).toBe(false); ).toBe(false);

View File

@ -112,8 +112,8 @@ test("picker rows explain groups and spaces", async ({ page }, testInfo) => {
test("later bot waits before showing the focus card; sending cancels it", async ({ page }) => { test("later bot waits before showing the focus card; sending cancels it", async ({ page }) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay"); await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
await completeOnboarding(page); await completeOnboarding(page, undefined, { mainConversation: true });
// First bot from onboarding shows the focus card immediately. // The main bot shows the focus card immediately in the main conversation.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
await page.clock.install(); await page.clock.install();

View File

@ -10,7 +10,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({
}, testInfo) => { }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `onboarding-${stamp}@rakazo.test`, "password12", "Robin"); await signup(page, `onboarding-${stamp}@rakazo.test`, "password12", "Robin");
await completeOnboarding(page); // The main bot greets the user in the main conversation, where onboarding lands.
await completeOnboarding(page, undefined, { mainConversation: true });
await expect( await expect(
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."), page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
@ -27,7 +28,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({
await page.getByTestId("focus-choice-day").click(); await page.getByTestId("focus-choice-day").click();
// The focus step suggests apps but must not rename the bot: the name the // The focus step suggests apps but must not rename the bot: the name the
// user chose during creation ("Chief") is preserved. // user chose during creation ("Chief") is preserved.
await expect(page.locator("main").getByText("Chief", { exact: true })).toBeVisible(); await expect(
page.getByTestId("bot-settings-trigger").getByText("Chief", { exact: true }),
).toBeVisible();
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByText("Slack", { exact: true })).toBeVisible(); await expect(page.getByText("Slack", { exact: true })).toBeVisible();
await expect(page.getByText("Gmail", { exact: true })).toBeVisible(); await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
@ -92,7 +96,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({
test("choice refresh failures leave options available for retry", async ({ page }) => { test("choice refresh failures leave options available for retry", async ({ page }) => {
await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry"); await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry");
await completeOnboarding(page); await completeOnboarding(page, undefined, { mainConversation: true });
const choice = page.getByTestId("focus-choice-day"); const choice = page.getByTestId("focus-choice-day");
await expect(choice).toBeEnabled(); await expect(choice).toBeEnabled();
// Keep the existing choice rendered while its save succeeds and navigation refresh fails. // Keep the existing choice rendered while its save succeeds and navigation refresh fails.

View File

@ -1,6 +1,13 @@
import { expect, type Page, test } from "@playwright/test"; import { expect, type Page, test } from "@playwright/test";
import type { Bot, Routine } from "@rakazo/contracts"; import type { Bot, Routine } from "@rakazo/contracts";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; import {
activeBotId,
captureScreenshot,
completeOnboarding,
createNamedBot,
rpc,
signup,
} from "./helpers";
async function addScheduleTrigger(page: Page, freq: string) { async function addScheduleTrigger(page: Page, freq: string) {
await page.getByRole("button", { name: "Add trigger" }).click(); await page.getByRole("button", { name: "Add trigger" }).click();
@ -191,7 +198,8 @@ test("switching bots while a routine save is pending does not reopen stale state
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `routine-switch-${stamp}@rakazo.test`, "password12", "Routine Switch"); await signup(page, `routine-switch-${stamp}@rakazo.test`, "password12", "Routine Switch");
await completeOnboarding(page); await completeOnboarding(page);
const firstBotId = activeBotId(page); // Both bots are roster entries; the main bot is reached through the main conversation.
const firstBotId = await createNamedBot(page, "First");
const secondBot = await rpc<Bot>(page, "bots/create", { const secondBot = await rpc<Bot>(page, "bots/create", {
name: "Second", name: "Second",
title: "", title: "",
@ -289,7 +297,7 @@ test("switching bots while a routine save is pending does not reopen stale state
); );
const botList = page.locator("aside").first(); const botList = page.locator("aside").first();
await botList.getByRole("button", { name: /^Chief/ }).click(); await botList.getByRole("button", { name: /^First/ }).click();
await staleListIntercepted; await staleListIntercepted;
await botList.getByRole("button", { name: /^Second/ }).click(); await botList.getByRole("button", { name: /^Second/ }).click();
await page.waitForURL(new RegExp(`/app/${secondBot.id}$`)); await page.waitForURL(new RegExp(`/app/${secondBot.id}$`));

View File

@ -99,9 +99,8 @@ test("spaces stay invisible by default and chat creation requires approval", asy
await expect await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(supportSpaceId); .toBe(supportSpaceId);
await page.goto("/onboarding");
await completeOnboarding(page);
// The main conversation stays the first entry while another space is open.
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
@ -119,9 +118,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
}); });
test("a new space auto-completes onboarding and can be deleted from its menu", async ({ test("a new space opens empty and can be deleted from its menu", async ({ page }, testInfo) => {
page,
}, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner"); await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page); await completeOnboarding(page);
@ -131,26 +128,18 @@ test("a new space auto-completes onboarding and can be deleted from its menu", a
const dialog = page.getByRole("dialog", { name: "New space" }); const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Temporary"); await dialog.getByLabel("Name").fill("Temporary");
await dialog.getByRole("button", { name: "Create space", exact: true }).click(); await dialog.getByRole("button", { name: "Create space", exact: true }).click();
await page.waitForURL(/\/onboarding/);
// A new empty space walks onboarding again, then lands in Chief. // Onboarding runs once per user: a new space opens directly, with no bot of its own.
await completeOnboarding(page); await page.waitForURL(/\/app(?:\/)?$/);
await expect(page).toHaveURL(/\/app\//);
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
await captureScreenshot(page, testInfo, "new-space-after-onboarding"); expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]);
await captureScreenshot(page, testInfo, "new-space-empty");
// Spaces can only be deleted when empty — remove auto-created Chief first. // The empty space stays selectable instead of trapping the user.
const [temporaryChief] = await rpc<Array<{ id: string }>>(page, "bots/list", {});
expect(temporaryChief).toBeTruthy();
await rpc(page, "bots/remove", { botId: temporaryChief!.id, deleteMemories: false });
await page.goto("/app");
await page.waitForURL(/\/app(?:\/)?$/);
// The emptied space stays selectable instead of trapping the user.
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible(); await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible();
// Deletion is discoverable through the visible Space actions menu. // Deletion is discoverable through the visible Space actions menu.
@ -179,35 +168,39 @@ test("deleting the last bot in a space stays in the app after first use", async
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `spaces-empty-${stamp}@rakazo.test`, "password12", "Space Owner"); await signup(page, `spaces-empty-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page); await completeOnboarding(page);
const [chief] = await rpc<Array<{ id: string }>>(page, "bots/list", {});
expect(chief).toBeTruthy();
await rpc(page, "bots/archive", { botId: chief!.id });
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 30_000,
});
await expect(page).not.toHaveURL(/\/onboarding/);
const sidebar = page.locator("aside").first(); const sidebar = page.locator("aside").first();
// No active bot thread after archiving the only bot — shell shows Archived, not Message. // The main bot cannot be archived, so the archived section comes from a specialist.
await expect(page.getByRole("combobox", { name: "Message" })).toHaveCount(0); const helper = await rpc<{ id: string }>(page, "bots/create", {
name: "Helper",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/archive", { botId: helper.id });
await page.reload({ waitUntil: "domcontentloaded" });
await expect(sidebar.getByRole("button", { name: /^Archived/ })).toBeVisible({ await expect(sidebar.getByRole("button", { name: /^Archived/ })).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
await openNewSpace(page); await openNewSpace(page);
const dialog = page.getByRole("dialog", { name: "New space" }); const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Side"); await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create space", exact: true }).click(); await dialog.getByRole("button", { name: "Create space", exact: true }).click();
await page.waitForURL(/\/(onboarding|app)/); await page.waitForURL(/\/app(?:\/)?$/);
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Side", { exact: true })).toBeVisible(); await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
// Delete the only bot in the new space: the app must stay put instead of // Delete the only bot in the new space: the app must stay put instead of
// forcing onboarding again. // forcing onboarding again.
const [sideChief] = await rpc<Array<{ id: string }>>(page, "bots/list", {}); const sideBot = await rpc<{ id: string }>(page, "bots/create", {
expect(sideChief).toBeTruthy(); name: "Side bot",
await rpc(page, "bots/remove", { botId: sideChief!.id, deleteMemories: false }); title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/remove", { botId: sideBot.id, deleteMemories: false });
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await page.goto("/app"); await page.goto("/app");
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
@ -225,5 +218,5 @@ test("deleting the last bot in a space stays in the app after first use", async
const archivedToggle = sidebar.getByRole("button", { name: /^Archived/ }); const archivedToggle = sidebar.getByRole("button", { name: /^Archived/ });
await expect(archivedToggle).toBeVisible({ timeout: 30_000 }); await expect(archivedToggle).toBeVisible({ timeout: 30_000 });
await archivedToggle.click(); await archivedToggle.click();
await expect(sidebar.getByText("Chief", { exact: true })).toBeVisible(); await expect(sidebar.getByText("Helper", { exact: true })).toBeVisible();
}); });

View File

@ -4,6 +4,7 @@ import {
captureScreenshot, captureScreenshot,
completeOnboarding, completeOnboarding,
createNamedBot, createNamedBot,
openMainBotThread,
realSandboxTimeout, realSandboxTimeout,
rpc, rpc,
signup, signup,
@ -90,7 +91,8 @@ test("user control leaves another Team bot's screen available", async ({ page },
const chiefId = activeBotId(page); const chiefId = activeBotId(page);
const workerId = await createBot(page, "Worker", "team"); const workerId = await createBot(page, "Worker", "team");
await openBot(page, "Chief"); // The sidebar entry for the main bot opens the main conversation; its computer is in the 1:1.
await openMainBotThread(page);
await page.getByTitle("Agent computer").click(); await page.getByTitle("Agent computer").click();
await page.getByTestId("computer-preview").hover(); await page.getByTestId("computer-preview").hover();
await page.getByTestId("computer-preview-open").click(); await page.getByTestId("computer-preview-open").click();

View File

@ -68,6 +68,12 @@ describe("group-stage", () => {
expect(speaker?.botId).toBe("b1"); expect(speaker?.botId).toBe("b1");
}); });
it("falls back to the lead, not the first member, when nobody has spoken", () => {
const quiet = { threadId: "t1", cursor: 0, messages: [], olderCursor: null, members };
expect(resolveActiveGroupSpeaker({ ...quiet, leadBotId: "b2" } as never, [])?.botId).toBe("b2");
expect(resolveActiveGroupSpeaker(quiet as never, [])?.botId).toBe("b1");
});
it("builds ensemble without foreground speaker", () => { it("builds ensemble without foreground speaker", () => {
const ensemble = resolveGroupEnsemble( const ensemble = resolveGroupEnsemble(
{ threadId: "t1", cursor: 0, messages: [], olderCursor: null, members } as never, { threadId: "t1", cursor: 0, messages: [], olderCursor: null, members } as never,

View File

@ -51,7 +51,7 @@ function findMember(
} }
/** /**
* Foreground Oo for a group encounter: streaming bot → latest bot reply → newest active run → ensemble fallback. * Foreground Oo for a group encounter: streaming bot → latest bot reply → newest active run → lead → first member.
*/ */
export function resolveActiveGroupSpeaker( export function resolveActiveGroupSpeaker(
snapshot: ThreadSnapshot | null, snapshot: ThreadSnapshot | null,
@ -86,6 +86,8 @@ export function resolveActiveGroupSpeaker(
} }
if (workingBots[0]) return workingBots[0]; if (workingBots[0]) return workingBots[0];
const lead = snapshot?.leadBotId ? findMember(members, snapshot.leadBotId, enrich) : null;
if (lead) return lead;
if (members[0]) return { ...members[0], ...enrich?.(members[0].botId) }; if (members[0]) return { ...members[0], ...enrich?.(members[0].botId) };
return null; return null;
} }

View File

@ -1,24 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { isEmptyProjectSpace, projectGroupChatsInSpace } from "./project-empty-state.js";
isEmptyProjectSpace,
isHauptthreadGroup,
projectGroupChatsInSpace,
} from "./project-empty-state.js";
describe("isHauptthreadGroup", () => {
it("recognizes Hauptthread and Team Chat", () => {
expect(isHauptthreadGroup({ id: "1", name: "Hauptthread" })).toBe(true);
expect(isHauptthreadGroup({ id: "2", name: "Team Chat" })).toBe(true);
expect(isHauptthreadGroup({ id: "3", name: "Launch" })).toBe(false);
});
});
describe("projectGroupChatsInSpace", () => { describe("projectGroupChatsInSpace", () => {
it("returns project groups in a space without Hauptthread", () => { it("returns project groups in a space without the main conversation", () => {
const groups = [ const groups = [
{ id: "g1", name: "Launch", spaceId: "space-a" }, { id: "g1", kind: "group" as const, spaceId: "space-a" },
{ id: "g2", name: "Hauptthread", spaceId: "space-a" }, { id: "g2", kind: "main" as const, spaceId: "space-a" },
{ id: "g3", name: "Ops", spaceId: "space-b" }, { id: "g3", kind: "group" as const, spaceId: "space-b" },
]; ];
expect(projectGroupChatsInSpace(groups, "space-a").map((group) => group.id)).toEqual(["g1"]); expect(projectGroupChatsInSpace(groups, "space-a").map((group) => group.id)).toEqual(["g1"]);
}); });
@ -39,12 +27,12 @@ describe("isEmptyProjectSpace", () => {
}); });
it("is false when the project space already has a group chat", () => { it("is false when the project space already has a group chat", () => {
const groups = [{ id: "g1", name: "Launch", spaceId: "project" }]; const groups = [{ id: "g1", kind: "group" as const, spaceId: "project" }];
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false); expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false);
}); });
it("ignores Hauptthread when deciding emptiness", () => { it("ignores the main conversation when deciding emptiness", () => {
const groups = [{ id: "g1", name: "Hauptthread", spaceId: "project" }]; const groups = [{ id: "g1", kind: "main" as const, spaceId: "project" }];
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true); expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true);
}); });
}); });

View File

@ -1,22 +1,18 @@
import type { Group, Space } from "@rakazo/contracts"; import type { Group, Space } from "@rakazo/contracts";
export function isHauptthreadGroup(group: Pick<Group, "id" | "name">): boolean { /** Project-scoped group chats in a space (excludes the main conversation). */
return group.name === "Hauptthread" || group.name === "Team Chat";
}
/** Project-scoped group chats in a space (excludes Hauptthread). */
export function projectGroupChatsInSpace( export function projectGroupChatsInSpace(
groups: readonly Pick<Group, "id" | "name" | "spaceId">[], groups: readonly Pick<Group, "id" | "kind" | "spaceId">[],
spaceId: string, spaceId: string,
): readonly Pick<Group, "id">[] { ): readonly Pick<Group, "id">[] {
return groups.filter((group) => group.spaceId === spaceId && !isHauptthreadGroup(group)); return groups.filter((group) => group.spaceId === spaceId && group.kind !== "main");
} }
/** Non-default project space with no project group conversations yet. */ /** Non-default project space with no project group conversations yet. */
export function isEmptyProjectSpace( export function isEmptyProjectSpace(
spaceId: string | null | undefined, spaceId: string | null | undefined,
spaces: readonly Pick<Space, "id" | "isDefault">[], spaces: readonly Pick<Space, "id" | "isDefault">[],
groups: readonly Pick<Group, "id" | "name" | "spaceId">[], groups: readonly Pick<Group, "id" | "kind" | "spaceId">[],
homeSpaceId: string | undefined, homeSpaceId: string | undefined,
): boolean { ): boolean {
if (!spaceId || !homeSpaceId || spaceId === homeSpaceId) return false; if (!spaceId || !homeSpaceId || spaceId === homeSpaceId) return false;

View File

@ -164,6 +164,8 @@ export function GroupSettings({
const [selected, setSelected] = useState(group.members.map((member) => member.botId)); const [selected, setSelected] = useState(group.members.map((member) => member.botId));
const [pending, setPending] = useState<"save" | "remove" | null>(null); const [pending, setPending] = useState<"save" | "remove" | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Bots join the main conversation when addressed, and it cannot be deleted.
const main = group.kind === "main";
async function mutate(kind: "save" | "remove", action: () => Promise<void>) { async function mutate(kind: "save" | "remove", action: () => Promise<void>) {
if (pending) return; if (pending) return;
@ -217,32 +219,38 @@ export function GroupSettings({
className="mt-2" className="mt-2"
/> />
</label> </label>
<div className="mt-5 text-sm text-muted-foreground"> {main ? null : (
<Trans> <>
Members ({GROUP_MEMBER_MIN}–{GROUP_MEMBER_MAX}) <div className="mt-5 text-sm text-muted-foreground">
</Trans> <Trans>
</div> Members ({GROUP_MEMBER_MIN}–{GROUP_MEMBER_MAX})
<MemberPicker </Trans>
bots={bots} </div>
selected={selected} <MemberPicker
onChange={setSelected} bots={bots}
maxHeight="max-h-[240px]" selected={selected}
/> onChange={setSelected}
maxHeight="max-h-[240px]"
/>
</>
)}
<Button <Button
className="mt-5 w-full" className="mt-5 w-full"
disabled={pending !== null || !validSelection(name, selected)} disabled={pending !== null || !(main ? name.trim() : validSelection(name, selected))}
onClick={() => void mutate("save", save)} onClick={() => void mutate("save", save)}
> >
{pending === "save" ? <Trans>Saving…</Trans> : <Trans>Save</Trans>} {pending === "save" ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
</Button> </Button>
<Button {main ? null : (
variant="destructive" <Button
className="mt-4 w-full" variant="destructive"
disabled={pending !== null} className="mt-4 w-full"
onClick={() => void mutate("remove", onRemove)} disabled={pending !== null}
> onClick={() => void mutate("remove", onRemove)}
{pending === "remove" ? <Trans>Deleting…</Trans> : <Trans>Delete group</Trans>} >
</Button> {pending === "remove" ? <Trans>Deleting…</Trans> : <Trans>Delete group</Trans>}
</Button>
)}
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import type { IntegrationSetupState } from "@rakazo/contracts"; import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
import { Button, Input } from "@rakazo/ui-web"; import { Button, Input } from "@rakazo/ui-web";
import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -20,61 +20,15 @@ import {
type OnboardingDiscoveryPhase, type OnboardingDiscoveryPhase,
} from "../lib/onboarding-discovery"; } from "../lib/onboarding-discovery";
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
import { rpc } from "../lib/rpc"; import { rpc, selectSpace } from "../lib/rpc";
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale"; import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
const FIRST_BOT_NAME = "Chief"; /** Selects the main conversation's space and returns its route. */
const FIRST_BOT_SPAWN_KEY = "onboarding:first"; function enterMainConversation(main: MainConversationRef): string {
const FIRST_BOT_LOCK = "rakazo:onboarding-first-bot"; selectSpace(main.spaceId);
return `/app/g/${main.groupId}`;
let firstBotEnsure: Promise<{ id: string }> | null = null;
function findFirstBot(
bots: Array<{ id: string; name: string; spawnKey: string | null }>,
): { id: string } | undefined {
const bySpawnKey = bots.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY);
if (bySpawnKey) return { id: bySpawnKey.id };
const byName = bots.find((bot) => bot.name === FIRST_BOT_NAME);
return byName ? { id: byName.id } : undefined;
}
async function createOrReuseFirstBot(avatarColor?: string): Promise<{ id: string }> {
const existing = await rpc.bots.list();
const reuse = findFirstBot(existing);
if (reuse) return reuse;
try {
const created = await rpc.bots.create({
name: FIRST_BOT_NAME,
title: "Chief of Staff",
description: "Coordinates specialists — routes work, never a do-everything generalist.",
instructions: "",
notifyOnFinish: true,
spawnKey: FIRST_BOT_SPAWN_KEY,
color: avatarColor,
});
return { id: created.id };
} catch (error) {
const afterConflict = await rpc.bots.list();
const winner = afterConflict.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY);
if (winner) return { id: winner.id };
throw error;
}
}
async function withFirstBotLock<T>(run: () => Promise<T>): Promise<T> {
const locks = globalThis.navigator?.locks;
if (!locks?.request) return run();
return locks.request(FIRST_BOT_LOCK, run);
}
async function ensureFirstBot(avatarColor?: string): Promise<{ id: string }> {
if (firstBotEnsure) return firstBotEnsure;
firstBotEnsure = withFirstBotLock(() => createOrReuseFirstBot(avatarColor)).finally(() => {
firstBotEnsure = null;
});
return firstBotEnsure;
} }
function defaultEmojiConfig(): OnboardingEmojiConfig { function defaultEmojiConfig(): OnboardingEmojiConfig {
@ -187,23 +141,26 @@ export function OnboardingPage() {
// Get bot ID for scan phase // Get bot ID for scan phase
try { try {
const botList = await rpc.bots.list(); const botList = await rpc.bots.list();
const firstBot = botList.find( const mainBot = botList.find((b) => b.kind === "main");
(b) => b.spawnKey === FIRST_BOT_SPAWN_KEY || b.name === FIRST_BOT_NAME, if (mainBot && !cancelled) setScanBotId(mainBot.id);
);
if (firstBot && !cancelled) setScanBotId(firstBot.id);
} catch { } catch {
/* best effort */ /* best effort */
} }
setIntegrationSetup(integrations); setIntegrationSetup(integrations);
setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji"); // The first step renders before this resolves; never pull a user back who moved on.
// A space that already has a bot does not need the designer again. if (!me.needsModel && integrations?.needsSetup) {
// Empty spaces (a new project) still walk onboarding. Resolve this after setStep((current) => (current === "emoji" ? "integrations" : current));
// the first step renders so a slow spaces list cannot leave E2E on loading. }
// Onboarding runs once: a user with a main bot, or a space that already
// has a bot, does not need the designer again. Resolve this after the
// first step renders so a slow spaces list cannot leave E2E on loading.
try { try {
const navigation = await rpc.spaces.list(); const navigation = await rpc.spaces.list();
if (cancelled) return; if (cancelled) return;
const existing = navigation.current.bots[0]; const existing = navigation.current.bots[0];
if (existing && !me.needsModel) { if (navigation.main && !me.needsModel) {
navigate(enterMainConversation(navigation.main), { replace: true });
} else if (existing && !me.needsModel) {
navigate(`/app/${existing.id}`, { replace: true }); navigate(`/app/${existing.id}`, { replace: true });
} }
} catch { } catch {
@ -211,7 +168,7 @@ export function OnboardingPage() {
} }
}) })
.catch(() => { .catch(() => {
if (!cancelled) setStep("emoji"); if (!cancelled) setStep((current) => (current === "loading" ? "emoji" : current));
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@ -262,7 +219,8 @@ export function OnboardingPage() {
createStartedRef.current = true; createStartedRef.current = true;
setError(null); setError(null);
try { try {
const bot = await ensureFirstBot(emojiConfig.avatarValue); const bot = await rpc.bots.ensureMain();
await rpc.bots.update({ botId: bot.id, color: emojiConfig.avatarValue }).catch(() => null);
setCrewReady(true); setCrewReady(true);
const ctxParts: string[] = []; const ctxParts: string[] = [];
@ -305,7 +263,11 @@ export function OnboardingPage() {
if (started) { if (started) {
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
} }
navigate(`/app/${bot.id}`); const main = await rpc.spaces
.list()
.then((navigation) => navigation.main)
.catch(() => null);
navigate(main ? enterMainConversation(main) : `/app/${bot.id}`);
} catch (err) { } catch (err) {
createStartedRef.current = false; createStartedRef.current = false;
setError(err instanceof Error ? err.message : t`Could not create your bot`); setError(err instanceof Error ? err.message : t`Could not create your bot`);

View File

@ -27,6 +27,7 @@ import type {
Connection, Connection,
ConnectionCatalogItem, ConnectionCatalogItem,
Group, Group,
MainConversationRef,
Me, Me,
ProductEvent, ProductEvent,
Routine, Routine,
@ -273,6 +274,7 @@ import {
PickerInfoDialog, PickerInfoDialog,
RenameBotSectionDialog, RenameBotSectionDialog,
} from "./shell/dialogs"; } from "./shell/dialogs";
import { MainConversationRow } from "./shell/main-conversation-row";
import { import {
AppConnectCard, AppConnectCard,
ArtifactImage, ArtifactImage,
@ -280,6 +282,13 @@ import {
ChoiceCard, ChoiceCard,
McpApprovalCard, McpApprovalCard,
} from "./shell/message-cards"; } from "./shell/message-cards";
import {
rosterNameClass,
rosterPreviewClass,
rosterRowClass,
rosterTimeClass,
rosterUnreadDotClass,
} from "./shell/roster-row-style";
import { WindowChrome } from "./WindowChrome"; import { WindowChrome } from "./WindowChrome";
const BotContextMenu = lazyWithRecovery("BotContextMenu", () => const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
@ -443,6 +452,7 @@ export function ShellPage() {
const savingBotOrderRef = useRef(false); const savingBotOrderRef = useRef(false);
const [botSections, setBotSections] = useState<BotSection[]>([]); const [botSections, setBotSections] = useState<BotSection[]>([]);
const [spaces, setSpaces] = useState<Space[]>([]); const [spaces, setSpaces] = useState<Space[]>([]);
const [mainConversation, setMainConversation] = useState<MainConversationRef | null>(null);
const [archivedBots, setArchivedBots] = useState<Bot[]>([]); const [archivedBots, setArchivedBots] = useState<Bot[]>([]);
const [archivedGroups, setArchivedGroups] = useState<Group[]>([]); const [archivedGroups, setArchivedGroups] = useState<Group[]>([]);
const { const {
@ -1033,6 +1043,7 @@ export function ShellPage() {
setBotSections(sections); setBotSections(sections);
setGroups(groupList); setGroups(groupList);
setSpaces(navigation.spaces); setSpaces(navigation.spaces);
setMainConversation(navigation.main);
setInitialBotsLoaded(true); setInitialBotsLoaded(true);
botsRefreshApplied.current = request; botsRefreshApplied.current = request;
if ( if (
@ -1272,6 +1283,7 @@ export function ShellPage() {
setArchivedGroups(bootstrap.archivedGroups); setArchivedGroups(bootstrap.archivedGroups);
setGroups(groupList); setGroups(groupList);
setSpaces(bootstrap.spaces); setSpaces(bootstrap.spaces);
setMainConversation(bootstrap.main);
setInitialBotsLoaded(true); setInitialBotsLoaded(true);
} }
if (!groupId && bootstrap.thread) { if (!groupId && bootstrap.thread) {
@ -1603,6 +1615,10 @@ export function ShellPage() {
if (event.type === "group.created") { if (event.type === "group.created") {
void refreshBots().catch(() => undefined); void refreshBots().catch(() => undefined);
} }
if (event.type === "group.updated") {
void refreshGroupThread(groupId).catch(() => undefined);
void refreshBots().catch(() => undefined);
}
if (event.type === "run.started" || isRunTerminalEvent(event)) { if (event.type === "run.started" || isRunTerminalEvent(event)) {
void refreshBots().catch(() => undefined); void refreshBots().catch(() => undefined);
} }
@ -1699,11 +1715,6 @@ export function ShellPage() {
}); });
}, [bootstrapMe, botSections, bots, groups, spaces, query]); }, [bootstrapMe, botSections, bots, groups, spaces, query]);
const hauptthreadGroup = useMemo(
() => groups.find((g) => g.name === "Hauptthread" || g.name === "Team Chat"),
[groups],
);
const homeSpaceBots = useMemo(() => { const homeSpaceBots = useMemo(() => {
if (!homeSpaceId) return bots; if (!homeSpaceId) return bots;
const home = spaces.find((space) => space.id === homeSpaceId); const home = spaces.find((space) => space.id === homeSpaceId);
@ -1718,11 +1729,27 @@ export function ShellPage() {
...group, ...group,
title: stripSpaceTitlePrefix(group.title, group.spaceName), title: stripSpaceTitlePrefix(group.title, group.spaceName),
showLock: false, showLock: false,
bots: group.bots.filter((item) => item.kind === "bot"), bots: group.bots
.filter((item) => item.kind === "bot")
.filter((item) => item.chat.kind !== "main"),
})) }))
.filter((group) => group.bots.length > 0); .filter((group) => group.bots.length > 0);
}, [homeSpaceId, sidebarGroups]); }, [homeSpaceId, sidebarGroups]);
const mainConversationEntry = useMemo(() => {
if (!mainConversation) return null;
const inCurrentSpace = mainConversation.spaceId === bootstrapMe?.spaceId;
const space = spaces.find((item) => item.id === mainConversation.spaceId);
const bot = (inCurrentSpace ? bots : space?.bots)?.find(
(item) => item.id === mainConversation.botId,
);
const group = (inCurrentSpace ? groups : space?.groups)?.find(
(item) => item.id === mainConversation.groupId,
);
if (!bot || !group) return null;
return { ...mainConversation, bot, group };
}, [bootstrapMe?.spaceId, bots, groups, mainConversation, spaces]);
const projectSidebarGroups = useMemo(() => { const projectSidebarGroups = useMemo(() => {
if (!homeSpaceId) return []; if (!homeSpaceId) return [];
const entries: typeof sidebarGroups = []; const entries: typeof sidebarGroups = [];
@ -1744,9 +1771,7 @@ export function ShellPage() {
seenProjectSpaceIds.add(space.id); seenProjectSpaceIds.add(space.id);
const groupsInSpace = sidebarGroups.flatMap((group) => const groupsInSpace = sidebarGroups.flatMap((group) =>
group.spaceId === space.id group.spaceId === space.id
? group.bots.filter( ? group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main")
(item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id,
)
: [], : [],
); );
const projectBots = dedupeSidebarBots(groupsInSpace); const projectBots = dedupeSidebarBots(groupsInSpace);
@ -1767,7 +1792,7 @@ export function ShellPage() {
for (const group of sidebarGroups) { for (const group of sidebarGroups) {
if (group.spaceId !== homeSpaceId) continue; if (group.spaceId !== homeSpaceId) continue;
const groupChats = dedupeSidebarBots( const groupChats = dedupeSidebarBots(
group.bots.filter((item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id), group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main"),
).filter((item) => { ).filter((item) => {
if (seenProjectGroupChatIds.has(item.chat.id)) return false; if (seenProjectGroupChatIds.has(item.chat.id)) return false;
seenProjectGroupChatIds.add(item.chat.id); seenProjectGroupChatIds.add(item.chat.id);
@ -1798,7 +1823,7 @@ export function ShellPage() {
} }
return entries; return entries;
}, [hauptthreadGroup?.id, homeSpaceId, sidebarGroups, spaces, projectOrder]); }, [homeSpaceId, sidebarGroups, spaces, projectOrder]);
// DnD handlers // DnD handlers
const handleProjectDragEnd = useCallback( const handleProjectDragEnd = useCallback(
@ -1850,10 +1875,20 @@ export function ShellPage() {
[openSpaceChat], [openSpaceChat],
); );
const openMainConversation = useCallback(() => {
if (!mainConversation) return;
setActiveNavPillar("team");
openSpaceChat(mainConversation.spaceId, `/app/g/${mainConversation.groupId}`);
}, [mainConversation, openSpaceChat]);
const openConversationBot = useCallback( const openConversationBot = useCallback(
(botId: string) => { (botId: string) => {
setConversationPickerOpen(false); setConversationPickerOpen(false);
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
if (botId === mainConversation?.botId) {
openMainConversation();
return;
}
setActiveNavPillar("team"); setActiveNavPillar("team");
const bot = const bot =
homeSpaceBots.find((item) => item.id === botId) ?? bots.find((item) => item.id === botId); homeSpaceBots.find((item) => item.id === botId) ?? bots.find((item) => item.id === botId);
@ -1863,7 +1898,7 @@ export function ShellPage() {
} }
navigate(`/app/${botId}`); navigate(`/app/${botId}`);
}, },
[bots, homeSpaceBots, navigate, openSpaceChat], [bots, homeSpaceBots, mainConversation?.botId, navigate, openMainConversation, openSpaceChat],
); );
const flushBotOrder = useCallback(async () => { const flushBotOrder = useCallback(async () => {
@ -2150,6 +2185,13 @@ export function ShellPage() {
[active?.id, groupId, inGroup], [active?.id, groupId, inGroup],
); );
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members; const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
const knownBots = useMemo(() => {
const byId = new Map<string, { name: string; color: string; status?: string }>();
for (const space of spaces) for (const bot of space.bots) byId.set(bot.id, bot);
for (const bot of bots) byId.set(bot.id, bot);
return byId;
}, [bots, spaces]);
const resolveTranscriptBot = useCallback( const resolveTranscriptBot = useCallback(
(botId: string) => { (botId: string) => {
const bot = bots.find((candidate) => candidate.id === botId); const bot = bots.find((candidate) => candidate.id === botId);
@ -2158,6 +2200,10 @@ export function ShellPage() {
}, },
[bots, transcriptMembers], [bots, transcriptMembers],
); );
const resolveSpeakerBot = useCallback(
(botId: string) => resolveTranscriptBot(botId) ?? knownBots.get(botId),
[knownBots, resolveTranscriptBot],
);
const enrichStageMember = useCallback( const enrichStageMember = useCallback(
(botId: string) => { (botId: string) => {
const bot = resolveTranscriptBot(botId); const bot = resolveTranscriptBot(botId);
@ -2352,8 +2398,10 @@ export function ShellPage() {
}, [activeSnapshot?.messages, inGroup]); }, [activeSnapshot?.messages, inGroup]);
const resolveTranscriptMemberName = useCallback( const resolveTranscriptMemberName = useCallback(
(botId: string | undefined) => memberName(transcriptMembers, botId), (botId: string | undefined) =>
[transcriptMembers], memberName(transcriptMembers, botId) ??
(inGroup && botId ? knownBots.get(botId)?.name : undefined),
[inGroup, knownBots, transcriptMembers],
); );
const replyTargetName = activeReplyTarget const replyTargetName = activeReplyTarget
? activeReplyTarget.role === "user" ? activeReplyTarget.role === "user"
@ -3292,23 +3340,6 @@ export function ShellPage() {
</div> </div>
) : null; ) : null;
useEffect(() => {
if (!active && !activeGroup && initialBotsLoaded && !emptyProjectSpace) {
const hauptthread = groups.find((g) => g.name === "Hauptthread" || g.name === "Team Chat");
if (hauptthread) {
navigate(`/app/g/${hauptthread.id}`);
} else if (bots.length > 0) {
rpc.groups
.create({ name: "Hauptthread", botIds: bots.map((b) => b.id) })
.then((group) => {
setGroups((curr) => [group, ...curr]);
navigate(`/app/g/${group.id}`);
})
.catch(console.error);
}
}
}, [active, activeGroup, emptyProjectSpace, initialBotsLoaded, groups, bots, navigate]);
const userName = session.data?.user.name ?? t`You`; const userName = session.data?.user.name ?? t`You`;
const initials = userName const initials = userName
.split(" ") .split(" ")
@ -3585,7 +3616,25 @@ export function ShellPage() {
</Popover> </Popover>
</div> </div>
</div> </div>
{conversationSidebarGroups.length === 0 ? ( {mainConversationEntry ? (
<div data-sidebar-group="main">
<MainConversationRow
name={mainConversationEntry.group.name}
botId={mainConversationEntry.bot.id}
color={mainConversationEntry.bot.color}
status={mainConversationEntry.bot.status}
unread={mainConversationEntry.group.unread}
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
subtitle={rosterActivitySubtitle(
mainConversationEntry.bot.status,
cleanRosterPreview(mainConversationEntry.group.preview),
)}
active={inGroup && groupId === mainConversationEntry.groupId}
onOpen={openMainConversation}
/>
</div>
) : null}
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
<div className="p-4 text-center text-ui-label text-muted-foreground"> <div className="p-4 text-center text-ui-label text-muted-foreground">
<p>{t`Noch keine Unterhaltung.`}</p> <p>{t`Noch keine Unterhaltung.`}</p>
<Button <Button
@ -3759,11 +3808,9 @@ export function ShellPage() {
position: { x: event.clientX, y: event.clientY }, position: { x: event.clientX, y: event.clientY },
}); });
}} }}
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] cursor-grab active:cursor-grabbing ${ className={`${rosterRowClass(
!inGroup && active?.id === item.chat.id !inGroup && active?.id === item.chat.id,
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30" )} cursor-grab active:cursor-grabbing`}
: "hover:bg-white/[0.05] border border-transparent"
}`}
style={{ style={{
opacity: draggedBotId === item.chat.id ? 0.55 : 1, opacity: draggedBotId === item.chat.id ? 0.55 : 1,
}} }}
@ -3791,11 +3838,9 @@ export function ShellPage() {
<span <span
dir="auto" dir="auto"
data-roster-bot-name="" data-roster-bot-name=""
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${ className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
item.chat.unread item.chat.unread,
? "font-bold text-white" )}`}
: "font-semibold text-white/95"
}`}
> >
{item.chat.name} {item.chat.name}
</span> </span>
@ -3815,22 +3860,19 @@ export function ShellPage() {
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium"> <span className={rosterTimeClass}>
{formatRosterTime(item.chat.updatedAt)} {formatRosterTime(item.chat.updatedAt)}
</span> </span>
{item.chat.unread ? ( {item.chat.unread ? (
<span <span aria-hidden="true" className={rosterUnreadDotClass} />
aria-hidden="true"
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
/>
) : null} ) : null}
</div> </div>
</div> </div>
<div <div
dir="auto" dir="auto"
className={`mt-0.5 truncate text-ui-body leading-snug ${ className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
item.chat.unread ? "font-medium text-white" : "text-zinc-300" item.chat.unread,
}`} )}`}
> >
{rosterActivitySubtitle( {rosterActivitySubtitle(
item.chat.status, item.chat.status,
@ -4453,7 +4495,7 @@ export function ShellPage() {
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug"> <span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Gruppenunterhaltung`} {t`Gruppenunterhaltung`}
</span> </span>
) : inGroup ? ( ) : inGroup && activeGroup?.kind !== "main" ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug"> <span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Projekt · Sessions & Ordner`} {t`Projekt · Sessions & Ordner`}
</span> </span>
@ -4582,7 +4624,7 @@ export function ShellPage() {
setPeerConversation(peer); setPeerConversation(peer);
}} }}
memberName={resolveTranscriptMemberName} memberName={resolveTranscriptMemberName}
peerBot={resolveTranscriptBot} peerBot={resolveSpeakerBot}
onRefresh={refreshActiveThread} onRefresh={refreshActiveThread}
onBotChanged={refreshBots} onBotChanged={refreshBots}
onAddRoutine={addSkillRoutine} onAddRoutine={addSkillRoutine}
@ -5459,7 +5501,7 @@ export function ShellPage() {
await refreshBots(); await refreshBots();
return; return;
} }
window.location.assign(newSpaceDialogVariant === "project" ? "/app" : "/onboarding"); window.location.assign("/app");
}} }}
/> />
) : null} ) : null}

View File

@ -0,0 +1,65 @@
import { useLingui } from "@lingui/react/macro";
import { BotAvatar } from "@rakazo/ui-web";
import {
rosterNameClass,
rosterPreviewClass,
rosterRowClass,
rosterTimeClass,
rosterUnreadDotClass,
} from "./roster-row-style";
/** First sidebar entry in every space: the main conversation, shown as its main bot. */
export function MainConversationRow({
name,
botId,
color,
status,
unread,
time,
subtitle,
active,
onOpen,
}: {
name: string;
botId: string;
color: string;
status?: string;
unread: boolean;
time: string;
subtitle: string;
active: boolean;
onOpen: () => void;
}) {
const { t } = useLingui();
return (
<button
type="button"
data-main-conversation=""
aria-label={t`${name}, main conversation`}
onClick={onOpen}
className={rosterRowClass(active)}
>
<BotAvatar color={color} identity={botId} size={40} status={status} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1.5">
<span
dir="auto"
className={`truncate text-ui-body tracking-tight ${rosterNameClass(unread)}`}
>
{name}
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className={rosterTimeClass}>{time}</span>
{unread ? <span aria-hidden="true" className={rosterUnreadDotClass} /> : null}
</span>
</div>
<div
dir="auto"
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(unread)}`}
>
{subtitle}
</div>
</div>
</button>
);
}

View File

@ -0,0 +1,20 @@
/** One look for sidebar conversation rows: the main conversation entry and the bot roster. */
export function rosterRowClass(selected: boolean): string {
return `flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
selected
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
: "hover:bg-white/[0.05] border border-transparent"
}`;
}
export function rosterNameClass(unread: boolean): string {
return unread ? "font-bold text-white" : "font-semibold text-white/95";
}
export const rosterTimeClass = "text-ui-body-sm text-zinc-400 tabular-nums font-medium";
export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-emerald-400";
export function rosterPreviewClass(unread: boolean): string {
return unread ? "font-medium text-white" : "text-zinc-300";
}

View File

@ -243,6 +243,45 @@ describe("spawned bot archival", () => {
), ),
).resolves.toMatchObject({ ok: true, botId: "child-1", name: "Scout" }); ).resolves.toMatchObject({ ok: true, botId: "child-1", name: "Scout" });
}); });
it("refuses to archive the main bot", async () => {
const transaction = vi.fn();
const prisma = {
bot: {
findMany: vi.fn().mockResolvedValue([
{
id: "main-1",
spaceId: "workspace-1",
userId: "user-1",
parentBotId: "parent-1",
name: "Chief",
kind: "main",
archivedAt: null,
},
]),
},
$transaction: transaction,
} as unknown as PrismaClient;
await expect(
archiveSpawnedBot(
{
prisma,
sandbox: {} as SandboxProvider,
home: {} as AgentHomeStore,
jobs: { cancel: vi.fn() } as unknown as JobPublisher,
},
{
spawnedByBotId: "parent-1",
userId: "user-1",
spaceId: "workspace-1",
confirmName: "Chief",
},
context,
),
).resolves.toEqual({ error: "The main bot can't be archived." });
expect(transaction).not.toHaveBeenCalled();
});
}); });
describe("destroyBot", () => { describe("destroyBot", () => {
@ -334,7 +373,7 @@ describe("destroyBot", () => {
expect(removeArtifact).toHaveBeenCalledWith("stored-artifact", context); expect(removeArtifact).toHaveBeenCalledWith("stored-artifact", context);
}); });
it("dissolves groups with fewer than two active members after deleting the bot", async () => { it("dissolves groups with fewer than two active members, never the main conversation", async () => {
const deleteGroups = vi.fn().mockResolvedValue({ count: 1 }); const deleteGroups = vi.fn().mockResolvedValue({ count: 1 });
const deleteMemberships = vi.fn().mockResolvedValue({ count: 1 }); const deleteMemberships = vi.fn().mockResolvedValue({ count: 1 });
const cancel = vi.fn().mockResolvedValue(undefined); const cancel = vi.fn().mockResolvedValue(undefined);
@ -387,6 +426,15 @@ describe("destroyBot", () => {
{ botId: "bot-3", bot: { archivedAt: null } }, { botId: "bot-3", bot: { archivedAt: null } },
], ],
}, },
{
id: "main-conversation",
kind: "main",
thread: { id: "thread-main" },
members: [
{ botId: "bot-1", bot: { archivedAt: null } },
{ botId: "main-bot", bot: { archivedAt: null } },
],
},
]), ]),
deleteMany: deleteGroups, deleteMany: deleteGroups,
}, },

View File

@ -255,6 +255,9 @@ export async function archiveSpawnedBot(
if (target.id === input.spawnedByBotId) { if (target.id === input.spawnedByBotId) {
return { error: "A bot cannot archive itself with archive_bot." }; return { error: "A bot cannot archive itself with archive_bot." };
} }
if (target.kind === "main") {
return { error: "The main bot can't be archived." };
}
await archiveBot(deps, target, context); await archiveBot(deps, target, context);
return { ok: true as const, botId: target.id, name: target.name }; return { ok: true as const, botId: target.id, name: target.name };
@ -481,6 +484,8 @@ async function detachBotFromGroups(tx: Prisma.TransactionClient, botId: string)
}); });
const dissolvedGroupIds: string[] = []; const dissolvedGroupIds: string[] = [];
for (const group of affectedGroups) { for (const group of affectedGroups) {
// The main conversation outlives every member; its lead is restored on next use.
if (group.kind === "main") continue;
const activeMembersAfterDeletion = group.members.filter( const activeMembersAfterDeletion = group.members.filter(
(member) => member.botId !== botId && member.bot.archivedAt === null, (member) => member.botId !== botId && member.bot.archivedAt === null,
).length; ).length;

View File

@ -303,6 +303,14 @@ export const SpaceSchema = z.object({
}); });
export type Space = z.infer<typeof SpaceSchema>; export type Space = z.infer<typeof SpaceSchema>;
/** Where the user's main bot and main conversation live; the space may differ from the current one. */
export const MainConversationRefSchema = z.object({
botId: Id,
groupId: Id,
spaceId: Id,
});
export type MainConversationRef = z.infer<typeof MainConversationRefSchema>;
export const SpaceNavigationSchema = z.object({ export const SpaceNavigationSchema = z.object({
current: z.object({ current: z.object({
id: Id, id: Id,
@ -313,6 +321,8 @@ export const SpaceNavigationSchema = z.object({
botSections: z.array(BotSectionSchema), botSections: z.array(BotSectionSchema),
}), }),
spaces: z.array(SpaceSchema), spaces: z.array(SpaceSchema),
/** Null until onboarding has created the main bot. */
main: MainConversationRefSchema.nullable(),
}); });
export type SpaceNavigation = z.infer<typeof SpaceNavigationSchema>; export type SpaceNavigation = z.infer<typeof SpaceNavigationSchema>;
@ -901,6 +911,8 @@ export const ThreadSnapshotSchema = z.object({
groupId: Id.optional(), groupId: Id.optional(),
groupName: z.string().optional(), groupName: z.string().optional(),
members: z.array(GroupMemberSchema).optional(), members: z.array(GroupMemberSchema).optional(),
/** The member that answers when nobody is addressed (the main conversation's main bot). */
leadBotId: Id.optional(),
run: RunSchema.nullable(), run: RunSchema.nullable(),
activeRuns: z.array(RunSchema).optional(), activeRuns: z.array(RunSchema).optional(),
computer: ComputerStatusSchema.optional(), computer: ComputerStatusSchema.optional(),
@ -1237,6 +1249,7 @@ export const AppBootstrapSchema = z.object({
thread: ThreadSnapshotSchema.nullable(), thread: ThreadSnapshotSchema.nullable(),
routines: z.array(RoutineSchema), routines: z.array(RoutineSchema),
spaces: z.array(SpaceSchema), spaces: z.array(SpaceSchema),
main: MainConversationRefSchema.nullable(),
}); });
export type AppBootstrap = z.infer<typeof AppBootstrapSchema>; export type AppBootstrap = z.infer<typeof AppBootstrapSchema>;

View File

@ -251,6 +251,8 @@ export const appContract = {
listArchived: oc.output(z.array(BotSchema)), listArchived: oc.output(z.array(BotSchema)),
get: oc.input(botId).output(BotSchema), get: oc.input(botId).output(BotSchema),
create: oc.input(CreateBotInput).output(BotSchema), create: oc.input(CreateBotInput).output(BotSchema),
/** The user's main bot, created with its main conversation in the home space when missing. */
ensureMain: oc.output(BotSchema),
duplicate: oc.input(botId).output(BotSchema), duplicate: oc.input(botId).output(BotSchema),
reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })), reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })),
update: oc.input(UpdateBotInput).output(BotSchema), update: oc.input(UpdateBotInput).output(BotSchema),

View File

@ -89,6 +89,21 @@ describe("resolveGroupTargetBotIds", () => {
}), }),
).toEqual(["a"]); ).toEqual(["a"]);
}); });
it("lets the lead answer when unmentioned, but not when someone is addressed", () => {
expect(resolveGroupTargetBotIds({ text: "hello team", members, defaultBotId: "c" })).toEqual([
"c",
]);
expect(
resolveGroupTargetBotIds({ text: "@BotB take this", members, defaultBotId: "c" }),
).toEqual(["b"]);
});
it("falls back to the first member when the lead is not a member", () => {
expect(resolveGroupTargetBotIds({ text: "hello team", members, defaultBotId: "gone" })).toEqual(
["a"],
);
});
}); });
describe("inferHandoffTargetName", () => { describe("inferHandoffTargetName", () => {

View File

@ -32,6 +32,8 @@ export function resolveGroupTargetBotIds(input: {
members: GroupMemberRef[]; members: GroupMemberRef[];
/** Bot ids from typed mention chips (non-members are ignored for wake). */ /** Bot ids from typed mention chips (non-members are ignored for wake). */
explicitMentions?: string[]; explicitMentions?: string[];
/** Member that answers when nobody is addressed, such as a group's lead; else the first. */
defaultBotId?: string | null;
}): string[] { }): string[] {
const membersById = new Map(input.members.map((member) => [member.id, member])); const membersById = new Map(input.members.map((member) => [member.id, member]));
const targetIds = new Set<string>(); const targetIds = new Set<string>();
@ -49,8 +51,12 @@ export function resolveGroupTargetBotIds(input: {
} }
} }
if (targetIds.size === 0 && input.members[0]) { if (targetIds.size === 0) {
targetIds.add(input.members[0].id); const fallback =
input.defaultBotId && membersById.has(input.defaultBotId)
? input.defaultBotId
: input.members[0]?.id;
if (fallback) targetIds.add(fallback);
} }
return [...targetIds]; return [...targetIds];

View File

@ -81,6 +81,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
["bots/listArchived"], ["bots/listArchived"],
["bots/get", { botId: "missing-bot" }], ["bots/get", { botId: "missing-bot" }],
["bots/create", botInput("Unauthenticated")], ["bots/create", botInput("Unauthenticated")],
["bots/ensureMain"],
["bots/duplicate", { botId: "missing-bot" }], ["bots/duplicate", { botId: "missing-bot" }],
["bots/update", { botId: "missing-bot", name: "Nope" }], ["bots/update", { botId: "missing-bot", name: "Nope" }],
["bots/archive", { botId: "missing-bot" }], ["bots/archive", { botId: "missing-bot" }],
@ -387,6 +388,29 @@ describeWithDatabase("API authorization and resource isolation", () => {
groupCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)), groupCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
); );
// The main conversation's own refusals must not reveal it to another user: a foreign main
// conversation is answered exactly like a group that does not exist.
await rpc(app, owner, "bots/ensureMain");
const ownerMain = (await rpc<SpaceNavigation>(app, owner, "spaces/list")).main;
expect(ownerMain).not.toBeNull();
for (const [procedure, input] of [
["groups/archive", {}],
["groups/remove", {}],
["groups/update", { botIds: [intruderBot.id, ownerBot.id] }],
] as const) {
const missing = await raw(app, intruder, procedure, { ...input, groupId: "missing-group" });
const foreign = await raw(app, intruder, procedure, {
...input,
groupId: ownerMain!.groupId,
});
expect(foreign.status, procedure).toBe(missing.status);
expect(foreign.status, procedure).not.toBe(400);
}
expect(
(await rpc<SpaceNavigation>(app, intruder, "spaces/list")).main,
"an intruder has no main conversation of their own yet",
).toBeNull();
// A caller cannot pair their own bot with another workspace's run ID. // A caller cannot pair their own bot with another workspace's run ID.
await expectDenied(app, intruder, "threads/answer", { await expectDenied(app, intruder, "threads/answer", {
botId: intruderBot.id, botId: intruderBot.id,

View File

@ -2212,6 +2212,110 @@ describeJourneys("required product journeys", () => {
expect(remainingBotIds).not.toContain(artifactOwnerId); expect(remainingBotIds).not.toContain(artifactOwnerId);
}); });
it("55: the main bot leads the main conversation and addressed bots join it", async () => {
const cookie = await signup(app, `main-conversation-${stamp}@rakazo.test`, "Main Conversation");
const me = await rpc<Me>(app, cookie, "me");
type MainRef = { botId: string; groupId: string; spaceId: string };
// Reading navigation must not create a main bot before onboarding asks for one.
expect((await rpc<{ main: MainRef | null }>(app, cookie, "spaces/list")).main).toBeNull();
const mainBot = await rpc<Bot & { kind: string }>(app, cookie, "bots/ensureMain");
expect(mainBot.kind).toBe("main");
expect((await rpc<Bot>(app, cookie, "bots/ensureMain")).id).toBe(mainBot.id);
const { main } = await rpc<{ main: MainRef | null }>(app, cookie, "bootstrap", {});
expect(main).toMatchObject({ botId: mainBot.id, spaceId: me.spaceId });
expect((await rpc<{ main: MainRef | null }>(app, cookie, "spaces/list")).main).toEqual(main);
const groupId = main!.groupId;
const { id: threadId } = await prisma.thread.findUniqueOrThrow({
where: { groupId },
select: { id: true },
});
const analyst = await rpc<Bot>(app, cookie, "bots/create", {
name: "Analyst",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const userRunBots = async () =>
(
await prisma.run.findMany({
where: { threadId, trigger: "user" },
orderBy: { createdAt: "asc" },
select: { botId: true },
})
).map((run) => run.botId);
// The lead answers when nobody is addressed, even with a single member.
await sendGroupAndWait(app, cookie, groupId, "hello there");
expect(await userRunBots()).toEqual([mainBot.id]);
// Addressing a same-space bot makes it join the conversation and answer there.
await sendGroupAndWait(app, cookie, groupId, "@Analyst check the numbers", analyst.id);
expect(await userRunBots()).toEqual([mainBot.id, analyst.id]);
const joined = await rpc<{ leadBotId?: string; members: Array<{ botId: string }> }>(
app,
cookie,
"threads/get",
{ groupId },
);
expect(joined.leadBotId).toBe(mainBot.id);
expect(joined.members.map((member) => member.botId).sort()).toEqual(
[mainBot.id, analyst.id].sort(),
);
expect(await prisma.event.count({ where: { threadId, type: "group.updated" } })).toBe(1);
expect(
await prisma.message.count({ where: { threadId, role: "bot", botId: analyst.id } }),
).toBeGreaterThan(0);
// A chip for a member routes only to that member.
await rpc(app, cookie, "threads/send", {
groupId,
text: "and the totals",
mentions: [{ kind: "bot", id: analyst.id }],
});
await waitForDatabase(async () => (await userRunBots()).length === 3);
expect((await userRunBots()).at(-1)).toBe(analyst.id);
for (const [procedure, input] of [
["groups/update", { groupId, botIds: [mainBot.id, analyst.id] }],
["groups/archive", { groupId }],
["groups/remove", { groupId }],
["bots/archive", { botId: mainBot.id }],
["bots/remove", { botId: mainBot.id }],
] as const) {
expect((await raw(app, cookie, procedure, input)).status, procedure).toBe(400);
}
await rpc(app, cookie, "groups/update", { groupId, pinned: true });
await rpc(app, cookie, "bots/update", { botId: mainBot.id, name: "Ada" });
expect((await prisma.chatGroup.findUniqueOrThrow({ where: { id: groupId } })).name).toBe("Ada");
// A member leaving never dissolves the main conversation.
await waitForDatabase(
async () =>
(await prisma.run.count({
where: { threadId, status: { in: [...ACTIVE_RUN_STATUSES] } },
})) === 0,
);
await rpc(app, cookie, "bots/remove", { botId: analyst.id, deleteMemories: true });
const afterLeave = await rpc<{ members: Array<{ botId: string }> }>(
app,
cookie,
"threads/get",
{
groupId,
},
);
expect(afterLeave.members.map((member) => member.botId)).toEqual([mainBot.id]);
await rpc(app, cookie, "threads/clear", { groupId });
const cleared = await prisma.event.findFirstOrThrow({
where: { threadId, type: "thread.cleared" },
orderBy: { seq: "desc" },
});
expect(cleared.botId).toBe(mainBot.id);
});
it("17: teach a task end to end", async () => { it("17: teach a task end to end", async () => {
const cookie = await signup(app, `teach-j-${stamp}@rakazo.test`, "Teach Ada"); const cookie = await signup(app, `teach-j-${stamp}@rakazo.test`, "Teach Ada");
const bot = await rpc<Bot>(app, cookie, "bots/create", { const bot = await rpc<Bot>(app, cookie, "bots/create", {