diff --git a/webviews/src/agent-session/acpmux/threadMinimap/PreviewCard.tsx b/webviews/src/agent-session/acpmux/threadMinimap/PreviewCard.tsx index 7729f10c6567..17dc7402d001 100644 --- a/webviews/src/agent-session/acpmux/threadMinimap/PreviewCard.tsx +++ b/webviews/src/agent-session/acpmux/threadMinimap/PreviewCard.tsx @@ -34,7 +34,10 @@ const linkLabel = (href: string) => { /// one, and the card renders again for each row the pointer (or a wheel under it) passes. let timeFormat: Intl.DateTimeFormat | undefined; const timeLabel = (at: number) => - (timeFormat ??= new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" })).format(new Date(at)); + (timeFormat ??= new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + })).format(new Date(at)); // Only bounded preview spans pass here. Preserve both ends of hashes, paths and URLs; the full // token stays available to hover and assistive technology instead of taking over the whole card. @@ -83,6 +86,39 @@ function forwardScrollKey(scroller: HTMLElement, event: React.KeyboardEvent): vo const underCard: CoveredCandidates = (surface) => surface.parentElement?.querySelectorAll(":scope > .acpmux-scroll") ?? []; +/// A prompt card's measured text budget: the reply's lines under the prompt's, at the card's width and +/// its type (`type`: the prompt's and the reply's line heights, which a text size or density changes). +type Fit = { width: number; type: string; lineHeight: number; lines: number }; +/// The card's type as `Fit.type` keeps it: one style read each, no layout of their own. +function typeOf(prompt: HTMLElement, reply: HTMLElement): { type: string; prompt: number; reply: number } | undefined { + const view = prompt.ownerDocument.defaultView; + if (!view) return undefined; + const promptLine = Number.parseFloat(view.getComputedStyle(prompt).lineHeight) || 20; + const replyLine = Number.parseFloat(view.getComputedStyle(reply).lineHeight) || promptLine; + return { type: `${promptLine}/${replyLine}`, prompt: promptLine, reply: replyLine }; +} +/// What a fit is remembered by: the reply's blocks (a turn keeps its own) and the text beside them. +type FitKey = { reply: readonly ReplyBlock[]; text: string }; +/// Every card's measured budgets, by what it showed (PreviewCard `fit`). A sweep back over rows it +/// crossed writes their budgets and reads once, instead of measuring the prompt and the reply again. +/// Weak by the reply's blocks, so a turn that goes takes its entries with it. +const fits = new WeakMap>(); +const fitOf = ({ reply, text }: FitKey) => fits.get(reply)?.get(text); +function rememberFit({ reply, text }: FitKey, fit: Fit): void { + let byText = fits.get(reply); + if (!byText) fits.set(reply, (byText = new Map())); + byText.set(text, fit); +} + +/// Sets the reply's line budget, writing only what changed: an unchanged inline style restyles nothing. +function budget(reply: HTMLElement, lines: number, lineHeight: number): void { + const set = (name: string, value: string) => { + if (reply.style.getPropertyValue(name) !== value) reply.style.setProperty(name, value); + }; + set("--acpmux-minimap-reply-lines", String(lines)); + set("--acpmux-minimap-reply-line-height", `${lineHeight}px`); +} + export type PreviewBookmark = { marked: boolean; toggle(): void }; export const PreviewCard = React.forwardRef< @@ -165,112 +201,177 @@ export const PreviewCard = React.forwardRef< /// Where the card goes, as of the last render: read by `place`, which a commit or a resize runs. const at = useRef({ x, center, viewportHeight, layer, side }); at.current = { x, center, viewportHeight, layer, side }; - /// Puts the card beside its row: the rail's origin in the card's containing block (none without a - /// layer), plus `x`, and its own height centered on `center` inside the viewport. It writes the - /// transform itself, before paint, with no state: a card that opens, follows the pointer to another - /// row or grows renders once, and lays out once for the reads here (#28540: an origin and a height - /// held in state took three commits and six forced layouts to open a card). - const place = useCallback(() => { - const node = self.current; - if (!node) return; - const { x: inset, center: middle, viewportHeight: room, layer: into, side: edge } = at.current; - let origin = { x: 0, y: 0 }; + /// Where the card's row is: the rail's origin in the card's containing block (none without a + /// layer). Reads only: the anchor and the layer, never the card's own size, so it shares one layout + /// with the fit's reads. + const origin = useCallback((node: HTMLElement) => { + const into = at.current.layer; const from = anchor.current; - if (into && from) { - const base = node.offsetParent instanceof HTMLElement ? node.offsetParent : into; - const mark = from.getBoundingClientRect(); - const frame = base.getBoundingClientRect(); - const shiftX = - Number.parseFloat(from.ownerDocument.defaultView?.getComputedStyle(from.parentElement!).translate ?? "") || 0; - origin = { - x: mark.left - shiftX - frame.left - base.clientLeft + base.scrollLeft, - y: mark.top - frame.top - base.clientTop + base.scrollTop, - }; - } - const top = cardOffset(middle, node.offsetHeight, room); + if (!into || !from) return { x: 0, y: 0 }; + const base = node.offsetParent instanceof HTMLElement ? node.offsetParent : into; + const mark = from.getBoundingClientRect(); + const frame = base.getBoundingClientRect(); + const shiftX = + Number.parseFloat(from.ownerDocument.defaultView?.getComputedStyle(from.parentElement!).translate ?? "") || 0; + return { + x: mark.left - shiftX - frame.left - base.clientLeft + base.scrollLeft, + y: mark.top - frame.top - base.clientTop + base.scrollTop, + }; + }, []); + /// Writes the card's transform: `from` (the rail's origin, `origin`) plus `x`, and its `height` + /// centered on `center` inside the viewport. Writes only. + const move = useCallback((node: HTMLElement, from: { x: number; y: number }, height: number) => { + const { x: inset, center: middle, viewportHeight: room, layer: into, side: edge } = at.current; + const top = cardOffset(middle, height, room); // On the left the card starts `inset` past the rail's origin (card.left == rail.right). On the // right the origin is the pane's right edge and the card ends `inset` in from it (card.right == // rail.left): placed in the layer by its own width, or pinned to the right edge without one. const transform = edge === "left" - ? `translate3d(${origin.x + inset}px, ${origin.y + top}px, 0)` + ? `translate3d(${from.x + inset}px, ${from.y + top}px, 0)` : into - ? `translate3d(${origin.x - inset}px, ${origin.y + top}px, 0) translateX(-100%)` + ? `translate3d(${from.x - inset}px, ${from.y + top}px, 0) translateX(-100%)` : `translate3d(${-inset}px, ${top}px, 0)`; if (node.style.transform !== transform) node.style.transform = transform; }, []); - // oxlint-disable-next-line react-hooks/exhaustive-deps -- runs after every commit on purpose: the rail can move under the card with any render. - useLayoutEffect(() => place()); - // After the card is placed, so the blur's first measure finds it where it draws. - useSeeThroughBlur(self, !!layer, underCard); + /// Puts the card beside its row. It writes the transform itself, before paint, with no state: a + /// card that opens, follows the pointer to another row or grows renders once, and lays out once for + /// the reads here (#28540: an origin and a height held in state took three commits and six forced + /// layouts to open a card). Every read comes before the one write. + const place = useCallback(() => { + const node = self.current; + if (!node) return; + const from = origin(node); + move(node, from, node.offsetHeight); + }, [origin, move]); // The text budget is six lines: the old two-line prompt plus four-line reply. Measure the // prompt's actual wrapped lines when the card opens (and when its width changes), then give the // reply the remaining whole lines. Metadata and failure details keep their old natural rows. /// Sets the reply's line budget for this card's kind, in place (no state: it changes no React /// output), then places the card by its height after that change. Every card kind takes this one /// path, at open and on every resize, so a card reused for another kind (a prompt's card, then a - /// reply's) is measured after its clamp changed, never before (#28540 review). + /// reply's) is measured after its clamp changed, never before (#28540 review). Returns the card's + /// size as that last read found it. + /// + /// Each row a sweep crosses fits the card (Leo, 2026-11-04: "hovering across the session rail gets + /// a bit laggy ... it needs to get faster"). Every read after a write lays the page out again, and + /// this read and wrote by turns: with the commit's place before it, four forced layouts per row + /// (test/thread-rail-hover-reads.test.tsx). Now its reads come first and its writes after, in three + /// passes on a row it has not measured (the prompt's lines with the card's place, the reply's + /// natural height with its clamp lifted, the card's height under its budget), and a row it has + /// measured at this width (`known`) writes its remembered budget and reads once. const fit = useCallback( - (node: HTMLElement, promptCard: boolean) => { + (node: HTMLElement, promptCard: boolean, known: FitKey, reuse: boolean): { width: number; height: number } => { const replyNode = node.querySelector(".acpmux-minimap__reply"); const promptNode = node.querySelector(".acpmux-minimap__prompt"); - const style = promptNode?.ownerDocument.defaultView?.getComputedStyle(promptNode); - if (!promptCard || !replyNode || !promptNode || !style) { + const settle = () => { + const from = origin(node); + const size = { width: node.offsetWidth, height: node.offsetHeight }; + move(node, from, size.height); + return size; + }; + if (!promptCard || !replyNode || !promptNode) { // A reply's card takes the reply's own clamp (data-only): no prompt card's budget. replyNode?.style.removeProperty("--acpmux-minimap-reply-lines"); replyNode?.style.removeProperty("--acpmux-minimap-reply-line-height"); - } else { - const lineHeight = Number.parseFloat(style.lineHeight) || 20; - const replyStyle = replyNode.ownerDocument.defaultView?.getComputedStyle(replyNode); - const replyLineHeight = Number.parseFloat(replyStyle?.lineHeight ?? "") || lineHeight; - const promptLines = Math.max(1, Math.min(3, Math.ceil(promptNode.getBoundingClientRect().height / lineHeight))); - // The reply's natural height, read from the reply itself with its clamp lifted for the read - // (an inline style, put back before anything paints). A measuring copy appended to the card - // and removed again was two DOM insertions under the page's `:has()` rules on every row the - // pointer crossed, and each restyled the whole page (about 1,100 elements) twice per move. - const clamp = replyNode.getAttribute("style"); - replyNode.style.setProperty("display", "block"); - replyNode.style.setProperty("height", "auto"); - replyNode.style.setProperty("max-height", "none"); - replyNode.style.setProperty("-webkit-line-clamp", "unset"); - const naturalReplyLines = Math.max( - 1, - Math.min(4, Math.ceil(replyNode.getBoundingClientRect().height / replyLineHeight)), - ); - if (clamp === null) replyNode.removeAttribute("style"); - else replyNode.setAttribute("style", clamp); - // Match the old two-line prompt + four-line reply text budget for this same content. A - // longer prompt spends only the reply lines it needs, so the card never grows because of it. - const oldTextLines = Math.min(2, promptLines) + naturalReplyLines; - const lines = Math.max(1, Math.min(4, oldTextLines - promptLines)); - replyNode.style.setProperty("--acpmux-minimap-reply-lines", String(lines)); - // The reply clamp uses the reply's own settled 20px row. Measuring and budgeting with the - // same value prevents a partial next row while prompt body copy stays on the denser 18px token. - replyNode.style.setProperty("--acpmux-minimap-reply-line-height", `${replyLineHeight}px`); + // The budget sets the card's height, and with it where the card centers. + return settle(); + } + const remembered = reuse ? fitOf(known) : undefined; + if (remembered) { + budget(replyNode, remembered.lines, remembered.lineHeight); + // One read pass after the write: the card's place and size, and its type, which the budget + // does not change. A text size or density changed since the fit (with the card closed, so + // its observer never saw it) measures again below, as does a card drawn wider or narrower. + const from = origin(node); + const size = { width: node.offsetWidth, height: node.offsetHeight }; + if (size.width === remembered.width && typeOf(promptNode, replyNode)?.type === remembered.type) { + move(node, from, size.height); + return size; + } } + const type = typeOf(promptNode, replyNode); + if (!type) return settle(); + const lineHeight = type.prompt; + const replyLineHeight = type.reply; + const promptLines = Math.max(1, Math.min(3, Math.ceil(promptNode.getBoundingClientRect().height / lineHeight))); + // The reply's natural height, read from the reply itself with its clamp lifted for the read + // (an inline style, put back before anything paints). A measuring copy appended to the card + // and removed again was two DOM insertions under the page's `:has()` rules on every row the + // pointer crossed, and each restyled the whole page (about 1,100 elements) twice per move. + const clamp = replyNode.getAttribute("style"); + replyNode.style.setProperty("display", "block"); + replyNode.style.setProperty("height", "auto"); + replyNode.style.setProperty("max-height", "none"); + replyNode.style.setProperty("-webkit-line-clamp", "unset"); + const naturalReplyLines = Math.max( + 1, + Math.min(4, Math.ceil(replyNode.getBoundingClientRect().height / replyLineHeight)), + ); + if (clamp === null) replyNode.removeAttribute("style"); + else replyNode.setAttribute("style", clamp); + // Match the old two-line prompt + four-line reply text budget for this same content. A + // longer prompt spends only the reply lines it needs, so the card never grows because of it. + const oldTextLines = Math.min(2, promptLines) + naturalReplyLines; + const lines = Math.max(1, Math.min(4, oldTextLines - promptLines)); + // The reply clamp uses the reply's own settled 20px row. Measuring and budgeting with the + // same value prevents a partial next row while prompt body copy stays on the denser 18px token. + budget(replyNode, lines, replyLineHeight); // The budget sets the card's height, and with it where the card centers. - place(); + const size = settle(); + rememberFit(known, { + width: size.width, + type: type.type, + lineHeight: replyLineHeight, + lines, + }); + return size; }, - [place], + [origin, move], ); /// The card's kind as of the last render, for the observer below, which outlives a content change. const promptCard = useRef(prompt !== undefined); promptCard.current = prompt !== undefined; + /// What the card shows, as its fit remembers it (`fitOf`): the reply's blocks, which a turn keeps, + /// and what else wraps the prompt and the reply. + const content: FitKey = { + reply, + text: `${pending ? "\u0001" : ""}${prompt ?? ""}\u0000${bookmark ? 1 : 0}`, + }; + const known = useRef(content); + known.current = content; /// The card's size as of its last fit: a resize notification of that size (the observer's first, /// or the one the fit's own budget change causes) fits nothing again. const fitted = useRef({ width: -1, height: -1 }); + /// `reuse`: a new row may take its remembered budget. A resize the observer saw (a font or an image + /// loading, a wrap) measures again, and that measure is what the row remembers from then on. const refit = useCallback( - (node: HTMLElement) => { - fit(node, promptCard.current); - // Laid out by the fit's own reads: these read no new layout. - fitted.current = { width: node.offsetWidth, height: node.offsetHeight }; + (node: HTMLElement, reuse: boolean) => { + // Laid out by the fit's own last read, and the size it returns. + fitted.current = fit(node, promptCard.current, known.current, reuse); }, [fit], ); - // A new prompt, reply or failure (the pointer reaching another row), or a change of kind: fit once. + /// The content the card was last fitted for: a commit that changes it fits (and so places) the + /// card; any other only places it. + const fittedFor = useRef([]); + // After every commit on purpose: the rail can move under the card with any render. A new prompt, + // reply or failure (the pointer reaching another row), or a change of kind, fits it once, and the + // fit places it: one pass, not a place and then a fit that places it again. useLayoutEffect(() => { - if (self.current) refit(self.current); - }, [metadata?.failure, metadata?.failed, prompt, from, reply, refit]); + const node = self.current; + if (!node) return; + const now = [metadata?.failure, metadata?.failed, prompt, from, reply]; + if ( + now.length === fittedFor.current.length && + now.every((value, index) => Object.is(value, fittedFor.current[index])) + ) + return place(); + fittedFor.current = now; + refit(node, true); + }); + // After the card is placed, so the blur's first measure finds it where it draws. + useSeeThroughBlur(self, !!layer, underCard); // Content that grows or shrinks without a render (a font or an image loading, a wrap) fits again. // One observer for the card's life, not one per row the pointer crosses (each new one notifies on // observe, which fitted the card a second time per move). It stops with the card: disconnected in @@ -280,7 +381,7 @@ export const PreviewCard = React.forwardRef< if (!node || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { if (node.offsetWidth === fitted.current.width && node.offsetHeight === fitted.current.height) return; - refit(node); + refit(node, false); }); observer.observe(node); return () => observer.disconnect(); diff --git a/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx b/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx index d699a509f7a5..f49dfac24ed9 100644 --- a/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx +++ b/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx @@ -1512,6 +1512,15 @@ const TickList = memo(function TickList({ }) { const t = useT(); const marks = usePromptMarks(sessionId); + /// Each drawn row's spoken label, by its prompt, as last built. Every row the pointer crosses + /// renders the list, and each drawn row (about 115 on a tall rail) built its label again through + /// `t`, for the few rows the hover changes (Leo, 2026-11-04: "hovering across the session rail gets + /// a bit laggy ... it needs to get faster"). A row whose place, count and mark are unchanged keeps + /// it; a change of language starts over. + const labels = useMemo( + () => (void t, new WeakMap()), + [t], + ); // An entrance belongs to a logical prompt, not each virtualized DOM mount. Only // prompts appended later enter. Initial history paints at its final geometry, as it does on // subsequent virtualized mounts. Prepending history introduces no animation. @@ -1552,8 +1561,29 @@ const TickList = memo(function TickList({ if (ticks.length > next) drawn.push(); return drawn; + function labelOf(tick: MinimapTickRow, index: number, marked: boolean): string { + const kept = labels.get(tick); + if (kept && kept.index === index && kept.count === ticks.length && kept.marked === marked) return kept.label; + const label = + tick.from !== undefined + ? t("minimap.tickFrom", { + index: index + 1, + count: ticks.length, + name: tick.from, + prompt: tick.members ? foldedTitle(t, tick.members.length) : tick.prompt, + }) + : t(marked ? "minimap.tickBookmarked" : "minimap.tick", { + index: index + 1, + count: ticks.length, + prompt: tick.prompt, + }); + labels.set(tick, { index, count: ticks.length, marked, label }); + return label; + } + function tickAt(index: number) { const tick = ticks.at(index)!; + const marked = marks.has(tick); // A list item that names its place among every prompt, drawn or not; it draws no box. return (
  • @@ -1565,23 +1595,10 @@ const TickList = memo(function TickList({ progress={hovered !== undefined ? tickProgress(index - hovered) : index === focused ? 1 : 0} active={index === active} current={index >= first && index <= last} - marked={marks.has(tick)} + marked={marked} incoming={tick.from !== undefined} describedBy={described === index ? cardId : undefined} - label={ - tick.from !== undefined - ? t("minimap.tickFrom", { - index: index + 1, - count: ticks.length, - name: tick.from, - prompt: tick.members ? foldedTitle(t, tick.members.length) : tick.prompt, - }) - : t(marks.has(tick) ? "minimap.tickBookmarked" : "minimap.tick", { - index: index + 1, - count: ticks.length, - prompt: tick.prompt, - }) - } + label={labelOf(tick, index, marked)} actions={actions} />
  • diff --git a/webviews/src/ui/glass/seeThroughBlur.ts b/webviews/src/ui/glass/seeThroughBlur.ts index 1b9aa0e266a0..f5936bae9709 100644 --- a/webviews/src/ui/glass/seeThroughBlur.ts +++ b/webviews/src/ui/glass/seeThroughBlur.ts @@ -16,6 +16,8 @@ // parents and on the surface's own inline style, and a requestAnimationFrame loop that follows the // surface while its rect, opacity or visibility changes (its enter and exit, a resize, a move, a // banner or Find bar coming in) and stops after STABLE_FRAMES unchanged frames. Each frame reads every rect first and writes only what changed. +// A change of the surface's own inline style alone (the rail's preview card moving to the next row by +// its transform) measures once on the next frame, not the loop: the new place is final when written. // Closing the surface removes all of it. A window without those observers (a test DOM) gets nothing. import { type RefObject, useLayoutEffect } from "react"; @@ -204,6 +206,9 @@ export function attachSeeThroughBlur( let frame = 0; let stable = 0; let last = ""; + /// Whether the frame asked for follows until STABLE_FRAMES unchanged frames (`kick`), or measures + /// once (`moved`). + let following = false; // Re-measure when anything the hole depends on changes: the surface (its size, its transitions), // each candidate's size, and the children of each candidate's parent (a banner or a Find bar // coming in moves the blocks, or hides the surface through a :has() rule, without resizing them). @@ -286,18 +291,30 @@ export function attachSeeThroughBlur( const key = measure(); stable = key === last ? stable + 1 : 0; last = key; - if (stable < STABLE_FRAMES) frame = view.requestAnimationFrame(tick); + if (following && stable < STABLE_FRAMES) frame = view.requestAnimationFrame(tick); + else following = false; }; const kick = () => { + following = true; stable = 0; if (!frame) frame = view.requestAnimationFrame(tick); }; + /// The surface's inline style changed (a transform written in place, the rail's card reaching the + /// next row): one measure on the next frame finds it where it draws. A move animates through CSS, + /// whose transitionrun follows it (`kick`), and a size change is the ResizeObserver's. The loop + /// restarted on every row a sweep crossed, about every 40 ms, so it never stopped: every frame of + /// the sweep re-read the surface's rect and style and each covered element's rect (Leo, + /// 2026-11-04: "hover previews kinda lag across me hovering over the sidebar stuff"). + const moved = () => { + if (!frame) frame = view.requestAnimationFrame(tick); + }; + const restyled = new view.MutationObserver(moved); watch(surface, false); watch(parent, true); // A surface placed by its own inline style (the rail's preview card follows the pointer from row // to row by its transform) moves with no resize or child change: its style measures again. - mutations.observe(surface, { attributes: true, attributeFilter: ["style"] }); + restyled.observe(surface, { attributes: true, attributeFilter: ["style"] }); measure(); kick(); surface.addEventListener("transitionrun", kick); @@ -310,6 +327,7 @@ export function attachSeeThroughBlur( frame = 0; resize.disconnect(); mutations.disconnect(); + restyled.disconnect(); surface.removeEventListener("transitionrun", kick); surface.removeEventListener("transitionend", kick); surface.removeEventListener("animationstart", kick); diff --git a/webviews/test/see-through-blur-follow.test.ts b/webviews/test/see-through-blur-follow.test.ts new file mode 100644 index 000000000000..c5c2c6dd3433 --- /dev/null +++ b/webviews/test/see-through-blur-follow.test.ts @@ -0,0 +1,99 @@ +// The see-through blur's follow loop (src/ui/glass/seeThroughBlur.ts) under the rail's preview card, +// which moves to the next row by its own inline transform about every 40 ms of a sweep (Leo, +// 2026-11-04: "hover previews kinda lag across me hovering over the sidebar stuff :("). Each move +// restarted the loop that follows a fade or a resize until STABLE_FRAMES unchanged frames, so through +// a sweep it never stopped: every frame re-read the card's rect and style and the transcript's rect. +// A move by the surface's inline style is final when written: one frame measures it. A CSS transition +// (transitionrun) and a resize still follow until they settle. +// +// jsdom has no layout or Chromium backdrop: the window says it draws backdrops over the page (as a +// see-through Chromium page does), the surface's rect follows its transform, and frames run when the +// test says. +import { afterAll, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { attachSeeThroughBlur, UNDER_ATTRIBUTE } from "../src/ui/glass/seeThroughBlur"; + +const dom = new JSDOM("
    ", { + pretendToBeVisual: true, +}); +const win = dom.window as unknown as Window & typeof globalThis & Record; +const queued = new Map(); +let lastFrame = 0; +Object.assign(win, { + CSS: { supports: (property: string) => property !== "-webkit-backdrop-filter" }, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + requestAnimationFrame: (callback: FrameRequestCallback) => { + queued.set(++lastFrame, callback); + return lastFrame; + }, + cancelAnimationFrame: (handle: number) => void queued.delete(handle), +}); +afterAll(() => dom.window.close()); + +const document = dom.window.document; +const rows = document.getElementById("rows")!; +const card = document.getElementById("card")!; +const rect = (x: number, y: number, width: number, height: number) => + ({ x, y, left: x, top: y, width, height, right: x + width, bottom: y + height, toJSON() {} }) as DOMRect; +rows.getBoundingClientRect = () => rect(0, 0, 800, 600); +/// The card's measures: each pass of the loop reads its rect once. +let measures = 0; +card.getBoundingClientRect = () => { + measures += 1; + const y = Number(/translate3d\([^,]+,\s*([-\d.]+)px/.exec(card.style.transform)?.[1] ?? 0); + return rect(40, y, 300, 120); +}; +card.style.transform = "translate3d(40px, 0px, 0)"; + +/// Lets the style's mutation record reach its observer, then runs every frame asked for until none is. +async function frames(): Promise { + await Promise.resolve(); + let ran = 0; + for (let round = 0; round < 20 && queued.size > 0; round += 1) { + const due = [...queued.values()]; + queued.clear(); + for (const callback of due) callback(0); + ran += 1; + } + return ran; +} +/// The hole's top in the rows' box, as the rows' filter has it. +const holeTop = () => Number(document.querySelector("filter feGaussianBlur")?.getAttribute("y") ?? Number.NaN); + +test("a surface moved by its inline transform measures once, and its hole moves with it", async () => { + const detach = attachSeeThroughBlur(card, () => [rows]); + try { + await frames(); + expect(rows.hasAttribute(UNDER_ATTRIBUTE)).toBe(true); + expect(holeTop()).toBe(0); + const passes: number[] = []; + for (const y of [40, 80, 120, 160]) { + measures = 0; + card.style.transform = `translate3d(40px, ${y}px, 0)`; + await frames(); + passes.push(measures); + expect(holeTop()).toBe(y); + } + expect(passes).toEqual([1, 1, 1, 1]); + } finally { + detach(); + } +}); + +test("a transition on the surface still follows until it settles", async () => { + const detach = attachSeeThroughBlur(card, () => [rows]); + try { + await frames(); + measures = 0; + card.dispatchEvent(new dom.window.Event("transitionrun")); + // Nothing moved: STABLE_FRAMES (3) unchanged frames, each a measure. + expect(await frames()).toBe(3); + expect(measures).toBe(3); + } finally { + detach(); + } +}); diff --git a/webviews/test/thread-minimap-rail.test.tsx b/webviews/test/thread-minimap-rail.test.tsx index cab3b160e0cd..4e6231409da3 100644 --- a/webviews/test/thread-minimap-rail.test.tsx +++ b/webviews/test/thread-minimap-rail.test.tsx @@ -650,10 +650,13 @@ test("a bookmark shows as the tick's dot; the prompt row, dot and card share its await act(async () => cardButton().click()); expect(tick(2).hasAttribute("data-bookmarked")).toBe(true); expect(tick(2).querySelector(".acpmux-minimap__dot")).not.toBeNull(); + // Its spoken label says so: a row's kept label (TickList `labelOf`) follows its mark. + expect(tick(2).getAttribute("aria-label")).toBe("Message 3 of 8, bookmarked: Question 2"); // The card shows it filled, and its button removes it. expect(cardButton().getAttribute("aria-pressed")).toBe("true"); await act(async () => cardButton().click()); expect(tick(2).hasAttribute("data-bookmarked")).toBe(false); + expect(tick(2).getAttribute("aria-label")).toBe("Message 3 of 8: Question 2"); // A click on the dot's spot past a hovered tick toggles the mark and does not jump. const before = view.scroller.scrollTop; await enter(5); diff --git a/webviews/test/thread-rail-hover-reads.test.tsx b/webviews/test/thread-rail-hover-reads.test.tsx new file mode 100644 index 000000000000..54216e012c56 --- /dev/null +++ b/webviews/test/thread-rail-hover-reads.test.tsx @@ -0,0 +1,341 @@ +// The prompt rail's hover reads (Leo, 2026-11-04: "hovering across the session rail gets a bit laggy +// ... it needs to get faster" and "hover previews kinda lag across me hovering over the sidebar stuff +// :("). A sweep crosses a row about every 40 ms, and each row the pointer reaches commits the card's +// new content. Every layout read that follows a DOM write makes the browser lay the page out again +// before the frame (a forced layout), and the card's fit read and wrote by turns: three to five +// layouts per row, on top of the frame's own. +// +// jsdom has no layout, so this counts what the browser would do: a write (a style, an attribute, a +// node) dirties the page, and the first layout read after it (a rect, an offset size, a computed +// style, a scroll offset) is one forced layout. Reads with no write between them share it. +import { afterAll, afterEach, expect, jest, test } from "bun:test"; +import { JSDOM, VirtualConsole } from "jsdom"; +import type { AcpmuxRow } from "../src/agent-session/acpmux/model"; +import { turnView } from "../src/agent-session/acpmux/conversation/turns"; +// These tests drive the compact (Codex) rail; the default arm draws no minimap (layout.experiment.ts). +(globalThis as { cmuxExperiments?: Record }).cmuxExperiments = { + "thread-minimap-layout": "compact", +}; + +const dom = new JSDOM("
    ", { + url: "http://localhost/", + pretendToBeVisual: true, + virtualConsole: new VirtualConsole(), +}); +const scope = globalThis as Record; +const observed: Element[] = []; +const queued = new Map(); +let lastFrame = 0; +const replacements: Record = { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + customElements: dom.window.customElements, + Node: dom.window.Node, + MutationObserver: dom.window.MutationObserver, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + // Reduce Motion: scrolls land at once, so a test reads where they land. + matchMedia: (query: string) => ({ matches: query.includes("reduce"), media: query }), + IntersectionObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + // Each observed element is recorded, so a test can bound the observers a hover sweep puts on the card. + ResizeObserver: class { + observe(target: Element) { + observed.push(target); + } + unobserve() {} + disconnect() {} + }, + WheelEvent: dom.window.WheelEvent, + KeyboardEvent: dom.window.KeyboardEvent, + // Frames run when the test says (`frame`), between rows, as the browser draws between them: a frame + // that landed inside a row's turn counted its own layouts there. + requestAnimationFrame: (callback: FrameRequestCallback) => { + queued.set(++lastFrame, callback); + return lastFrame; + }, + cancelAnimationFrame: (handle: number) => void queued.delete(handle), + IS_REACT_ACT_ENVIRONMENT: true, +}; +for (const key of Object.getOwnPropertyNames(dom.window)) + if (/^(HTML|SVG|CSS|Shadow|Document|Mutation)/.test(key) && !(key in scope)) + replacements[key] = (dom.window as unknown as Record)[key]; +const saved = Object.keys(replacements).map((key) => [key, key in scope, scope[key]] as const); +Object.assign(scope, replacements); + +// jsdom has no layout: a 600 px viewport, a pane `paneWidth` wide, and a scroll offset. +const VIEWPORT = 600; +let paneWidth = 1040; +Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { + configurable: true, + get: () => VIEWPORT, +}); +Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { + configurable: true, + get: () => paneWidth, +}); +const offsets = new WeakMap(); +Object.defineProperty(dom.window.HTMLElement.prototype, "scrollTop", { + configurable: true, + get(this: object) { + return offsets.get(this) ?? 0; + }, + set(this: object, value: number) { + offsets.set(this, value); + }, +}); +dom.window.HTMLElement.prototype.scrollTo = function (this: HTMLElement, options?: ScrollToOptions | number) { + this.scrollTop = typeof options === "number" ? 0 : (options?.top ?? 0); +} as typeof HTMLElement.prototype.scrollTo; + +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { VirtualTranscript } = await import("../src/agent-session/acpmux/App"); +const { setBookmarkTransport } = await import("../src/agent-session/acpmux/threadMinimap/bookmarks"); +// acpmux's bookmarks for the chat, answering at once (thread-bookmarks.test.tsx covers its replies). +const acpmuxMarks = new Set(); +setBookmarkTransport({ + get: async (sessionId) => ({ sessionId, bookmarks: [...acpmuxMarks] }), + set: async (sessionId, seq, bookmarked) => { + if (bookmarked) acpmuxMarks.add(seq); + else acpmuxMarks.delete(seq); + return { sessionId, bookmarks: [...acpmuxMarks] }; + }, +}); + +afterAll(() => { + setBookmarkTransport(undefined); + dom.window.close(); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); +afterEach(() => { + jest.useRealTimers(); + paneWidth = 1040; +}); + +const document = dom.window.document; +const row = (id: string, kind: string, text?: string): AcpmuxRow => ({ + id, + kind, + text, + version: 1, + at: 1, +}); +const chat = (count: number) => + Array.from({ length: count }, (_, index) => [ + // Prompt rows are `user-`, the seq acpmux logs them at (a bookmark names it). + row(`user-${index + 1}`, "user", `Question ${index}`), + row(`t${index}-answer`, "assistant", `Answer ${index}.\n\nWith a second paragraph so the turn has height.`), + { ...row(`t${index}-end`, "turnSummary"), status: "completed", durationMs: 1_000 }, + ]).flat(); + +async function mount(prompts: number) { + const root = createRoot(document.getElementById("root")!); + await act(async () => + root.render( + {}} + expanded={new Set()} + />, + ), + ); + const scroller = document.querySelector(".acpmux-scroll")!; + const scrolled = () => act(async () => void scroller.dispatchEvent(new dom.window.Event("scroll"))); + await act(async () => { + scroller.scrollTop = 0; + scroller.dispatchEvent(new dom.window.Event("scroll")); + }); + return { root, scroller, scrolled, unmount: () => act(async () => root.unmount()) }; +} + +const ticks = () => [...document.querySelectorAll(".acpmux-minimap__tick")]; +const tick = (index: number) => ticks()[index]!; +const card = () => document.querySelector(".acpmux-minimap__card"); +const pointer = (type: string, target: Element, init: Record = {}) => + act(async () => { + const Event = (dom.window as unknown as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? dom.window.MouseEvent; + target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true, button: 0, ...init })); + }); +const enter = (index: number) => pointer("pointerover", tick(index), { relatedTarget: document.body }); + +// The dirty-page model: every write marks the page dirty; a layout read while it is dirty counts as +// one forced layout and cleans it. +let dirty = false; +let layouts = 0; +let counting = false; +const write = (_target?: unknown) => void (dirty = true); +/// The rail's and its card's own reads: the rail, the card and the layer it draws in. The +/// transcript's reads (its scroll pacer's frame, which lands in whichever row's turn it lands) are not +/// this test's. +const ours = (target: unknown) => { + if (!(target instanceof dom.window.Element)) return false; + const shown = document.querySelector(".acpmux-minimap__card"); + return !!target.closest(".acpmux-minimap, .acpmux-minimap__card") || target === shown?.parentElement; +}; +const read = (target?: unknown) => { + if (!counting || !dirty || !ours(target)) return; + dirty = false; + layouts += 1; +}; +const wrapSetter = (proto: object, name: string) => { + const desc = Object.getOwnPropertyDescriptor(proto, name); + if (!desc?.set) return; + const { set } = desc; + Object.defineProperty(proto, name, { + ...desc, + set(this: unknown, value: unknown) { + write(); + set.call(this, value); + }, + }); +}; +const wrapGetter = (proto: object, name: string) => { + const desc = Object.getOwnPropertyDescriptor(proto, name); + if (!desc?.get) return; + const { get } = desc; + Object.defineProperty(proto, name, { + ...desc, + get(this: unknown) { + read(this); + return get.call(this); + }, + }); +}; +const wrapMethod = (proto: Record, name: string, then: (target: unknown) => void) => { + const original = proto[name] as (...args: unknown[]) => unknown; + proto[name] = function (this: unknown, ...args: unknown[]) { + then(this); + return original.apply(this, args); + }; +}; +{ + const win = dom.window as unknown as Record }>; + const styleProto = Object.getPrototypeOf(document.createElement("div").style) as Record; + for (const name of Object.getOwnPropertyNames(styleProto)) wrapSetter(styleProto, name); + const declaration = Object.getPrototypeOf(styleProto) as Record; + wrapSetter(declaration, "cssText"); + for (const name of ["setProperty", "removeProperty"]) wrapMethod(declaration, name, write); + const element = win.Element!.prototype; + for (const name of ["setAttribute", "removeAttribute", "toggleAttribute"]) wrapMethod(element, name, write); + const node = win.Node!.prototype; + for (const name of ["appendChild", "insertBefore", "removeChild", "replaceChild"]) wrapMethod(node, name, write); + wrapSetter(node, "textContent"); + wrapSetter(node, "nodeValue"); + wrapSetter(win.CharacterData!.prototype, "data"); + wrapMethod(element, "getBoundingClientRect", read); + const html = win.HTMLElement!.prototype; + for (const name of [ + "offsetHeight", + "offsetWidth", + "offsetTop", + "offsetLeft", + "offsetParent", + "scrollTop", + "clientHeight", + "clientWidth", + ]) + wrapGetter(html, name); + const computed = dom.window.getComputedStyle.bind(dom.window); + dom.window.getComputedStyle = ((target: Element, pseudo?: string | null) => { + read(target); + return computed(target, pseudo); + }) as typeof dom.window.getComputedStyle; + scope.getComputedStyle = dom.window.getComputedStyle; +} + +/// The frame after a row: what it asked for runs, and its paint leaves the page laid out. +const frame = () => + act(async () => { + for (let round = 0; round < 4 && queued.size > 0; round += 1) { + const due = [...queued.values()]; + queued.clear(); + for (const callback of due) callback(performance.now()); + } + }); + +/// The forced layouts of `run`, from a clean page (the frame before it painted). +async function forcedLayouts(run: () => Promise): Promise { + dirty = false; + layouts = 0; + counting = true; + try { + await run(); + } finally { + counting = false; + } + return layouts; +} +test("a row the sweep reaches forces at most three layouts, and a row it comes back to forces one", async () => { + const view = await mount(40); + // No timer lands in a row's turn: a row is its commit alone. + jest.useFakeTimers(); + try { + // The first rows open the card and settle the transcript under the rail (its own reads, once). + await enter(4); + await frame(); + await enter(5); + await frame(); + expect(card()).not.toBeNull(); + const out: number[] = []; + for (let index = 6; index < 15; index += 1) { + out.push(await forcedLayouts(() => enter(index))); + await frame(); + } + const back: number[] = []; + for (let index = 13; index >= 6; index -= 1) { + back.push(await forcedLayouts(() => enter(index))); + await frame(); + expect(card()!.textContent).toContain(`Question ${index}`); + } + // The transcript's scroll pacer runs on the page's own clock, and its frame sometimes lands in a + // row's turn: that commit renders the rail and its card again with the same props, and the card's + // place() reads once more. So each row may take one read past its own, and most rows take none. + const median = (values: number[]) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!; + // A row seen first: the card's reads come first and its writes after, in at most three passes + // (the prompt's lines with the card's place, the reply's natural height, the card's own height). + expect(median(out)).toBeLessThanOrEqual(3); + expect(Math.max(...out)).toBeLessThanOrEqual(4); + // A row seen before: its fit is remembered, so the card writes it and reads its place once + // (base: four on every row). + expect(median(back)).toBeLessThanOrEqual(1); + expect(Math.max(...back)).toBeLessThanOrEqual(2); + } finally { + await view.unmount(); + } +}); + +test("a row the card fitted before measures again once the text size changed, at the same width", async () => { + const view = await mount(40); + jest.useFakeTimers(); + const sized = document.createElement("style"); + try { + for (const index of [4, 5, 6, 7]) { + await enter(index); + await frame(); + } + const reply = () => card()!.querySelector(".acpmux-minimap__reply")!; + const before = reply().style.getPropertyValue("--acpmux-minimap-reply-line-height"); + // A larger text size (or a denser layout) while the card is elsewhere: its observer sees no + // resize for this row, and the card is as wide as it was. + sized.textContent = ".acpmux-minimap__reply { line-height: 31px; } .acpmux-minimap__prompt { line-height: 27px; }"; + document.head.append(sized); + await enter(5); + await frame(); + expect(card()!.textContent).toContain("Question 5"); + expect(before).not.toBe("31px"); + expect(reply().style.getPropertyValue("--acpmux-minimap-reply-line-height")).toBe("31px"); + } finally { + sized.remove(); + await view.unmount(); + } +});