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
2 changes: 1 addition & 1 deletion webviews/src/agent-session/acpmux/summary/summary.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions webviews/src/agent-session/acpmux/threadMinimap/PreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { forwardWheel } from "../wheelPassThrough";
import { BookmarkIcon } from "./BookmarkIcon";
import { cardOffset, type MinimapTurnMetadata, type ReplyBlock } from "./model";
import { sliceWhole, sliceWholeEnd } from "../../../ui/wholeChars";
import { type CoveredCandidates, useSeeThroughBlur } from "../../../ui/glass/seeThroughBlur";

const middleEllipsis = (value: string, max = 36) => {
if (value.length <= max) return value;
Expand Down Expand Up @@ -72,6 +73,15 @@ function forwardScrollKey(scroller: HTMLElement, event: React.KeyboardEvent): vo
scroller.scrollTop += by;
}

/// What the card covers when it draws in the scroller's parent: the transcript scroller alone, never
/// the rail or a Find bar beside it. In a see-through Chromium page the card's backdrop blur draws
/// over the rows, not in their place, so they read sharp through its tint (Lawrence via Leo,
/// 2026-10-28: "popovers in general need glassier bg blur"); the scroller blurs its own rows under
/// the card instead (ui/glass/seeThroughBlur.ts). In place (no layer) the card is inside the
/// scroller and covers nothing.
const underCard: CoveredCandidates = (surface) =>
surface.parentElement?.querySelectorAll(":scope > .acpmux-scroll") ?? [];

export type PreviewBookmark = { marked: boolean; toggle(): void };

export const PreviewCard = React.forwardRef<
Expand Down Expand Up @@ -170,6 +180,8 @@ export const PreviewCard = React.forwardRef<
}, []);
// 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);
// 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.
Expand Down
46 changes: 36 additions & 10 deletions webviews/src/ui/glass/seeThroughBlur.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
// Cost: nothing in WebKit (no listeners, no elements). In Chromium, while one surface is open: one
// hidden <svg> with a filter per covered element, a ResizeObserver on the surface, its parent and
// the candidates, a MutationObserver (direct children only, never the subtree) on the candidates'
// parents, and a requestAnimationFrame loop that follows the surface while its rect, opacity or
// visibility changes (its enter and exit, a resize, 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.
// 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.
// Closing the surface removes all of it. A window without those observers (a test DOM) gets nothing.
import { type RefObject, useLayoutEffect } from "react";

Expand Down Expand Up @@ -146,15 +146,35 @@ function canFollow(view: Window): boolean {

let nextId = 0;

/** Every surface's filter on each covered element, by surface: two glasses over one element (the
* rail's preview card and the Summary popover, both over the transcript) each keep their own hole.
* The element draws their filters one after the other and carries every owner, so one surface's
* release takes out its own hole alone. */
const holesOn = new WeakMap<HTMLElement, Map<number, { owner: string; filter: string }>>();

/** Writes `target`'s filter list and owners from its holes, or clears both when it has none. */
function writeHoles(target: HTMLElement): void {
const holes = [...(holesOn.get(target)?.values() ?? [])];
if (holes.length === 0) {
holesOn.delete(target);
target.removeAttribute(UNDER_ATTRIBUTE);
target.style.removeProperty(UNDER_FILTER_PROPERTY);
return;
}
target.setAttribute(UNDER_ATTRIBUTE, [...new Set(holes.map((hole) => hole.owner))].join(" ").trim());
target.style.setProperty(UNDER_FILTER_PROPERTY, holes.map((hole) => `url(#${hole.filter})`).join(" "));
}

/** The elements a surface may cover: by default its siblings. */
export type CoveredCandidates = (surface: HTMLElement) => Iterable<Element>;

const siblings: CoveredCandidates = (surface) => surface.parentElement?.children ?? [];

export type SeeThroughOptions = {
/** The value of data-glass-under on what this surface covers, so a stylesheet can switch off
* one surface's filters. A surface CSS hides with `visibility` must: its holes stay (summary.css
* does it for the Summary card under Find, review and inspect). */
/** A word of data-glass-under on what this surface covers (a list, matched with `~=`, when
* several glasses cover one element), so a stylesheet can switch off one surface's filters. A
* surface CSS hides with `visibility` must: its holes stay (summary.css does it for the Summary
* card under Find, review and inspect). */
owner?: string;
/** Elements that must not take a filter (one would re-anchor their fixed menus: the composer).
* While the surface overlaps one, it keeps its underlay, which hides what is under it there. */
Expand All @@ -173,6 +193,7 @@ export function attachSeeThroughBlur(
const view = doc.defaultView;
const parent = surface.parentElement;
if (!view || !parent || !drawsBackdropOver(view) || !canFollow(view)) return () => {};
const self = ++nextId;
const svg = doc.createElementNS(SVG_NS, "svg");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("width", "0");
Expand Down Expand Up @@ -201,8 +222,8 @@ export function attachSeeThroughBlur(
const entry = covered.get(target);
if (!entry) return;
entry.filter.remove();
target.removeAttribute(UNDER_ATTRIBUTE);
target.style.removeProperty(UNDER_FILTER_PROPERTY);
holesOn.get(target)?.delete(self);
writeHoles(target);
covered.delete(target);
};

Expand Down Expand Up @@ -247,8 +268,10 @@ export function attachSeeThroughBlur(
svg.append(filter);
entry = { filter, key: "" };
covered.set(target, entry);
target.setAttribute(UNDER_ATTRIBUTE, owner);
target.style.setProperty(UNDER_FILTER_PROPERTY, `url(#${filter.id})`);
let holes = holesOn.get(target);
if (!holes) holesOn.set(target, (holes = new Map()));
holes.set(self, { owner, filter: filter.id });
writeHoles(target);
}
if (entry.key !== key) {
writeHoleFilter(entry.filter, box, hole);
Expand All @@ -272,6 +295,9 @@ export function attachSeeThroughBlur(

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"] });
measure();
kick();
surface.addEventListener("transitionrun", kick);
Expand Down
47 changes: 47 additions & 0 deletions webviews/test/see-through-blur.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,53 @@ await requireBrowserLane("see-through-blur.test.ts", async () => {
// flicker"). A popover caught mid-fade blurred the bars only part way, mixing them with their
// sharp selves by its opacity: they are blurred in full while it shows at all, sharp once it is
// gone.
// The rail's preview card follows the pointer from row to row by its own inline transform, with
// no resize or child change (threadMinimap/PreviewCard.tsx): the blur follows it there.
test("a surface moved by its inline transform takes its blur with it", async () => {
const page = await open(browser);
await attach(page);
expect(await barContrast(page, MIDDLE, ...INSIDE)).toBeLessThan(5);
const before = await barContrast(page, MIDDLE, ...OUTSIDE);
await page.evaluate(() => (document.getElementById("popover")!.style.transform = "translateX(-200px)"));
await settle(page);
// Its new place, over the left bars, is blurred; its old place is sharp again.
expect(await barContrast(page, MIDDLE, 48, 232)).toBeLessThan(5);
expect(await barContrast(page, MIDDLE, 268, 436)).toBeGreaterThan(before * 0.9);
await page.close();
});

// The rail's preview card and the Summary popover both cover the transcript: each keeps its own
// hole, and the card closing leaves the Summary's blur (and its owner word) in place.
test("two surfaces over one element each keep their blur; one detaching leaves the other's", async () => {
const page = await open(browser);
await attach(page, { owner: "summary" });
await page.evaluate(() => {
const target = window as unknown as {
attachSeeThroughBlur(e: HTMLElement, candidates?: (e: HTMLElement) => Iterable<Element>): () => void;
detachCard?: () => void;
};
const card = document.createElement("aside");
card.id = "card";
card.style.cssText = "position:absolute;left:24px;top:40px;width:160px;height:100px;border-radius:12px";
document.querySelector(".stage")!.append(card);
target.detachCard = target.attachSeeThroughBlur(card, () => document.querySelectorAll(".chat"));
});
await settle(page);
const owners = () => page.evaluate(() => document.querySelector(".chat")!.getAttribute("data-glass-under"));
expect(await owners()).toBe("summary");
expect(await barContrast(page, MIDDLE, ...INSIDE)).toBeLessThan(5);
expect(await barContrast(page, MIDDLE, 48, 160)).toBeLessThan(5);
await page.evaluate(() => {
(window as unknown as { detachCard(): void }).detachCard();
document.getElementById("card")!.remove();
});
await settle(page);
expect(await owners()).toBe("summary");
expect(await barContrast(page, MIDDLE, ...INSIDE)).toBeLessThan(5);
expect(await barContrast(page, MIDDLE, 48, 160)).toBeGreaterThan(40);
await page.close();
});

test("a popover mid-fade blurs the bars in full, never part way", async () => {
const page = await open(browser);
const before = await barContrast(page, MIDDLE, ...INSIDE);
Expand Down
Loading