From cf4d178caba605507b84d630a10babb906eb9eb9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 03:10:04 +0000 Subject: [PATCH 1/9] test(webviews): the queue closed at the latest row keeps no room that glides away A browser test of the transcript end under the queue and the composer (Leo 2026-10-21: "It doesn't scroll to the very bottom ... it cuts off at the bottom"): the queue opened and scrolled to its end, the queue closed at the latest row, a new line in the composer, and the wheel beside the composer. Red on base: closing the fold holds the last row a fold row above the stack for a second, then drops it. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- .../agent-pane-queue-scroll-bottom.test.ts | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 webviews/test/agent-pane-queue-scroll-bottom.test.ts diff --git a/webviews/test/agent-pane-queue-scroll-bottom.test.ts b/webviews/test/agent-pane-queue-scroll-bottom.test.ts new file mode 100644 index 000000000000..f9e5e5517026 --- /dev/null +++ b/webviews/test/agent-pane-queue-scroll-bottom.test.ts @@ -0,0 +1,304 @@ +// l10n-allow-file: test fixture prompts, not shipped UI. +// The transcript's end under the queue and the composer (Leo 2026-10-21: "there's something just a +// bit awkward about the UI of this queuing and steering and expanding and, like, scrolling. It +// doesn't scroll to the very bottom ... it's almost like it's allowed to, but then it just shoots +// back up ... it cuts off at the bottom"). +// +// - The queue opened and the view scrolled to its end: the latest row ends above the stack, whole, +// and stays there. +// - The queue closed at the latest row: the room under the last row shrinks with the stack in the +// same frame. A queued card the fold hides has not left the queue: the view keeps no room for it +// (VirtualTranscript `floor`, kept for a sent prompt) that would hold a hole above the stack for a +// second and then glide the rows down. +// - A new line in the composer leaves the transcript's distance from its end as it was, at the +// latest row and scrolled up (#23302). +// - The wheel beside the composer, left and right of the queue, its fold and the card, scrolls the +// transcript (#23243). +// +// One chat of 40 finished turns and a running one (support/papercutChat.ts), six prompts queued (two +// shown, "4 more queued"), through the pane's dev entry and a stand-in acpmux. Real Chromium and +// WebKit (the app's engine) with real wheel and key events; every read is of a painted frame +// (support/paintedFrames.ts). CI fails the file when it cannot launch them (requireBrowserLane). +// Locally: CMUX_BROWSER_TESTS=1 bun test test/agent-pane-queue-scroll-bottom.test.ts +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; +import { PAPERCUT_CHAT, papercutEvents } from "./support/papercutChat"; +import { installPaintedFrames } from "./support/paintedFrames"; +import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; + +await requireBrowserLane("agent-pane-queue-scroll-bottom.test.ts", async () => { + setDefaultTimeout(300_000); + const engines: [string, BrowserType][] = []; + for (const engine of [ + ["chromium", chromium], + ["webkit", webkit], + ] as [string, BrowserType][]) { + try { + await (await engine[1].launch({ headless: true })).close(); + engines.push(engine); + } catch { + console.warn(`agent-pane-queue-scroll-bottom: skipping ${engine[0]}`); + } + } + if (engines.length === 0) { + describe.skip("queue scroll bottom", () => test("browsers", () => {})); + return; + } + + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + const queue = ["one", "two", "three", "four", "five", "six"].map((word, index) => ({ + promptId: `q-${index + 1}`, + prompt: `The ${word} queued prompt, about the parser and its tests`, + })); + const { events, session } = papercutEvents({ + turns: 40, + running: true, + session: { steering: true, queuePaused: false, queued: queue.length, queue }, + }); + const answer = (method: string): unknown => { + switch (method) { + case "initialize": + return { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; + case "_acpmux/watch": + return { sessions: [session] }; + case "_acpmux/attach": + return { session, lastSeq: events.length, events, hasMore: false }; + case "_acpmux/events": + return { events: [], hasMore: false, lastSeq: events.length }; + default: + return null; + } + }; + const browsers = new Map(); + let daemon: ReturnType | undefined; + let vite: Awaited> | undefined; + beforeAll(async () => { + daemon = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, server) { + return server.upgrade(request, { data: undefined }) ? undefined : new Response("acpmux", { status: 426 }); + }, + websocket: { + message(ws, raw) { + const message = JSON.parse(String(raw)) as { id?: number; method: string }; + if (message.id !== undefined) + ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method) })); + }, + }, + }); + vite = await createServer({ + configFile: path.join(webviews, "vite.config.acpmux-pane.mjs"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + await warmViteServer(vite, "/"); + for (const [name, engine] of engines) browsers.set(name, await engine.launch({ headless: true })); + }); + afterAll(async () => { + for (const browser of browsers.values()) await browser.close(); + await vite?.close(); + await daemon?.stop(true); + }); + + /// Where the transcript's end is, in a painted frame: `fromEnd` is the scroller's distance from its + /// end, `gap` how far the last row ends above the bottom stack (negative: under it). + type Placement = { top: number; fromEnd: number; gap: number }; + /// The placement in each painted frame for `ms`, starting with the next one; `act` runs first. + async function paintedFor(page: Page, ms: number, act?: () => Promise): Promise { + const done = page.evaluate( + (ms) => + new Promise((resolve) => { + const out: Placement[] = []; + let start: number | undefined; + window.cmuxAfterPaint((frame) => { + start ??= frame; + const scroller = document.querySelector(".acpmux-scroll")!; + const rows = document.querySelectorAll(".acpmux-thread .acpmux-row"); + const dock = document.querySelector(".acpmux-main > .acpmux-dock")!; + out.push({ + top: scroller.scrollTop, + fromEnd: scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight, + gap: dock.getBoundingClientRect().top - rows[rows.length - 1].getBoundingClientRect().bottom, + }); + if (frame - start < ms) return true; + resolve(out); + return false; + }); + }), + ms, + ); + await act?.(); + return done; + } + /// Waits until the transcript and the stack hold still over 12 painted frames, and no finite + /// animation runs. + const settle = (page: Page) => + page.evaluate( + () => + new Promise((resolve) => { + let last = ""; + let still = 0; + let frames = 0; + window.cmuxAfterPaint(() => { + frames++; + const scroller = document.querySelector(".acpmux-scroll")!; + const rows = document.querySelectorAll(".acpmux-thread .acpmux-row"); + const dock = document.querySelector(".acpmux-main > .acpmux-dock")!; + const now = [ + scroller.scrollTop, + scroller.scrollHeight, + dock.getBoundingClientRect().top, + rows[rows.length - 1].getBoundingClientRect().bottom, + ].join(","); + const running = document + .getAnimations() + .some( + (animation) => + animation.playState === "running" && animation.effect?.getComputedTiming().endTime !== Infinity, + ); + still = now === last && !running ? still + 1 : 0; + last = now; + if (still < 12 && frames < 600) return true; + resolve(); + return false; + }); + }), + ); + const placement = async (page: Page) => (await paintedFor(page, 0))[0]; + /// The wheel over the transcript's rows above the stack, in steps of 40 px as a trackpad sends. + async function wheel(page: Page, total: number) { + await page.mouse.move(450, 120); + for (let moved = 0; moved < Math.abs(total); moved += 40) await page.mouse.wheel(0, Math.sign(total) * 40); + } + const fold = (page: Page) => page.locator(".acpmux-queue-fold").click(); + const expanded = (page: Page) => page.locator(".acpmux-queue-fold").getAttribute("aria-expanded"); + const prompt = (page: Page) => + page.locator(".acpmux-composer-box [contenteditable=true], .acpmux-composer-box textarea").first(); + + /// Opens the chat at its latest row and notes where the last row rests over the stack there. + async function open(name: string) { + const page = await browsers.get(name)!.newPage({ viewport: { width: 900, height: 700 } }); + await installPaintedFrames(page); + const { port } = vite!.httpServer!.address() as { port: number }; + const endpoint = `ws://127.0.0.1:${daemon!.port}/`; + await page.goto( + `http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${PAPERCUT_CHAT}`, + ); + await page.locator(".acpmux-queue-fold").waitFor({ timeout: 120_000 }); + await page.getByText("Answer 40:", { exact: false }).waitFor({ timeout: 120_000 }); + await settle(page); + const rest = await placement(page); + expect({ rest: rest.fromEnd <= 1 && rest.gap >= 0 }).toEqual({ rest: true }); + return { page, rest: rest.gap }; + } + + /// Every frame in `frames` shows the last row whole, where the latest row rests over the stack. + function expectAtRest(name: string, frames: Placement[], rest: number) { + expect(frames.length).toBeGreaterThan(5); + const off = frames.filter((frame) => frame.fromEnd > 1.5 || Math.abs(frame.gap - rest) > 2); + expect({ name, off: off.slice(0, 4).map((frame) => ({ fromEnd: frame.fromEnd, gap: frame.gap })), rest }).toEqual({ + name, + off: [], + rest, + }); + } + + for (const [name] of engines) { + describe(name, () => { + test("the queue opened, the view scrolled to its end shows the last row whole, and it stays", async () => { + const { page, rest } = await open(name); + await wheel(page, -1200); + await settle(page); + expect((await placement(page)).fromEnd).toBeGreaterThan(600); + await fold(page); + expect(await expanded(page)).toBe("true"); + await settle(page); + await wheel(page, 2400); + await settle(page); + // Whole above the stack, and still there 500 ms later. + expectAtRest(`${name}: opened, scrolled down`, await paintedFor(page, 500), rest); + // Opened at the latest row too. + await fold(page); + await settle(page); + await fold(page); + await settle(page); + expectAtRest(`${name}: opened at the latest row`, await paintedFor(page, 500), rest); + await page.close(); + }); + + test("the queue closed at the latest row keeps the last row on the stack, with no room that glides away", async () => { + const { page, rest } = await open(name); + await fold(page); + await settle(page); + expectAtRest(`${name}: opened`, await paintedFor(page, 200), rest); + // From the frame after the click on, through FLOOR_HOLD_MS and past it: the last row never + // floats a card's height above the stack, and never glides down to it later. + const frames = await paintedFor(page, 1500, () => fold(page)); + expect(await expanded(page)).toBe("false"); + expectAtRest(`${name}: closed`, frames.slice(2), rest); + await page.close(); + }); + + test("a new line in the composer leaves the transcript's distance from its end as it was", async () => { + const { page, rest } = await open(name); + await prompt(page).click(); + await page.keyboard.type("Check the parser"); + await settle(page); + for (let line = 0; line < 3; line++) { + const frames = await paintedFor(page, 300, () => page.keyboard.press("Shift+Enter")); + expectAtRest(`${name}: new line ${line + 1} at the latest row`, frames, rest); + } + // Scrolled up: the offset and the distance from the end hold still. + await wheel(page, -600); + await settle(page); + const before = await placement(page); + expect(before.fromEnd).toBeGreaterThan(300); + await prompt(page).click(); + for (let line = 0; line < 3; line++) { + const frames = await paintedFor(page, 300, () => page.keyboard.press("Shift+Enter")); + const moved = frames.filter( + (frame) => Math.abs(frame.fromEnd - before.fromEnd) > 1 || Math.abs(frame.top - before.top) > 1, + ); + expect({ name, line, moved: moved.slice(0, 3) }).toEqual({ name, line, moved: [] }); + } + await page.close(); + }); + + test("the wheel beside the composer, left and right of the queue, its fold and the card, scrolls the transcript", async () => { + const { page } = await open(name); + await wheel(page, -1200); + await settle(page); + for (const part of [".acpmux-composer-queue", ".acpmux-queue-foldbar", ".acpmux-composer-box"]) { + const box = (await page.locator(`.acpmux-main > .acpmux-dock > .acpmux-composer > ${part}`).boundingBox())!; + const y = box.y + box.height / 2; + for (const x of [box.x - 8, box.x + box.width + 8]) { + const reaches = await page.evaluate( + ([x, y]) => Boolean(document.elementFromPoint(x, y)?.closest(".acpmux-scroll")), + [x, y] as const, + ); + expect({ part, x, reaches }).toEqual({ part, x, reaches: true }); + for (const delta of [-300, 300]) { + const before = (await placement(page)).top; + await page.mouse.move(x, y); + await page.mouse.wheel(0, delta); + await settle(page); + const moved = (await placement(page)).top - before; + expect({ + part, + x, + delta, + scrolls: Math.sign(moved) === Math.sign(delta) && Math.abs(moved) > 100, + }).toEqual({ part, x, delta, scrolls: true }); + } + } + } + await page.close(); + }); + }); + } +}); From b6af5cf52d61a4591e583e76b07cfe72c7d10c0d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 03:24:29 +0000 Subject: [PATCH 2/9] Agent chat: closing the queue at the latest row keeps the last row on the composer Closing "Show less" on a long queue hides its rows past the fold, and the transcript took each hidden row for a queued card that left the stack (a prompt on its way to the transcript). It kept the room for it under the last row (VirtualTranscript floor) for FLOOR_HOLD_MS: the last row hung a fold row above the stack for a second, then the rows dropped to the composer (Leo 2026-10-21: "it's almost like it's allowed to, but then it just shoots back up ... it cuts off at the bottom"). The queue names the prompts its fold hides (data-folded-ids) and noteQueue counts them as still queued, so closing the fold shrinks the room in the same frame, as opening grows it. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- webviews/src/agent-session/acpmux/App.tsx | 13 +++++++++---- webviews/src/agent-session/acpmux/ComposerQueue.tsx | 10 ++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index a05fd55de842..18668ca5c6cf 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -1821,12 +1821,17 @@ export function VirtualTranscript({ dockRef.current = dock; /// Notes the stack's queued prompts; called first by each size observer (the stack's, and the /// view's, for a stack laid out under the transcript), so the render a size change makes knows. + /// A prompt the queue's fold hides (ComposerQueue `data-folded-ids`) is still queued: closing the + /// fold is not its card leaving, and no room is kept under the last row for a prompt that stays + /// queued (Leo 2026-10-21: the queue closed left a hole over the stack, then the rows dropped). const noteQueue = useCallback(() => { - const now = new Set( - [...(dockRef.current?.querySelectorAll(".acpmux-queued") ?? [])].map( - (card) => card.dataset.promptId ?? "", + const dock = dockRef.current; + const now = new Set([ + ...[...(dock?.querySelectorAll(".acpmux-queued") ?? [])].map((card) => card.dataset.promptId ?? ""), + ...[...(dock?.querySelectorAll("[data-folded-ids]") ?? [])].flatMap((list) => + (list.dataset.foldedIds ?? "").split(" "), ), - ); + ]); const left = [...queuedIds.current].filter((id) => !now.has(id)); queuedIds.current = now; if (!left.length) return; diff --git a/webviews/src/agent-session/acpmux/ComposerQueue.tsx b/webviews/src/agent-session/acpmux/ComposerQueue.tsx index dae732d9cc8f..a4171b00923c 100644 --- a/webviews/src/agent-session/acpmux/ComposerQueue.tsx +++ b/webviews/src/agent-session/acpmux/ComposerQueue.tsx @@ -581,6 +581,16 @@ export function ComposerQueue({ ref={list} className="ui-glass acpmux-composer-queue" data-unfolded={foldable && !folded ? "" : undefined} + // The prompts the fold hides, still queued: the transcript keeps no room for them as it + // does for a card that leaves (VirtualTranscript `noteQueue`). + data-folded-ids={ + folded + ? shown + .slice(visible.length) + .map((entry) => entry.id) + .join(" ") + : undefined + } // A row that takes focus from the keyboard (keys, a move, ↑ from the prompt) scrolls in whole. onFocus={(event) => { const row = (event.target as HTMLElement).closest("li"); From 926f57404d19fae2587a308c5e283a7c856e0356 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 07:11:59 +0000 Subject: [PATCH 3/9] test(webviews): a queued prompt run elsewhere while out of sight keeps no room above the stack Codex review of #24182: a prompt the fold hides (or past an opened list's cap), run by another client, changes no size, so neither size observer noted it. The next resize that shrank the stack took it for a card that left and kept FLOOR_HOLD_MS of room above the stack. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- .../agent-pane-queue-scroll-bottom.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/webviews/test/agent-pane-queue-scroll-bottom.test.ts b/webviews/test/agent-pane-queue-scroll-bottom.test.ts index f9e5e5517026..df88d2802615 100644 --- a/webviews/test/agent-pane-queue-scroll-bottom.test.ts +++ b/webviews/test/agent-pane-queue-scroll-bottom.test.ts @@ -10,6 +10,8 @@ // same frame. A queued card the fold hides has not left the queue: the view keeps no room for it // (VirtualTranscript `floor`, kept for a sent prompt) that would hold a hole above the stack for a // second and then glide the rows down. +// - A queued prompt another client runs while its card is out of sight (folded, or past the opened +// list's cap) changes no size; the stack shrinking, opening or closing later keeps no room for it. // - A new line in the composer leaves the transcript's distance from its end as it was, at the // latest row and scrolled up (#23302). // - The wheel beside the composer, left and right of the queue, its fold and the card, scrolls the @@ -72,6 +74,18 @@ await requireBrowserLane("agent-pane-queue-scroll-bottom.test.ts", async () => { return null; } }; + /// The page's socket (the last one to call), and a record every client hears on it, as acpmux + /// sends one (another client running a queued prompt: `dequeued`). + let socket: { send(data: string): void } | undefined; + let seq = events.length; + const hear = (kind: string, msg: Record) => + socket!.send( + JSON.stringify({ + jsonrpc: "2.0", + method: "_acpmux/event", + params: { sessionId: PAPERCUT_CHAT, seq: ++seq, at: Date.now(), dir: "mux", kind, msg }, + }), + ); const browsers = new Map(); let daemon: ReturnType | undefined; let vite: Awaited> | undefined; @@ -85,6 +99,7 @@ await requireBrowserLane("agent-pane-queue-scroll-bottom.test.ts", async () => { websocket: { message(ws, raw) { const message = JSON.parse(String(raw)) as { id?: number; method: string }; + socket = ws; if (message.id !== undefined) ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method) })); }, @@ -244,6 +259,72 @@ await requireBrowserLane("agent-pane-queue-scroll-bottom.test.ts", async () => { await page.close(); }); + // Codex review of #24182: a queued prompt run by another client while its card is out of sight + // (one the fold hides, or past an opened list's cap) changes no size, so no size observer noted + // it. Still noted as queued, the next resize that shrank the stack took it for a card that left + // then, and kept FLOOR_HOLD_MS of room for it above the stack, which then glided away. + const stack = (page: Page) => + page.locator(".acpmux-main > .acpmux-dock").evaluate((dock) => dock.getBoundingClientRect().height); + /// Another client runs queued prompt `id` (acpmux `dequeued`); waits until the pane drew it. + async function runElsewhere(page: Page, id: string) { + hear("dequeued", { promptId: id }); + await page.waitForFunction( + (id) => + !document.querySelector(`.acpmux-queued[data-prompt-id="${id}"]`) && + !document + .querySelector(".acpmux-main > .acpmux-dock .acpmux-composer-queue") + ?.getAttribute("data-folded-ids") + ?.split(" ") + .includes(id), + id, + ); + await settle(page); + } + + test("a prompt the fold hides, run elsewhere, leaves no room as the stack shrinks, opens and closes", async () => { + const { page, rest } = await open(name); + await prompt(page).click(); + await page.keyboard.type("Check the parser"); + await page.keyboard.press("Shift+Enter"); + await settle(page); + const list = page.locator(".acpmux-main > .acpmux-dock .acpmux-composer-queue"); + expect((await list.getAttribute("data-folded-ids"))?.split(" ")).toContain("q-5"); + const before = await stack(page); + await runElsewhere(page, "q-5"); + // Five queued, still folded: the stack's size is as it was. + expect({ folded: await expanded(page), height: await stack(page) }).toEqual({ + folded: "false", + height: before, + }); + expectAtRest(`${name}: run elsewhere`, await paintedFor(page, 200), rest); + // The composer's line deleted: the stack shrinks, and no card left it. + expectAtRest( + `${name}: line deleted`, + (await paintedFor(page, 1500, () => page.keyboard.press("Backspace"))).slice(2), + rest, + ); + await settle(page); + expectAtRest(`${name}: opened`, (await paintedFor(page, 1500, () => fold(page))).slice(2), rest); + expect(await expanded(page)).toBe("true"); + await settle(page); + expectAtRest(`${name}: closed`, (await paintedFor(page, 1500, () => fold(page))).slice(2), rest); + expect(await expanded(page)).toBe("false"); + await page.close(); + }); + + test("a prompt past the opened queue's cap, run elsewhere, leaves no room when the queue closes", async () => { + const { page, rest } = await open(name); + await fold(page); + await settle(page); + const before = await stack(page); + await runElsewhere(page, "q-5"); + // The list keeps its capped height: the stack's size is as it was. + expect({ height: await stack(page) }).toEqual({ height: before }); + expectAtRest(`${name}: closed`, (await paintedFor(page, 1500, () => fold(page))).slice(2), rest); + expect(await expanded(page)).toBe("false"); + await page.close(); + }); + test("a new line in the composer leaves the transcript's distance from its end as it was", async () => { const { page, rest } = await open(name); await prompt(page).click(); From 71d078163398ebdbc5681f0d5a7b47d1ad42993a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 07:11:59 +0000 Subject: [PATCH 4/9] Agent chat: the transcript notes the queue on each change, not only on a resize VirtualTranscript noted the queued prompts only from its two ResizeObservers. A queued prompt run by another client while its card is out of sight (folded, or past the opened list's cap) changes no size, so its id stayed in queuedIds; the next Show more/Show less or composer resize counted it as a card that left and held a phantom gap above the composer for FLOOR_HOLD_MS. The snapshot's queue now reaches VirtualTranscript, and a layout effect keyed on it calls noteQueue in the commit that draws the change. No new observer or timer. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- webviews/src/agent-session/acpmux/App.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 18668ca5c6cf..013bd3120bf2 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -1720,6 +1720,7 @@ export function VirtualTranscript({ needsYou, dock, queueWithdrawn, + queue, focusComposer, openAt, onAnchor, @@ -1748,6 +1749,9 @@ export function VirtualTranscript({ /// Prompts withdrawn from the queue (snapshot `queueWithdrawn`): no row comes for one whose card /// leaves the stack, so the view keeps no room for it. queueWithdrawn?: readonly string[]; + /// The queue the stack's cards draw (snapshot `queue`): each change notes the queued prompts again + /// (`noteQueue`), whether or not it changes the stack's size. + queue?: readonly unknown[]; /// Where a keyboard Jump to latest leaves focus, since the button hides (inert) once there. focusComposer?: () => void; onToggleActivity: (id: string) => void; @@ -1820,8 +1824,8 @@ export function VirtualTranscript({ const dockRef = useRef(dock); dockRef.current = dock; /// Notes the stack's queued prompts; called first by each size observer (the stack's, and the - /// view's, for a stack laid out under the transcript), so the render a size change makes knows. - /// A prompt the queue's fold hides (ComposerQueue `data-folded-ids`) is still queued: closing the + /// view's, for a stack laid out under the transcript), so the render a size change makes knows, + /// and by each change of the queue (below). A prompt the queue's fold hides (ComposerQueue `data-folded-ids`) is still queued: closing the /// fold is not its card leaving, and no room is kept under the last row for a prompt that stays /// queued (Leo 2026-10-21: the queue closed left a hole over the stack, then the rows dropped). const noteQueue = useCallback(() => { @@ -1876,6 +1880,11 @@ export function VirtualTranscript({ setDockCard(card(space)); return () => observer.disconnect(); }, [dock, noteQueue]); + // And each time the queue changes, in the commit that draws it. A change that leaves the stack's + // size as it was (a prompt the fold hides, or one an opened list's cap cuts off, run by another + // client) runs neither observer: still noted as queued, the next resize took it for a card that + // left then, and kept room above the stack for FLOOR_HOLD_MS that then glided away (#24182). + useLayoutEffect(() => noteQueue(), [queue, noteQueue]); const [width, setWidth] = useState(() => lastTranscriptBox.width); // The column's cap (layout.json `chatWidth`): a new one measures every row again. const chatWidth = useChatWidth(); @@ -6916,6 +6925,7 @@ function AcpmuxPane() { // Quick Chat's panel stacks its composer below the transcript. dock={quick ? undefined : dock} queueWithdrawn={snapshot.queueWithdrawn} + queue={quick ? undefined : snapshot.queue} find={quick ? undefined : find} sessionId={ childRows From a8a8ca0451cdce5d36ddee6cf3b3efabe6f34431 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 07:50:52 +0000 Subject: [PATCH 5/9] test(webviews): a prompt withdrawn in a chat switched to keeps no room for the last chat's queue Codex review of #24182 at 71d0781: switched to a chat with its own queue, the last chat's queued prompts read as cards that left and stayed in heldFor, so a prompt withdrawn later held room above the stack for FLOOR_HOLD_MS. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- .../agent-pane-queue-scroll-bottom.test.ts | 68 ++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/webviews/test/agent-pane-queue-scroll-bottom.test.ts b/webviews/test/agent-pane-queue-scroll-bottom.test.ts index df88d2802615..be75c1f0b090 100644 --- a/webviews/test/agent-pane-queue-scroll-bottom.test.ts +++ b/webviews/test/agent-pane-queue-scroll-bottom.test.ts @@ -60,16 +60,36 @@ await requireBrowserLane("agent-pane-queue-scroll-bottom.test.ts", async () => { running: true, session: { steering: true, queuePaused: false, queued: queue.length, queue }, }); - const answer = (method: string): unknown => { + /// A second chat, idle, with one queued prompt of its own: switched to from the first (#24182). + const OTHER_CHAT = "0190a000-0000-7000-8000-00000000fc02"; + const other = papercutEvents({ + turns: 40, + running: false, + session: { + sessionId: OTHER_CHAT, + title: "Other papercuts", + steering: true, + queuePaused: false, + queued: 1, + queue: [{ promptId: "b-1", prompt: "The other chat's queued prompt, about the docs" }], + }, + }); + other.events.forEach((event) => (event.sessionId = OTHER_CHAT)); + const chats = new Map([ + [PAPERCUT_CHAT, { events, session }], + [OTHER_CHAT, { events: other.events, session: other.session }], + ]); + const answer = (method: string, params: { sessionId?: string }): unknown => { + const chat = chats.get(params.sessionId ?? PAPERCUT_CHAT) ?? chats.get(PAPERCUT_CHAT)!; switch (method) { case "initialize": return { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; case "_acpmux/watch": - return { sessions: [session] }; + return { sessions: [...chats.values()].map((each) => each.session) }; case "_acpmux/attach": - return { session, lastSeq: events.length, events, hasMore: false }; + return { session: chat.session, lastSeq: chat.events.length, events: chat.events, hasMore: false }; case "_acpmux/events": - return { events: [], hasMore: false, lastSeq: events.length }; + return { events: [], hasMore: false, lastSeq: chat.events.length }; default: return null; } @@ -77,15 +97,18 @@ await requireBrowserLane("agent-pane-queue-scroll-bottom.test.ts", async () => { /// The page's socket (the last one to call), and a record every client hears on it, as acpmux /// sends one (another client running a queued prompt: `dequeued`). let socket: { send(data: string): void } | undefined; - let seq = events.length; - const hear = (kind: string, msg: Record) => + const seqs = new Map([...chats].map(([id, chat]) => [id, chat.events.length])); + const hear = (kind: string, msg: Record, sessionId = PAPERCUT_CHAT) => { + const seq = seqs.get(sessionId)! + 1; + seqs.set(sessionId, seq); socket!.send( JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/event", - params: { sessionId: PAPERCUT_CHAT, seq: ++seq, at: Date.now(), dir: "mux", kind, msg }, + params: { sessionId, seq, at: Date.now(), dir: "mux", kind, msg }, }), ); + }; const browsers = new Map(); let daemon: ReturnType | undefined; let vite: Awaited> | undefined; @@ -98,10 +121,12 @@ await requireBrowserLane("agent-pane-queue-scroll-bottom.test.ts", async () => { }, websocket: { message(ws, raw) { - const message = JSON.parse(String(raw)) as { id?: number; method: string }; + const message = JSON.parse(String(raw)) as { id?: number; method: string; params?: { sessionId?: string } }; socket = ws; if (message.id !== undefined) - ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method) })); + ws.send( + JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method, message.params ?? {}) }), + ); }, }, }); @@ -325,6 +350,31 @@ await requireBrowserLane("agent-pane-queue-scroll-bottom.test.ts", async () => { await page.close(); }); + // Codex review of #24182 at 71d0781: the queue noted in one chat is not another chat's. Switched + // to a chat with its own queue, the first chat's prompts read as cards that left, and stayed held + // for: a prompt withdrawn from the second chat later kept room above its stack, as for a sent one. + test("in a chat switched to, a queued prompt withdrawn leaves no room held for the last chat's queue", async () => { + const { page } = await open(name); + await page.evaluate((id) => { + const actions = (window as { cmuxAcpmuxActions?: Record Promise> }) + .cmuxAcpmuxActions; + void actions?.["chat.select"]?.({ sessionId: id }); + }, OTHER_CHAT); + await page.locator('.acpmux-queued[data-prompt-id="b-1"]').waitFor({ timeout: 60_000 }); + await page.waitForFunction(() => !document.querySelector('.acpmux-queued[data-prompt-id="q-1"]')); + await settle(page); + const rest = await placement(page); + expect({ rest: rest.fromEnd <= 1 && rest.gap >= 0 }).toEqual({ rest: true }); + const before = await stack(page); + const frames = await paintedFor(page, 1500, async () => hear("queue_removed", { promptId: "b-1" }, OTHER_CHAT)); + await page.waitForFunction(() => !document.querySelector('.acpmux-queued[data-prompt-id="b-1"]')); + expect(await stack(page)).toBeLessThan(before - 20); + // From the frame the card left on: the last row ends where the chat's latest row rests. + const left = frames.findIndex((frame, index) => index > 0 && frame.gap !== frames[0].gap); + expectAtRest(`${name}: withdrawn after a switch`, frames.slice(Math.max(0, left) + 2), rest.gap); + await page.close(); + }); + test("a new line in the composer leaves the transcript's distance from its end as it was", async () => { const { page, rest } = await open(name); await prompt(page).click(); From c8d5ea507e3c26025362fb606cd26d5b4ef3bfca Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 07:50:52 +0000 Subject: [PATCH 6/9] Agent chat: the queue noted in one chat is not another chat's noteQueue keeps the chat and the stack it noted queuedIds in. In another chat or stack it starts over: queuedIds from what is drawn, heldFor cleared, queueLeft false, and no prompt counts as having left. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- webviews/src/agent-session/acpmux/App.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 013bd3120bf2..6ce619216821 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -1823,11 +1823,16 @@ export function VirtualTranscript({ withdrawn.current = withdrawnSet; const dockRef = useRef(dock); dockRef.current = dock; + const queueSession = useRef(sessionId); + queueSession.current = sessionId; + /// The chat and the stack `queuedIds` were noted in. + const notedIn = useRef<{ sessionId?: string; dock?: HTMLElement | null }>({}); /// Notes the stack's queued prompts; called first by each size observer (the stack's, and the /// view's, for a stack laid out under the transcript), so the render a size change makes knows, - /// and by each change of the queue (below). A prompt the queue's fold hides (ComposerQueue `data-folded-ids`) is still queued: closing the - /// fold is not its card leaving, and no room is kept under the last row for a prompt that stays - /// queued (Leo 2026-10-21: the queue closed left a hole over the stack, then the rows dropped). + /// and by each change of the queue (below). A prompt the queue's fold hides (ComposerQueue + /// `data-folded-ids`) is still queued: closing the fold is not its card leaving, and no room is + /// kept under the last row for a prompt that stays queued (Leo 2026-10-21: the queue closed left a + /// hole over the stack, then the rows dropped). const noteQueue = useCallback(() => { const dock = dockRef.current; const now = new Set([ @@ -1836,6 +1841,16 @@ export function VirtualTranscript({ (list.dataset.foldedIds ?? "").split(" "), ), ]); + // Another chat's queue, or another stack, starts over: the last one's prompts never left this + // one, and no room is held for them (#24182: switched to a chat with its own queue, the last + // chat's prompts read as cards that left, and a prompt withdrawn later kept room as a sent one). + if (notedIn.current.sessionId !== queueSession.current || notedIn.current.dock !== dock) { + notedIn.current = { sessionId: queueSession.current, dock }; + queuedIds.current = now; + heldFor.current.clear(); + queueLeft.current = false; + return; + } const left = [...queuedIds.current].filter((id) => !now.has(id)); queuedIds.current = now; if (!left.length) return; From 035b8938e6451a42cc3d42471c2889c061ac14e8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 23 Oct 2026 13:21:38 -0700 Subject: [PATCH 7/9] fix(next): keep folded queue prompts accounted Agent-Session: https://cmux.com/deeplink/agent?harness=codex&session=01a16a36-eea5-75b3-a577-2cfab2287850&host=Air-Blue --- webviews/src/agent-session/acpmux/App.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 91191c5c18d6..6f8fb4ffe00d 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -1792,7 +1792,7 @@ export function VirtualTranscript({ needsYou, dock, queueWithdrawn, - queue, + queueIds, focusComposer, openAt, onAnchor, @@ -1824,9 +1824,9 @@ export function VirtualTranscript({ /// Prompts withdrawn from the queue (snapshot `queueWithdrawn`): no row comes for one whose card /// leaves the stack, so the view keeps no room for it. queueWithdrawn?: readonly string[]; - /// The queue the stack's cards draw (snapshot `queue`): each change notes the queued prompts again - /// (`noteQueue`), whether or not it changes the stack's size. - queue?: readonly unknown[]; + /// IDs from the queue snapshot: a folded prompt has no card in the DOM, but remains queued and + /// must not be recorded as withdrawn when the fold changes. + queueIds?: readonly string[]; /// Where a keyboard Jump to latest leaves focus, since the button hides (inert) once there. focusComposer?: () => void; onToggleActivity: (id: string) => void; @@ -1942,6 +1942,7 @@ export function VirtualTranscript({ ...[...(dock?.querySelectorAll("[data-folded-ids]") ?? [])].flatMap((list) => (list.dataset.foldedIds ?? "").split(" "), ), + ...(queueIds ?? []), ]); // Another chat's queue, or another stack, starts over: the last one's prompts never left this // one, and no room is held for them (#24182: switched to a chat with its own queue, the last @@ -1961,7 +1962,7 @@ export function VirtualTranscript({ queueLeft.current = true; // Only the size change of this frame is the card leaving. requestAnimationFrame(() => requestAnimationFrame(() => (queueLeft.current = false))); - }, []); + }, [queueIds]); useLayoutEffect(() => { if (!dock) { setDockSpace(0); @@ -2001,7 +2002,7 @@ export function VirtualTranscript({ // size as it was (a prompt the fold hides, or one an opened list's cap cuts off, run by another // client) runs neither observer: still noted as queued, the next resize took it for a card that // left then, and kept room above the stack for FLOOR_HOLD_MS that then glided away (#24182). - useLayoutEffect(() => noteQueue(), [queue, noteQueue]); + useLayoutEffect(() => noteQueue(), [queueIds, noteQueue]); const [width, setWidth] = useState(() => lastTranscriptBox.width); // The column's cap (layout.json `chatWidth`): a new one measures every row again. const chatWidth = useChatWidth(); @@ -7586,7 +7587,7 @@ function AcpmuxPane() { // Quick Chat's panel stacks its composer below the transcript. dock={quick ? undefined : dock} queueWithdrawn={snapshot.queueWithdrawn} - queue={quick ? undefined : snapshot.queue} + queueIds={quick ? undefined : snapshot.queue.map((entry) => entry.id)} find={quick ? undefined : find} sessionId={ childRows From d2f9f72fb67fcddb1da7dc09f1f2d4de5122c43d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 23 Oct 2026 13:38:17 -0700 Subject: [PATCH 8/9] perf(next): memoize queue transcript ids Agent-Session: https://cmux.com/deeplink/agent?harness=codex&session=01a16a36-eea5-75b3-a577-2cfab2287850&host=Air-Blue --- webviews/src/agent-session/acpmux/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 6f8fb4ffe00d..719ae8904c9d 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -3991,6 +3991,7 @@ function AcpmuxPane() { ), [clientSnapshot, switchView, catalog], ); + const queueIds = useMemo(() => snapshot.queue.map((entry) => entry.id), [snapshot.queue]); // Another chat ends the replay: coming back to this one opens it live. if (replaying && snapshot.sessionId !== replaying) setReplaying(undefined); // A /btw failure belongs to its chat: another chat drops it. @@ -7587,7 +7588,7 @@ function AcpmuxPane() { // Quick Chat's panel stacks its composer below the transcript. dock={quick ? undefined : dock} queueWithdrawn={snapshot.queueWithdrawn} - queueIds={quick ? undefined : snapshot.queue.map((entry) => entry.id)} + queueIds={quick ? undefined : queueIds} find={quick ? undefined : find} sessionId={ childRows From a8ade6a5cd01bf848020b3ac656197cecc63606b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 23 Oct 2026 15:29:05 -0700 Subject: [PATCH 9/9] fix(next): reset queue floor on session changes Agent-Session: https://cmux.com/deeplink/agent?harness=codex&session=01a16a36-eea5-75b3-a577-2cfab2287850&host=Air-Blue --- webviews/src/agent-session/acpmux/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index bbfeab469040..aab5ef0821f7 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -2002,7 +2002,7 @@ export function VirtualTranscript({ // size as it was (a prompt the fold hides, or one an opened list's cap cuts off, run by another // client) runs neither observer: still noted as queued, the next resize took it for a card that // left then, and kept room above the stack for FLOOR_HOLD_MS that then glided away (#24182). - useLayoutEffect(() => noteQueue(), [queueIds, noteQueue]); + useLayoutEffect(() => noteQueue(), [sessionId, queueIds, noteQueue]); const [width, setWidth] = useState(() => lastTranscriptBox.width); // The column's cap (layout.json `chatWidth`): a new one measures every row again. const chatWidth = useChatWidth();