Skip to content

Commit 542dd26

Browse files
authored
Merge pull request Stack-Cairn#108 from Stack-Cairn/features
fix(transcript): stop unwanted auto-scroll while reading during and after streaming
2 parents 22a23a6 + f3671c8 commit 542dd26

14 files changed

Lines changed: 493 additions & 26 deletions

File tree

crates/agent-gateway/web/src/app/GatewayApp.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3890,6 +3890,7 @@ export default function GatewayApp() {
38903890
rows={transcriptRows}
38913891
liveStartIndex={transcriptLiveStartIndex}
38923892
activeTurnKey={displayedTranscript.activeTurnKey}
3893+
isViewportFollowing={transcriptFollow.isFollowing}
38933894
error={transcriptError}
38943895
toolStatus={transcriptToolStatus}
38953896
toolStatusIsCompaction={transcriptToolStatusIsCompaction}

crates/agent-gateway/web/src/components/GatewayTranscript.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import type { GitClient } from "@/lib/git/types";
3737
import { cn } from "@/lib/shared/utils";
3838
import { extractLiveRange } from "@/lib/transcript-virtual/liveRangeExtractor";
39+
import { createLiveRowScrollAdjustPolicy } from "@/lib/transcript-virtual/liveScrollAdjustPolicy";
3940
import {
4041
CHECKPOINT_ROW_ESTIMATE_PX,
4142
estimateAssistantRowHeight,
@@ -77,6 +78,9 @@ type GatewayTranscriptProps = {
7778
liveStartIndex?: number;
7879
// Key of the actively streaming turn (caret / live structural state).
7980
activeTurnKey?: string | null;
81+
// Whether the scroll-follow engine is attached to the bottom; gates the
82+
// virtualizer's resize-compensation carve-out for live-row growth.
83+
isViewportFollowing?: () => boolean;
8084
error?: string | null;
8185
toolStatus?: string | null;
8286
toolStatusIsCompaction?: boolean;
@@ -1088,6 +1092,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
10881092
liveStartIndex: number;
10891093
activeTurnKey?: string | null;
10901094
scrollViewport: HTMLDivElement | null;
1095+
isViewportFollowing?: () => boolean;
10911096
hasMoreHistory?: boolean;
10921097
isLoadingMoreHistory?: boolean;
10931098
onLoadFullHistory?: () => void;
@@ -1114,6 +1119,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
11141119
liveStartIndex,
11151120
activeTurnKey,
11161121
scrollViewport,
1122+
isViewportFollowing,
11171123
hasMoreHistory,
11181124
isLoadingMoreHistory,
11191125
onLoadFullHistory,
@@ -1240,6 +1246,15 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
12401246
enabled: scrollViewport !== null,
12411247
rangeExtractor: (range) => extractLiveRange(range, forceMountStartRef.current),
12421248
});
1249+
1250+
// TanStack exposes the resize-compensation predicate as an instance field,
1251+
// not an option; reassigning per render keeps the closure's inputs current.
1252+
transcriptVirtualizer.shouldAdjustScrollPositionOnItemSizeChange =
1253+
createLiveRowScrollAdjustPolicy({
1254+
getLiveStartIndex: () => forceMountStartRef.current,
1255+
isFollowing: () => isViewportFollowing?.() ?? false,
1256+
});
1257+
12431258
const virtualRows = transcriptVirtualizer.getVirtualItems();
12441259

12451260
// Prepend anchor: when loading earlier history inserts rows above the
@@ -1248,7 +1263,8 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
12481263
// getOffsetForIndex(_, "start") is the public offset accessor; the anchor
12491264
// and the restore read through the same function, so the delta is exact.
12501265
const prependAnchorRef = useRef<{ key: string; start: number } | null>(null);
1251-
// biome-ignore lint/correctness/useExhaustiveDependencies: runs per commit by design
1266+
// Intentionally no dependency array: the anchor must re-read on every
1267+
// commit, since any render can be the one that prepends rows.
12521268
useLayoutEffect(() => {
12531269
const firstRowKey = virtualItems[leadingOffset]?.key ?? null;
12541270
const startOf = (index: number) => transcriptVirtualizer.getOffsetForIndex(index, "start")?.[0];
@@ -1458,6 +1474,7 @@ export function GatewayTranscript({
14581474
rows,
14591475
liveStartIndex = -1,
14601476
activeTurnKey = null,
1477+
isViewportFollowing,
14611478
error,
14621479
toolStatus,
14631480
toolStatusIsCompaction = false,
@@ -1531,6 +1548,7 @@ export function GatewayTranscript({
15311548
liveStartIndex={liveStartIndex}
15321549
activeTurnKey={activeTurnKey}
15331550
scrollViewport={transcriptScrollViewport}
1551+
isViewportFollowing={isViewportFollowing}
15341552
hasMoreHistory={hasMoreHistory}
15351553
isLoadingMoreHistory={isLoadingMoreHistory}
15361554
onLoadFullHistory={onLoadFullHistory}

crates/agent-gateway/web/src/lib/chat-scroll/scrollFollowCore.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@
1414
// after a programmatic pin (the abort lands with the next main-thread
1515
// commit); those frames carry no input and no drag, so they can never
1616
// detach.
17-
// - ATTACH is position-driven: landing at the physical clamp always attaches;
18-
// a gesture-latched downward arrival inside the reattach zone attaches; a
19-
// pointer released inside the zone after downward movement attaches. The
20-
// gesture latch is the only timing heuristic left and it gates attach only —
21-
// a false positive re-pins, it can never tear follow down.
17+
// - ATTACH is position-driven and intent-gated: landing at the physical clamp
18+
// attaches while following (our own pin write echoing back) or inside an
19+
// active gesture latch; a gesture-latched downward arrival inside the
20+
// reattach zone attaches; a pointer released inside the zone after downward
21+
// movement attaches. Content shrinking under a detached reader (a reply
22+
// settling out of the row list, a collapsing block) also clamps scrollTop
23+
// to the bottom with no input — that arrival carries no latch, so it can
24+
// never re-engage follow and the next growth cannot yank the reader down.
25+
// The gesture latch is the only timing heuristic left and it gates attach
26+
// only — a false positive re-pins, it can never tear follow down.
2227
// - While following, a scroll event that leaves a gap open is corrected by
2328
// re-pinning rather than classified.
2429
// - ResizeObserver deliveries (contentGrowth) never change follow state; they
@@ -194,11 +199,17 @@ export function reduceFollowEvent(
194199
const next = { ...state, lastGap: gap };
195200

196201
if (isAtBottom(gap, config)) {
197-
// At the physical clamp — attaching is always safe, whether the user
198-
// landed here or our own pin write echoed back. Also counts as
199-
// downward movement for the pointer-release re-check.
200-
next.following = true;
202+
// At the physical clamp. Counts as downward movement for the
203+
// pointer-release re-check, but attaching needs evidence of intent:
204+
// our own pin write echoing back (still following) or an arrival
205+
// inside an active gesture latch. Content shrinking under a detached
206+
// reader (a reply settling out of the row list, a collapsing block)
207+
// also clamps scrollTop here with no input — attaching on that would
208+
// let the next growth yank the reader to the bottom.
201209
next.dragTowardBottom = true;
210+
if (state.following || now <= state.latchUntil) {
211+
next.following = true;
212+
}
202213
return { state: next, pin: false };
203214
}
204215

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual";
2+
3+
// Resize-compensation policy for the transcript virtualizer.
4+
//
5+
// virtual-core's default shouldAdjustScrollPositionOnItemSizeChange treats any
6+
// resize of a row whose START sits above the viewport top as "content above
7+
// the reader changed" and shifts scrollTop by the delta. That is correct for
8+
// rows entirely above the viewport, but wrong for the live streaming row once
9+
// it grows taller than the viewport: a reader scrolled up into that row has
10+
// the row's start above the viewport top while the stream appends at the
11+
// row's BOTTOM edge, below everything visible. The default then drags
12+
// scrollTop down by exactly the growth delta on every stream flush — the
13+
// view creeps toward the bottom and the transcript is unreadable.
14+
//
15+
// This policy replicates the upstream default and carves out exactly that
16+
// case: growth (delta > 0) of a live row that still extends past the viewport
17+
// top never adjusts while the user is detached from the bottom. Everything
18+
// else keeps the default:
19+
// - rows entirely above the viewport compensate as before (estimate→measured
20+
// corrections must not jump the view);
21+
// - while following, the compensation cooperates with the scroll-follow pin,
22+
// so it stays on;
23+
// - live-row shrinks (delta < 0, e.g. a thinking block collapsing near the
24+
// row's top) keep compensating so content under the reader stays put.
25+
export type LiveRowScrollAdjustPolicyArgs = {
26+
// Index of the first live (streaming) row in the virtualizer's item list,
27+
// -1 while idle. Read per call — the boundary moves between renders.
28+
getLiveStartIndex: () => number;
29+
// Whether the scroll-follow engine is attached to the bottom.
30+
isFollowing: () => boolean;
31+
};
32+
33+
export function createLiveRowScrollAdjustPolicy<
34+
TScrollElement extends Element | Window,
35+
TItemElement extends Element,
36+
>(
37+
args: LiveRowScrollAdjustPolicyArgs,
38+
): (
39+
item: VirtualItem,
40+
delta: number,
41+
instance: Virtualizer<TScrollElement, TItemElement>,
42+
) => boolean {
43+
const { getLiveStartIndex, isFollowing } = args;
44+
return (item, delta, instance) => {
45+
// Un-echoed scroll writes accumulate in a private field until the next
46+
// scroll event; the upstream default folds them into the comparison, so
47+
// mirror that (fall back to 0 if the field ever disappears).
48+
const pendingAdjustments =
49+
(instance as unknown as { scrollAdjustments?: number }).scrollAdjustments ?? 0;
50+
const viewportTop = (instance.scrollOffset ?? 0) + pendingAdjustments;
51+
52+
// Upstream default: only above-viewport resizes may shift scrollTop, and
53+
// never while the user is actively scrolling backward.
54+
if (item.start >= viewportTop || instance.scrollDirection === "backward") {
55+
return false;
56+
}
57+
58+
// The carve-out. `item` carries the pre-resize measurement, so
59+
// `item.end > viewportTop` means the row straddles the viewport top and
60+
// its bottom-appended growth lands below the reading line.
61+
const liveStartIndex = getLiveStartIndex();
62+
if (
63+
liveStartIndex >= 0 &&
64+
item.index >= liveStartIndex &&
65+
delta > 0 &&
66+
item.end > viewportTop &&
67+
!isFollowing()
68+
) {
69+
return false;
70+
}
71+
72+
return true;
73+
};
74+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { fileURLToPath } from "node:url";
4+
5+
import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs";
6+
7+
const loader = createWebModuleLoader({
8+
rootDir: fileURLToPath(new URL("../", import.meta.url)),
9+
});
10+
11+
const { createLiveRowScrollAdjustPolicy } = loader.loadModule(
12+
"src/lib/transcript-virtual/liveScrollAdjustPolicy.ts",
13+
);
14+
15+
const makeItem = ({ index = 5, start, size }) => ({
16+
index,
17+
key: index,
18+
start,
19+
size,
20+
end: start + size,
21+
lane: 0,
22+
});
23+
24+
const makeInstance = ({ scrollOffset = 1000, scrollDirection = null, scrollAdjustments = 0 } = {}) => ({
25+
scrollOffset,
26+
scrollDirection,
27+
scrollAdjustments,
28+
});
29+
30+
const makePolicy = ({ liveStartIndex = -1, following = false } = {}) =>
31+
createLiveRowScrollAdjustPolicy({
32+
getLiveStartIndex: () => liveStartIndex,
33+
isFollowing: () => following,
34+
});
35+
36+
test("row entirely above the viewport keeps the default compensation", () => {
37+
const policy = makePolicy();
38+
const item = makeItem({ index: 1, start: 100, size: 200 });
39+
assert.equal(policy(item, 40, makeInstance()), true);
40+
assert.equal(policy(item, -40, makeInstance()), true);
41+
});
42+
43+
test("row starting at or below the viewport top never adjusts", () => {
44+
const policy = makePolicy();
45+
assert.equal(policy(makeItem({ start: 1000, size: 200 }), 40, makeInstance()), false);
46+
assert.equal(policy(makeItem({ start: 1200, size: 200 }), 40, makeInstance()), false);
47+
});
48+
49+
test("active backward scrolling suppresses compensation (upstream default)", () => {
50+
const policy = makePolicy();
51+
const item = makeItem({ index: 1, start: 100, size: 200 });
52+
assert.equal(policy(item, 40, makeInstance({ scrollDirection: "backward" })), false);
53+
assert.equal(policy(item, 40, makeInstance({ scrollDirection: "forward" })), true);
54+
});
55+
56+
test("detached reader inside the growing live row is left alone (streaming creep)", () => {
57+
const policy = makePolicy({ liveStartIndex: 5, following: false });
58+
// Live row spans 400..5400, viewport top at 3000: the reader scrolled up
59+
// into the streaming reply. Growth appends below the reading line.
60+
const item = makeItem({ index: 5, start: 400, size: 5000 });
61+
assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000 })), false);
62+
});
63+
64+
test("the same live-row growth while following keeps compensating (pin assist)", () => {
65+
const policy = makePolicy({ liveStartIndex: 5, following: true });
66+
const item = makeItem({ index: 5, start: 400, size: 5000 });
67+
assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000 })), true);
68+
});
69+
70+
test("live-row shrink keeps compensating so content under the reader stays put", () => {
71+
const policy = makePolicy({ liveStartIndex: 5, following: false });
72+
const item = makeItem({ index: 5, start: 400, size: 5000 });
73+
assert.equal(policy(item, -80, makeInstance({ scrollOffset: 3000 })), true);
74+
});
75+
76+
test("settled row straddling the viewport keeps the default", () => {
77+
const policy = makePolicy({ liveStartIndex: 5, following: false });
78+
const item = makeItem({ index: 2, start: 400, size: 5000 });
79+
assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000 })), true);
80+
});
81+
82+
test("idle transcript (liveStartIndex -1) keeps the default everywhere", () => {
83+
const policy = makePolicy({ liveStartIndex: -1, following: false });
84+
const item = makeItem({ index: 5, start: 400, size: 5000 });
85+
assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000 })), true);
86+
});
87+
88+
test("live row entirely above the viewport still compensates", () => {
89+
const policy = makePolicy({ liveStartIndex: 5, following: false });
90+
// end (300) <= viewport top (1000): the growth lands above the reader.
91+
const item = makeItem({ index: 5, start: 100, size: 200 });
92+
assert.equal(policy(item, 40, makeInstance({ scrollOffset: 1000 })), true);
93+
});
94+
95+
test("pending scroll adjustments fold into the viewport-top comparison", () => {
96+
const policy = makePolicy();
97+
const item = makeItem({ index: 1, start: 1040, size: 5 });
98+
// Without pending adjustments the row start sits below the viewport top…
99+
assert.equal(policy(item, 40, makeInstance({ scrollOffset: 1000 })), false);
100+
// …with 50px of un-echoed writes it counts as above, like upstream.
101+
assert.equal(policy(item, 40, makeInstance({ scrollOffset: 1000, scrollAdjustments: 50 })), true);
102+
});
103+
104+
test("missing private scrollAdjustments field falls back to zero", () => {
105+
const policy = makePolicy();
106+
const item = makeItem({ index: 1, start: 100, size: 200 });
107+
assert.equal(policy(item, 40, { scrollOffset: 1000, scrollDirection: null }), true);
108+
});

crates/agent-gateway/web/test/scroll-follow-core.test.mjs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,39 @@ test("constants coupling: reserve band and DPR tolerance", () => {
5050
assert.ok(BOTTOM_ATTACH_THRESHOLD_PX >= 4);
5151
});
5252

53-
test("fractional-DPR clamp shortfall still attaches (c4d6471)", () => {
53+
test("fractional-DPR clamp shortfall attaches inside the latch (c4d6471)", () => {
5454
// Windows 125%/150% scaling clamps scrollTop 1-3px short of the physical
55-
// bottom; landing there must attach with no latch and no pointer.
56-
const { state } = run([wheelUp(), growth(500), scroll(3, 10_000)]);
55+
// bottom; a latched downward arrival landing there must still attach (the
56+
// attach threshold covers the shortfall).
57+
const { state } = run([
58+
wheelUp({ now: 0 }),
59+
growth(500),
60+
wheelDown({ now: 9_800 }), // latch until 10_300
61+
scroll(3, 10_000),
62+
]);
63+
assert.equal(state.following, true);
64+
});
65+
66+
test("content shrink clamping a detached reader never re-attaches", () => {
67+
// A reply settling out of the row list (or a collapsing block) shrinks
68+
// scrollHeight; the browser clamps scrollTop and emits a scroll event at
69+
// the bottom with no input and no latch. Follow must stay off, and the
70+
// regrowth that lands right after must not pin — this was the "jump to
71+
// the bottom when the reply finishes" bug.
72+
const clamped = run([wheelUp({ now: 0 }), growth(2000), scroll(0, 10_000)]);
73+
assert.equal(clamped.state.following, false);
74+
// The clamp still counts as downward movement for a later release check.
75+
assert.equal(clamped.state.dragTowardBottom, true);
76+
77+
const regrown = run([growth(1800)], { state: clamped.state });
78+
assert.equal(regrown.state.following, false);
79+
assert.equal(regrown.pin, false);
80+
});
81+
82+
test("pin echo at the clamp keeps following without a latch", () => {
83+
// While following, our own pin write echoes back as a scroll event at the
84+
// clamp long after any input; it must keep follow engaged.
85+
const { state } = run([growth(0), scroll(2, 10_000)]);
5786
assert.equal(state.following, true);
5887
});
5988

crates/agent-gui/src/lib/chat-scroll/scrollFollowCore.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@
1414
// after a programmatic pin (the abort lands with the next main-thread
1515
// commit); those frames carry no input and no drag, so they can never
1616
// detach.
17-
// - ATTACH is position-driven: landing at the physical clamp always attaches;
18-
// a gesture-latched downward arrival inside the reattach zone attaches; a
19-
// pointer released inside the zone after downward movement attaches. The
20-
// gesture latch is the only timing heuristic left and it gates attach only —
21-
// a false positive re-pins, it can never tear follow down.
17+
// - ATTACH is position-driven and intent-gated: landing at the physical clamp
18+
// attaches while following (our own pin write echoing back) or inside an
19+
// active gesture latch; a gesture-latched downward arrival inside the
20+
// reattach zone attaches; a pointer released inside the zone after downward
21+
// movement attaches. Content shrinking under a detached reader (a reply
22+
// settling out of the row list, a collapsing block) also clamps scrollTop
23+
// to the bottom with no input — that arrival carries no latch, so it can
24+
// never re-engage follow and the next growth cannot yank the reader down.
25+
// The gesture latch is the only timing heuristic left and it gates attach
26+
// only — a false positive re-pins, it can never tear follow down.
2227
// - While following, a scroll event that leaves a gap open is corrected by
2328
// re-pinning rather than classified.
2429
// - ResizeObserver deliveries (contentGrowth) never change follow state; they
@@ -194,11 +199,17 @@ export function reduceFollowEvent(
194199
const next = { ...state, lastGap: gap };
195200

196201
if (isAtBottom(gap, config)) {
197-
// At the physical clamp — attaching is always safe, whether the user
198-
// landed here or our own pin write echoed back. Also counts as
199-
// downward movement for the pointer-release re-check.
200-
next.following = true;
202+
// At the physical clamp. Counts as downward movement for the
203+
// pointer-release re-check, but attaching needs evidence of intent:
204+
// our own pin write echoing back (still following) or an arrival
205+
// inside an active gesture latch. Content shrinking under a detached
206+
// reader (a reply settling out of the row list, a collapsing block)
207+
// also clamps scrollTop here with no input — attaching on that would
208+
// let the next growth yank the reader to the bottom.
201209
next.dragTowardBottom = true;
210+
if (state.following || now <= state.latchUntil) {
211+
next.following = true;
212+
}
202213
return { state: next, pin: false };
203214
}
204215

0 commit comments

Comments
 (0)