Skip to content
Closed
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
35 changes: 23 additions & 12 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -975,12 +975,23 @@ const currentRegistry = (): NativeRegistry => ({
/// so the transcript ends this far past their total height.
const THREAD_TOP = 16;

/// Small layout changes arrive constantly while a reply streams. Treat the bottom as a short
/// resting zone so every token does not write a new fractional scroll offset (and make the thumb
/// visibly twitch at the same cadence). A meaningful displacement still corrects immediately.
export const FOLLOW_BOTTOM_DEADBAND_PX = 2;

/// Where a scroller sits, read while its content still matches `totalHeight`.
const scrollPosition = (node: HTMLElement, totalHeight: number) => ({
top: node.scrollTop,
atLatest: node.scrollTop >= totalHeight - node.clientHeight - 1,
atLatest: node.scrollTop >= totalHeight - node.clientHeight - FOLLOW_BOTTOM_DEADBAND_PX,
});

function settleFollowBottom(node: HTMLElement, latest: number): boolean {
if (Math.abs(latest - node.scrollTop) <= FOLLOW_BOTTOM_DEADBAND_PX) return false;
node.scrollTop = latest;
return true;
}

/// Scroll steps of rows mounted ahead in the scroll direction, capped in viewports.
/// A scroll commits from its event, a frame after the offset moved, so without the
/// lead a fling shows a blank edge on every frame.
Expand Down Expand Up @@ -1654,7 +1665,7 @@ export function VirtualTranscript({
? true
: clamped || unmoved
? scrolledTo.current.atLatest
: top >= old.totalHeight - node.clientHeight - 1 ||
: top >= old.totalHeight - node.clientHeight - FOLLOW_BOTTOM_DEADBAND_PX ||
(scrolledTo.current.atLatest && strayFrom(top, old.totalHeight - node.clientHeight));
// At the latest row also at offset 0: a new chat's first pass can overshoot on estimated
// heights and settle back to fit, and its reply must still be followed once it overflows
Expand All @@ -1663,10 +1674,8 @@ export function VirtualTranscript({
// At the latest row: stay there as rows settle to their drawn heights.
const latest = Math.max(0, layout.totalHeight - node.clientHeight);
const step = latest - node.scrollTop;
if (Math.abs(step) > 0.5) {
node.scrollTop = latest;
followTrace.set(latest);
}
const settled = settleFollowBottom(node, latest);
if (settled) followTrace.set(latest);
// Growth glides in instead of stepping a line per frame (acp-streaming.md "Scroll"); a
// resized viewport only moves the offset. A row that grew is placed by its estimate, then
// again by its drawn height, both before the frame paints: each step glides, and the
Expand All @@ -1676,7 +1685,7 @@ export function VirtualTranscript({
// then drawn height, at every frame of the drag: none of that glides, and a glide in
// flight ends, so the text sits at the end of the view on every frame.
if (rewrapped) for (const animation of thread.current?.getAnimations?.() ?? []) animation.finish();
if (Math.abs(step) > 0.5 && Math.abs(step) < node.clientHeight && !resized && !reflowing.current)
if (settled && Math.abs(step) < node.clientHeight && !resized && !reflowing.current)
glide(thread.current, step);
} else if (top > 0 || rows[0]?.id !== oldRows[0]?.id) {
// Keep the row at the top of the viewport where it is as rows above it change height.
Expand Down Expand Up @@ -1885,14 +1894,16 @@ export function VirtualTranscript({
// A layout-driven scroll keeps the previous offset; a reader scroll changes it. The latter is
// also what the jsdom behavior tests dispatch without a wheel event, so do not classify every
// programmatic-looking event as layout work.
const layoutDriven = followingRef.current && programmatic && Math.abs(next - scrolledTo.current.top) <= 0.5;
const layoutDriven =
followingRef.current && programmatic && Math.abs(next - scrolledTo.current.top) <= FOLLOW_BOTTOM_DEADBAND_PX;
if (layoutDriven) {
const latest = Math.max(0, layout.totalHeight - node.clientHeight);
if (Math.abs(next - latest) > 0.5) node.scrollTop = latest;
scrolledTo.current = { top: latest, atLatest: true };
followTrace.set(latest);
const settled = settleFollowBottom(node, latest);
const resting = settled ? latest : next;
scrolledTo.current = { top: resting, atLatest: true };
followTrace.set(resting);
follow(true);
setScroll((current) => ({ top: latest, delta: latest - current.top }));
setScroll((current) => ({ top: resting, delta: resting - current.top }));
return;
}
const was = scrolledTo.current;
Expand Down
Loading