Merge main conversation: land in it, led by the main bot

The only conflict was the onboarding imports: onboarding now creates the
main bot, writes the profile and lands in the main conversation.

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-03 00:26:31 +00:00
commit f359da0bf9
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

@ -117,6 +117,7 @@ import {
createSpaceForMember, createSpaceForMember,
deleteEmptySpaceForMember, deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret, deleteUnreferencedCredentialSecret,
ensureMainConversation,
findDefaultModelCredential, findDefaultModelCredential,
findDefaultVoiceCredential, findDefaultVoiceCredential,
findModelCredential, findModelCredential,
@ -135,6 +136,7 @@ import {
projectWorkspaceDto, projectWorkspaceDto,
releaseSpaceDeletionClaim, releaseSpaceDeletionClaim,
renewSpaceDeletionClaim, renewSpaceDeletionClaim,
requireMembership,
SPACE_DELETION_CLAIM_TIMEOUT_MS, SPACE_DELETION_CLAIM_TIMEOUT_MS,
SpaceDeletionInProgressError, SpaceDeletionInProgressError,
SpaceLimitError, SpaceLimitError,
@ -157,6 +159,12 @@ import {
} from "./computer-status.js"; } from "./computer-status.js";
import { listInbox } from "./inbox.js"; import { listInbox } from "./inbox.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,
@ -198,6 +206,7 @@ import {
} from "./thread-message-pages.js"; } from "./thread-message-pages.js";
import { import {
followUpGroupThreadMessage, followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage, reactToThreadMessage,
resolveThreadTarget, resolveThreadTarget,
sendThreadMessage, sendThreadMessage,
@ -753,6 +762,7 @@ export function createRouter(deps: RouterDeps) {
thread, thread,
routines, routines,
spaces: navigation.spaces, spaces: navigation.spaces,
main: navigation.main,
}; };
try { try {
@ -1016,6 +1026,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
@ -1191,6 +1218,9 @@ export function createRouter(deps: RouterDeps) {
allowedBuiltinTools: input.allowedBuiltinTools, allowedBuiltinTools: input.allowedBuiltinTools,
}, },
}); });
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();
@ -1265,6 +1295,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,
@ -1287,6 +1318,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,
@ -1403,6 +1435,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) =>
@ -1412,6 +1452,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) =>
@ -1455,6 +1501,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) =>
@ -1568,7 +1620,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,
@ -4860,6 +4912,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 },
@ -4989,6 +5043,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";
@ -22,61 +22,15 @@ import {
} 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 { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { onboardingProfileUpdate } from "../lib/onboarding-profile";
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 {
@ -190,23 +144,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 {
@ -214,7 +171,7 @@ export function OnboardingPage() {
} }
}) })
.catch(() => { .catch(() => {
if (!cancelled) setStep("emoji"); if (!cancelled) setStep((current) => (current === "loading" ? "emoji" : current));
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@ -265,7 +222,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);
await rpc.profile await rpc.profile
@ -310,7 +268,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,
@ -275,6 +276,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,
@ -282,6 +284,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", () =>
@ -445,6 +454,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 {
@ -1035,6 +1045,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 (
@ -1274,6 +1285,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) {
@ -1605,6 +1617,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);
} }
@ -1701,11 +1717,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);
@ -1720,11 +1731,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 = [];
@ -1746,9 +1773,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);
@ -1769,7 +1794,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);
@ -1800,7 +1825,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(
@ -1852,10 +1877,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);
@ -1865,7 +1900,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 () => {
@ -2152,6 +2187,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);
@ -2160,6 +2202,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);
@ -2354,8 +2400,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"
@ -3294,23 +3342,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(" ")
@ -3587,7 +3618,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
@ -3761,11 +3810,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,
}} }}
@ -3793,11 +3840,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>
@ -3817,22 +3862,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,
@ -4455,7 +4497,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>
@ -4584,7 +4626,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}
@ -5465,7 +5507,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>;
@ -939,6 +949,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(),
@ -1275,6 +1287,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

@ -273,6 +273,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

@ -88,6 +88,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/builtinTools", { botId: "missing-bot" }], ["bots/builtinTools", { botId: "missing-bot" }],
@ -405,6 +406,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

@ -2430,6 +2430,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", {