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
329 lines
14 KiB
TypeScript
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);
|
|
});
|