Skip to content

Commit f3671c8

Browse files
su-fenclaude
andcommitted
fix(transcript): stop auto-jump to the bottom when a reply completes
A detached reader gets yanked to the bottom the moment a run finishes, through two independent paths: 1. Settle shrink re-attach (both ends). When a reply settles, the row list briefly shrinks (desktop persistence can lag the run's end; the WebUI drops its pending bubble). The browser clamps scrollTop to the new bottom and emits a scroll event, and the follow reducer's clamp branch attached unconditionally — so the regrowth that lands right after pinned the reader to the bottom. Gate the clamp attach on evidence of intent: our own pin write echoing back (still following) or an arrival inside the 500ms gesture latch. Every deliberate return-to-bottom still attaches (wheel/touch/key arrivals ride the latch chain, thumb drags re-engage on release, the jump button uses forceFollow); a shrink clamp carries no input and no latch, so it can no longer re-engage follow. 2. Queued-turn auto-start pin (GUI). Draining the chat queue after a run completes went through markConversationRunStarted, which force-pinned the visible conversation. Queue auto-starts are not a user gesture — skip the pin for them (preserveComposerOnStart is the queue-only marker); manual sends keep pinning. The old "landing at the physical clamp always attaches with no latch and no pointer" test codified the hole; it is rewritten as a latched arrival (preserving its fractional-DPR tolerance intent), with new regression coverage for the shrink clamp and the pin echo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b5c95c6 commit f3671c8

5 files changed

Lines changed: 109 additions & 25 deletions

File tree

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

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

crates/agent-gui/src/pages/ChatPage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3831,7 +3831,11 @@ export function ChatPage(props: ChatPageProps) {
38313831
resetLiveTranscript(transcriptStore);
38323832
setConversationAbortController(conversationId, cancellation.userStop);
38333833
setConversationSendingState(conversationId, true);
3834-
if (isConversationVisible()) {
3834+
// Queue-drained auto-starts are not a user gesture: the reader may be
3835+
// deep in history when the previous run finishes, and force-pinning
3836+
// for the next queued turn would yank them to the bottom. Manual sends
3837+
// still pin (here and via resetVisibleTransientState below).
3838+
if (isConversationVisible() && !overrides?.preserveComposerOnStart) {
38353839
scrollFollowRef.current?.stickToBottom();
38363840
}
38373841
}

crates/agent-gui/test/chat/scroll-follow-core.test.mjs

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

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

0 commit comments

Comments
 (0)