evOo-local/apps/web/e2e/group-chats.spec.ts
cynarAI e28c3acf02
feat: let the pane beside a conversation follow the bot's work (#123)
The live screen opens wide beside the chat while the bot works on it or asks
for a takeover, a file the bot presents as its result opens next to the
conversation, and a question that scrolled away gets a "Needs you" pill back
to it. Shared core rules derive the focus from signals clients already have
(pointer events, takeover requests, a new `present` flag on attach_file,
pending asks), move the pane only on focus edges, never replace a pane the
user opened, and learn per bot whether the screen opens on its own. Pane
widths persist per device; mobile shows a pill that opens the computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
2026-10-03 22:23:46 +02:00

329 lines
14 KiB
TypeScript

import { expect, type Page, type Route, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
createNamedBot,
fetchRpcEnvelope,
fulfillFetchedRpc,
openNewGroup,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
async function createBot(page: Page, name: string) {
return createNamedBot(page, name);
}
function patchGroupMembers(groups: unknown) {
if (!Array.isArray(groups)) return groups;
return groups.map((group) => {
if (!group || typeof group !== "object" || !("members" in group)) return group;
const members = (group as { members?: unknown }).members;
if (!Array.isArray(members)) return group;
return {
...group,
members: members.map((member, index) => {
if (!member || typeof member !== "object") return member;
const row = member as { status?: string };
return { ...row, status: index === 0 ? "running" : row.status };
}),
};
});
}
/** Sidebar avatars read bootstrap and spaces/list, not groups/list. */
async function showFirstMemberWorking(route: Route) {
const fetched = await fetchRpcEnvelope(route);
const payload = fetched.body.json;
if (!payload) {
await fulfillFetchedRpc(route, fetched);
return;
}
if ("groups" in payload) payload.groups = patchGroupMembers(payload.groups);
if (Array.isArray(payload.spaces)) {
payload.spaces = payload.spaces.map((space) => {
if (!space || typeof space !== "object") return space;
return { ...space, groups: patchGroupMembers((space as { groups?: unknown }).groups) };
});
}
if (payload.current && typeof payload.current === "object") {
const current = payload.current as { groups?: unknown };
current.groups = patchGroupMembers(current.groups);
}
await fulfillFetchedRpc(route, fetched);
}
test("create group from + and see two bots in one transcript", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `group-${stamp}@rakazo.test`, "password12", "Group E2E");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
const researcherId = await createBot(page, "Researcher");
const writerId = await createBot(page, "Research Writer");
await openNewGroup(page);
await page.locator("label:has-text('Name') input").fill("Draft team");
const panel = page.getByTestId("side-panel");
await panel.getByRole("button", { name: "Researcher" }).click();
await panel.getByRole("button", { name: "Research Writer" }).click();
await captureScreenshot(page, testInfo, "group-creation");
await page.route("**/rpc/groups/create", async (route) => route.abort("failed"));
await page.getByRole("button", { name: "Create group", exact: true }).click();
await expect(panel.getByRole("alert")).toHaveText("Failed to fetch");
await expect(page.getByRole("button", { name: "Create group", exact: true })).toBeEnabled();
await page.unroute("**/rpc/groups/create");
await page.getByRole("button", { name: "Create group", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/);
const groupUrl = page.url();
const draftGroupId = new URL(groupUrl).pathname.split("/").at(-1)!;
const reviewGroup = await rpc<{ id: string }>(page, "groups/create", {
name: "Review team",
botIds: [researcherId, writerId],
});
await rpc(page, "voice/connect", {
provider: "scripted",
apiKey: "fake-group-voice-key",
});
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(groupUrl);
await waitForChatComposer(page);
const groups = await rpc<
Array<{
id: string;
members: Array<{ botId: string; name: string; color: string; status?: string }>;
}>
>(page, "groups/list", {});
const groupSnapshot = await rpc<{
members?: Array<{ botId: string; name: string; color: string; status?: string }>;
}>(page, "threads/get", { groupId: draftGroupId });
await page.route("**/rpc/bootstrap", showFirstMemberWorking);
await page.route("**/rpc/spaces/list", showFirstMemberWorking);
await page.route("**/rpc/groups/list", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
json: groups.map((group) => ({
...group,
members: group.members.map((member, index) => ({
...member,
status: index === 0 ? "running" : "idle",
})),
})),
}),
});
});
await page.route("**/rpc/threads/get", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
json: {
...groupSnapshot,
members: groupSnapshot.members?.map((member, index) => ({
...member,
status: index === 0 ? "running" : "idle",
})),
},
}),
});
});
await page.reload();
// Anchor ^ so Now/Recent activity rows ("Bot · Draft team, …") do not match.
const groupAvatar = page
.locator("aside")
.first()
.getByRole("button", { name: /^Draft team/ })
.locator(".rakazo-group-avatar");
await expect(groupAvatar).toBeVisible();
await expect(groupAvatar.locator(".rakazo-bot-avatar")).toHaveCount(2);
const workingAvatar = groupAvatar.locator('[data-working="true"]');
await expect(workingAvatar).toHaveCount(1);
await expect(workingAvatar.locator(".rakazo-bot-avatar-ring")).toHaveCSS(
"animation-name",
"rakazo-avatar-spin",
);
await captureScreenshot(page, testInfo, "group-avatar-active");
await page.unroute("**/rpc/bootstrap", showFirstMemberWorking);
await page.unroute("**/rpc/spaces/list", showFirstMemberWorking);
await page.unroute("**/rpc/groups/list");
await page.unroute("**/rpc/threads/get");
await page.reload();
await page.getByTestId("bot-settings-trigger").click();
const desktopSettings = page.getByTestId("side-panel");
const groupName = desktopSettings.locator("label:has-text('Name') input");
await groupName.fill("Unsaved Draft team name");
const projekte = page.locator(".sidebar-projekte");
await page.goto(`/app/g/${reviewGroup.id}`);
await page.getByTestId("bot-settings-trigger").click();
await expect(groupName).toHaveValue("Review team");
await page.goto(`/app/g/${draftGroupId}`);
await page.getByTestId("bot-settings-trigger").click();
await expect(groupName).toHaveValue("Draft team");
await page.route("**/rpc/groups/update", async (route) => route.abort("failed"));
await desktopSettings.getByRole("button", { name: "Save", exact: true }).click();
await expect(desktopSettings.getByRole("alert")).toHaveText("Failed to fetch");
await expect(desktopSettings.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
await page.unroute("**/rpc/groups/update");
await desktopSettings.getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("combobox", { name: "Message" }).fill("@Researcher unfinished draft");
await projekte
.getByRole("button", { name: /^Review team/ })
.first()
.click();
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
await projekte
.getByRole("button", { name: /^Draft team/ })
.first()
.click();
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("@Res");
await captureScreenshot(page, testInfo, "group-mention-picker");
await page.getByRole("option", { name: "@Research Writer", exact: true }).click();
await expect(
page.getByTestId("mention-chip").filter({ hasText: "Research Writer" }),
).toBeVisible();
await composer.fill("turn the sources into a draft. @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()}gather sources.`);
await composer.press("Enter");
await expect(page.getByTestId("transcript")).toContainText(/handled|on it|gather/i, {
timeout: 60_000,
});
const transcript = page.getByTestId("transcript");
await expect(transcript.getByText("Researcher", { exact: true }).first()).toBeVisible();
await expect(transcript.getByText("Research Writer", { exact: true }).first()).toBeVisible();
const researcherSpeak = transcript
.locator("[data-message-id]")
.filter({ has: page.getByText("Researcher", { exact: true }) })
.filter({ has: page.getByRole("button", { name: "Speak this reply" }) })
.first()
.getByRole("button", { name: "Speak this reply" });
const [speechRequest] = await Promise.all([
page.waitForRequest(
(request) => request.url().includes("/api/voice/speak") && request.method() === "POST",
),
researcherSpeak.click(),
]);
expect(speechRequest.postDataJSON()).toMatchObject({ botId: researcherId });
await captureScreenshot(page, testInfo, "group-transcript");
await composer.fill("@Res");
await page.getByRole("option", { name: "@Research Writer", exact: true }).click();
await expect(
page.getByTestId("mention-chip").filter({ hasText: "Research Writer" }),
).toBeVisible();
await composer.fill("ask me which city to use");
await composer.press("Enter");
// threads/get can observe waiting_input before the shell realtime feed paints the ask card.
await expect
.poll(
async () => {
const snapshot = await rpc<{ run?: { status: string } | null }>(page, "threads/get", {
groupId: draftGroupId,
});
return snapshot.run?.status ?? null;
},
{ timeout: 60_000 },
)
.toBe("waiting_input");
const cityAsk = page.locator("p").filter({ hasText: /^Which city should I use\?$/ });
if ((await cityAsk.count()) === 0) {
await page.reload({ waitUntil: "domcontentloaded" });
await waitForChatComposer(page);
}
await expect(cityAsk).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "Edit first" }).click();
await page.getByRole("textbox", { name: "Answer" }).fill("Paris");
await page.getByRole("button", { name: "Send answer" }).click();
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
const replyRow = transcript
.locator("[data-message-id]")
.filter({ has: page.getByRole("button", { name: "Speak this reply" }) })
.first();
const replyRail = replyRow.getByTestId("message-hover-rail");
await expect
.poll(async () => {
await replyRow.hover();
return replyRail.evaluate((element) => getComputedStyle(element).pointerEvents);
})
.toBe("auto");
const replyButton = replyRail.getByRole("button", { name: "Reply" });
await replyButton.focus();
await replyButton.click();
await expect(page.getByTestId("reply-chip")).toContainText(/Replying to/);
await page.getByRole("button", { name: "Cancel reply" }).click();
let releaseReviewSnapshot!: () => void;
let sawReviewSnapshot!: () => void;
const reviewSnapshotReleased = new Promise<void>((resolve) => {
releaseReviewSnapshot = resolve;
});
const reviewSnapshotIntercepted = new Promise<void>((resolve) => {
sawReviewSnapshot = resolve;
});
await page.route("**/rpc/threads/get", async (route) => {
if (route.request().postData()?.includes(reviewGroup.id) !== true) {
await route.continue();
return;
}
sawReviewSnapshot();
await reviewSnapshotReleased;
await route.continue();
});
await projekte
.getByRole("button", { name: /^Review team/ })
.first()
.click();
await reviewSnapshotIntercepted;
await expect(page).toHaveURL(new RegExp(`/app/g/${reviewGroup.id}$`));
await expect(page.getByTestId("transcript")).not.toContainText("Answered: Paris");
releaseReviewSnapshot();
await waitForChatComposer(page);
await page.unroute("**/rpc/threads/get");
await projekte
.getByRole("button", { name: /^Draft team/ })
.first()
.click();
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible();
await composer.fill(
"@Researcher write path notes/group-preview.md and attach it to the thread says # Group artifact",
);
await composer.press("Enter");
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
await groupMarkdown.click();
// Wide windows open the file beside the group conversation.
const markdownPane = page.getByTestId("artifact-pane");
await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownPane.getByRole("button", { name: "Close panel" }).click();
await expect(markdownPane).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();
expect((await transcript.boundingBox())?.width).toBeGreaterThan(350);
await page.getByRole("button", { name: "Open navigation" }).click();
await expect(page.getByRole("button", { name: "Close navigation" })).toBeVisible();
await page.getByRole("button", { name: "Close navigation" }).click();
await page.getByTestId("bot-settings-trigger").click();
const settings = page.getByTestId("side-panel");
await expect(settings).toHaveAttribute("data-panel", "group-settings");
expect((await settings.boundingBox())?.width).toBeLessThanOrEqual(390);
await captureScreenshot(page, testInfo, "group-settings-mobile");
await rpc(page, "groups/remove", { groupId: reviewGroup.id });
await page.goto(`/app/g/${reviewGroup.id}`, { waitUntil: "domcontentloaded" });
await page.waitForURL(/\/app\/(?!g\/)[^/]+$/);
await waitForChatComposer(page);
});