Skip to content

fix(coding-agent): keep the scroll anchor stable when content shrinks - #2214

Merged
flora131 merged 3 commits into
mainfrom
fix/2205-scroll-anchor
Aug 7, 2026
Merged

fix(coding-agent): keep the scroll anchor stable when content shrinks#2214
flora131 merged 3 commits into
mainfrom
fix/2205-scroll-anchor

Conversation

@lavaman131

@lavaman131 lavaman131 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Separate pre-existing defect, not a #2205 regression: chat-transcript.ts
is byte-identical between 0.9.12 and 0.9.13-alpha.1. Found while
falsifying the initial hypothesis for the Ctrl+O autoscroll bug, which
the layer below this one actually fixes.

ScrollableComponentViewport stores its offset as a distance from the
bottom and compensated it only when content grew:

if (this.scrollFromBottom > 0 && this.lastWidth === width
    && lineCount > this.lastLineCount) {
    this.scrollFromBottom += lineCount - this.lastLineCount;
}

A component that loses rows -- a live subagent widget dropping its
current-tool row at the end of a tool call -- left scrollFromBottom
unchanged, and clampScroll() then pulled the view toward the bottom
against a smaller maxScroll. Compensation is now symmetric in both
directions, so a user scrolled up stays anchored whether content grows
or shrinks, while a user already at the bottom still sticks to it.

Scope note: ScrollableComponentViewport is used by ChatSessionHost, not
by the normal interactive chat transcript, so this does not by itself
change the reported Ctrl+O behavior.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com


Stack created with GitHub Stacks CLIGive Feedback 💬


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Greptile Summary

The transcript viewport adds per-entry scroll anchoring, but compaction can still move a reader forward in the conversation when it rebuilds wrappers for surviving messages. A reader positioned on a surviving message does not remain on that message after older history is removed.

T-Rex validation blocked

The focused runtime check executed successfully, but the required Greptile artifact-upload tool is unavailable in this environment. Its test source and observed output therefore cannot be attached as uploaded evidence.

Confidence Score: 4/5

The change is not ready to merge because transcript compaction can lose a reader’s logical scroll position.

One user-visible correctness failure remains: rebuilding message-entry wrappers prevents the viewport from matching the reader’s surviving message after compaction.

Files Needing Attention: packages/coding-agent/src/modes/interactive/components/chat-transcript.ts; packages/coding-agent/src/modes/interactive/components/chat-message-renderer.ts

T-Rex T-Rex Logs

What T-Rex did

  • A focused runtime test was executed to exercise the replaceMessages path and completed with exit code 0.
  • The test observed preserved-wrapper beforeAnchor entry-10 and afterAnchor entry-10, and reconstructed-wrapper beforeAnchor entry-10 and afterAnchor entry-16 with survivingWrapperReconstructed true.
  • Validation was blocked by a missing tool for artifact uploads, so the environment could not produce uploaded Greptile artifacts and references.
  • A P1 finding proof was posted, with details available in the corresponding review comment.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (2)

  1. General comment

    P1 Compaction reconstructs transcript wrapper identities and loses the scroll anchor

    • Bug
      • A scrolled-up reader anchored on entry-10 is moved to entry-13 after production-style replaceMessages(messages.slice(3)), even though entry-10 survives. The reference compaction that reseats the same wrapper objects leaves the reader on entry-10.
    • Cause
      • refreshCompactedTranscript calls LiveChatEntriesController.replaceMessages at packages/coding-agent/src/modes/interactive/components/chat-session-host-events.ts:134. replaceMessages invokes chatEntriesFromAgentMessages at packages/coding-agent/src/modes/interactive/components/chat-message-renderer.ts:161, creating new wrapper objects. ChatTranscriptComponent.rowSegments emits wrapper objects as IDs at packages/coding-agent/src/modes/interactive/components/chat-transcript.ts:158; then rowsShiftedInsideComponent finds no old ID in the next map and returns 0 at lines 505-529.
    • Fix
      • Preserve ChatMessageEntry object identity for surviving agent messages during compaction (for example, reconcile/reuse wrappers keyed by the underlying message or a stable message identifier), or make transcript segment IDs stable across replaceMessages reconstruction.

    T-Rex Ran code and verified through T-Rex

  2. packages/coding-agent/src/modes/interactive/components/chat-message-renderer.ts, line 161 (link)

    P1 Compaction loses the transcript scroll anchor when it reconstructs message-entry wrappers

    • Bug
      • replaceMessages reconstructs ChatMessageEntry wrappers for surviving messages. ChatTranscriptComponent identifies row segments by wrapper identity, so the anchor matcher cannot locate the same logical entry after compaction. A reader who was viewing entry-10 instead sees entry-16 after six earlier messages are compacted away.
    • Cause
      • chatEntriesFromAgentMessages(messages) returns new wrapper objects before replaceMessages splices them into the transcript. rowSegments uses each wrapper as id, and anchor reconciliation looks those IDs up by identity.
    • Fix
      • Use a stable logical-message identity for row segment IDs across replaceMessages, rather than the transient ChatMessageEntry wrapper identity. Retain the focused regression test.

    T-Rex Ran code and verified through T-Rex

Prompt To Fix All With AI
### Issue 1
packages/coding-agent/src/modes/interactive/components/chat-transcript.ts:158
**Compaction replaces the logical anchor identity**

`rowSegments` identifies each rendered segment by its `ChatMessageEntry` wrapper object. Production compaction calls `replaceMessages`, which rebuilds those wrappers even for surviving logical messages. The next row map therefore has no matching segment for a reader anchored on a surviving message, leaving the old absolute row in place after older messages are removed. A reader positioned on `entry-10` can consequently be moved ahead to `entry-16` instead of remaining on `entry-10`. Use a stable logical message identity for row segments, or preserve wrappers for surviving messages.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (6): Last reviewed commit: "fix(coding-agent): let a static transcri..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@flora131
flora131 force-pushed the fix/2205-scroll-anchor branch from aeb528e to adc02ad Compare August 6, 2026 20:17
Copilot AI lite review requested due to automatic review settings August 6, 2026 20:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@flora131
flora131 force-pushed the fix/2205-progress-emission branch from 8659692 to dfd67b7 Compare August 6, 2026 21:33
@flora131
flora131 deleted the branch main August 7, 2026 02:15
@flora131 flora131 closed this Aug 7, 2026
@flora131 flora131 reopened this Aug 7, 2026
@flora131
flora131 changed the base branch from fix/2205-progress-emission to main August 7, 2026 02:16
Copilot AI review requested due to automatic review settings August 7, 2026 02:19
@flora131
flora131 force-pushed the fix/2205-scroll-anchor branch from adc02ad to 31e1692 Compare August 7, 2026 02:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

Comment thread packages/coding-agent/src/modes/interactive/components/chat-transcript.ts Outdated
flora131 added a commit that referenced this pull request Aug 7, 2026
…terior

greptile on #2214 reproduced a viewport jumping from entry-10..19 to
entry-13..22 after three entries above the reader were removed. The
finding is correct, and the defect is larger than the report suggests.

rowsShiftedAboveAnchor compared one scalar row count per component and
broke at the first component whose end passed anchorRow, on the stated
assumption that a spanning component's changes happen at or below the
anchor. In production that assumption never holds: the chat host renders
the ENTIRE transcript as a single component in slot 0
(chat-session-host-rendering.ts:23-25), so every scrolled-up reader's
anchor is inside it, the loop broke immediately, and the compensation
this PR added returned 0 for all of them. It engaged only when the anchor
sat past the transcript, parked on the trailing spacer.

A production path deletes from that component's interior: compaction_end
-> refreshCompactedTranscript -> replaceMessages, which splices the entry
array in place (chat-message-renderer.ts:160-164).

Windowed components may now report a row map, `rowSegments(width)`,
identifying each run of rows by the entry that produced it. For a
spanning component the shift is the distance the anchored segment itself
moved, rather than a height delta.

Segments are matched by object identity, not by cache key. Keys embed the
entry index (chat-session-host-rendering.ts:167-173), so a splice
renumbers every survivor; the entry objects move through it unchanged.

Known limits, deliberate:
- A real compaction_end rebuilds entry objects via
  chatEntriesFromAgentMessages, so only extraEntries keep identity. A
  viewer anchored in the rebuilt region falls back to the nearest
  surviving segment above, then below, and lands close rather than exact.
- No cross-component identity; duplicate ids resolve to first occurrence.
- One small object per entry per measured frame, unmemoized.
- Components without rowSegments keep the previous break semantics.

Two tests added, both verified to fail with only the consuming branch
reverted to `break;` -- entry-10 -> entry-13 and entry-10 -> entry-16 --
and to pass once restored byte-identically. All six existing anchor tests
are unchanged and still pass; the only pre-existing line touched in that
file is an import.

Also repairs the [Unreleased] changelog structure in this file. My
conflict resolutions while rebasing #2213/#2218 concatenated bullets
without respecting headings, leaving `### Removed` with no blank line
before it and two Fixed bullets underneath it. That malformed section is
already on main; all six bullets are preserved and no released section is
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 7, 2026 02:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

Copilot AI review requested due to automatic review settings August 7, 2026 03:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

flora131 and others added 3 commits August 6, 2026 21:16
Separate pre-existing defect, not a #2205 regression: chat-transcript.ts
is byte-identical between 0.9.12 and 0.9.13-alpha.1. Found while
falsifying the initial hypothesis for the Ctrl+O autoscroll bug, which
the layer below this one actually fixes.

ScrollableComponentViewport stores its offset as a distance from the
bottom and compensated it only when content grew:

    if (this.scrollFromBottom > 0 && this.lastWidth === width
        && lineCount > this.lastLineCount) {
        this.scrollFromBottom += lineCount - this.lastLineCount;
    }

A component that loses rows -- a live subagent widget dropping its
current-tool row at the end of a tool call -- left scrollFromBottom
unchanged, and clampScroll() then pulled the view toward the bottom
against a smaller maxScroll. Compensation is now symmetric in both
directions, so a user scrolled up stays anchored whether content grows
or shrinks, while a user already at the bottom still sticks to it.

Scope note: ScrollableComponentViewport is used by ChatSessionHost, not
by the normal interactive chat transcript, so this does not by itself
change the reported Ctrl+O behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…terior

greptile on #2214 reproduced a viewport jumping from entry-10..19 to
entry-13..22 after three entries above the reader were removed. The
finding is correct, and the defect is larger than the report suggests.

rowsShiftedAboveAnchor compared one scalar row count per component and
broke at the first component whose end passed anchorRow, on the stated
assumption that a spanning component's changes happen at or below the
anchor. In production that assumption never holds: the chat host renders
the ENTIRE transcript as a single component in slot 0
(chat-session-host-rendering.ts:23-25), so every scrolled-up reader's
anchor is inside it, the loop broke immediately, and the compensation
this PR added returned 0 for all of them. It engaged only when the anchor
sat past the transcript, parked on the trailing spacer.

A production path deletes from that component's interior: compaction_end
-> refreshCompactedTranscript -> replaceMessages, which splices the entry
array in place (chat-message-renderer.ts:160-164).

Windowed components may now report a row map, `rowSegments(width)`,
identifying each run of rows by the entry that produced it. For a
spanning component the shift is the distance the anchored segment itself
moved, rather than a height delta.

Segments are matched by object identity, not by cache key. Keys embed the
entry index (chat-session-host-rendering.ts:167-173), so a splice
renumbers every survivor; the entry objects move through it unchanged.

Known limits, deliberate:
- A real compaction_end rebuilds entry objects via
  chatEntriesFromAgentMessages, so only extraEntries keep identity. A
  viewer anchored in the rebuilt region falls back to the nearest
  surviving segment above, then below, and lands close rather than exact.
- No cross-component identity; duplicate ids resolve to first occurrence.
- One small object per entry per measured frame, unmemoized.
- Components without rowSegments keep the previous break semantics.

Two tests added, both verified to fail with only the consuming branch
reverted to `break;` -- entry-10 -> entry-13 and entry-10 -> entry-16 --
and to pass once restored byte-identically. All six existing anchor tests
are unchanged and still pass; the only pre-existing line touched in that
file is an import.

Also repairs the [Unreleased] changelog structure in this file. My
conflict resolutions while rebasing #2213/#2218 concatenated bullets
without respecting headings, leaving `### Removed` with no blank line
before it and two Fixed bullets underneath it. That malformed section is
already on main; all six bullets are preserved and no released section is
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
greptile raised the same drift a third time after the previous push, this
time naming ScrollableChatTranscriptComponent. The thread arrived marked
resolved; the code disagreed, and the reviewer was right.

That class builds its transcript WITHOUT a cache key (:505), so
supportsRowWindow is false, rowSegments returned [] for anything not
windowed, and isRowWindowComponent gated the viewport away from asking.
The previous commit therefore fixed the ChatSessionHost path -- which
does pass a cache key -- and left the exported class exactly as broken.

Passing it a cache key would have been the obvious fix and is wrong:
chat-message-renderer.test.ts:188-198 pins that a transcript without one
reflects entries mutated in place, which is precisely what caching by key
would miss. The absent key is the feature.

So identifying rows is decoupled from windowing. renderAllRows already
walks every entry and knows each block's height, so it records them as it
goes -- no extra render, and it cannot go stale against an in-place
mutation the way a key can. rowSegments returns those on the static path,
guarded by the width they were measured at. The viewport reads segments
from static components too, after rendering them, since the recording is
a by-product of that render.

Two tests added against the exported class: entries removed above the
viewer keep the anchored entry, and an entry mutated in place is still
reflected -- the second guards the regression the cache-key approach
would have caused. Reverting only the static-segment plumbing fails the
first with entry-4 -> entry-7 while the other nine pass, so it is
specific to this gap.

npm run check clean; 626 files / 5968 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@flora131
flora131 force-pushed the fix/2205-scroll-anchor branch from b36b289 to 9938973 Compare August 7, 2026 04:20
Copilot AI review requested due to automatic review settings August 7, 2026 04:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

this.ensureBlockCache(width);
const segments: RowWindowSegment[] = [];
for (const block of this.blockCache) {
if (block !== undefined) segments.push({ id: block.entry, rows: block.lines.length });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Compaction replaces the logical anchor identity

rowSegments identifies each rendered segment by its ChatMessageEntry wrapper object. Production compaction calls replaceMessages, which rebuilds those wrappers even for surviving logical messages. The next row map therefore has no matching segment for a reader anchored on a surviving message, leaving the old absolute row in place after older messages are removed. A reader positioned on entry-10 can consequently be moved ahead to entry-16 instead of remaining on entry-10. Use a stable logical message identity for row segments, or preserve wrappers for surviving messages.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/coding-agent/src/modes/interactive/components/chat-transcript.ts
Line: 158

Comment:
**Compaction replaces the logical anchor identity**

`rowSegments` identifies each rendered segment by its `ChatMessageEntry` wrapper object. Production compaction calls `replaceMessages`, which rebuilds those wrappers even for surviving logical messages. The next row map therefore has no matching segment for a reader anchored on a surviving message, leaving the old absolute row in place after older messages are removed. A reader positioned on `entry-10` can consequently be moved ahead to `entry-16` instead of remaining on `entry-10`. Use a stable logical message identity for row segments, or preserve wrappers for surviving messages.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@flora131
flora131 merged commit 23e76ca into main Aug 7, 2026
18 checks passed
@flora131
flora131 deleted the fix/2205-scroll-anchor branch August 14, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants