diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 21cd688e9ed3..fd6f1bbb6c83 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -369,6 +369,7 @@ import { foldHoldTarget, foldHolding, foldRoomFor, + opensBody, foldToggleAt, holdFold, keepFold, @@ -5210,9 +5211,10 @@ export function VirtualTranscript({ setFoldRoom(end); }; const beginBottomStackFold = (toggle: HTMLElement, line: HTMLElement): boolean => { - // This ask is for tool/work disclosures. Message Show more/less and other controls retain - // their established row anchor, including the room they need when closing at the end. - if (!toggle.matches(".cv-tool.is-toggle, .cv-worked, .cv-subagents")) { + // Tool/work disclosures, and any line that opens (Leo, 2026-11-02: "YOU EXPAND UP WHILE YOUR + // SCCROLL IS AT THE VERY BOTTOM ... JUST THAT CASE"): a thought, the edits card, a cut row. + // Closing other controls (Show less) keeps its established row anchor and the room it needs. + if (!toggle.matches(".cv-tool.is-toggle, .cv-worked, .cv-subagents") && !opensBody(toggle)) { stopBottomStackFold(); return false; } @@ -5233,7 +5235,7 @@ export function VirtualTranscript({ // Reopening at the physical bottom consumes any room retained by a prior collapse. The // expanded body itself supplies the height; keeping the old blank room would add a second, // jarring gap while the stack moves up. - if (!stickyFoldRoom.current || toggle.getAttribute("aria-expanded") === "false") dropFoldRoom(); + if (!stickyFoldRoom.current || opensBody(toggle)) dropFoldRoom(); const stack: BottomStackFold = { toggle: line, row, beforeTop: layoutScrollTop(node), delta: 0, from: 0 }; bottomStackFold.current = stack; row.setAttribute("data-bottom-fold", ""); diff --git a/webviews/src/agent-session/acpmux/conversation/ExpandAtBottom.gallery.ts b/webviews/src/agent-session/acpmux/conversation/ExpandAtBottom.gallery.ts new file mode 100644 index 000000000000..bc4c797eda21 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/ExpandAtBottom.gallery.ts @@ -0,0 +1,98 @@ +// l10n-allow-file: gallery fixtures (sample prompts, commands and replies), not shipped UI. +// Opening a line with the transcript at its very bottom (Leo, 2026-11-02: "YOU EXPAND UP WHILE YOUR +// SCCROLL IS AT THE VERY BOTTOM AKA YOU FUCKING PUSH THE TEXT UP VISUALLY INSTEAD OF EXPANDING DOWN +// LIKE USUAL / JUST THAT CASE"). A long chat whose last turn ran two commands under "Ran 2 commands" +// and wrote a short reply after them, scrolled to its end. Opening the group there keeps the bottom +// where it is and pushes everything above it up (App.tsx `beginBottomStackFold`); anywhere else the +// line stays put and its body grows down (transcript-expand-at-bottom.test.ts). +import { agentPaneEntry } from "../../../gallery/format"; +import { activity, assistant, chat, summary, tool, user } from "../../../gallery/fixtures/acpmux"; +import type { Play } from "../../../gallery/play"; + +const command = (description: string, line: string, output: string) => + tool(line, "execute", "completed", { command: line, description, output, exitCode: 0 }); + +const paragraph = (index: number) => + `Step ${index}. The fetch helper retries every failure the same way, so a 404 waits through three ` + + "backoffs before it fails. I read how the helper decides to retry and who calls it."; + +const earlier = Array.from({ length: 6 }, (_, index) => { + const minutes = 60 - index * 8; + return [ + user(`Check part ${index + 1} of the retry loop`, minutes), + assistant([paragraph(index * 2 + 1), paragraph(index * 2 + 2)].join("\n\n"), minutes - 1), + ]; +}).flat(); + +const log = Array.from({ length: 12 }, (_, index) => `ok ${index + 1} - fetch retries case ${index + 1}`).join("\n"); + +/// A finished turn that ran a command, above the last one: its "Worked for" line stays closed. +const middle = [ + user("Find the retry helper", 12), + activity([command("Find the retry helper", "rg -n fetchWithRetry src", log)], 11, { id: "expand-mid-run" }), + assistant(paragraph(13), 10), + summary(9.9, { status: "completed", durationMs: 9_000, toolCount: 1 }), +]; + +const rows = [ + ...earlier, + ...middle, + user("Run the fetch tests and show me the log", 6), + activity( + [command("Run the fetch tests", "bun test fetch", log), command("Show the test log", "tail -n 12 fetch.log", log)], + 5, + { id: "expand-at-bottom-run" }, + ), + assistant("All twelve fetch cases pass.", 4), + summary(3.9, { status: "completed", durationMs: 21_000, toolCount: 2 }), +]; + +/// The same chat ending on a long prompt cut to its first lines, with "Show more" under it, and a +/// short reply: a line that is not a tool's, opened at the bottom. +const LONG_PROMPT = Array.from( + { length: 18 }, + (_, index) => `${index + 1}. Check that case ${index + 1} of the fetch retries fails fast on a client error.`, +).join("\n"); +const promptRows = [ + ...earlier, + user(LONG_PROMPT, 6), + assistant("All eighteen cases fail fast now.", 4), + summary(3.9, { status: "completed", durationMs: 21_000, toolCount: 0 }), +]; + +/// Opens the last turn's "Worked for" line when the turn folded its run under one, then puts the +/// view at the transcript's very bottom, as the reader leaves it after reading the reply. +const atBottom: Play = async (ctx) => { + const worked = () => [...ctx.document.querySelectorAll(".cv-worked")].at(-1); + const group = () => + [...ctx.document.querySelectorAll("button.cv-tool.is-toggle[aria-expanded]")].find((node) => + /2/.test(node.textContent ?? ""), + ); + await ctx.waitFor(() => worked() ?? group() ?? ctx.document.querySelector(".cv-user__more"), { capMs: 10_000 }); + if (!group() && worked()?.getAttribute("aria-expanded") === "false") { + worked()!.click(); + await ctx.waitFor(group, { capMs: 10_000 }); + } + const scroller = ctx.document.querySelector(".acpmux-scroll"); + if (scroller) scroller.scrollTop = scroller.scrollHeight; +}; + +export default agentPaneEntry({ + id: "agent-pane.expand-at-bottom", + title: "Opening a line at the bottom", + area: "Agent pane", + height: 640, + covers: ["agent-session/acpmux/App.tsx#VirtualTranscript", "agent-session/acpmux/conversation/foldAnchor.ts"], + variants: { + shipped: { + note: "Scrolled to the very bottom with “Ran 2 commands” closed. Opening it keeps the bottom pinned and pushes the text above up; away from the bottom it opens down and nothing moves.", + snapshot: chat(rows, { title: "Fetch tests" }), + play: atBottom, + }, + prompt: { + note: "Scrolled to the very bottom under a long prompt cut to its first lines. Its Show more opens the whole prompt by pushing the text above up; the reply under it stays where it was.", + snapshot: chat(promptRows, { title: "Fetch tests" }), + play: atBottom, + }, + }, +}); diff --git a/webviews/src/agent-session/acpmux/conversation/foldAnchor.test.ts b/webviews/src/agent-session/acpmux/conversation/foldAnchor.test.ts index 27c0f17a6239..d7fa9fd4b52c 100644 --- a/webviews/src/agent-session/acpmux/conversation/foldAnchor.test.ts +++ b/webviews/src/agent-session/acpmux/conversation/foldAnchor.test.ts @@ -12,6 +12,7 @@ import { foldToggleAt, holdFold, keepFold, + opensBody, trimmedFoldRoom, verticalWheel, } from "./foldAnchor"; @@ -256,3 +257,32 @@ describe("hold timing", () => { expect(verticalWheel({ deltaX: 60, deltaY: 4 })).toBe(false); }); }); + +describe("opensBody", () => { + // Leo, 2026-11-02: "YOU EXPAND UP WHILE YOUR SCCROLL IS AT THE VERY BOTTOM ... JUST THAT CASE". + const dom = (html: string) => { + const { document } = new JSDOM(`${html}`).window; + return document.querySelector("[data-t]")!; + }; + test("a closed disclosure opens; an open one closes", () => { + expect(opensBody(dom(''))).toBe(true); + expect(opensBody(dom(''))).toBe(false); + }); + test("a header toggle reads its own button, not a menu's", () => { + expect( + opensBody( + dom( + '
', + ), + ), + ).toBe(true); + expect(opensBody(dom('
'))).toBe(false); + }); + test("a summary opens its closed details", () => { + expect(opensBody(dom("
MoreBody
"))).toBe(true); + expect(opensBody(dom("
MoreBody
"))).toBe(false); + }); + test("a control that says nothing of its state opens nothing", () => { + expect(opensBody(dom(""))).toBe(false); + }); +}); diff --git a/webviews/src/agent-session/acpmux/conversation/foldAnchor.ts b/webviews/src/agent-session/acpmux/conversation/foldAnchor.ts index 1d8bec2663ef..4d2eb2ce7108 100644 --- a/webviews/src/agent-session/acpmux/conversation/foldAnchor.ts +++ b/webviews/src/agent-session/acpmux/conversation/foldAnchor.ts @@ -5,8 +5,9 @@ // during the hold moves the offset by however far the line moved, and none of them follows the // latest row meanwhile. // -// Tool/work disclosures clicked at or near the end instead pin the bottom and move the thread -// as one stack (App.tsx, Leo 2026-10-31). Other disclosures keep the clicked line even when closing +// Tool/work disclosures clicked at or near the end, and any line opened there (`opensBody`, Leo +// 2026-11-02), instead pin the bottom and move the thread as one stack: what opens pushes the text +// above it up (App.tsx, Leo 2026-10-31). Other disclosures keep the clicked line even when closing // near the end: foldRoomFor prevents a browser clamp, and trimmedFoldRoom releases that room as the // reader scrolls up, the rows fill it, or a send/Jump to latest returns to following. import { foldMs } from "./fold"; @@ -48,6 +49,16 @@ export function atFoldBottom(scrollTop: number, clientHeight: number, scrollHeig return scrollHeight > clientHeight + 0.5 && scrollHeight - clientHeight - scrollTop <= deadband; } +/// Whether a click on `toggle` opens what it holds: a closed disclosure (`aria-expanded="false"`, on +/// itself or on its header's button) or the `` of a closed `
`. +export function opensBody(toggle: HTMLElement): boolean { + if (toggle.tagName === "SUMMARY") return !(toggle.parentElement as HTMLDetailsElement | null)?.open; + const expanded = toggle.hasAttribute("aria-expanded") + ? toggle + : toggle.querySelector("[aria-expanded]:not([aria-haspopup])"); + return expanded?.getAttribute("aria-expanded") === "false"; +} + export type FoldAnchor = { toggle: HTMLElement; /// Where the line was drawn when clicked (its viewport top). diff --git a/webviews/test/agent-pane-fold-keeps-place.test.ts b/webviews/test/agent-pane-fold-keeps-place.test.ts index 25498afc39cc..aebf6695a9ec 100644 --- a/webviews/test/agent-pane-fold-keeps-place.test.ts +++ b/webviews/test/agent-pane-fold-keeps-place.test.ts @@ -89,6 +89,16 @@ await requireBrowserLane("agent-pane-fold-keeps-place.test.ts", async () => { test("opening a line grows its panel under it; the line does not move", async () => { const page = await open(); + // Off the very bottom: there, opening a line pushes the text above it up instead (Leo, + // 2026-11-02, transcript-expand-at-bottom.test.ts). + const fromEnd = await page.locator(".acpmux-scroll").evaluate(async (node) => { + node.scrollTop = Math.max(0, node.scrollHeight - node.clientHeight - 48); + node.dispatchEvent(new Event("scroll")); + await window.cmuxPaintFrame(); + return node.scrollHeight - node.clientHeight - node.scrollTop; + }); + await settled(page); + expect(fromEnd).toBeGreaterThan(8); const before = await topOf(page); await toggleOf(page).click(); await page.locator(".cv-received__text table").waitFor(); diff --git a/webviews/test/incoming-message-disclosure-browser.test.ts b/webviews/test/incoming-message-disclosure-browser.test.ts index 82d591c9df66..6de8a35b22aa 100644 --- a/webviews/test/incoming-message-disclosure-browser.test.ts +++ b/webviews/test/incoming-message-disclosure-browser.test.ts @@ -332,7 +332,10 @@ await requireBrowserLane("incoming-message-disclosure-browser.test.ts", async () } }); - test("the chat's last row, opened and closed with the view at its end, stays on its pixel", async () => { + // At the view's end, opening pushes the text above up and the end stays put (Leo, 2026-11-02: + // "YOU EXPAND UP WHILE YOUR SCCROLL IS AT THE VERY BOTTOM ... JUST THAT CASE"); closing keeps the + // line where it was clicked. + test("the chat's last row, opened with the view at its end, pushes the text up; closed, stays on its pixel", async () => { const page = await openChat( [...Array.from({ length: 6 }, (_, index) => turn(index)).flat(), fromAgent(LONG, "Grid fix", "child-3", "m-end")], "Paragraph 1 of the landing report", @@ -349,14 +352,19 @@ await requireBrowserLane("incoming-message-disclosure-browser.test.ts", async () await rest(page); const opened = await topOf(toggle); console.log(`pinned end: line top ${before} -> ${opened} opened`); - expect(Math.abs(opened - before)).toBeLessThan(1); + expect(opened).toBeLessThan(before - 1); + expect(await atLatest(page)).toBe(true); + // The opened message may have pushed its line above the view: back in view, closing keeps it. + await toggle.scrollIntoViewIfNeeded(); + await rest(page); + const shown = await topOf(toggle); await toggle.click(); expect(await toggle.getAttribute("aria-expanded")).toBe("false"); await rest(page); const closed = await topOf(toggle); - console.log(`pinned end: line top ${before} -> ${closed} closed`); - expect(Math.abs(closed - before)).toBeLessThan(1); + console.log(`pinned end: line top ${shown} -> ${closed} closed`); + expect(Math.abs(closed - shown)).toBeLessThan(1); } finally { await page.close(); } diff --git a/webviews/test/received-message-fold-browser.test.ts b/webviews/test/received-message-fold-browser.test.ts index a8d34b8f5147..79d613358120 100644 --- a/webviews/test/received-message-fold-browser.test.ts +++ b/webviews/test/received-message-fold-browser.test.ts @@ -186,7 +186,9 @@ await requireBrowserLane("received-message-fold-browser.test.ts", async () => { scroller.scrollTop += line.getBoundingClientRect().top - scroller.getBoundingClientRect().top - down; }, y); - test("the message opened at the latest row stays on its pixel and grows down in a fold", async () => { + test("the message opened at the latest row keeps the bottom and pushes the text up", async () => { + // At the very bottom, what opens pushes the text above it up (Leo, 2026-11-02: "YOU EXPAND UP + // WHILE YOUR SCCROLL IS AT THE VERY BOTTOM ... JUST THAT CASE"); away from it, it grows down. const page = await open("no-preference"); await show(page, [...done, received]); await settle(page); @@ -197,21 +199,19 @@ await requireBrowserLane("received-message-fold-browser.test.ts", async () => { const opened = await clickAndWatch(page); expect(await expanded(page)).toBe("true"); - expect(opened.folded).toBe(true); - expect(steps(opened.heights)).toBeGreaterThan(2); - expect(opened.moved).toBeLessThanOrEqual(1); await settle(page); - expect(Math.abs((await probedTop(page)) - opened.top)).toBeLessThanOrEqual(1); - // The card opened below the line, past the view's end: the view stopped following. - expect(await atLatest(page)).toBe(false); + expect(await probedTop(page)).toBeLessThan(opened.top - 1); + expect(await atLatest(page)).toBe(true); await page.close(); }); test("the message and its Details open and close in place, away from the end", async () => { const page = await open("no-preference"); - await show(page, [...done, received, after]); + // Two more turns under the reply, so the line 40px down the view is well away from the end. + await show(page, [...done, received, after, ...turn(8), ...turn(9)]); await settle(page); await lineAt(page, 40); + expect(await atLatest(page)).toBe(false); await settle(page); await probe(page, LINE); diff --git a/webviews/test/transcript-expand-at-bottom.test.ts b/webviews/test/transcript-expand-at-bottom.test.ts new file mode 100644 index 000000000000..6a8fb9f45e15 --- /dev/null +++ b/webviews/test/transcript-expand-at-bottom.test.ts @@ -0,0 +1,201 @@ +// l10n-allow-file: the gallery fixture supplies the transcript's messages. +// Opening a line with the transcript at its very bottom pushes the text up (Leo, 2026-11-02: "YOU +// EXPAND UP WHILE YOUR SCCROLL IS AT THE VERY BOTTOM AKA YOU FUCKING PUSH THE TEXT UP VISUALLY INSTEAD +// OF EXPANDING DOWN LIKE USUAL / JUST THAT CASE"): the view stays at its end, the rows under the +// opened line stay on their pixels, and the line itself moves up by what opened. Anywhere else the +// line stays put and its body grows down (#28780). +// +// Real Chromium from Playwright on the agent pane's gallery scene (ExpandAtBottom.gallery.ts). +import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser, type Page } from "playwright"; +import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { installPaintedFrames } from "./support/paintedFrames"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("transcript-expand-at-bottom.test.ts", async () => { + const ENTRY = "agent-pane.expand-at-bottom"; + const VARIANT = "shipped"; + setDefaultTimeout(120_000); + const launchOptions = process.env.CMUX_BROWSER_EXECUTABLE + ? { executablePath: process.env.CMUX_BROWSER_EXECUTABLE } + : {}; + try { + await (await chromium.launch({ headless: true, ...launchOptions })).close(); + } catch { + console.warn("transcript-expand-at-bottom: skipping (run `bunx playwright install chromium`)"); + return; + } + const webviews = path.resolve(import.meta.dir, ".."); + const { createServer } = await import("vite"); + let vite: Awaited> | undefined; + let browser: Browser | undefined; + beforeAll(async () => { + vite = await createServer({ + configFile: path.join(webviews, "vite.config.gallery-dev.ts"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + browser = await chromium.launch({ headless: true, ...launchOptions }); + await warmGalleryFrame(vite, ENTRY, { browser, params: { variant: VARIANT } }); + await warmGalleryFrame(vite, ENTRY, { browser, params: { variant: "prompt" } }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + /// The last turn's "Ran 2 commands" line. + const GROUP = '[data-row-id="expand-at-bottom-run"] button.cv-tool.is-toggle[aria-expanded]'; + /// Every finite animation over, then the scroller's offset and height the same over 16 painted frames. + const settled = (page: Page) => + page.evaluate(async () => { + for (let tries = 0; tries < 20; tries++) { + const running = document + .getAnimations() + .filter((each) => each.effect?.getComputedTiming().endTime !== Infinity && each.playState === "running"); + if (running.length === 0) break; + await Promise.all(running.map((each) => each.finished.catch(() => undefined))); + await window.cmuxPaintFrame(); + } + const scroller = document.querySelector(".acpmux-scroll"); + let last = ""; + let still = 0; + for (let frames = 0; frames < 240 && still < 16; frames++) { + await window.cmuxPaintFrame(); + const now = `${scroller?.scrollTop}:${scroller?.scrollHeight}`; + still = now === last ? still + 1 : 0; + last = now; + } + }); + /// Where things are drawn: the group's line, the last row's foot, and the scroller. + const measure = (page: Page) => + page.evaluate((query) => { + const scroller = document.querySelector(".acpmux-scroll")!; + const rows = [...scroller.querySelectorAll(".acpmux-row")]; + return { + line: document.querySelector(query)!.getBoundingClientRect().top, + lastFoot: rows.at(-1)!.getBoundingClientRect().bottom, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + fromEnd: scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop, + open: document.querySelector(query)!.getAttribute("aria-expanded"), + }; + }, GROUP); + + /// Clicks the middle of `query`'s line with the mouse (a locator's click scrolls a line the + /// composer covers into view first), after checking the point is the line's own. + const clickLine = async (page: Page, query: string) => { + const point = await page.evaluate((selector) => { + const line = document.querySelector(selector)!; + const box = line.getBoundingClientRect(); + const x = box.left + Math.min(40, box.width / 2); + const y = box.top + box.height / 2; + return { x, y, own: line.contains(document.elementFromPoint(x, y)) }; + }, query); + expect(point.own).toBe(true); + await page.mouse.click(point.x, point.y); + }; + + async function open(variant = VARIANT, ready = GROUP): Promise { + const port = (vite!.httpServer!.address() as { port: number }).port; + const page = await browser!.newPage({ viewport: { width: 900, height: 640 } }); + await installPaintedFrames(page); + await page.addInitScript(() => localStorage.clear()); + await page.goto(`http://127.0.0.1:${port}${galleryFrame(ENTRY, { variant })}`); + await page.locator(ready).waitFor(); + await settled(page); + return page; + } + + test("at the very bottom, opening a line keeps the bottom and pushes the text above up", async () => { + const page = await open(); + await page.locator(".acpmux-scroll").evaluate((node) => { + node.scrollTop = node.scrollHeight; + }); + await settled(page); + const before = await measure(page); + expect(before.open).toBe("false"); + expect(before.fromEnd).toBeLessThanOrEqual(2); + await clickLine(page, GROUP); + await settled(page); + const after = await measure(page); + expect(after.open).toBe("true"); + // Still at the very bottom, and the rows under the line did not move. + expect(after.fromEnd).toBeLessThanOrEqual(2); + expect(Math.abs(after.lastFoot - before.lastFoot)).toBeLessThanOrEqual(2); + // What opened pushed the line (and the text above it) up by its height. + expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 40); + expect(before.line - after.line).toBeGreaterThan(40); + await page.close(); + }); + + test("at the very bottom, a long prompt's Show more opens it by pushing the text above up", async () => { + const MORE = '.cv-user__more[aria-expanded="false"]'; + const page = await open("prompt", MORE); + await page.locator(".acpmux-scroll").evaluate((node) => { + node.scrollTop = node.scrollHeight; + }); + await settled(page); + const where = () => + page.evaluate(() => { + const scroller = document.querySelector(".acpmux-scroll")!; + const rows = [...scroller.querySelectorAll(".acpmux-row")]; + const bubbles = [...scroller.querySelectorAll(".cv-user__text")]; + return { + prompt: bubbles.at(-1)!.getBoundingClientRect().top, + lastFoot: rows.at(-1)!.getBoundingClientRect().bottom, + scrollHeight: scroller.scrollHeight, + fromEnd: scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop, + }; + }); + const before = await where(); + expect(before.fromEnd).toBeLessThanOrEqual(2); + await clickLine(page, MORE); + await page.locator('.cv-user__more[aria-expanded="true"]').waitFor(); + await settled(page); + const after = await where(); + expect(after.fromEnd).toBeLessThanOrEqual(2); + expect(Math.abs(after.lastFoot - before.lastFoot)).toBeLessThanOrEqual(2); + expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 40); + expect(before.prompt - after.prompt).toBeGreaterThan(40); + await page.close(); + }); + + test("away from the bottom, opening a line keeps it put and grows its body down", async () => { + const page = await open(); + // The earlier turn's "Worked for" line, 160px under the view's top, the rest of the chat under it. + const MID = "[data-mid-probe]"; + await page.locator(".acpmux-scroll").evaluate((node) => { + const line = [...node.querySelectorAll(".cv-worked")].at(-2)!; + line.dataset.midProbe = "true"; + node.scrollTop += line.getBoundingClientRect().top - node.getBoundingClientRect().top - 160; + }); + await settled(page); + const where = (query: string) => + page.evaluate((selector) => { + const scroller = document.querySelector(".acpmux-scroll")!; + const line = document.querySelector(selector)!; + return { + line: line.getBoundingClientRect().top, + open: line.getAttribute("aria-expanded"), + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + fromEnd: scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop, + }; + }, query); + const before = await where(MID); + expect(before.open).toBe("false"); + expect(before.fromEnd).toBeGreaterThan(40); + await clickLine(page, MID); + await settled(page); + const after = await where(MID); + expect(after.open).toBe("true"); + // The line stays on its pixel and the view where it was; what opened grew the chat downward. + expect(Math.abs(after.line - before.line)).toBeLessThanOrEqual(1); + expect(Math.abs(after.scrollTop - before.scrollTop)).toBeLessThanOrEqual(1); + expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 20); + await page.close(); + }); +});