Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ import {
foldHoldTarget,
foldHolding,
foldRoomFor,
opensBody,
foldToggleAt,
holdFold,
keepFold,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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", "");
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".cv-worked")].at(-1);
const group = () =>
[...ctx.document.querySelectorAll<HTMLButtonElement>("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<HTMLElement>(".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,
},
},
});
30 changes: 30 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/foldAnchor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
foldToggleAt,
holdFold,
keepFold,
opensBody,
trimmedFoldRoom,
verticalWheel,
} from "./foldAnchor";
Expand Down Expand Up @@ -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(`<body>${html}</body>`).window;
return document.querySelector<HTMLElement>("[data-t]")!;
};
test("a closed disclosure opens; an open one closes", () => {
expect(opensBody(dom('<button data-t aria-expanded="false"></button>'))).toBe(true);
expect(opensBody(dom('<button data-t aria-expanded="true"></button>'))).toBe(false);
});
test("a header toggle reads its own button, not a menu's", () => {
expect(
opensBody(
dom(
'<div data-t data-header-toggle><button aria-haspopup="menu" aria-expanded="false"></button><button aria-expanded="false"></button></div>',
),
),
).toBe(true);
expect(opensBody(dom('<div data-t data-header-toggle><button aria-expanded="true"></button></div>'))).toBe(false);
});
test("a summary opens its closed details", () => {
expect(opensBody(dom("<details><summary data-t>More</summary>Body</details>"))).toBe(true);
expect(opensBody(dom("<details open><summary data-t>More</summary>Body</details>"))).toBe(false);
});
test("a control that says nothing of its state opens nothing", () => {
expect(opensBody(dom("<button data-t>Raw</button>"))).toBe(false);
});
});
15 changes: 13 additions & 2 deletions webviews/src/agent-session/acpmux/conversation/foldAnchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 `<summary>` of a closed `<details>`.
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).
Expand Down
10 changes: 10 additions & 0 deletions webviews/test/agent-pane-fold-keeps-place.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 12 additions & 4 deletions webviews/test/incoming-message-disclosure-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();
}
Expand Down
16 changes: 8 additions & 8 deletions webviews/test/received-message-fold-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);

Expand Down
Loading
Loading