evOo-local/apps/web/e2e/new-bot-ux.spec.ts
Claude 311551da04
feat: land in the main conversation, led by the main bot
After onboarding the user ended up in a 1:1 chat with "Chief", and the
"Hauptthread" was a group the web client created and found by name. The
main bot had no conversation it actually led, and addressing another bot
there did nothing unless the user had added it as a member first.

Now the main conversation is a real place:

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 00:24:16 +00:00

188 lines
9.0 KiB
TypeScript

import { expect, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
createBotFromPicker,
openNewBot,
rpc,
signup,
} from "./helpers";
test("create opens form, then empty chat; picker lists bots; sidebar collapses", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `new-bot-ux-${stamp}@rakazo.test`, "password12", "New Bot UX");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await page.getByTestId("create-menu-trigger").click();
const picker = page.getByTestId("bot-create-picker");
await expect(picker).toBeVisible();
await expect(picker.getByPlaceholder("Search")).toBeVisible();
await expect(picker.getByTestId("create-new-bot")).toBeVisible();
await expect(picker.getByText("Chief", { exact: true })).toBeVisible();
await captureScreenshot(page, testInfo, "plus-picker-bots");
await picker.getByTestId("create-new-bot").click();
const form = page.getByTestId("create-bot-form");
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
await expect(form).toBeVisible();
await expect(form.locator("label:has-text('Name') input")).toBeVisible();
await expect(form.locator("label:has-text('Title') input")).toBeVisible();
await expect(form.locator("label:has-text('Description') textarea")).toBeVisible();
await expect(form.getByTestId("create-bot-computer")).toBeVisible();
await expect(form.getByTestId("create-bot-team")).toBeVisible();
await expect(form.getByTestId("create-bot-private")).toBeVisible();
await captureScreenshot(page, testInfo, "create-bot-form");
await form.locator("label:has-text('Name') input").fill("New Bot");
await form.getByRole("button", { name: "Create", exact: true }).click();
await page.waitForURL(/\/app\/[^/]+$/);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
await page.getByTestId("toggle-bots-sidebar").click();
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "true");
const edge = page.getByTestId("bots-sidebar-edge");
await expect(edge).toBeVisible();
const restore = page.getByTestId("toggle-bots-sidebar");
await expect(restore).toBeVisible();
await captureScreenshot(page, testInfo, "bots-sidebar-collapsed");
await restore.click();
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "false");
await expect(page.getByRole("button", { name: "Minimize bots", exact: true })).toBeVisible();
await page.getByTestId("toggle-bots-sidebar").click();
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "true");
const box = await edge.boundingBox();
expect(box).toBeTruthy();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.down();
await page.mouse.move(box!.x + 80, box!.y + box!.height / 2, { steps: 8 });
await page.mouse.up();
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "false");
await captureScreenshot(page, testInfo, "bots-sidebar-expanded");
});
test("picker rows explain groups and spaces", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `picker-info-${stamp}@rakazo.test`, "password12", "Picker Info");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await page.getByTestId("create-menu-trigger").click();
const picker = page.getByTestId("bot-create-picker");
await expect(picker).toBeVisible();
await picker.getByTestId("create-new-group").hover();
const groupInfo = picker.getByTestId("picker-info-group");
await expect.poll(() => groupInfo.evaluate((el) => getComputedStyle(el).opacity)).toBe("1");
await captureScreenshot(page, testInfo, "picker-group-info-hover");
await groupInfo.click();
const dialog = page.getByTestId("picker-info-dialog");
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Groups", { exact: true })).toBeVisible();
await expect(dialog).toContainText("same thread");
await expect(page.getByTestId("side-panel")).not.toHaveAttribute("data-panel", "create-group");
await captureScreenshot(page, testInfo, "picker-group-info-dialog");
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await page.getByTestId("create-menu-trigger").click();
const spaceInfo = picker.getByTestId("picker-info-space");
await expect.poll(() => spaceInfo.evaluate((el) => getComputedStyle(el).opacity)).toBe("0");
await picker.getByTestId("create-new-space").hover();
await expect.poll(() => spaceInfo.evaluate((el) => getComputedStyle(el).opacity)).toBe("1");
await spaceInfo.click();
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Spaces", { exact: true })).toBeVisible();
await expect(dialog).toContainText("own bots and groups");
await captureScreenshot(page, testInfo, "picker-space-info-dialog");
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
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, 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();
await createBotFromPicker(page);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await page.clock.fastForward(9_000);
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await page.clock.fastForward(1_500);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
await createBotFromPicker(page, { name: "Later Bot" });
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("I'll set this up myself");
// Send must finish before the delay is advanced: cancel runs after a successful
// RPC, and promptFocus will still post if the clock fires while send is in flight.
const sent = page.waitForResponse(
(response) => response.url().includes("/rpc/threads/send") && response.ok(),
);
await page.keyboard.press("Enter");
await sent;
await expect(
page.getByTestId("transcript").getByText("I'll set this up myself").first(),
).toBeVisible();
await page.clock.fastForward(12_000);
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
});
test("plus picker can create a Private computer bot", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `new-bot-private-${stamp}@rakazo.test`, "password12", "New Bot Private");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await createBotFromPicker(page, { computerMode: "dedicated" });
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await captureScreenshot(page, testInfo, "create-private-computer-bot");
const botId = page.url().split("/").pop()!;
const bots = await rpc<Array<{ id: string; computerMode: string }>>(page, "bots/list", {});
expect(bots.find((bot) => bot.id === botId)?.computerMode).toBe("dedicated");
});
test("second bot from plus opens create form before persist", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `second-bot-form-${stamp}@rakazo.test`, "password12", "Second Bot Form");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await openNewBot(page);
const form = page.getByTestId("create-bot-form");
await expect(form).toBeVisible();
await form.locator("label:has-text('Name') input").fill("Researcher");
await form.locator("label:has-text('Title') input").fill("Finds sources");
await form.locator("label:has-text('Description') textarea").fill("Briefs from the web.");
await captureScreenshot(page, testInfo, "second-bot-create-form");
const create = page.waitForResponse(
(response) => response.url().includes("/rpc/bots/create") && response.ok(),
);
await form.getByRole("button", { name: "Create", exact: true }).click();
await create;
await page.waitForURL(/\/app\/[^/]+$/);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await captureScreenshot(page, testInfo, "second-bot-created");
});