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
243 changes: 172 additions & 71 deletions webviews/src/agent-session/acpmux/threadMinimap/PreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<readonly ReplyBlock[], Map<string, Fit>>();
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<
Expand Down Expand Up @@ -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<HTMLElement>(".acpmux-minimap__reply");
const promptNode = node.querySelector<HTMLElement>(".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<readonly unknown[]>([]);
// 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
Expand All @@ -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();
Expand Down
Loading
Loading