Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-08-03 23:59:39 +02:00
10 changed files with 1195 additions and 334 deletions
@@ -0,0 +1,236 @@
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import {
readScrollMetrics,
scrollChatAwayFromBottom,
} from "../support/helpers/agent-bottom-anchor";
import { getServerId } from "../support/helpers/server-id";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "../support/helpers/mock-agent";
import {
continueToNextQuestion,
fillQuestionAnswer,
submitQuestionAnswers,
waitForQuestionPrompt,
} from "../support/helpers/questions";
import { openMobileAgentSidebar } from "../support/helpers/sidebar";
import {
expectTimelinePromptVisible,
openAgentTimeline,
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
async function expectChatAtBottom(page: Page): Promise<void> {
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom, {
timeout: 10_000,
})
.toBeLessThanOrEqual(72);
}
async function openCompactWorkspace(
page: Page,
workspace: Pick<MockAgentWorkspace, "workspaceId">,
): Promise<void> {
await openMobileAgentSidebar(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
});
}
function armDelayedScrollForHiddenChat(page: Page): Promise<void> {
return page
.locator('[data-testid="agent-chat-scroll"]:visible')
.first()
.evaluate((root) => {
const scroll = root as HTMLElement;
return new Promise<void>((resolve) => {
const observer = new ResizeObserver(() => {
if (scroll.getClientRects().length > 0) return;
observer.disconnect();
scroll.dispatchEvent(new Event("scroll"));
resolve();
});
observer.observe(scroll);
});
});
}
test("pointer jitter cannot turn geometry changes into scroll intent", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const backgroundPrompt = "Continue after a pointer is released outside the chat";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const bounds = await scroll.boundingBox();
expect(bounds, "Expected visible chat scroll bounds").not.toBeNull();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
await page.mouse.down();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 - 3);
await page.setViewportSize({ width: 390, height: 1124 });
await page.setViewportSize({ width: 390, height: 844 });
await page.mouse.move(410, bounds!.y + bounds!.height / 2 - 3);
await page.mouse.up();
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectTimelinePromptVisible(page, backgroundPrompt);
await expectChatAtBottom(page);
} finally {
await longChat.cleanup();
}
});
test("a retained chat ignores delayed scroll delivery while hidden", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-hidden-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
const backgroundPrompt = "emit 500 coalesced agent stream updates";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
const delayedScrollDelivered = armDelayedScrollForHiddenChat(page);
await openCompactWorkspace(page, otherWorkspace);
await delayedScrollDelivered;
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await openCompactWorkspace(page, longChat);
await expectTimelinePromptVisible(page, backgroundPrompt);
await expectChatAtBottom(page);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("a retained streaming chat returns to the bottom and continues following", async ({
page,
}) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-streaming-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
const streamingPrompt = "Continue streaming while this workspace is hidden";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
await longChat.client.sendAgentMessage(longChat.agentId, streamingPrompt);
await longChat.client.waitForAgentUpsert(
longChat.agentId,
(snapshot) => snapshot.status === "running",
15_000,
);
await openCompactWorkspace(page, otherWorkspace);
await openCompactWorkspace(page, longChat);
await expectTimelinePromptVisible(page, streamingPrompt);
await expectChatAtBottom(page);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectChatAtBottom(page);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("a retained chat preserves an intentional reading position", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const otherWorkspace = await seedMockAgentWorkspace({
repoPrefix: "scroll-return-reading-position-other-",
title: "Other workspace",
initialPrompt: "Open the other workspace",
});
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
const readingPosition = await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await openCompactWorkspace(page, otherWorkspace);
await openCompactWorkspace(page, longChat);
await expect
.poll(async () => Math.abs((await readScrollMetrics(page)).offsetY - readingPosition.offsetY))
.toBeLessThanOrEqual(24);
} finally {
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
}
});
test("editing keys in an embedded question keep output following", async ({ page }) => {
test.setTimeout(240_000);
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
const questionPrompt = "Emit synthetic questions: two free-write questions.";
const firstQuestion = "What is the GitHub private repo URL to push to?";
const secondQuestion = "What should the first commit message be?";
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentTimeline(page, longChat);
await expectTimelinePromptVisible(page, longChat.newestPrompt);
await expectChatAtBottom(page);
await longChat.client.sendAgentMessage(longChat.agentId, questionPrompt);
await waitForQuestionPrompt(page, 30_000);
await fillQuestionAnswer(page, {
question: firstQuestion,
answer: "git@github.com:user/private-repo.git",
});
const firstInput = page
.getByTestId("question-form-card")
.first()
.getByRole("textbox", { name: firstQuestion });
const inputBounds = await firstInput.boundingBox();
expect(inputBounds, "Expected embedded question input bounds").not.toBeNull();
await page.mouse.move(
inputBounds!.x + inputBounds!.width / 2,
inputBounds!.y + inputBounds!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
inputBounds!.x + inputBounds!.width / 2 + 20,
inputBounds!.y + inputBounds!.height / 2 - 3,
);
await page.mouse.up();
await firstInput.press("Home");
await firstInput.press("ArrowUp");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: secondQuestion,
answer: "Initialize private repo",
});
await submitQuestionAnswers(page);
await longChat.client.waitForFinish(longChat.agentId, 20_000);
await expectChatAtBottom(page);
} finally {
await longChat.cleanup();
}
});
@@ -97,7 +97,7 @@ async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<v
}
test.describe("Viewed agent timelines", () => {
test("a turn that finishes while unsubscribed reopens with consistently idle chrome", async ({
test("a turn that finishes while hidden reopens with consistently idle chrome", async ({
page,
}) => {
test.setTimeout(150_000);
@@ -105,9 +105,9 @@ test.describe("Viewed agent timelines", () => {
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
try {
await openAgent(page, scenario, scenario.firstAgentId);
await startVisibleTurn(page, scenario, "Finish after this chat becomes unsubscribed.");
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
expect(finish.status).toBe("idle");
@@ -118,33 +118,7 @@ test.describe("Viewed agent timelines", () => {
}
});
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
test.setTimeout(90_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
await commitMessage(
scenario,
scenario.firstAgentId,
"Committed after the first chat unsubscribed.",
);
await expect(
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
).toHaveCount(0);
await selectAgent(page, "First viewed chat");
await expect(
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
} finally {
await scenario.cleanup();
}
});
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
test("a hidden hot chat stays current", async ({ page }) => {
test.setTimeout(60_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();