fix(threads): allow scrolling through long clarification footers (#3655)

This commit is contained in:
José Almeida
2026-09-14 11:43:38 +02:00
committed by GitHub
parent 9475abe480
commit c092acac8b
12 changed files with 5352 additions and 5 deletions
@@ -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 }[] = [];