diff --git a/apps/api/src/main-conversation.ts b/apps/api/src/main-conversation.ts new file mode 100644 index 0000000..b3ec5da --- /dev/null +++ b/apps/api/src/main-conversation.ts @@ -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 { + 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, + 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, + 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 }); +} diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index 2d07569..b0bf565 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -4,6 +4,7 @@ import { featuredConnectorProvidersMatch } from "@rakazo/core"; import { appendEventInTransaction, createThreadMessageInTransaction, + ensureMainConversation, IsolationError, type Prisma, type PrismaClient, @@ -13,7 +14,8 @@ import { /** * First-run conversational onboarding, seeded deterministically into the bot's * 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 = { @@ -76,6 +78,10 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin include: { thread: true }, }); 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 }; } @@ -89,6 +95,7 @@ async function post( threadId: target.threadId, role: "bot", blocks, + botId: target.botId, }); const event = await appendEventInTransaction(tx, { spaceId: target.spaceId, @@ -176,6 +183,7 @@ export async function promptFocus( threadId: target.threadId, role: "bot", blocks, + botId: target.botId, }); const event = await appendEventInTransaction(tx, { spaceId: target.spaceId, diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index bb016a3..2baf21e 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -117,6 +117,7 @@ import { createSpaceForMember, deleteEmptySpaceForMember, deleteUnreferencedCredentialSecret, + ensureMainConversation, findDefaultModelCredential, findDefaultVoiceCredential, findModelCredential, @@ -135,6 +136,7 @@ import { projectWorkspaceDto, releaseSpaceDeletionClaim, renewSpaceDeletionClaim, + requireMembership, SPACE_DELETION_CLAIM_TIMEOUT_MS, SpaceDeletionInProgressError, SpaceLimitError, @@ -157,6 +159,12 @@ import { } from "./computer-status.js"; import { listInbox } from "./inbox.js"; import { searchIntegrationCatalog } from "./integration-catalog.js"; +import { + assertNotMainBot, + assertNotMainConversation, + loadMainConversationRef, + renameMainConversation, +} from "./main-conversation.js"; import { buildMcpUpdateMaterial } from "./mcp-material.js"; import { disconnectMemoryProvider, @@ -198,6 +206,7 @@ import { } from "./thread-message-pages.js"; import { followUpGroupThreadMessage, + groupDefaultBotId, reactToThreadMessage, resolveThreadTarget, sendThreadMessage, @@ -753,6 +762,7 @@ export function createRouter(deps: RouterDeps) { thread, routines, spaces: navigation.spaces, + main: navigation.main, }; try { @@ -1016,6 +1026,23 @@ export function createRouter(deps: RouterDeps) { 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 }) => { const source = await repos.getBot(context.actor, input.botId); const duplicate = await repos @@ -1191,6 +1218,9 @@ export function createRouter(deps: RouterDeps) { 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 bot = bots.find((b) => b.id === input.botId); if (!bot) throw new IsolationError(); @@ -1265,6 +1295,7 @@ export function createRouter(deps: RouterDeps) { }), archive: authed.bots.archive.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true }); + assertNotMainBot(bot, "The main bot can't be archived."); await archiveBot( { prisma: deps.prisma, @@ -1287,6 +1318,7 @@ export function createRouter(deps: RouterDeps) { }), remove: authed.bots.remove.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true }); + assertNotMainBot(bot, "The main bot can't be deleted."); await destroyBot( { prisma: deps.prisma, @@ -1403,6 +1435,14 @@ export function createRouter(deps: RouterDeps) { }); 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); await Promise.all( updated.cancelledRunIds.map((runId) => @@ -1412,6 +1452,12 @@ export function createRouter(deps: RouterDeps) { return updated.group; }), 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); await Promise.all( archived.cancelledRunIds.map((runId) => @@ -1455,6 +1501,12 @@ export function createRouter(deps: RouterDeps) { return { ok: true as const }; }), 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 cleanup = await Promise.allSettled( removed.artifactStorageKeys.map((storageKey) => @@ -1568,7 +1620,7 @@ export function createRouter(deps: RouterDeps) { }), clear: authed.threads.clear.handler(async ({ context, 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(); const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({ spaceId: context.actor.spaceId, @@ -4860,6 +4912,8 @@ async function spaceNavigationDto( repos: ReturnType, groupRepos: ReturnType, ): Promise { + // 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({ where: { id: actor.spaceId }, select: { organizationId: true }, @@ -4989,6 +5043,7 @@ async function spaceNavigationDto( botSections: sectionsFor(membership.spaceId), }; }), + main, }; } diff --git a/apps/api/src/thread-target.test.ts b/apps/api/src/thread-target.test.ts index 14deefc..8658532 100644 --- a/apps/api/src/thread-target.test.ts +++ b/apps/api/src/thread-target.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { cancelSupersededQueuedRuns, followUpGroupThreadMessage, + groupDefaultBotId, reactToThreadMessage, resolveGroupFollowUpBotId, sendThreadMessage, @@ -1660,6 +1661,44 @@ describe("resolveGroupFollowUpBotId", () => { }), ).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", () => { diff --git a/apps/api/src/thread-target.ts b/apps/api/src/thread-target.ts index 9acf19b..42b5b5d 100644 --- a/apps/api/src/thread-target.ts +++ b/apps/api/src/thread-target.ts @@ -4,6 +4,7 @@ import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@raka import { type Actor, GROUP_MEMBER_MIN, + type GroupKind, type GroupMember, type MessageBlock, type MessageReaction, @@ -40,6 +41,7 @@ import { resolveSendAttachments, } from "./artifacts.js"; import { resolveBusyBotName, toComputerStatus } from "./computer-status.js"; +import { joinAddressedBots } from "./main-conversation.js"; import { withSerializableRetry } from "./serializable-retry.js"; import { loadMessagePage } from "./thread-message-pages.js"; @@ -55,10 +57,20 @@ export type ThreadTarget = groupId: string; threadId: string; groupName: string; + groupKind: GroupKind; + /** Answers when nobody is addressed; null for ordinary groups. */ + leadBotId: string | null; members: GroupMember[]; memberBotIds: string[]; }; +/** The member that answers when nobody is addressed: the group's lead, else its first member. */ +export function groupDefaultBotId(target: Extract) { + return target.leadBotId && target.memberBotIds.includes(target.leadBotId) + ? target.leadBotId + : target.memberBotIds[0]; +} + /** * Flatten text for excerpt comparison. The parent blocks hold markdown * 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) => ({ botId: member.bot.id, name: member.bot.name, @@ -383,6 +397,8 @@ export async function resolveThreadTarget( groupId: group.id, threadId: group.thread.id, groupName: group.name, + groupKind: group.kind === "main" ? "main" : "group", + leadBotId: group.leadBotId, members, memberBotIds: members.map((member) => member.botId), }; @@ -563,6 +579,7 @@ export async function threadSnapshot( groupId: target.groupId, groupName: target.groupName, members: target.members, + ...(target.leadBotId ? { leadBotId: target.leadBotId } : {}), threadId: target.threadId, cursor: core.last?.seq ?? -1, messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents), @@ -683,6 +700,13 @@ export async function sendThreadMessage( ) { const existing = await replayExistingSend(deps, target.threadId, input.clientNonce); 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 // can't be substring-verified verbatim — enforce the cap here, then check // the flattened form against the parent inside the transaction. @@ -828,6 +852,7 @@ export async function sendThreadMessage( text: input.text ?? "", members: members.map((member) => ({ id: member.botId, name: member.name })), explicitMentions: mentionTargets.botMentionIds, + defaultBotId: target.leadBotId, }); const { blocks: attachmentBlocks, artifacts } = await resolveGroupSendAttachments( { prisma: tx }, @@ -953,12 +978,13 @@ export async function sendThreadMessage( * 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 * 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: { text: string; members: Array<{ botId: string; name: string }>; activeRuns: Array<{ botId: string; status: string; createdAt: Date }>; + defaultBotId?: string | null; }): string { const memberBotIds = new Set(input.members.map((member) => member.botId)); const memberRuns = input.activeRuns @@ -974,6 +1000,7 @@ export function resolveGroupFollowUpBotId(input: { text: input.text, members: input.members.map((member) => ({ id: member.botId, name: member.name })), explicitMentions: [], + defaultBotId: input.defaultBotId, })[0]; if (mentioned) return mentioned; const fallback = input.members[0]?.botId; @@ -999,7 +1026,12 @@ export async function followUpGroupThreadMessage( select: { id: true, botId: true, status: true, createdAt: true }, 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 message = await createThreadMessageInTransaction(tx, { threadId: target.threadId, @@ -1085,7 +1117,7 @@ export async function reactToThreadMessage( select: { id: true }, }); 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(); const blocks: MessageBlock[] = [{ kind: "text", text: input.reaction }]; const message = await createThreadMessageInTransaction(tx, { diff --git a/apps/web/e2e/activity-list.spec.ts b/apps/web/e2e/activity-list.spec.ts index 8e64ccd..05ee392 100644 --- a/apps/web/e2e/activity-list.spec.ts +++ b/apps/web/e2e/activity-list.spec.ts @@ -2,9 +2,10 @@ import { expect, type Page, test } from "@playwright/test"; import { captureScreenshot, completeOnboarding, signup } from "./helpers"; /** 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) { return page.locator("aside").getByRole("button", { - name: new RegExp(`^${botName}, (?!1:1 conversation)`), + name: new RegExp(`^${botName}, (?!1:1 conversation|main conversation)`), }); } diff --git a/apps/web/e2e/bot-organization.spec.ts b/apps/web/e2e/bot-organization.spec.ts index 6f8f212..99dbe16 100644 --- a/apps/web/e2e/bot-organization.spec.ts +++ b/apps/web/e2e/bot-organization.spec.ts @@ -1,19 +1,26 @@ 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) => { const stamp = Date.now(); await signup(page, `bot-organize-${stamp}@rakazo.test`, "password12", "Test User"); await completeOnboarding(page); - await page.goto("/app"); - await page.waitForURL(/\/app\/[^/]+$/); + // The main bot is reached through the main conversation; specialists are organized here. + await createNamedBot(page, "Scout"); 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 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 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:"]'); await expect(projects).toContainText("Projects"); - await expect(projects).toContainText("Chief"); + await expect(projects).toContainText("Scout"); await captureScreenshot(page, testInfo, "bot-sections"); await page.reload(); await expect(projects).toContainText("Projects"); - await expect(projects).toContainText("Chief"); + await expect(projects).toContainText("Scout"); await bot.click({ button: "right" }); 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("menuitem", { name: "Unassigned", exact: true }) .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 }) => { const stamp = Date.now(); await signup(page, `bot-reorder-${stamp}@rakazo.test`, "password12", "Bot Order"); await completeOnboarding(page); - await page.goto("/app"); - await page.waitForURL(/\/app\/[^/]+$/); - - const chiefId = activeBotId(page); + // The roster lists specialists; the main bot is reached through the main conversation. + const leadId = ( + await rpc<{ id: string }>(page, "bots/create", { + name: "Lead", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + computerMode: "team", + }) + ).id; const alpha = await rpc<{ id: string }>(page, "bots/create", { name: "Alpha", 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 order = () => 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 markStaleListReady!: () => void; @@ -126,20 +140,20 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p ); await sidebar .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; releaseStaleList(); 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 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}"]`); await betaRow.focus(); 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 expect.poll(order).toEqual([chiefId, beta.id, alpha.id]); + await expect.poll(order).toEqual([leadId, beta.id, alpha.id]); let releaseRejectedReorder!: () => 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 .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; 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}"]`)); releaseRejectedReorder(); 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 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 }) => { diff --git a/apps/web/e2e/collapsible-sidebar-sections.spec.ts b/apps/web/e2e/collapsible-sidebar-sections.spec.ts index 3ae0f4c..7fb9fd9 100644 --- a/apps/web/e2e/collapsible-sidebar-sections.spec.ts +++ b/apps/web/e2e/collapsible-sidebar-sections.spec.ts @@ -1,15 +1,15 @@ 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) => { const stamp = Date.now(); await signup(page, `sidebar-collapse-${stamp}@rakazo.test`, "password12", "Test User"); await completeOnboarding(page); - await page.goto("/app"); - await page.waitForURL(/\/app\/[^/]+$/); + // The main bot is reached through the main conversation; specialists live in sections. + await createNamedBot(page, "Scout"); 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 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:"]'); 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/ }); 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 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 toggle.hover(); const headerBox = await toggle.boundingBox(); @@ -45,11 +45,11 @@ test("titled sidebar section expands and collapses", async ({ page }, testInfo) await toggle.click(); 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 captureScreenshot(page, testInfo, "sidebar-section-collapsed"); await toggle.click(); 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); }); diff --git a/apps/web/e2e/empty-project-starten.spec.ts b/apps/web/e2e/empty-project-starten.spec.ts index f840c59..dcf80dc 100644 --- a/apps/web/e2e/empty-project-starten.spec.ts +++ b/apps/web/e2e/empty-project-starten.spec.ts @@ -49,8 +49,6 @@ test("Starten in an onboarded project opens the group create flow", async ({ pag await page.waitForURL(/\/app(?:\/)?$/); await selectProjectSpace(projekte, "Website"); await page.waitForURL(/\/app(?:\/)?$/); - await page.goto("/onboarding"); - await completeOnboarding(page); await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { 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.getByRole("button", { name: "Create project", exact: true }).click(); await page.waitForURL(/\/app(?:\/)?$/); - await page.goto("/onboarding"); - await completeOnboarding(page); - const [chief] = await rpc>(page, "bots/list", {}); - expect(chief?.name).toMatch(/^Chief/); - await rpc(page, "bots/archive", { botId: chief!.id }); + const sideBot = await rpc<{ id: string }>(page, "bots/create", { + name: "Side bot", + title: "", + description: "", + notifyOnFinish: true, + computerMode: "team", + }); + await rpc(page, "bots/archive", { botId: sideBot.id }); await page.goto("/app"); await page.waitForURL(/\/app(?:\/)?$/); diff --git a/apps/web/e2e/golden.spec.ts b/apps/web/e2e/golden.spec.ts index 3b8cbab..367349b 100644 --- a/apps/web/e2e/golden.spec.ts +++ b/apps/web/e2e/golden.spec.ts @@ -3,6 +3,7 @@ import { activeBotId, captureScreenshot, completeOnboarding, + createNamedBot, openUserSettings, realSandboxTimeout, rpc, @@ -298,12 +299,14 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te await captureScreenshot(page, testInfo, "11d-provider-emulators"); 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(); - const gear = page.getByRole("button", { name: "Show settings" }); - if (!(await gear.isVisible().catch(() => false))) { + // Agent computer toggles the panel, so only open it when it is closed. + if ((await sidePanel.getAttribute("data-panel")) !== "computer") { await page.getByTitle("Agent computer").click(); } + await expect(sidePanel).toHaveAttribute("data-panel", "computer"); + const gear = page.getByRole("button", { name: "Show settings" }); await gear.click(); const downloadPromise = page.waitForEvent("download"); 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 page.getByRole("button", { name: "Close panel" }).click(); - await page.locator("aside").first().getByRole("button", { name: /Chief/ }).first().click({ - button: "right", - }); - const botMenu = page.getByRole("menu", { name: "Actions for Chief" }); + // The main bot cannot be archived or deleted; a specialist's menu offers both. + await createNamedBot(page, "Scout"); + await page + .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 botMenu.getByRole("menuitem", { name: "Delete" }).click(); 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 page - .locator("[data-sidebar-group]") - .getByRole("button", { name: /^Chief/ }) - .click(); await composer.fill("keep working until I stop you"); await page.keyboard.press("Enter"); 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.getByRole("button", { name: "Continue with email" }).click(); 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 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(); await signup(page, `menu-${stamp}@rakazo.test`, "password12", "Menu"); 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(); - await chief.click({ button: "right" }); - await expect(page.getByRole("menu", { name: "Actions for Chief" })).toBeVisible(); + const scout = page + .locator("aside") + .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: "Duplicate" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible(); await captureScreenshot(page, testInfo, "16-bot-context-menu"); 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 chief.click({ button: "right" }); + await scout.click({ button: "right" }); await expect(page.getByRole("menuitem", { name: "Mark as Read" })).toBeVisible(); 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 chief.click({ button: "right" }); + await scout.click({ button: "right" }); await expect(page.getByRole("menuitem", { name: "Unpin", exact: true })).toBeVisible(); 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"); - const copy = page.getByRole("button", { name: /Chief copy/ }).first(); + const copy = page.getByRole("button", { name: /Scout copy/ }).first(); await copy.click({ button: "right" }); 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 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 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"); }); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 0eed244..84d7794 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -87,7 +87,15 @@ async function visible(locator: Locator, timeout: number) { 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 }); 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(); } - // Scan step auto-advances to first bot creation before landing in /app. - await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 }); + // Scan step auto-advances to the main bot, then onboarding lands in the main conversation. + await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); await expect(chief).toBeVisible({ timeout: 15_000 }); 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, "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( diff --git a/apps/web/e2e/integration-setup.spec.ts b/apps/web/e2e/integration-setup.spec.ts index 6a23866..df673ad 100644 --- a/apps/web/e2e/integration-setup.spec.ts +++ b/apps/web/e2e/integration-setup.spec.ts @@ -107,7 +107,8 @@ test("direct MCP connects a catalog result without asking for a URL and assigns const createGate = new Promise((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; await createGate; await route.continue(); diff --git a/apps/web/e2e/main-conversation.spec.ts b/apps/web/e2e/main-conversation.spec.ts new file mode 100644 index 0000000..f87dcf1 --- /dev/null +++ b/apps/web/e2e/main-conversation.spec.ts @@ -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(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(); +}); diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index 6fd480e..4667825 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -67,7 +67,7 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( }, testInfo) => { const stamp = Date.now(); 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"]); const transcript = page.getByTestId("transcript"); @@ -383,7 +383,7 @@ test.describe("touch message actions", () => { test("More exposes actions without simulated hover", async ({ page }, testInfo) => { await signup(page, `touch-actions-${Date.now()}@rakazo.test`, "password12", "Touch Actions"); - await completeOnboarding(page); + await completeOnboarding(page, undefined, { mainConversation: true }); expect( await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches), ).toBe(false); diff --git a/apps/web/e2e/new-bot-ux.spec.ts b/apps/web/e2e/new-bot-ux.spec.ts index 7686e18..a640a48 100644 --- a/apps/web/e2e/new-bot-ux.spec.ts +++ b/apps/web/e2e/new-bot-ux.spec.ts @@ -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 }) => { const stamp = Date.now(); await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay"); - await completeOnboarding(page); - // First bot from onboarding shows the focus card immediately. + await completeOnboarding(page, undefined, { mainConversation: true }); + // 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 page.clock.install(); diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts index 1e17f61..57bd3bb 100644 --- a/apps/web/e2e/onboarding-conversation.spec.ts +++ b/apps/web/e2e/onboarding-conversation.spec.ts @@ -10,7 +10,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({ }, testInfo) => { const stamp = Date.now(); 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( 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(); // The focus step suggests apps but must not rename the bot: the name the // 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.getByText("Slack", { 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 }) => { 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"); await expect(choice).toBeEnabled(); // Keep the existing choice rendered while its save succeeds and navigation refresh fails. diff --git a/apps/web/e2e/routine-crud.spec.ts b/apps/web/e2e/routine-crud.spec.ts index 81cae43..fca9483 100644 --- a/apps/web/e2e/routine-crud.spec.ts +++ b/apps/web/e2e/routine-crud.spec.ts @@ -1,6 +1,13 @@ import { expect, type Page, test } from "@playwright/test"; 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) { 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(); await signup(page, `routine-switch-${stamp}@rakazo.test`, "password12", "Routine Switch"); 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(page, "bots/create", { name: "Second", 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(); - await botList.getByRole("button", { name: /^Chief/ }).click(); + await botList.getByRole("button", { name: /^First/ }).click(); await staleListIntercepted; await botList.getByRole("button", { name: /^Second/ }).click(); await page.waitForURL(new RegExp(`/app/${secondBot.id}$`)); diff --git a/apps/web/e2e/spaces.spec.ts b/apps/web/e2e/spaces.spec.ts index ed3cfa9..d3b1dc6 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -99,9 +99,8 @@ test("spaces stay invisible by default and chat creation requires approval", asy await expect .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .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(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); 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(); }); -test("a new space auto-completes onboarding and can be deleted from its menu", async ({ - page, -}, testInfo) => { +test("a new space opens empty and can be deleted from its menu", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner"); 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" }); await dialog.getByLabel("Name").fill("Temporary"); 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. - await completeOnboarding(page); - await expect(page).toHaveURL(/\/app\//); + // Onboarding runs once per user: a new space opens directly, with no bot of its own. + await page.waitForURL(/\/app(?:\/)?$/); const projekte = page.locator(".sidebar-projekte"); await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); 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: "Starten", exact: true })).toBeVisible(); - await captureScreenshot(page, testInfo, "new-space-after-onboarding"); + expect(await rpc(page, "bots/list", {})).toEqual([]); + await captureScreenshot(page, testInfo, "new-space-empty"); - // Spaces can only be deleted when empty — remove auto-created Chief first. - const [temporaryChief] = await rpc>(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. + // The empty space stays selectable instead of trapping the user. await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible(); // 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(); await signup(page, `spaces-empty-${stamp}@rakazo.test`, "password12", "Space Owner"); await completeOnboarding(page); - const [chief] = await rpc>(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(); - // No active bot thread after archiving the only bot — shell shows Archived, not Message. - await expect(page.getByRole("combobox", { name: "Message" })).toHaveCount(0); + // The main bot cannot be archived, so the archived section comes from a specialist. + 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({ timeout: 30_000, }); + await openNewSpace(page); const dialog = page.getByRole("dialog", { name: "New space" }); await dialog.getByLabel("Name").fill("Side"); await dialog.getByRole("button", { name: "Create space", exact: true }).click(); - await page.waitForURL(/\/(onboarding|app)/); - await completeOnboarding(page); + await page.waitForURL(/\/app(?:\/)?$/); const projekte = page.locator(".sidebar-projekte"); await expect(projekte.getByText("Side", { exact: true })).toBeVisible(); // Delete the only bot in the new space: the app must stay put instead of // forcing onboarding again. - const [sideChief] = await rpc>(page, "bots/list", {}); - expect(sideChief).toBeTruthy(); - await rpc(page, "bots/remove", { botId: sideChief!.id, deleteMemories: false }); + const sideBot = await rpc<{ id: string }>(page, "bots/create", { + name: "Side bot", + title: "", + description: "", + notifyOnFinish: true, + computerMode: "team", + }); + await rpc(page, "bots/remove", { botId: sideBot.id, deleteMemories: false }); await expect(page).not.toHaveURL(/\/onboarding/); await page.goto("/app"); 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/ }); await expect(archivedToggle).toBeVisible({ timeout: 30_000 }); await archivedToggle.click(); - await expect(sidebar.getByText("Chief", { exact: true })).toBeVisible(); + await expect(sidebar.getByText("Helper", { exact: true })).toBeVisible(); }); diff --git a/apps/web/e2e/team-computer.spec.ts b/apps/web/e2e/team-computer.spec.ts index d5fbf01..f0413f8 100644 --- a/apps/web/e2e/team-computer.spec.ts +++ b/apps/web/e2e/team-computer.spec.ts @@ -4,6 +4,7 @@ import { captureScreenshot, completeOnboarding, createNamedBot, + openMainBotThread, realSandboxTimeout, rpc, signup, @@ -90,7 +91,8 @@ test("user control leaves another Team bot's screen available", async ({ page }, const chiefId = activeBotId(page); 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.getByTestId("computer-preview").hover(); await page.getByTestId("computer-preview-open").click(); diff --git a/apps/web/src/lib/group-stage.test.ts b/apps/web/src/lib/group-stage.test.ts index bb83f7a..2be82e6 100644 --- a/apps/web/src/lib/group-stage.test.ts +++ b/apps/web/src/lib/group-stage.test.ts @@ -68,6 +68,12 @@ describe("group-stage", () => { 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", () => { const ensemble = resolveGroupEnsemble( { threadId: "t1", cursor: 0, messages: [], olderCursor: null, members } as never, diff --git a/apps/web/src/lib/group-stage.ts b/apps/web/src/lib/group-stage.ts index 3f0a803..90028e6 100644 --- a/apps/web/src/lib/group-stage.ts +++ b/apps/web/src/lib/group-stage.ts @@ -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( snapshot: ThreadSnapshot | null, @@ -86,6 +86,8 @@ export function resolveActiveGroupSpeaker( } 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) }; return null; } diff --git a/apps/web/src/lib/project-empty-state.test.ts b/apps/web/src/lib/project-empty-state.test.ts index c079987..a31562d 100644 --- a/apps/web/src/lib/project-empty-state.test.ts +++ b/apps/web/src/lib/project-empty-state.test.ts @@ -1,24 +1,12 @@ import { describe, expect, it } from "vitest"; -import { - 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); - }); -}); +import { isEmptyProjectSpace, projectGroupChatsInSpace } from "./project-empty-state.js"; describe("projectGroupChatsInSpace", () => { - it("returns project groups in a space without Hauptthread", () => { + it("returns project groups in a space without the main conversation", () => { const groups = [ - { id: "g1", name: "Launch", spaceId: "space-a" }, - { id: "g2", name: "Hauptthread", spaceId: "space-a" }, - { id: "g3", name: "Ops", spaceId: "space-b" }, + { id: "g1", kind: "group" as const, spaceId: "space-a" }, + { id: "g2", kind: "main" as const, spaceId: "space-a" }, + { id: "g3", kind: "group" as const, spaceId: "space-b" }, ]; 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", () => { - 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); }); - it("ignores Hauptthread when deciding emptiness", () => { - const groups = [{ id: "g1", name: "Hauptthread", spaceId: "project" }]; + it("ignores the main conversation when deciding emptiness", () => { + const groups = [{ id: "g1", kind: "main" as const, spaceId: "project" }]; expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true); }); }); diff --git a/apps/web/src/lib/project-empty-state.ts b/apps/web/src/lib/project-empty-state.ts index 3bd8132..a8afad7 100644 --- a/apps/web/src/lib/project-empty-state.ts +++ b/apps/web/src/lib/project-empty-state.ts @@ -1,22 +1,18 @@ import type { Group, Space } from "@rakazo/contracts"; -export function isHauptthreadGroup(group: Pick): boolean { - return group.name === "Hauptthread" || group.name === "Team Chat"; -} - -/** Project-scoped group chats in a space (excludes Hauptthread). */ +/** Project-scoped group chats in a space (excludes the main conversation). */ export function projectGroupChatsInSpace( - groups: readonly Pick[], + groups: readonly Pick[], spaceId: string, ): readonly Pick[] { - 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. */ export function isEmptyProjectSpace( spaceId: string | null | undefined, spaces: readonly Pick[], - groups: readonly Pick[], + groups: readonly Pick[], homeSpaceId: string | undefined, ): boolean { if (!spaceId || !homeSpaceId || spaceId === homeSpaceId) return false; diff --git a/apps/web/src/pages/GroupPanel.tsx b/apps/web/src/pages/GroupPanel.tsx index 4ae7384..9b7f21b 100644 --- a/apps/web/src/pages/GroupPanel.tsx +++ b/apps/web/src/pages/GroupPanel.tsx @@ -164,6 +164,8 @@ export function GroupSettings({ const [selected, setSelected] = useState(group.members.map((member) => member.botId)); const [pending, setPending] = useState<"save" | "remove" | null>(null); const [error, setError] = useState(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) { if (pending) return; @@ -217,32 +219,38 @@ export function GroupSettings({ className="mt-2" /> -
- - Members ({GROUP_MEMBER_MIN}–{GROUP_MEMBER_MAX}) - -
- + {main ? null : ( + <> +
+ + Members ({GROUP_MEMBER_MIN}–{GROUP_MEMBER_MAX}) + +
+ + + )} - + {main ? null : ( + + )} ); } diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index b3c31c4..0423eaa 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -1,5 +1,5 @@ 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 { useEffect, useId, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -22,61 +22,15 @@ import { } from "../lib/onboarding-discovery"; import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; 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 { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; -const FIRST_BOT_NAME = "Chief"; -const FIRST_BOT_SPAWN_KEY = "onboarding:first"; -const FIRST_BOT_LOCK = "rakazo:onboarding-first-bot"; - -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(run: () => Promise): Promise { - 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; +/** Selects the main conversation's space and returns its route. */ +function enterMainConversation(main: MainConversationRef): string { + selectSpace(main.spaceId); + return `/app/g/${main.groupId}`; } function defaultEmojiConfig(): OnboardingEmojiConfig { @@ -190,23 +144,26 @@ export function OnboardingPage() { // Get bot ID for scan phase try { const botList = await rpc.bots.list(); - const firstBot = botList.find( - (b) => b.spawnKey === FIRST_BOT_SPAWN_KEY || b.name === FIRST_BOT_NAME, - ); - if (firstBot && !cancelled) setScanBotId(firstBot.id); + const mainBot = botList.find((b) => b.kind === "main"); + if (mainBot && !cancelled) setScanBotId(mainBot.id); } catch { /* best effort */ } setIntegrationSetup(integrations); - setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji"); - // A space that already has a bot does not need the designer again. - // Empty spaces (a new project) still walk onboarding. Resolve this after - // the first step renders so a slow spaces list cannot leave E2E on loading. + // The first step renders before this resolves; never pull a user back who moved on. + if (!me.needsModel && integrations?.needsSetup) { + setStep((current) => (current === "emoji" ? "integrations" : current)); + } + // 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 { const navigation = await rpc.spaces.list(); if (cancelled) return; 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 }); } } catch { @@ -214,7 +171,7 @@ export function OnboardingPage() { } }) .catch(() => { - if (!cancelled) setStep("emoji"); + if (!cancelled) setStep((current) => (current === "loading" ? "emoji" : current)); }); return () => { cancelled = true; @@ -265,7 +222,8 @@ export function OnboardingPage() { createStartedRef.current = true; setError(null); 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); await rpc.profile @@ -310,7 +268,11 @@ export function OnboardingPage() { if (started) { 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) { createStartedRef.current = false; setError(err instanceof Error ? err.message : t`Could not create your bot`); diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 37d01b7..07af7f4 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -27,6 +27,7 @@ import type { Connection, ConnectionCatalogItem, Group, + MainConversationRef, Me, ProductEvent, Routine, @@ -275,6 +276,7 @@ import { PickerInfoDialog, RenameBotSectionDialog, } from "./shell/dialogs"; +import { MainConversationRow } from "./shell/main-conversation-row"; import { AppConnectCard, ArtifactImage, @@ -282,6 +284,13 @@ import { ChoiceCard, McpApprovalCard, } from "./shell/message-cards"; +import { + rosterNameClass, + rosterPreviewClass, + rosterRowClass, + rosterTimeClass, + rosterUnreadDotClass, +} from "./shell/roster-row-style"; import { WindowChrome } from "./WindowChrome"; const BotContextMenu = lazyWithRecovery("BotContextMenu", () => @@ -445,6 +454,7 @@ export function ShellPage() { const savingBotOrderRef = useRef(false); const [botSections, setBotSections] = useState([]); const [spaces, setSpaces] = useState([]); + const [mainConversation, setMainConversation] = useState(null); const [archivedBots, setArchivedBots] = useState([]); const [archivedGroups, setArchivedGroups] = useState([]); const { @@ -1035,6 +1045,7 @@ export function ShellPage() { setBotSections(sections); setGroups(groupList); setSpaces(navigation.spaces); + setMainConversation(navigation.main); setInitialBotsLoaded(true); botsRefreshApplied.current = request; if ( @@ -1274,6 +1285,7 @@ export function ShellPage() { setArchivedGroups(bootstrap.archivedGroups); setGroups(groupList); setSpaces(bootstrap.spaces); + setMainConversation(bootstrap.main); setInitialBotsLoaded(true); } if (!groupId && bootstrap.thread) { @@ -1605,6 +1617,10 @@ export function ShellPage() { if (event.type === "group.created") { 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)) { void refreshBots().catch(() => undefined); } @@ -1701,11 +1717,6 @@ export function ShellPage() { }); }, [bootstrapMe, botSections, bots, groups, spaces, query]); - const hauptthreadGroup = useMemo( - () => groups.find((g) => g.name === "Hauptthread" || g.name === "Team Chat"), - [groups], - ); - const homeSpaceBots = useMemo(() => { if (!homeSpaceId) return bots; const home = spaces.find((space) => space.id === homeSpaceId); @@ -1720,11 +1731,27 @@ export function ShellPage() { ...group, title: stripSpaceTitlePrefix(group.title, group.spaceName), 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); }, [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(() => { if (!homeSpaceId) return []; const entries: typeof sidebarGroups = []; @@ -1746,9 +1773,7 @@ export function ShellPage() { seenProjectSpaceIds.add(space.id); const groupsInSpace = sidebarGroups.flatMap((group) => group.spaceId === space.id - ? group.bots.filter( - (item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id, - ) + ? group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main") : [], ); const projectBots = dedupeSidebarBots(groupsInSpace); @@ -1769,7 +1794,7 @@ export function ShellPage() { for (const group of sidebarGroups) { if (group.spaceId !== homeSpaceId) continue; 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) => { if (seenProjectGroupChatIds.has(item.chat.id)) return false; seenProjectGroupChatIds.add(item.chat.id); @@ -1800,7 +1825,7 @@ export function ShellPage() { } return entries; - }, [hauptthreadGroup?.id, homeSpaceId, sidebarGroups, spaces, projectOrder]); + }, [homeSpaceId, sidebarGroups, spaces, projectOrder]); // DnD handlers const handleProjectDragEnd = useCallback( @@ -1852,10 +1877,20 @@ export function ShellPage() { [openSpaceChat], ); + const openMainConversation = useCallback(() => { + if (!mainConversation) return; + setActiveNavPillar("team"); + openSpaceChat(mainConversation.spaceId, `/app/g/${mainConversation.groupId}`); + }, [mainConversation, openSpaceChat]); + const openConversationBot = useCallback( (botId: string) => { setConversationPickerOpen(false); setMobileSidebarOpen(false); + if (botId === mainConversation?.botId) { + openMainConversation(); + return; + } setActiveNavPillar("team"); const bot = homeSpaceBots.find((item) => item.id === botId) ?? bots.find((item) => item.id === botId); @@ -1865,7 +1900,7 @@ export function ShellPage() { } navigate(`/app/${botId}`); }, - [bots, homeSpaceBots, navigate, openSpaceChat], + [bots, homeSpaceBots, mainConversation?.botId, navigate, openMainConversation, openSpaceChat], ); const flushBotOrder = useCallback(async () => { @@ -2152,6 +2187,13 @@ export function ShellPage() { [active?.id, groupId, inGroup], ); 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(); + 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( (botId: string) => { const bot = bots.find((candidate) => candidate.id === botId); @@ -2160,6 +2202,10 @@ export function ShellPage() { }, [bots, transcriptMembers], ); + const resolveSpeakerBot = useCallback( + (botId: string) => resolveTranscriptBot(botId) ?? knownBots.get(botId), + [knownBots, resolveTranscriptBot], + ); const enrichStageMember = useCallback( (botId: string) => { const bot = resolveTranscriptBot(botId); @@ -2354,8 +2400,10 @@ export function ShellPage() { }, [activeSnapshot?.messages, inGroup]); const resolveTranscriptMemberName = useCallback( - (botId: string | undefined) => memberName(transcriptMembers, botId), - [transcriptMembers], + (botId: string | undefined) => + memberName(transcriptMembers, botId) ?? + (inGroup && botId ? knownBots.get(botId)?.name : undefined), + [inGroup, knownBots, transcriptMembers], ); const replyTargetName = activeReplyTarget ? activeReplyTarget.role === "user" @@ -3294,23 +3342,6 @@ export function ShellPage() { ) : 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 initials = userName .split(" ") @@ -3587,7 +3618,25 @@ export function ShellPage() { - {conversationSidebarGroups.length === 0 ? ( + {mainConversationEntry ? ( +
+ +
+ ) : null} + {conversationSidebarGroups.length === 0 && !mainConversationEntry ? (

{t`Noch keine Unterhaltung.`}

- + {formatRosterTime(item.chat.updatedAt)} {item.chat.unread ? ( -
{rosterActivitySubtitle( item.chat.status, @@ -4455,7 +4497,7 @@ export function ShellPage() { {t`Gruppenunterhaltung`} - ) : inGroup ? ( + ) : inGroup && activeGroup?.kind !== "main" ? ( {t`Projekt · Sessions & Ordner`} @@ -4584,7 +4626,7 @@ export function ShellPage() { setPeerConversation(peer); }} memberName={resolveTranscriptMemberName} - peerBot={resolveTranscriptBot} + peerBot={resolveSpeakerBot} onRefresh={refreshActiveThread} onBotChanged={refreshBots} onAddRoutine={addSkillRoutine} @@ -5465,7 +5507,7 @@ export function ShellPage() { await refreshBots(); return; } - window.location.assign(newSpaceDialogVariant === "project" ? "/app" : "/onboarding"); + window.location.assign("/app"); }} /> ) : null} diff --git a/apps/web/src/pages/shell/main-conversation-row.tsx b/apps/web/src/pages/shell/main-conversation-row.tsx new file mode 100644 index 0000000..fba7dfb --- /dev/null +++ b/apps/web/src/pages/shell/main-conversation-row.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/pages/shell/roster-row-style.ts b/apps/web/src/pages/shell/roster-row-style.ts new file mode 100644 index 0000000..f4e68dd --- /dev/null +++ b/apps/web/src/pages/shell/roster-row-style.ts @@ -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"; +} diff --git a/packages/adapters/src/child-bots.test.ts b/packages/adapters/src/child-bots.test.ts index 0a04cf4..6f5479e 100644 --- a/packages/adapters/src/child-bots.test.ts +++ b/packages/adapters/src/child-bots.test.ts @@ -243,6 +243,45 @@ describe("spawned bot archival", () => { ), ).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", () => { @@ -334,7 +373,7 @@ describe("destroyBot", () => { 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 deleteMemberships = vi.fn().mockResolvedValue({ count: 1 }); const cancel = vi.fn().mockResolvedValue(undefined); @@ -387,6 +426,15 @@ describe("destroyBot", () => { { 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, }, diff --git a/packages/adapters/src/child-bots.ts b/packages/adapters/src/child-bots.ts index 08eb6a0..9ab1df9 100644 --- a/packages/adapters/src/child-bots.ts +++ b/packages/adapters/src/child-bots.ts @@ -255,6 +255,9 @@ export async function archiveSpawnedBot( if (target.id === input.spawnedByBotId) { 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); 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[] = []; 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( (member) => member.botId !== botId && member.bot.archivedAt === null, ).length; diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 75c986d..18b4bcc 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -303,6 +303,14 @@ export const SpaceSchema = z.object({ }); export type Space = z.infer; +/** 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; + export const SpaceNavigationSchema = z.object({ current: z.object({ id: Id, @@ -313,6 +321,8 @@ export const SpaceNavigationSchema = z.object({ botSections: z.array(BotSectionSchema), }), spaces: z.array(SpaceSchema), + /** Null until onboarding has created the main bot. */ + main: MainConversationRefSchema.nullable(), }); export type SpaceNavigation = z.infer; @@ -939,6 +949,8 @@ export const ThreadSnapshotSchema = z.object({ groupId: Id.optional(), groupName: z.string().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(), activeRuns: z.array(RunSchema).optional(), computer: ComputerStatusSchema.optional(), @@ -1275,6 +1287,7 @@ export const AppBootstrapSchema = z.object({ thread: ThreadSnapshotSchema.nullable(), routines: z.array(RoutineSchema), spaces: z.array(SpaceSchema), + main: MainConversationRefSchema.nullable(), }); export type AppBootstrap = z.infer; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5696760..6640d4c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -273,6 +273,8 @@ export const appContract = { listArchived: oc.output(z.array(BotSchema)), get: oc.input(botId).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), reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })), update: oc.input(UpdateBotInput).output(BotSchema), diff --git a/packages/core/src/group-mentions.test.ts b/packages/core/src/group-mentions.test.ts index 3db3d13..275fba3 100644 --- a/packages/core/src/group-mentions.test.ts +++ b/packages/core/src/group-mentions.test.ts @@ -89,6 +89,21 @@ describe("resolveGroupTargetBotIds", () => { }), ).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", () => { diff --git a/packages/core/src/group-mentions.ts b/packages/core/src/group-mentions.ts index 10d5dac..b60ffd2 100644 --- a/packages/core/src/group-mentions.ts +++ b/packages/core/src/group-mentions.ts @@ -32,6 +32,8 @@ export function resolveGroupTargetBotIds(input: { members: GroupMemberRef[]; /** Bot ids from typed mention chips (non-members are ignored for wake). */ explicitMentions?: string[]; + /** Member that answers when nobody is addressed, such as a group's lead; else the first. */ + defaultBotId?: string | null; }): string[] { const membersById = new Map(input.members.map((member) => [member.id, member])); const targetIds = new Set(); @@ -49,8 +51,12 @@ export function resolveGroupTargetBotIds(input: { } } - if (targetIds.size === 0 && input.members[0]) { - targetIds.add(input.members[0].id); + if (targetIds.size === 0) { + const fallback = + input.defaultBotId && membersById.has(input.defaultBotId) + ? input.defaultBotId + : input.members[0]?.id; + if (fallback) targetIds.add(fallback); } return [...targetIds]; diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index b6a9575..ce1e785 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -88,6 +88,7 @@ describeWithDatabase("API authorization and resource isolation", () => { ["bots/listArchived"], ["bots/get", { botId: "missing-bot" }], ["bots/create", botInput("Unauthenticated")], + ["bots/ensureMain"], ["bots/duplicate", { botId: "missing-bot" }], ["bots/update", { botId: "missing-bot", name: "Nope" }], ["bots/builtinTools", { botId: "missing-bot" }], @@ -405,6 +406,29 @@ describeWithDatabase("API authorization and resource isolation", () => { 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(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(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. await expectDenied(app, intruder, "threads/answer", { botId: intruderBot.id, diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index 91b48e0..f5ae027 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -2430,6 +2430,110 @@ describeJourneys("required product journeys", () => { 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(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(app, cookie, "bots/ensureMain"); + expect(mainBot.kind).toBe("main"); + expect((await rpc(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(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 () => { const cookie = await signup(app, `teach-j-${stamp}@rakazo.test`, "Teach Ada"); const bot = await rpc(app, cookie, "bots/create", {