fix(threads): allow scrolling through long clarification footers (#3655)
This commit is contained in:
@@ -5,6 +5,7 @@ import { IconChevronDown, IconChevronUp, IconMessageQuestion } from "@tabler/ico
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClarificationInputOverlay } from "./clarification-input-overlay";
|
||||
import { useComposerDisclosureContext } from "./composer-disclosure";
|
||||
import { ResizeHandle } from "./resize-handle";
|
||||
import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay";
|
||||
import type { ClarificationRequestMetadata, Message } from "@/lib/types/http";
|
||||
@@ -64,6 +65,7 @@ export function ClarificationPanelSection({
|
||||
onOutcome,
|
||||
}: ClarificationPanelSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const disclosure = useComposerDisclosureContext();
|
||||
const pendingId = pendingIdFromMessages(messages);
|
||||
const [collapsed, setCollapsed] = useCollapsedForBundle(pendingId);
|
||||
const contentId = useId();
|
||||
@@ -101,7 +103,12 @@ export function ClarificationPanelSection({
|
||||
style={
|
||||
compact
|
||||
? undefined
|
||||
: { maxHeight: `${maxHeightVh}vh`, ...(height !== null ? { height } : {}) }
|
||||
: {
|
||||
maxHeight: `${maxHeightVh}vh`,
|
||||
// Threads owns the outer vertical scroll boundary in its bounded footer.
|
||||
overscrollBehaviorY: disclosure ? "auto" : undefined,
|
||||
...(height !== null ? { height } : {}),
|
||||
}
|
||||
}
|
||||
>
|
||||
{compact && (
|
||||
|
||||
@@ -23,8 +23,6 @@ function seedTaskAndWaitForIdle(
|
||||
}
|
||||
|
||||
test.describe("Mock agent clarification slash commands", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
// Smoke test that the /ask-single alias routes to the clarification scenario.
|
||||
// The underlying clarification behaviour (option click, multi-question carousel,
|
||||
// submit, etc.) is exhaustively covered by clarification.spec.ts — this only
|
||||
|
||||
@@ -2,6 +2,12 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import { assertNoHorizontalOverflow } from "../../helpers/session-stream-overload";
|
||||
import { seedSecondaryClarificationTask } from "../../helpers/clarification";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import {
|
||||
answerLongThreadQuestion,
|
||||
expectThreadQuestionSubmitted,
|
||||
seedLongThreadQuestion,
|
||||
threadDeckGeometry,
|
||||
} from "./threads-clarification-helpers";
|
||||
import {
|
||||
captureThreadSettings,
|
||||
capturePresentation,
|
||||
@@ -9,6 +15,60 @@ import {
|
||||
startPresentationThread,
|
||||
} from "./threads-presentation-helpers";
|
||||
|
||||
for (const short of [false, true]) {
|
||||
// @covers AC-UI-THREADS-DECK-004.6, AC-UI-THREADS-DECK-004.7, AC-UI-THREADS-DECK-005.5, AC-UI-THREADS-DECK-005.7, AC-UI-THREADS-DECK-005.8
|
||||
test(`scrolls and submits a long required question by touch (${short ? "short phone" : "Pixel 5"})`, async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const original = await captureThreadSettings(apiClient);
|
||||
try {
|
||||
if (short) await testPage.setViewportSize({ width: 393, height: 500 });
|
||||
const { task } = await seedLongThreadQuestion(apiClient, seedData);
|
||||
await seedThreadPresentation(apiClient, { layout: "grid", autoHideComposer: true });
|
||||
await testPage.goto(`/threads?taskId=${task.id}`);
|
||||
const board = testPage.getByTestId("threads-board");
|
||||
const tile = testPage.getByTestId(`thread-column-${task.id}`);
|
||||
await expect(board).toHaveAttribute("data-layout", "columns");
|
||||
await expect(board.getByTestId("session-chat")).toHaveCount(1);
|
||||
await expect(tile.getByTestId("clarification-option")).toHaveCount(3);
|
||||
await expect(tile.getByTestId("chat-input-editor")).toBeVisible();
|
||||
await expect(board.getByTestId("collapse-composer")).toHaveCount(0);
|
||||
expect(
|
||||
(await tile.locator(".chat-message-list").boundingBox())!.height,
|
||||
).toBeGreaterThanOrEqual(79);
|
||||
const baseline = await threadDeckGeometry(testPage);
|
||||
await capturePresentation(testPage, testInfo, "phone-long-question-before");
|
||||
await answerLongThreadQuestion(testPage, tile, "touch", testInfo);
|
||||
await expectThreadQuestionSubmitted(apiClient, task.session_id!, tile);
|
||||
expect(await threadDeckGeometry(testPage)).toEqual(baseline);
|
||||
await expect(board.getByTestId("session-chat")).toHaveCount(1);
|
||||
await expect(tile.getByTestId("chat-input-editor")).toBeVisible();
|
||||
const tileBox = (await tile.boundingBox())!;
|
||||
expect(tileBox.y + tileBox.height).toBeLessThanOrEqual(testPage.viewportSize()!.height);
|
||||
await assertNoHorizontalOverflow(testPage, "phone long question submitted");
|
||||
await capturePresentation(testPage, testInfo, "phone-long-question-submitted");
|
||||
|
||||
await testPage.setViewportSize({ width: 767, height: 1100 });
|
||||
await expect(board).toHaveAttribute("data-layout", "columns");
|
||||
await expect(board.getByTestId("session-chat")).toHaveCount(1);
|
||||
await testPage.setViewportSize({ width: 768, height: 1100 });
|
||||
await expect(board).toHaveAttribute("data-layout", "grid");
|
||||
await expect(board.getByTestId("session-chat")).toHaveCount(2);
|
||||
await expect(board.getByTestId("collapse-composer")).toHaveCount(0);
|
||||
expect((await captureThreadSettings(apiClient)).thread_views[0]).toMatchObject({
|
||||
layout: "grid",
|
||||
auto_hide_composer: true,
|
||||
});
|
||||
await assertNoHorizontalOverflow(testPage, "phone to tablet boundary");
|
||||
} finally {
|
||||
await apiClient.saveUserSettings(original);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// @covers AC-UI-THREADS-DECK-004.6, AC-UI-THREADS-DECK-005.7, AC-UI-THREADS-DECK-005.8, AC-UI-THREADS-DECK-005.10
|
||||
test("keeps the touch composer visible and restores drafts through single-chat navigation", async ({
|
||||
testPage,
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { chromium, expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import { waitForAgentMessage, waitForSessionState } from "../../helpers/session";
|
||||
import { PrAssetCapture } from "../../helpers/pr-asset-capture";
|
||||
import { capturePresentation, threadBoxes } from "./threads-presentation-helpers";
|
||||
|
||||
export async function attachQuestionRecord(info: TestInfo, name: string, record: unknown) {
|
||||
const path = info.outputPath(`${name}.json`);
|
||||
await writeFile(path, JSON.stringify(record, null, 2));
|
||||
await info.attach(name, { path, contentType: "application/json" });
|
||||
}
|
||||
|
||||
export async function seedLongThreadQuestion(api: ApiClient, seed: SeedData) {
|
||||
const tasks = [];
|
||||
for (const [title, scenario] of [
|
||||
["A long required question", "clarification-multi"],
|
||||
["B neighboring question", "clarification"],
|
||||
]) {
|
||||
const task = await api.createTaskWithAgent(seed.workspaceId, title, seed.agentProfileId, {
|
||||
description: `/e2e:${scenario}`,
|
||||
workflow_id: seed.workflowId,
|
||||
workflow_step_id: seed.startStepId,
|
||||
repository_ids: [seed.repositoryId],
|
||||
executor_profile_id: seed.worktreeExecutorProfileId,
|
||||
});
|
||||
if (!task.session_id) throw new Error("Required question has no session");
|
||||
await waitForSessionState(api, {
|
||||
taskId: task.id,
|
||||
sessionId: task.session_id,
|
||||
expectedState: "WAITING_FOR_INPUT",
|
||||
message: `${title} must block on its real clarification request`,
|
||||
timeout: 60_000,
|
||||
});
|
||||
tasks.push(task);
|
||||
}
|
||||
return { task: tasks[0], sibling: tasks[1] };
|
||||
}
|
||||
|
||||
/** Native tab zoom, with neither viewport/device-scale emulation nor CSS zoom. */
|
||||
export async function withNativeThreadZoom(
|
||||
baseURL: string,
|
||||
zoom: number,
|
||||
info: TestInfo,
|
||||
run: (page: Page) => Promise<void>,
|
||||
) {
|
||||
const root = await mkdtemp(join(tmpdir(), "threads-question-zoom-"));
|
||||
const extension = join(root, "extension");
|
||||
await mkdir(extension);
|
||||
await writeFile(
|
||||
join(extension, "manifest.json"),
|
||||
JSON.stringify({
|
||||
manifest_version: 3,
|
||||
name: "Threads question native zoom test",
|
||||
version: "1.0",
|
||||
permissions: ["tabs"],
|
||||
background: { service_worker: "background.js" },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(extension, "background.js"),
|
||||
"chrome.runtime.onInstalled.addListener(() => {});",
|
||||
);
|
||||
let context;
|
||||
try {
|
||||
context = await chromium.launchPersistentContext(join(root, "profile"), {
|
||||
channel: "chromium",
|
||||
headless: true,
|
||||
baseURL,
|
||||
viewport: null,
|
||||
deviceScaleFactor: undefined,
|
||||
args: [
|
||||
`--disable-extensions-except=${extension}`,
|
||||
`--load-extension=${extension}`,
|
||||
"--window-size=1366,855",
|
||||
],
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem("kandev.onboarding.completed", "true");
|
||||
});
|
||||
const page = context.pages()[0];
|
||||
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
|
||||
await page.goto(baseURL);
|
||||
const initialScale = await page.evaluate(() => devicePixelRatio);
|
||||
const actualZoom = await worker.evaluate(
|
||||
async ({ baseURL, zoom }) => {
|
||||
const { tabs } = (
|
||||
globalThis as unknown as {
|
||||
chrome: {
|
||||
tabs: {
|
||||
query: (query: { url: string }) => Promise<{ id: number }[]>;
|
||||
setZoom: (id: number, factor: number) => Promise<void>;
|
||||
getZoom: (id: number) => Promise<number>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).chrome;
|
||||
const [tab] = await tabs.query({ url: `${new URL(baseURL).origin}/*` });
|
||||
await tabs.setZoom(tab.id, zoom);
|
||||
return tabs.getZoom(tab.id);
|
||||
},
|
||||
{ baseURL, zoom },
|
||||
);
|
||||
expect(actualZoom).toBe(zoom);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => devicePixelRatio))
|
||||
.toBeCloseTo(initialScale * zoom, 5);
|
||||
await attachQuestionRecord(info, "native-browser-zoom", {
|
||||
browser: context.browser()!.version(),
|
||||
zoom: actualZoom,
|
||||
...(await page.evaluate(() => ({
|
||||
width: innerWidth,
|
||||
height: innerHeight,
|
||||
devicePixelRatio,
|
||||
visualScale: visualViewport?.scale,
|
||||
}))),
|
||||
});
|
||||
try {
|
||||
await run(page);
|
||||
} finally {
|
||||
await capturePresentation(page, info, "native-question-final-state");
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await context?.close();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Intersect every clipping ancestor, including the inner question and outer footer. */
|
||||
export function questionTargetGeometry(target: Locator) {
|
||||
return target.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
const clip = { top: 0, bottom: innerHeight, left: 0, right: innerWidth };
|
||||
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
||||
const style = getComputedStyle(parent);
|
||||
const bounds = parent.getBoundingClientRect();
|
||||
if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) {
|
||||
clip.top = Math.max(clip.top, bounds.top);
|
||||
clip.bottom = Math.min(clip.bottom, bounds.bottom);
|
||||
}
|
||||
if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) {
|
||||
clip.left = Math.max(clip.left, bounds.left);
|
||||
clip.right = Math.min(clip.right, bounds.right);
|
||||
}
|
||||
}
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const footer = element.closest('[data-testid="thread-footer-allocation"]')!;
|
||||
const inner = element.closest('[data-testid="clarification-scroll-region"]')!;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
clip,
|
||||
contained:
|
||||
box.top >= clip.top - 1 &&
|
||||
box.bottom <= clip.bottom + 1 &&
|
||||
box.left >= clip.left - 1 &&
|
||||
box.right <= clip.right + 1,
|
||||
hit: !!hit && element.contains(hit),
|
||||
footerScrollTop: footer.scrollTop,
|
||||
innerScrollTop: inner.scrollTop,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type QuestionInput = "wheel" | "touch" | "keyboard";
|
||||
|
||||
async function scrollQuestion(page: Page, tile: Locator, direction: number, touch: boolean) {
|
||||
const area = await tile.getByTestId("clarification-scroll-region").evaluate((inner) => {
|
||||
const box = inner.getBoundingClientRect();
|
||||
const footer = inner
|
||||
.closest('[data-testid="thread-footer-allocation"]')!
|
||||
.getBoundingClientRect();
|
||||
const top = Math.max(box.top, footer.top, 0);
|
||||
const bottom = Math.min(box.bottom, footer.bottom, innerHeight);
|
||||
return { x: box.x + box.width / 2, y: (top + bottom) / 2, height: bottom - top };
|
||||
});
|
||||
expect(area.height).toBeGreaterThan(40);
|
||||
if (!touch) {
|
||||
await page.mouse.move(area.x, area.y);
|
||||
await page.mouse.wheel(0, direction * 40);
|
||||
return;
|
||||
}
|
||||
const client = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: area.x, y: area.y + direction * 20 }],
|
||||
});
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: area.x, y: area.y + direction * (20 - step * 5) }],
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
} finally {
|
||||
await client.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reachQuestionTarget(
|
||||
page: Page,
|
||||
tile: Locator,
|
||||
target: Locator,
|
||||
input: QuestionInput,
|
||||
reverseTab = false,
|
||||
) {
|
||||
await waitForFiniteAnimations(tile);
|
||||
if (input === "keyboard") {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await target.evaluate((el) => el === document.activeElement)) return true;
|
||||
await page.keyboard.press(reverseTab ? "Shift+Tab" : "Tab");
|
||||
return false;
|
||||
},
|
||||
{ intervals: [50], timeout: 10_000, message: "Native tab navigation reaches the action" },
|
||||
)
|
||||
.toBe(true);
|
||||
} else {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const geometry = await questionTargetGeometry(target);
|
||||
if (!geometry.contained || !geometry.hit) {
|
||||
await scrollQuestion(
|
||||
page,
|
||||
tile,
|
||||
geometry.bottom > geometry.clip.bottom + 1 ? 1 : -1,
|
||||
input === "touch",
|
||||
);
|
||||
}
|
||||
return geometry;
|
||||
},
|
||||
{
|
||||
intervals: [100],
|
||||
timeout: 10_000,
|
||||
message: `${input} reaches the complete question action through the footer`,
|
||||
},
|
||||
)
|
||||
.toMatchObject({ contained: true, hit: true });
|
||||
}
|
||||
const geometry = await questionTargetGeometry(target);
|
||||
expect(geometry).toMatchObject({ contained: true, hit: true });
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export async function answerLongThreadQuestion(
|
||||
page: Page,
|
||||
tile: Locator,
|
||||
input: QuestionInput,
|
||||
info: TestInfo,
|
||||
beforeSubmit?: () => Promise<void>,
|
||||
) {
|
||||
const records = [];
|
||||
const capture = new PrAssetCapture(page, info.file, { captureKey: info.title });
|
||||
const option = (label: string) =>
|
||||
tile.getByTestId("clarification-option").filter({
|
||||
has: page.getByTestId("clarification-option-label").getByText(label, { exact: true }),
|
||||
});
|
||||
const activate = async (target: Locator, key = "Enter", reverse = false) => {
|
||||
const geometry = await reachQuestionTarget(page, tile, target, input, reverse);
|
||||
records.push({ target: await target.innerText(), ...geometry });
|
||||
if ((await target.getAttribute("data-testid")) === "clarification-submit") {
|
||||
await beforeSubmit?.();
|
||||
await capture.screenshot("submit-reachable");
|
||||
}
|
||||
if (input === "keyboard") await page.keyboard.press(key);
|
||||
else if (input === "touch") await page.touchscreen.tap(geometry.x, geometry.y);
|
||||
else await page.mouse.click(geometry.x, geometry.y);
|
||||
};
|
||||
try {
|
||||
// Prove the last option is reachable before any activation can focus/scroll it.
|
||||
await reachQuestionTarget(page, tile, option("SQLite"), input);
|
||||
await capture.screenshot("last-option-reachable");
|
||||
await activate(tile.getByTestId("clarification-next"));
|
||||
await expect(tile.getByTestId("clarification-step").nth(1)).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
await expect(tile.getByTestId("clarification-step").nth(0)).toHaveAttribute(
|
||||
"data-answered",
|
||||
"false",
|
||||
);
|
||||
await activate(tile.getByTestId("clarification-prev"), "Enter", true);
|
||||
await expect(tile.getByTestId("clarification-step").nth(0)).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
for (const [index, label] of ["SQLite", "Rust", "Bare metal"].entries()) {
|
||||
await activate(option(label), index === 1 ? "Enter" : "Space");
|
||||
await expect(tile.getByTestId("clarification-step").nth(index)).toHaveAttribute(
|
||||
"data-answered",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
await activate(tile.getByTestId("clarification-submit"), "Enter", true);
|
||||
} finally {
|
||||
await attachQuestionRecord(info, `${input}-question-hit-targets`, records);
|
||||
capture.flush();
|
||||
}
|
||||
if (input === "touch") {
|
||||
for (const record of records.filter((record) =>
|
||||
/SQLite|Rust|Bare metal|Submit/.test(record.target),
|
||||
)) {
|
||||
expect(record.height).toBeGreaterThanOrEqual(44);
|
||||
expect(record.width).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectThreadQuestionSubmitted(
|
||||
api: ApiClient,
|
||||
sessionId: string,
|
||||
tile: Locator,
|
||||
) {
|
||||
await waitForAgentMessage(api, sessionId, "You answered:");
|
||||
const { messages } = await api.listSessionMessages(sessionId);
|
||||
const receipt = messages
|
||||
.filter((message) => message.author_type === "agent")
|
||||
.map((message) => message.content)
|
||||
.join("\n");
|
||||
expect(receipt).toMatch(/"db":\s*{\s*"selected_option":\s*"q1_opt3"/);
|
||||
expect(receipt).toMatch(/"language":\s*{\s*"selected_option":\s*"q2_opt3"/);
|
||||
expect(receipt).toMatch(/"deploy":\s*{\s*"selected_option":\s*"q3_opt2"/);
|
||||
await expect(tile.getByTestId("clarification-overlay-container")).toHaveCount(0);
|
||||
await expect(tile.locator(".chat-message-list")).toContainText("You answered:");
|
||||
}
|
||||
|
||||
export async function threadDeckGeometry(page: Page) {
|
||||
const board = page.getByTestId("threads-board");
|
||||
return {
|
||||
boxes: await threadBoxes(board),
|
||||
scroll: await board.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop })),
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,15 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import { dwell } from "../../helpers/causal-waits";
|
||||
import { seedSecondaryClarificationTask } from "../../helpers/clarification";
|
||||
import { waitForLatestSessionDone } from "../../helpers/session";
|
||||
import {
|
||||
answerLongThreadQuestion,
|
||||
attachQuestionRecord,
|
||||
expectThreadQuestionSubmitted,
|
||||
questionTargetGeometry,
|
||||
seedLongThreadQuestion,
|
||||
threadDeckGeometry,
|
||||
withNativeThreadZoom,
|
||||
} from "./threads-clarification-helpers";
|
||||
import {
|
||||
captureThreadSettings,
|
||||
capturePresentation,
|
||||
@@ -13,6 +22,123 @@ import {
|
||||
|
||||
let original: ThreadPresentationSettings;
|
||||
|
||||
for (const zoom of [1, 0.9]) {
|
||||
for (const autoHideComposer of [false, true]) {
|
||||
// @covers AC-UI-THREADS-DECK-005.5, AC-UI-THREADS-DECK-005.6, AC-UI-THREADS-DECK-004.1
|
||||
test(`scrolls long required questions through the Grid footer (zoom ${zoom}, auto-hide ${autoHideComposer})`, async ({
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { task } = await seedLongThreadQuestion(apiClient, seedData);
|
||||
await apiClient.seedAgentMessages(task.session_id!, 35, "Question history");
|
||||
await seedThreadPresentation(apiClient, { layout: "grid", autoHideComposer });
|
||||
await withNativeThreadZoom(backend.frontendUrl, zoom, testInfo, async (page) => {
|
||||
await page.goto(`/threads?taskId=${task.id}`);
|
||||
const board = page.getByTestId("threads-board");
|
||||
const tile = page.getByTestId(`thread-column-${task.id}`);
|
||||
await expect(board).toHaveAttribute("data-layout", "grid");
|
||||
await expect(tile.getByTestId("clarification-option")).toHaveCount(3);
|
||||
await expect(tile.getByTestId("chat-input-editor")).toBeVisible();
|
||||
const list = tile.locator(".chat-message-list");
|
||||
await expect(list.getByText("Question history 35", { exact: true })).toHaveCount(1);
|
||||
expect((await list.boundingBox())!.height).toBeGreaterThanOrEqual(79);
|
||||
await expect
|
||||
.poll(() => list.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight))
|
||||
.toBeLessThan(3);
|
||||
if (autoHideComposer) {
|
||||
const freeze = tile.getByTestId("auto-scroll-toggle-button");
|
||||
await freeze.click();
|
||||
await expect(freeze).toHaveAttribute("aria-pressed", "false");
|
||||
// Return from the composer control to the question's original allocation.
|
||||
const footer = tile.getByTestId("thread-footer-allocation");
|
||||
const box = (await footer.boundingBox())!;
|
||||
await page.mouse.move(box.x + 2, box.y + box.height - 10);
|
||||
await page.mouse.wheel(0, -3000);
|
||||
await expect.poll(() => footer.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
}
|
||||
// Frozen positions only adopt real reader gestures, never synthetic scrollTop writes.
|
||||
await list.evaluate((el) => {
|
||||
el.setAttribute("data-history-scrolling", "true");
|
||||
el.addEventListener("scrollend", () => el.removeAttribute("data-history-scrolling"), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
const listBox = (await list.boundingBox())!;
|
||||
await page.mouse.move(listBox.x + listBox.width / 2, listBox.y + listBox.height / 2);
|
||||
await page.mouse.wheel(0, -600);
|
||||
await expect(list).not.toHaveAttribute("data-history-scrolling", "true");
|
||||
const anchor = await list.evaluate((el) => {
|
||||
const top = el.getBoundingClientRect().top;
|
||||
const paragraph = Array.from(el.querySelectorAll("p")).find(
|
||||
(p) =>
|
||||
p.textContent?.startsWith("Question history") &&
|
||||
p.getBoundingClientRect().top > top + 5,
|
||||
)!;
|
||||
return {
|
||||
text: paragraph.textContent!,
|
||||
y: paragraph.getBoundingClientRect().y,
|
||||
scrollTop: el.scrollTop,
|
||||
};
|
||||
});
|
||||
const baseline = await threadDeckGeometry(page);
|
||||
await attachQuestionRecord(testInfo, "question-before-wheel", {
|
||||
...(await questionTargetGeometry(tile.getByTestId("clarification-option").last())),
|
||||
tile: await tile.boundingBox(),
|
||||
footer: await tile.getByTestId("thread-footer-allocation").boundingBox(),
|
||||
});
|
||||
await answerLongThreadQuestion(page, tile, "wheel", testInfo, async () => {
|
||||
expect((await list.getByText(anchor.text, { exact: true }).boundingBox())!.y).toBeCloseTo(
|
||||
anchor.y,
|
||||
0,
|
||||
);
|
||||
expect(await threadDeckGeometry(page)).toEqual(baseline);
|
||||
});
|
||||
await expectThreadQuestionSubmitted(apiClient, task.session_id!, tile);
|
||||
expect(await threadDeckGeometry(page)).toEqual(baseline);
|
||||
if (autoHideComposer) {
|
||||
expect(await list.evaluate((el) => el.scrollTop)).toBeCloseTo(anchor.scrollTop, 0);
|
||||
} else {
|
||||
// Resuming agent work follows the bottom when auto-scroll is enabled.
|
||||
await expect
|
||||
.poll(() => list.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight))
|
||||
.toBeLessThan(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: "answers a long required question with keyboard navigation",
|
||||
layout: "grid",
|
||||
input: "keyboard",
|
||||
},
|
||||
{ name: "keeps long required answers usable in Columns", layout: "columns", input: "wheel" },
|
||||
] as const) {
|
||||
// @covers AC-UI-THREADS-DECK-005.5, AC-UI-THREADS-DECK-005.6, AC-UI-THREADS-DECK-005.9
|
||||
test(scenario.name, async ({ testPage, apiClient, seedData }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await testPage.setViewportSize({ width: 1366, height: 768 });
|
||||
const { task } = await seedLongThreadQuestion(apiClient, seedData);
|
||||
await seedThreadPresentation(apiClient, { layout: scenario.layout, autoHideComposer: true });
|
||||
await testPage.goto(`/threads?taskId=${task.id}`);
|
||||
const tile = testPage.getByTestId(`thread-column-${task.id}`);
|
||||
await expect(testPage.getByTestId("threads-board")).toHaveAttribute(
|
||||
"data-layout",
|
||||
scenario.layout,
|
||||
);
|
||||
await expect(tile.getByTestId("clarification-option")).toHaveCount(3);
|
||||
const baseline = await threadDeckGeometry(testPage);
|
||||
await answerLongThreadQuestion(testPage, tile, scenario.input, testInfo);
|
||||
await expectThreadQuestionSubmitted(apiClient, task.session_id!, tile);
|
||||
expect(await threadDeckGeometry(testPage)).toEqual(baseline);
|
||||
await capturePresentation(testPage, testInfo, `question-${scenario.input}-${scenario.layout}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function observeDisclosureMotion(tile: Locator) {
|
||||
await tile.evaluate((root) => {
|
||||
const samples: { early: number; late: number; duration: number; ciVisible: boolean }[] = [];
|
||||
|
||||
Reference in New Issue
Block a user