refactor(ui): make the transcript the only writer of its scroll position - #1

Closed
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority
Closed

refactor(ui): make the transcript the only writer of its scroll position#1
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on apache#4105 — review that first; this diff is against its branch.

Three writers moved scrollTop in the chat transcript: Astryx's auto-follow lock and spring, Maka's height-delta compensation and scrollIntoView, and the browser's own anchoring. None of them held the answer to "where should the viewport be", so they avoided each other through flags, effect ordering, and heuristics rebuilt from DOM signals — scroll direction, height deltas, wheel events — each of which has more than one cause. apache#4105 fixed the symptoms that were reachable; the three regressions it records as knowingly unfixed are the ones that need the ownership itself to move, and this PR eliminates all three.

The policy is now one boolean:

  • pinned — content that grows writes scrollTop = scrollHeight
  • not pinned — nothing here writes scrollTop, ever, and overflow-anchor: auto (already the initial value, no CSS added) keeps the reader where they were reading

Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at scrollTop === 0 — the single place native anchoring declines to help. Each releases the pin before it moves anything, so a command cannot race the policy.

Being the only writer is what makes the state exact rather than guessed. A write flags itself, so an unflagged scroll event is the reader by construction. And because scroll does not bubble, a gesture a nested scroller consumed never reaches the authority at all.

Deliberate choices worth a reviewer's attention:

  • Astryx's scroll layer is disabled per call site, through a new scrollOwner prop on ChatSurfaceLayout, not globally. The workhub surfaces render no ChatView and still want stock auto-follow.
  • No new observer. The growth signal is the turn virtualizer's existing ResizeObserver, called synchronously in its callback so the pin writes in the frame the content grew.
  • The history anchor is an element, not a height. A scrollHeight delta counts growth below the reader too, and counts a load that returned nothing as a push. It also picks the first visible turn, not the first mounted one — the virtual window is free to drop turns above the viewport while the load lands.
  • attach runs in a passive effect, not a layout one. The scroller is Astryx's layout root, an ancestor, and React attaches a parent's ref after its children's layout effects have run.
  • Nothing here reads an animation flag. Astryx gated its wheel predicate on animatingRef, and CDP measurement on the built branch found that ref sitting at true on a resting transcript (scrollHeight constant for 2.5s, spring target reachable, six seconds of quiet before the gesture) — emptying the stock wheel handler made the release disappear and restoring it brought it back, three times over. A "is something animating" flag desyncs from the rAF chains it is supposed to describe, so no equivalent flag was kept for compatibility.

Because Maka now owns the position, the patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks: conversationKey, unlockAutoFollow and resetInitialFill are gone, replaced by forwarding one autoScroll flag into useChatStreamScroll's existing enabled switch. Patch file: 55 hunks → 49, 149 of its lines rewritten.

Two unit test files are deleted rather than ported. Both exercised unlockAutoFollow and the conversation-swap reset — mechanisms this PR removes, not behaviours it changes.

Refs apache#4105

Verification

  • apps/desktop/e2e/transcript-scroll.spec.ts — 5 passed (1.0m), real Electron window:
    • a streaming answer keeps the viewport at the tail
    • content that arrives after the reader scrolls up does not pull them back
    • a gesture a nested scroller consumed does not release the tail (real mouse.wheel over a real overflowing child, guarded by asserting the child actually moved)
    • the dock affordance returns the reader to the tail
    • earlier history lands above the turn the reader is on
  • packages/ui scroll + rail unit tests: 13 passed, 0 failed (node --test --test-concurrency 4), including 4 new cases for the authority.
  • Previously affected e2e suites — prompt-rail, streaming-remount, quote-selection: 12 passed, 1 skipped.
  • npm run build -w @maka/ui, npm run typecheck -w apps/desktop (preload/main/renderer/storybook), npm run typecheck:stories -w apps/desktop, npm run format — all clean.
  • Patch integrity: git apply --check --reverse patches/@astryxdesign+core+0.5.0.patch against an npm ci-installed tree exits 0.
  • Not run: the full repository suite.

Review focus

The e2e assertions deliberately avoid fixed pixel budgets. They compare element positions before and after, and where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that same frame's growth, because following by ResizeObserver is one frame behind by construction and that frame is never painted.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — implementation, tests, and this description, under human direction and review. Affected commits carry Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Three writers moved `scrollTop` in the chat transcript — Astryx's
auto-follow lock and spring, Maka's height-delta compensation and
`scrollIntoView`, and the browser's own anchoring — and none of them held
the answer to "where should the viewport be". They avoided each other
through flags, effect ordering and heuristics reconstructed from DOM
signals, and each heuristic had more than one cause.
Collapse the policy to one boolean. `pinned` means growth writes
`scrollTop = scrollHeight`; not pinned means nothing here writes it, ever,
and `overflow-anchor: auto` — already the initial value — keeps the reader
where they were reading for free. Around it sit three one-shot commands:
return to the tail, jump to a turn, and compensate earlier history at
`scrollTop === 0`, the one place native anchoring declines to help. Every
command releases the pin first, so a command can never race the policy.
Being the only writer is what makes the state exact. A write flags itself,
so an unflagged `scroll` event is the reader by construction — and because
`scroll` does not bubble, a gesture a nested scroller consumed never
reaches the authority at all.
Astryx's scroll layer is turned off per call site through a new
`scrollOwner` prop rather than globally: the workhub surfaces render no
`ChatView` and still want stock auto-follow. The growth signal reuses the
turn virtualizer's existing `ResizeObserver`; no new observer is added.
The patch's scroll-related surface shrinks from six files and ten hunks to
three files and five hunks — `conversationKey`, `unlockAutoFollow` and
`resetInitialFill` all disappear, replaced by forwarding one `autoScroll`
flag into the hook's existing `enabled` switch.
Generated-by: Claude Opus 5 via Claude Code
@coderabbitai

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five Playwright assertions over the real Electron window, one per property
the single scroll authority is supposed to have: a streaming answer keeps
the tail on screen, content arriving after the reader scrolls up does not
pull them back, a gesture a nested scroller consumed does not release the
tail, the dock affordance returns the reader to the tail, and earlier
history lands above the turn the reader is on.
They assert element positions rather than pixel deltas. Where lag has to
be measured at all it is self-calibrating — the worst frame's lag is
compared against that frame's own growth, because following by
`ResizeObserver` is one frame behind by construction and that frame is
never painted. A fixed pixel budget would encode the machine it was
written on.
Generated-by: Claude Opus 5 via Claude Code
@Astro-Han
Astro-Hanforce-pushed the refactor/chat-scroll-single-authority branch from 9ddbeb8 to ecd3290CompareAugust 29, 2026 03:46
@Astro-Han

Copy link
Copy Markdown
OwnerAuthor

Superseded: this work is being merged into apache#4105 directly, since that PR is the teardown and this is the rebuild — they cannot land independently.

@Astro-Han
Astro-Han deleted the refactor/chat-scroll-single-authority branch August 29, 2026 04:02
Sign up for freeto 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.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

refactor(ui): make the transcript the only writer of its scroll position - #1

Closed
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority
Closed

refactor(ui): make the transcript the only writer of its scroll position#1
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on apache#4105 — review that first; this diff is against its branch.

Three writers moved scrollTop in the chat transcript: Astryx's auto-follow lock and spring, Maka's height-delta compensation and scrollIntoView, and the browser's own anchoring. None of them held the answer to "where should the viewport be", so they avoided each other through flags, effect ordering, and heuristics rebuilt from DOM signals — scroll direction, height deltas, wheel events — each of which has more than one cause. apache#4105 fixed the symptoms that were reachable; the three regressions it records as knowingly unfixed are the ones that need the ownership itself to move, and this PR eliminates all three.

The policy is now one boolean:

  • pinned — content that grows writes scrollTop = scrollHeight
  • not pinned — nothing here writes scrollTop, ever, and overflow-anchor: auto (already the initial value, no CSS added) keeps the reader where they were reading

Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at scrollTop === 0 — the single place native anchoring declines to help. Each releases the pin before it moves anything, so a command cannot race the policy.

Being the only writer is what makes the state exact rather than guessed. A write flags itself, so an unflagged scroll event is the reader by construction. And because scroll does not bubble, a gesture a nested scroller consumed never reaches the authority at all.

Deliberate choices worth a reviewer's attention:

  • Astryx's scroll layer is disabled per call site, through a new scrollOwner prop on ChatSurfaceLayout, not globally. The workhub surfaces render no ChatView and still want stock auto-follow.
  • No new observer. The growth signal is the turn virtualizer's existing ResizeObserver, called synchronously in its callback so the pin writes in the frame the content grew.
  • The history anchor is an element, not a height. A scrollHeight delta counts growth below the reader too, and counts a load that returned nothing as a push. It also picks the first visible turn, not the first mounted one — the virtual window is free to drop turns above the viewport while the load lands.
  • attach runs in a passive effect, not a layout one. The scroller is Astryx's layout root, an ancestor, and React attaches a parent's ref after its children's layout effects have run.
  • Nothing here reads an animation flag. Astryx gated its wheel predicate on animatingRef, and CDP measurement on the built branch found that ref sitting at true on a resting transcript (scrollHeight constant for 2.5s, spring target reachable, six seconds of quiet before the gesture) — emptying the stock wheel handler made the release disappear and restoring it brought it back, three times over. A "is something animating" flag desyncs from the rAF chains it is supposed to describe, so no equivalent flag was kept for compatibility.

Because Maka now owns the position, the patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks: conversationKey, unlockAutoFollow and resetInitialFill are gone, replaced by forwarding one autoScroll flag into useChatStreamScroll's existing enabled switch. Patch file: 55 hunks → 49, 149 of its lines rewritten.

Two unit test files are deleted rather than ported. Both exercised unlockAutoFollow and the conversation-swap reset — mechanisms this PR removes, not behaviours it changes.

Refs apache#4105

Verification

  • apps/desktop/e2e/transcript-scroll.spec.ts — 5 passed (1.0m), real Electron window:
    • a streaming answer keeps the viewport at the tail
    • content that arrives after the reader scrolls up does not pull them back
    • a gesture a nested scroller consumed does not release the tail (real mouse.wheel over a real overflowing child, guarded by asserting the child actually moved)
    • the dock affordance returns the reader to the tail
    • earlier history lands above the turn the reader is on
  • packages/ui scroll + rail unit tests: 13 passed, 0 failed (node --test --test-concurrency 4), including 4 new cases for the authority.
  • Previously affected e2e suites — prompt-rail, streaming-remount, quote-selection: 12 passed, 1 skipped.
  • npm run build -w @maka/ui, npm run typecheck -w apps/desktop (preload/main/renderer/storybook), npm run typecheck:stories -w apps/desktop, npm run format — all clean.
  • Patch integrity: git apply --check --reverse patches/@astryxdesign+core+0.5.0.patch against an npm ci-installed tree exits 0.
  • Not run: the full repository suite.

Review focus

The e2e assertions deliberately avoid fixed pixel budgets. They compare element positions before and after, and where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that same frame's growth, because following by ResizeObserver is one frame behind by construction and that frame is never painted.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — implementation, tests, and this description, under human direction and review. Affected commits carry Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Three writers moved `scrollTop` in the chat transcript — Astryx's
auto-follow lock and spring, Maka's height-delta compensation and
`scrollIntoView`, and the browser's own anchoring — and none of them held
the answer to "where should the viewport be". They avoided each other
through flags, effect ordering and heuristics reconstructed from DOM
signals, and each heuristic had more than one cause.
Collapse the policy to one boolean. `pinned` means growth writes
`scrollTop = scrollHeight`; not pinned means nothing here writes it, ever,
and `overflow-anchor: auto` — already the initial value — keeps the reader
where they were reading for free. Around it sit three one-shot commands:
return to the tail, jump to a turn, and compensate earlier history at
`scrollTop === 0`, the one place native anchoring declines to help. Every
command releases the pin first, so a command can never race the policy.
Being the only writer is what makes the state exact. A write flags itself,
so an unflagged `scroll` event is the reader by construction — and because
`scroll` does not bubble, a gesture a nested scroller consumed never
reaches the authority at all.
Astryx's scroll layer is turned off per call site through a new
`scrollOwner` prop rather than globally: the workhub surfaces render no
`ChatView` and still want stock auto-follow. The growth signal reuses the
turn virtualizer's existing `ResizeObserver`; no new observer is added.
The patch's scroll-related surface shrinks from six files and ten hunks to
three files and five hunks — `conversationKey`, `unlockAutoFollow` and
`resetInitialFill` all disappear, replaced by forwarding one `autoScroll`
flag into the hook's existing `enabled` switch.
Generated-by: Claude Opus 5 via Claude Code
@coderabbitai

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five Playwright assertions over the real Electron window, one per property
the single scroll authority is supposed to have: a streaming answer keeps
the tail on screen, content arriving after the reader scrolls up does not
pull them back, a gesture a nested scroller consumed does not release the
tail, the dock affordance returns the reader to the tail, and earlier
history lands above the turn the reader is on.
They assert element positions rather than pixel deltas. Where lag has to
be measured at all it is self-calibrating — the worst frame's lag is
compared against that frame's own growth, because following by
`ResizeObserver` is one frame behind by construction and that frame is
never painted. A fixed pixel budget would encode the machine it was
written on.
Generated-by: Claude Opus 5 via Claude Code
@Astro-Han
Astro-Hanforce-pushed the refactor/chat-scroll-single-authority branch from 9ddbeb8 to ecd3290CompareAugust 29, 2026 03:46
@Astro-Han

Copy link
Copy Markdown
OwnerAuthor

Superseded: this work is being merged into apache#4105 directly, since that PR is the teardown and this is the rebuild — they cannot land independently.

@Astro-Han
Astro-Han deleted the refactor/chat-scroll-single-authority branch August 29, 2026 04:02
Sign up for freeto 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.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(ui): make the transcript the only writer of its scroll position - #1

Closed
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority
Closed

refactor(ui): make the transcript the only writer of its scroll position#1
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on apache#4105 — review that first; this diff is against its branch.

Three writers moved scrollTop in the chat transcript: Astryx's auto-follow lock and spring, Maka's height-delta compensation and scrollIntoView, and the browser's own anchoring. None of them held the answer to "where should the viewport be", so they avoided each other through flags, effect ordering, and heuristics rebuilt from DOM signals — scroll direction, height deltas, wheel events — each of which has more than one cause. apache#4105 fixed the symptoms that were reachable; the three regressions it records as knowingly unfixed are the ones that need the ownership itself to move, and this PR eliminates all three.

The policy is now one boolean:

  • pinned — content that grows writes scrollTop = scrollHeight
  • not pinned — nothing here writes scrollTop, ever, and overflow-anchor: auto (already the initial value, no CSS added) keeps the reader where they were reading

Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at scrollTop === 0 — the single place native anchoring declines to help. Each releases the pin before it moves anything, so a command cannot race the policy.

Being the only writer is what makes the state exact rather than guessed. A write flags itself, so an unflagged scroll event is the reader by construction. And because scroll does not bubble, a gesture a nested scroller consumed never reaches the authority at all.

Deliberate choices worth a reviewer's attention:

  • Astryx's scroll layer is disabled per call site, through a new scrollOwner prop on ChatSurfaceLayout, not globally. The workhub surfaces render no ChatView and still want stock auto-follow.
  • No new observer. The growth signal is the turn virtualizer's existing ResizeObserver, called synchronously in its callback so the pin writes in the frame the content grew.
  • The history anchor is an element, not a height. A scrollHeight delta counts growth below the reader too, and counts a load that returned nothing as a push. It also picks the first visible turn, not the first mounted one — the virtual window is free to drop turns above the viewport while the load lands.
  • attach runs in a passive effect, not a layout one. The scroller is Astryx's layout root, an ancestor, and React attaches a parent's ref after its children's layout effects have run.
  • Nothing here reads an animation flag. Astryx gated its wheel predicate on animatingRef, and CDP measurement on the built branch found that ref sitting at true on a resting transcript (scrollHeight constant for 2.5s, spring target reachable, six seconds of quiet before the gesture) — emptying the stock wheel handler made the release disappear and restoring it brought it back, three times over. A "is something animating" flag desyncs from the rAF chains it is supposed to describe, so no equivalent flag was kept for compatibility.

Because Maka now owns the position, the patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks: conversationKey, unlockAutoFollow and resetInitialFill are gone, replaced by forwarding one autoScroll flag into useChatStreamScroll's existing enabled switch. Patch file: 55 hunks → 49, 149 of its lines rewritten.

Two unit test files are deleted rather than ported. Both exercised unlockAutoFollow and the conversation-swap reset — mechanisms this PR removes, not behaviours it changes.

Refs apache#4105

Verification

  • apps/desktop/e2e/transcript-scroll.spec.ts — 5 passed (1.0m), real Electron window:
    • a streaming answer keeps the viewport at the tail
    • content that arrives after the reader scrolls up does not pull them back
    • a gesture a nested scroller consumed does not release the tail (real mouse.wheel over a real overflowing child, guarded by asserting the child actually moved)
    • the dock affordance returns the reader to the tail
    • earlier history lands above the turn the reader is on
  • packages/ui scroll + rail unit tests: 13 passed, 0 failed (node --test --test-concurrency 4), including 4 new cases for the authority.
  • Previously affected e2e suites — prompt-rail, streaming-remount, quote-selection: 12 passed, 1 skipped.
  • npm run build -w @maka/ui, npm run typecheck -w apps/desktop (preload/main/renderer/storybook), npm run typecheck:stories -w apps/desktop, npm run format — all clean.
  • Patch integrity: git apply --check --reverse patches/@astryxdesign+core+0.5.0.patch against an npm ci-installed tree exits 0.
  • Not run: the full repository suite.

Review focus

The e2e assertions deliberately avoid fixed pixel budgets. They compare element positions before and after, and where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that same frame's growth, because following by ResizeObserver is one frame behind by construction and that frame is never painted.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — implementation, tests, and this description, under human direction and review. Affected commits carry Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Three writers moved `scrollTop` in the chat transcript — Astryx's
auto-follow lock and spring, Maka's height-delta compensation and
`scrollIntoView`, and the browser's own anchoring — and none of them held
the answer to "where should the viewport be". They avoided each other
through flags, effect ordering and heuristics reconstructed from DOM
signals, and each heuristic had more than one cause.
Collapse the policy to one boolean. `pinned` means growth writes
`scrollTop = scrollHeight`; not pinned means nothing here writes it, ever,
and `overflow-anchor: auto` — already the initial value — keeps the reader
where they were reading for free. Around it sit three one-shot commands:
return to the tail, jump to a turn, and compensate earlier history at
`scrollTop === 0`, the one place native anchoring declines to help. Every
command releases the pin first, so a command can never race the policy.
Being the only writer is what makes the state exact. A write flags itself,
so an unflagged `scroll` event is the reader by construction — and because
`scroll` does not bubble, a gesture a nested scroller consumed never
reaches the authority at all.
Astryx's scroll layer is turned off per call site through a new
`scrollOwner` prop rather than globally: the workhub surfaces render no
`ChatView` and still want stock auto-follow. The growth signal reuses the
turn virtualizer's existing `ResizeObserver`; no new observer is added.
The patch's scroll-related surface shrinks from six files and ten hunks to
three files and five hunks — `conversationKey`, `unlockAutoFollow` and
`resetInitialFill` all disappear, replaced by forwarding one `autoScroll`
flag into the hook's existing `enabled` switch.
Generated-by: Claude Opus 5 via Claude Code
@coderabbitai

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five Playwright assertions over the real Electron window, one per property
the single scroll authority is supposed to have: a streaming answer keeps
the tail on screen, content arriving after the reader scrolls up does not
pull them back, a gesture a nested scroller consumed does not release the
tail, the dock affordance returns the reader to the tail, and earlier
history lands above the turn the reader is on.
They assert element positions rather than pixel deltas. Where lag has to
be measured at all it is self-calibrating — the worst frame's lag is
compared against that frame's own growth, because following by
`ResizeObserver` is one frame behind by construction and that frame is
never painted. A fixed pixel budget would encode the machine it was
written on.
Generated-by: Claude Opus 5 via Claude Code
@Astro-Han
Astro-Hanforce-pushed the refactor/chat-scroll-single-authority branch from 9ddbeb8 to ecd3290CompareAugust 29, 2026 03:46
@Astro-Han

Copy link
Copy Markdown
OwnerAuthor

Superseded: this work is being merged into apache#4105 directly, since that PR is the teardown and this is the rebuild — they cannot land independently.

@Astro-Han
Astro-Han deleted the refactor/chat-scroll-single-authority branch August 29, 2026 04:02
Sign up for freeto 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.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(ui): make the transcript the only writer of its scroll position - #1

Closed
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority
Closed

refactor(ui): make the transcript the only writer of its scroll position#1
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on apache#4105 — review that first; this diff is against its branch.

Three writers moved scrollTop in the chat transcript: Astryx's auto-follow lock and spring, Maka's height-delta compensation and scrollIntoView, and the browser's own anchoring. None of them held the answer to "where should the viewport be", so they avoided each other through flags, effect ordering, and heuristics rebuilt from DOM signals — scroll direction, height deltas, wheel events — each of which has more than one cause. apache#4105 fixed the symptoms that were reachable; the three regressions it records as knowingly unfixed are the ones that need the ownership itself to move, and this PR eliminates all three.

The policy is now one boolean:

  • pinned — content that grows writes scrollTop = scrollHeight
  • not pinned — nothing here writes scrollTop, ever, and overflow-anchor: auto (already the initial value, no CSS added) keeps the reader where they were reading

Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at scrollTop === 0 — the single place native anchoring declines to help. Each releases the pin before it moves anything, so a command cannot race the policy.

Being the only writer is what makes the state exact rather than guessed. A write flags itself, so an unflagged scroll event is the reader by construction. And because scroll does not bubble, a gesture a nested scroller consumed never reaches the authority at all.

Deliberate choices worth a reviewer's attention:

  • Astryx's scroll layer is disabled per call site, through a new scrollOwner prop on ChatSurfaceLayout, not globally. The workhub surfaces render no ChatView and still want stock auto-follow.
  • No new observer. The growth signal is the turn virtualizer's existing ResizeObserver, called synchronously in its callback so the pin writes in the frame the content grew.
  • The history anchor is an element, not a height. A scrollHeight delta counts growth below the reader too, and counts a load that returned nothing as a push. It also picks the first visible turn, not the first mounted one — the virtual window is free to drop turns above the viewport while the load lands.
  • attach runs in a passive effect, not a layout one. The scroller is Astryx's layout root, an ancestor, and React attaches a parent's ref after its children's layout effects have run.
  • Nothing here reads an animation flag. Astryx gated its wheel predicate on animatingRef, and CDP measurement on the built branch found that ref sitting at true on a resting transcript (scrollHeight constant for 2.5s, spring target reachable, six seconds of quiet before the gesture) — emptying the stock wheel handler made the release disappear and restoring it brought it back, three times over. A "is something animating" flag desyncs from the rAF chains it is supposed to describe, so no equivalent flag was kept for compatibility.

Because Maka now owns the position, the patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks: conversationKey, unlockAutoFollow and resetInitialFill are gone, replaced by forwarding one autoScroll flag into useChatStreamScroll's existing enabled switch. Patch file: 55 hunks → 49, 149 of its lines rewritten.

Two unit test files are deleted rather than ported. Both exercised unlockAutoFollow and the conversation-swap reset — mechanisms this PR removes, not behaviours it changes.

Refs apache#4105

Verification

  • apps/desktop/e2e/transcript-scroll.spec.ts — 5 passed (1.0m), real Electron window:
    • a streaming answer keeps the viewport at the tail
    • content that arrives after the reader scrolls up does not pull them back
    • a gesture a nested scroller consumed does not release the tail (real mouse.wheel over a real overflowing child, guarded by asserting the child actually moved)
    • the dock affordance returns the reader to the tail
    • earlier history lands above the turn the reader is on
  • packages/ui scroll + rail unit tests: 13 passed, 0 failed (node --test --test-concurrency 4), including 4 new cases for the authority.
  • Previously affected e2e suites — prompt-rail, streaming-remount, quote-selection: 12 passed, 1 skipped.
  • npm run build -w @maka/ui, npm run typecheck -w apps/desktop (preload/main/renderer/storybook), npm run typecheck:stories -w apps/desktop, npm run format — all clean.
  • Patch integrity: git apply --check --reverse patches/@astryxdesign+core+0.5.0.patch against an npm ci-installed tree exits 0.
  • Not run: the full repository suite.

Review focus

The e2e assertions deliberately avoid fixed pixel budgets. They compare element positions before and after, and where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that same frame's growth, because following by ResizeObserver is one frame behind by construction and that frame is never painted.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — implementation, tests, and this description, under human direction and review. Affected commits carry Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Three writers moved `scrollTop` in the chat transcript — Astryx's
auto-follow lock and spring, Maka's height-delta compensation and
`scrollIntoView`, and the browser's own anchoring — and none of them held
the answer to "where should the viewport be". They avoided each other
through flags, effect ordering and heuristics reconstructed from DOM
signals, and each heuristic had more than one cause.
Collapse the policy to one boolean. `pinned` means growth writes
`scrollTop = scrollHeight`; not pinned means nothing here writes it, ever,
and `overflow-anchor: auto` — already the initial value — keeps the reader
where they were reading for free. Around it sit three one-shot commands:
return to the tail, jump to a turn, and compensate earlier history at
`scrollTop === 0`, the one place native anchoring declines to help. Every
command releases the pin first, so a command can never race the policy.
Being the only writer is what makes the state exact. A write flags itself,
so an unflagged `scroll` event is the reader by construction — and because
`scroll` does not bubble, a gesture a nested scroller consumed never
reaches the authority at all.
Astryx's scroll layer is turned off per call site through a new
`scrollOwner` prop rather than globally: the workhub surfaces render no
`ChatView` and still want stock auto-follow. The growth signal reuses the
turn virtualizer's existing `ResizeObserver`; no new observer is added.
The patch's scroll-related surface shrinks from six files and ten hunks to
three files and five hunks — `conversationKey`, `unlockAutoFollow` and
`resetInitialFill` all disappear, replaced by forwarding one `autoScroll`
flag into the hook's existing `enabled` switch.
Generated-by: Claude Opus 5 via Claude Code
@coderabbitai

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five Playwright assertions over the real Electron window, one per property
the single scroll authority is supposed to have: a streaming answer keeps
the tail on screen, content arriving after the reader scrolls up does not
pull them back, a gesture a nested scroller consumed does not release the
tail, the dock affordance returns the reader to the tail, and earlier
history lands above the turn the reader is on.
They assert element positions rather than pixel deltas. Where lag has to
be measured at all it is self-calibrating — the worst frame's lag is
compared against that frame's own growth, because following by
`ResizeObserver` is one frame behind by construction and that frame is
never painted. A fixed pixel budget would encode the machine it was
written on.
Generated-by: Claude Opus 5 via Claude Code
@Astro-Han
Astro-Hanforce-pushed the refactor/chat-scroll-single-authority branch from 9ddbeb8 to ecd3290CompareAugust 29, 2026 03:46
@Astro-Han

Copy link
Copy Markdown
OwnerAuthor

Superseded: this work is being merged into apache#4105 directly, since that PR is the teardown and this is the rebuild — they cannot land independently.

@Astro-Han
Astro-Han deleted the refactor/chat-scroll-single-authority branch August 29, 2026 04:02
Sign up for freeto 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.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

refactor(ui): make the transcript the only writer of its scroll position - #1

Closed
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority
Closed

refactor(ui): make the transcript the only writer of its scroll position#1
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on apache#4105 — review that first; this diff is against its branch.

Three writers moved scrollTop in the chat transcript: Astryx's auto-follow lock and spring, Maka's height-delta compensation and scrollIntoView, and the browser's own anchoring. None of them held the answer to "where should the viewport be", so they avoided each other through flags, effect ordering, and heuristics rebuilt from DOM signals — scroll direction, height deltas, wheel events — each of which has more than one cause. apache#4105 fixed the symptoms that were reachable; the three regressions it records as knowingly unfixed are the ones that need the ownership itself to move, and this PR eliminates all three.

The policy is now one boolean:

  • pinned — content that grows writes scrollTop = scrollHeight
  • not pinned — nothing here writes scrollTop, ever, and overflow-anchor: auto (already the initial value, no CSS added) keeps the reader where they were reading

Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at scrollTop === 0 — the single place native anchoring declines to help. Each releases the pin before it moves anything, so a command cannot race the policy.

Being the only writer is what makes the state exact rather than guessed. A write flags itself, so an unflagged scroll event is the reader by construction. And because scroll does not bubble, a gesture a nested scroller consumed never reaches the authority at all.

Deliberate choices worth a reviewer's attention:

  • Astryx's scroll layer is disabled per call site, through a new scrollOwner prop on ChatSurfaceLayout, not globally. The workhub surfaces render no ChatView and still want stock auto-follow.
  • No new observer. The growth signal is the turn virtualizer's existing ResizeObserver, called synchronously in its callback so the pin writes in the frame the content grew.
  • The history anchor is an element, not a height. A scrollHeight delta counts growth below the reader too, and counts a load that returned nothing as a push. It also picks the first visible turn, not the first mounted one — the virtual window is free to drop turns above the viewport while the load lands.
  • attach runs in a passive effect, not a layout one. The scroller is Astryx's layout root, an ancestor, and React attaches a parent's ref after its children's layout effects have run.
  • Nothing here reads an animation flag. Astryx gated its wheel predicate on animatingRef, and CDP measurement on the built branch found that ref sitting at true on a resting transcript (scrollHeight constant for 2.5s, spring target reachable, six seconds of quiet before the gesture) — emptying the stock wheel handler made the release disappear and restoring it brought it back, three times over. A "is something animating" flag desyncs from the rAF chains it is supposed to describe, so no equivalent flag was kept for compatibility.

Because Maka now owns the position, the patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks: conversationKey, unlockAutoFollow and resetInitialFill are gone, replaced by forwarding one autoScroll flag into useChatStreamScroll's existing enabled switch. Patch file: 55 hunks → 49, 149 of its lines rewritten.

Two unit test files are deleted rather than ported. Both exercised unlockAutoFollow and the conversation-swap reset — mechanisms this PR removes, not behaviours it changes.

Refs apache#4105

Verification

  • apps/desktop/e2e/transcript-scroll.spec.ts — 5 passed (1.0m), real Electron window:
    • a streaming answer keeps the viewport at the tail
    • content that arrives after the reader scrolls up does not pull them back
    • a gesture a nested scroller consumed does not release the tail (real mouse.wheel over a real overflowing child, guarded by asserting the child actually moved)
    • the dock affordance returns the reader to the tail
    • earlier history lands above the turn the reader is on
  • packages/ui scroll + rail unit tests: 13 passed, 0 failed (node --test --test-concurrency 4), including 4 new cases for the authority.
  • Previously affected e2e suites — prompt-rail, streaming-remount, quote-selection: 12 passed, 1 skipped.
  • npm run build -w @maka/ui, npm run typecheck -w apps/desktop (preload/main/renderer/storybook), npm run typecheck:stories -w apps/desktop, npm run format — all clean.
  • Patch integrity: git apply --check --reverse patches/@astryxdesign+core+0.5.0.patch against an npm ci-installed tree exits 0.
  • Not run: the full repository suite.

Review focus

The e2e assertions deliberately avoid fixed pixel budgets. They compare element positions before and after, and where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that same frame's growth, because following by ResizeObserver is one frame behind by construction and that frame is never painted.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — implementation, tests, and this description, under human direction and review. Affected commits carry Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Three writers moved `scrollTop` in the chat transcript — Astryx's
auto-follow lock and spring, Maka's height-delta compensation and
`scrollIntoView`, and the browser's own anchoring — and none of them held
the answer to "where should the viewport be". They avoided each other
through flags, effect ordering and heuristics reconstructed from DOM
signals, and each heuristic had more than one cause.
Collapse the policy to one boolean. `pinned` means growth writes
`scrollTop = scrollHeight`; not pinned means nothing here writes it, ever,
and `overflow-anchor: auto` — already the initial value — keeps the reader
where they were reading for free. Around it sit three one-shot commands:
return to the tail, jump to a turn, and compensate earlier history at
`scrollTop === 0`, the one place native anchoring declines to help. Every
command releases the pin first, so a command can never race the policy.
Being the only writer is what makes the state exact. A write flags itself,
so an unflagged `scroll` event is the reader by construction — and because
`scroll` does not bubble, a gesture a nested scroller consumed never
reaches the authority at all.
Astryx's scroll layer is turned off per call site through a new
`scrollOwner` prop rather than globally: the workhub surfaces render no
`ChatView` and still want stock auto-follow. The growth signal reuses the
turn virtualizer's existing `ResizeObserver`; no new observer is added.
The patch's scroll-related surface shrinks from six files and ten hunks to
three files and five hunks — `conversationKey`, `unlockAutoFollow` and
`resetInitialFill` all disappear, replaced by forwarding one `autoScroll`
flag into the hook's existing `enabled` switch.
Generated-by: Claude Opus 5 via Claude Code
@coderabbitai

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five Playwright assertions over the real Electron window, one per property
the single scroll authority is supposed to have: a streaming answer keeps
the tail on screen, content arriving after the reader scrolls up does not
pull them back, a gesture a nested scroller consumed does not release the
tail, the dock affordance returns the reader to the tail, and earlier
history lands above the turn the reader is on.
They assert element positions rather than pixel deltas. Where lag has to
be measured at all it is self-calibrating — the worst frame's lag is
compared against that frame's own growth, because following by
`ResizeObserver` is one frame behind by construction and that frame is
never painted. A fixed pixel budget would encode the machine it was
written on.
Generated-by: Claude Opus 5 via Claude Code
@Astro-Han
Astro-Hanforce-pushed the refactor/chat-scroll-single-authority branch from 9ddbeb8 to ecd3290CompareAugust 29, 2026 03:46
@Astro-Han

Copy link
Copy Markdown
OwnerAuthor

Superseded: this work is being merged into apache#4105 directly, since that PR is the teardown and this is the rebuild — they cannot land independently.

@Astro-Han
Astro-Han deleted the refactor/chat-scroll-single-authority branch August 29, 2026 04:02
Sign up for freeto 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.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(ui): make the transcript the only writer of its scroll position - #1

Closed
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority
Closed

refactor(ui): make the transcript the only writer of its scroll position#1
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on apache#4105 — review that first; this diff is against its branch.

Three writers moved scrollTop in the chat transcript: Astryx's auto-follow lock and spring, Maka's height-delta compensation and scrollIntoView, and the browser's own anchoring. None of them held the answer to "where should the viewport be", so they avoided each other through flags, effect ordering, and heuristics rebuilt from DOM signals — scroll direction, height deltas, wheel events — each of which has more than one cause. apache#4105 fixed the symptoms that were reachable; the three regressions it records as knowingly unfixed are the ones that need the ownership itself to move, and this PR eliminates all three.

The policy is now one boolean:

  • pinned — content that grows writes scrollTop = scrollHeight
  • not pinned — nothing here writes scrollTop, ever, and overflow-anchor: auto (already the initial value, no CSS added) keeps the reader where they were reading

Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at scrollTop === 0 — the single place native anchoring declines to help. Each releases the pin before it moves anything, so a command cannot race the policy.

Being the only writer is what makes the state exact rather than guessed. A write flags itself, so an unflagged scroll event is the reader by construction. And because scroll does not bubble, a gesture a nested scroller consumed never reaches the authority at all.

Deliberate choices worth a reviewer's attention:

  • Astryx's scroll layer is disabled per call site, through a new scrollOwner prop on ChatSurfaceLayout, not globally. The workhub surfaces render no ChatView and still want stock auto-follow.
  • No new observer. The growth signal is the turn virtualizer's existing ResizeObserver, called synchronously in its callback so the pin writes in the frame the content grew.
  • The history anchor is an element, not a height. A scrollHeight delta counts growth below the reader too, and counts a load that returned nothing as a push. It also picks the first visible turn, not the first mounted one — the virtual window is free to drop turns above the viewport while the load lands.
  • attach runs in a passive effect, not a layout one. The scroller is Astryx's layout root, an ancestor, and React attaches a parent's ref after its children's layout effects have run.
  • Nothing here reads an animation flag. Astryx gated its wheel predicate on animatingRef, and CDP measurement on the built branch found that ref sitting at true on a resting transcript (scrollHeight constant for 2.5s, spring target reachable, six seconds of quiet before the gesture) — emptying the stock wheel handler made the release disappear and restoring it brought it back, three times over. A "is something animating" flag desyncs from the rAF chains it is supposed to describe, so no equivalent flag was kept for compatibility.

Because Maka now owns the position, the patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks: conversationKey, unlockAutoFollow and resetInitialFill are gone, replaced by forwarding one autoScroll flag into useChatStreamScroll's existing enabled switch. Patch file: 55 hunks → 49, 149 of its lines rewritten.

Two unit test files are deleted rather than ported. Both exercised unlockAutoFollow and the conversation-swap reset — mechanisms this PR removes, not behaviours it changes.

Refs apache#4105

Verification

  • apps/desktop/e2e/transcript-scroll.spec.ts — 5 passed (1.0m), real Electron window:
    • a streaming answer keeps the viewport at the tail
    • content that arrives after the reader scrolls up does not pull them back
    • a gesture a nested scroller consumed does not release the tail (real mouse.wheel over a real overflowing child, guarded by asserting the child actually moved)
    • the dock affordance returns the reader to the tail
    • earlier history lands above the turn the reader is on
  • packages/ui scroll + rail unit tests: 13 passed, 0 failed (node --test --test-concurrency 4), including 4 new cases for the authority.
  • Previously affected e2e suites — prompt-rail, streaming-remount, quote-selection: 12 passed, 1 skipped.
  • npm run build -w @maka/ui, npm run typecheck -w apps/desktop (preload/main/renderer/storybook), npm run typecheck:stories -w apps/desktop, npm run format — all clean.
  • Patch integrity: git apply --check --reverse patches/@astryxdesign+core+0.5.0.patch against an npm ci-installed tree exits 0.
  • Not run: the full repository suite.

Review focus

The e2e assertions deliberately avoid fixed pixel budgets. They compare element positions before and after, and where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that same frame's growth, because following by ResizeObserver is one frame behind by construction and that frame is never painted.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — implementation, tests, and this description, under human direction and review. Affected commits carry Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Three writers moved `scrollTop` in the chat transcript — Astryx's
auto-follow lock and spring, Maka's height-delta compensation and
`scrollIntoView`, and the browser's own anchoring — and none of them held
the answer to "where should the viewport be". They avoided each other
through flags, effect ordering and heuristics reconstructed from DOM
signals, and each heuristic had more than one cause.
Collapse the policy to one boolean. `pinned` means growth writes
`scrollTop = scrollHeight`; not pinned means nothing here writes it, ever,
and `overflow-anchor: auto` — already the initial value — keeps the reader
where they were reading for free. Around it sit three one-shot commands:
return to the tail, jump to a turn, and compensate earlier history at
`scrollTop === 0`, the one place native anchoring declines to help. Every
command releases the pin first, so a command can never race the policy.
Being the only writer is what makes the state exact. A write flags itself,
so an unflagged `scroll` event is the reader by construction — and because
`scroll` does not bubble, a gesture a nested scroller consumed never
reaches the authority at all.
Astryx's scroll layer is turned off per call site through a new
`scrollOwner` prop rather than globally: the workhub surfaces render no
`ChatView` and still want stock auto-follow. The growth signal reuses the
turn virtualizer's existing `ResizeObserver`; no new observer is added.
The patch's scroll-related surface shrinks from six files and ten hunks to
three files and five hunks — `conversationKey`, `unlockAutoFollow` and
`resetInitialFill` all disappear, replaced by forwarding one `autoScroll`
flag into the hook's existing `enabled` switch.
Generated-by: Claude Opus 5 via Claude Code
@coderabbitai

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five Playwright assertions over the real Electron window, one per property
the single scroll authority is supposed to have: a streaming answer keeps
the tail on screen, content arriving after the reader scrolls up does not
pull them back, a gesture a nested scroller consumed does not release the
tail, the dock affordance returns the reader to the tail, and earlier
history lands above the turn the reader is on.
They assert element positions rather than pixel deltas. Where lag has to
be measured at all it is self-calibrating — the worst frame's lag is
compared against that frame's own growth, because following by
`ResizeObserver` is one frame behind by construction and that frame is
never painted. A fixed pixel budget would encode the machine it was
written on.
Generated-by: Claude Opus 5 via Claude Code
@Astro-Han
Astro-Hanforce-pushed the refactor/chat-scroll-single-authority branch from 9ddbeb8 to ecd3290CompareAugust 29, 2026 03:46
@Astro-Han

Copy link
Copy Markdown
OwnerAuthor

Superseded: this work is being merged into apache#4105 directly, since that PR is the teardown and this is the rebuild — they cannot land independently.

@Astro-Han
Astro-Han deleted the refactor/chat-scroll-single-authority branch August 29, 2026 04:02
Sign up for freeto 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.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(ui): make the transcript the only writer of its scroll position - #1

Closed
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority
Closed

refactor(ui): make the transcript the only writer of its scroll position#1
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on apache#4105 — review that first; this diff is against its branch.

Three writers moved scrollTop in the chat transcript: Astryx's auto-follow lock and spring, Maka's height-delta compensation and scrollIntoView, and the browser's own anchoring. None of them held the answer to "where should the viewport be", so they avoided each other through flags, effect ordering, and heuristics rebuilt from DOM signals — scroll direction, height deltas, wheel events — each of which has more than one cause. apache#4105 fixed the symptoms that were reachable; the three regressions it records as knowingly unfixed are the ones that need the ownership itself to move, and this PR eliminates all three.

The policy is now one boolean:

  • pinned — content that grows writes scrollTop = scrollHeight
  • not pinned — nothing here writes scrollTop, ever, and overflow-anchor: auto (already the initial value, no CSS added) keeps the reader where they were reading

Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at scrollTop === 0 — the single place native anchoring declines to help. Each releases the pin before it moves anything, so a command cannot race the policy.

Being the only writer is what makes the state exact rather than guessed. A write flags itself, so an unflagged scroll event is the reader by construction. And because scroll does not bubble, a gesture a nested scroller consumed never reaches the authority at all.

Deliberate choices worth a reviewer's attention:

  • Astryx's scroll layer is disabled per call site, through a new scrollOwner prop on ChatSurfaceLayout, not globally. The workhub surfaces render no ChatView and still want stock auto-follow.
  • No new observer. The growth signal is the turn virtualizer's existing ResizeObserver, called synchronously in its callback so the pin writes in the frame the content grew.
  • The history anchor is an element, not a height. A scrollHeight delta counts growth below the reader too, and counts a load that returned nothing as a push. It also picks the first visible turn, not the first mounted one — the virtual window is free to drop turns above the viewport while the load lands.
  • attach runs in a passive effect, not a layout one. The scroller is Astryx's layout root, an ancestor, and React attaches a parent's ref after its children's layout effects have run.
  • Nothing here reads an animation flag. Astryx gated its wheel predicate on animatingRef, and CDP measurement on the built branch found that ref sitting at true on a resting transcript (scrollHeight constant for 2.5s, spring target reachable, six seconds of quiet before the gesture) — emptying the stock wheel handler made the release disappear and restoring it brought it back, three times over. A "is something animating" flag desyncs from the rAF chains it is supposed to describe, so no equivalent flag was kept for compatibility.

Because Maka now owns the position, the patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks: conversationKey, unlockAutoFollow and resetInitialFill are gone, replaced by forwarding one autoScroll flag into useChatStreamScroll's existing enabled switch. Patch file: 55 hunks → 49, 149 of its lines rewritten.

Two unit test files are deleted rather than ported. Both exercised unlockAutoFollow and the conversation-swap reset — mechanisms this PR removes, not behaviours it changes.

Refs apache#4105

Verification

  • apps/desktop/e2e/transcript-scroll.spec.ts — 5 passed (1.0m), real Electron window:
    • a streaming answer keeps the viewport at the tail
    • content that arrives after the reader scrolls up does not pull them back
    • a gesture a nested scroller consumed does not release the tail (real mouse.wheel over a real overflowing child, guarded by asserting the child actually moved)
    • the dock affordance returns the reader to the tail
    • earlier history lands above the turn the reader is on
  • packages/ui scroll + rail unit tests: 13 passed, 0 failed (node --test --test-concurrency 4), including 4 new cases for the authority.
  • Previously affected e2e suites — prompt-rail, streaming-remount, quote-selection: 12 passed, 1 skipped.
  • npm run build -w @maka/ui, npm run typecheck -w apps/desktop (preload/main/renderer/storybook), npm run typecheck:stories -w apps/desktop, npm run format — all clean.
  • Patch integrity: git apply --check --reverse patches/@astryxdesign+core+0.5.0.patch against an npm ci-installed tree exits 0.
  • Not run: the full repository suite.

Review focus

The e2e assertions deliberately avoid fixed pixel budgets. They compare element positions before and after, and where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that same frame's growth, because following by ResizeObserver is one frame behind by construction and that frame is never painted.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — implementation, tests, and this description, under human direction and review. Affected commits carry Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Three writers moved `scrollTop` in the chat transcript — Astryx's
auto-follow lock and spring, Maka's height-delta compensation and
`scrollIntoView`, and the browser's own anchoring — and none of them held
the answer to "where should the viewport be". They avoided each other
through flags, effect ordering and heuristics reconstructed from DOM
signals, and each heuristic had more than one cause.
Collapse the policy to one boolean. `pinned` means growth writes
`scrollTop = scrollHeight`; not pinned means nothing here writes it, ever,
and `overflow-anchor: auto` — already the initial value — keeps the reader
where they were reading for free. Around it sit three one-shot commands:
return to the tail, jump to a turn, and compensate earlier history at
`scrollTop === 0`, the one place native anchoring declines to help. Every
command releases the pin first, so a command can never race the policy.
Being the only writer is what makes the state exact. A write flags itself,
so an unflagged `scroll` event is the reader by construction — and because
`scroll` does not bubble, a gesture a nested scroller consumed never
reaches the authority at all.
Astryx's scroll layer is turned off per call site through a new
`scrollOwner` prop rather than globally: the workhub surfaces render no
`ChatView` and still want stock auto-follow. The growth signal reuses the
turn virtualizer's existing `ResizeObserver`; no new observer is added.
The patch's scroll-related surface shrinks from six files and ten hunks to
three files and five hunks — `conversationKey`, `unlockAutoFollow` and
`resetInitialFill` all disappear, replaced by forwarding one `autoScroll`
flag into the hook's existing `enabled` switch.
Generated-by: Claude Opus 5 via Claude Code
@coderabbitai

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five Playwright assertions over the real Electron window, one per property
the single scroll authority is supposed to have: a streaming answer keeps
the tail on screen, content arriving after the reader scrolls up does not
pull them back, a gesture a nested scroller consumed does not release the
tail, the dock affordance returns the reader to the tail, and earlier
history lands above the turn the reader is on.
They assert element positions rather than pixel deltas. Where lag has to
be measured at all it is self-calibrating — the worst frame's lag is
compared against that frame's own growth, because following by
`ResizeObserver` is one frame behind by construction and that frame is
never painted. A fixed pixel budget would encode the machine it was
written on.
Generated-by: Claude Opus 5 via Claude Code
@Astro-Han
Astro-Hanforce-pushed the refactor/chat-scroll-single-authority branch from 9ddbeb8 to ecd3290CompareAugust 29, 2026 03:46
@Astro-Han

Copy link
Copy Markdown
OwnerAuthor

Superseded: this work is being merged into apache#4105 directly, since that PR is the teardown and this is the rebuild — they cannot land independently.

@Astro-Han
Astro-Han deleted the refactor/chat-scroll-single-authority branch August 29, 2026 04:02
Sign up for freeto 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.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

refactor(ui): make the transcript the only writer of its scroll position - #1

Closed
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority
Closed

refactor(ui): make the transcript the only writer of its scroll position#1
Astro-Han wants to merge 2 commits into
fix/chat-scroll-astryx-ownershipfrom
refactor/chat-scroll-single-authority

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on apache#4105 — review that first; this diff is against its branch.

Three writers moved scrollTop in the chat transcript: Astryx's auto-follow lock and spring, Maka's height-delta compensation and scrollIntoView, and the browser's own anchoring. None of them held the answer to "where should the viewport be", so they avoided each other through flags, effect ordering, and heuristics rebuilt from DOM signals — scroll direction, height deltas, wheel events — each of which has more than one cause. apache#4105 fixed the symptoms that were reachable; the three regressions it records as knowingly unfixed are the ones that need the ownership itself to move, and this PR eliminates all three.

The policy is now one boolean:

  • pinned — content that grows writes scrollTop = scrollHeight
  • not pinned — nothing here writes scrollTop, ever, and overflow-anchor: auto (already the initial value, no CSS added) keeps the reader where they were reading

Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at scrollTop === 0 — the single place native anchoring declines to help. Each releases the pin before it moves anything, so a command cannot race the policy.

Being the only writer is what makes the state exact rather than guessed. A write flags itself, so an unflagged scroll event is the reader by construction. And because scroll does not bubble, a gesture a nested scroller consumed never reaches the authority at all.

Deliberate choices worth a reviewer's attention:

  • Astryx's scroll layer is disabled per call site, through a new scrollOwner prop on ChatSurfaceLayout, not globally. The workhub surfaces render no ChatView and still want stock auto-follow.
  • No new observer. The growth signal is the turn virtualizer's existing ResizeObserver, called synchronously in its callback so the pin writes in the frame the content grew.
  • The history anchor is an element, not a height. A scrollHeight delta counts growth below the reader too, and counts a load that returned nothing as a push. It also picks the first visible turn, not the first mounted one — the virtual window is free to drop turns above the viewport while the load lands.
  • attach runs in a passive effect, not a layout one. The scroller is Astryx's layout root, an ancestor, and React attaches a parent's ref after its children's layout effects have run.
  • Nothing here reads an animation flag. Astryx gated its wheel predicate on animatingRef, and CDP measurement on the built branch found that ref sitting at true on a resting transcript (scrollHeight constant for 2.5s, spring target reachable, six seconds of quiet before the gesture) — emptying the stock wheel handler made the release disappear and restoring it brought it back, three times over. A "is something animating" flag desyncs from the rAF chains it is supposed to describe, so no equivalent flag was kept for compatibility.

Because Maka now owns the position, the patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks: conversationKey, unlockAutoFollow and resetInitialFill are gone, replaced by forwarding one autoScroll flag into useChatStreamScroll's existing enabled switch. Patch file: 55 hunks → 49, 149 of its lines rewritten.

Two unit test files are deleted rather than ported. Both exercised unlockAutoFollow and the conversation-swap reset — mechanisms this PR removes, not behaviours it changes.

Refs apache#4105

Verification

  • apps/desktop/e2e/transcript-scroll.spec.ts — 5 passed (1.0m), real Electron window:
    • a streaming answer keeps the viewport at the tail
    • content that arrives after the reader scrolls up does not pull them back
    • a gesture a nested scroller consumed does not release the tail (real mouse.wheel over a real overflowing child, guarded by asserting the child actually moved)
    • the dock affordance returns the reader to the tail
    • earlier history lands above the turn the reader is on
  • packages/ui scroll + rail unit tests: 13 passed, 0 failed (node --test --test-concurrency 4), including 4 new cases for the authority.
  • Previously affected e2e suites — prompt-rail, streaming-remount, quote-selection: 12 passed, 1 skipped.
  • npm run build -w @maka/ui, npm run typecheck -w apps/desktop (preload/main/renderer/storybook), npm run typecheck:stories -w apps/desktop, npm run format — all clean.
  • Patch integrity: git apply --check --reverse patches/@astryxdesign+core+0.5.0.patch against an npm ci-installed tree exits 0.
  • Not run: the full repository suite.

Review focus

The e2e assertions deliberately avoid fixed pixel budgets. They compare element positions before and after, and where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that same frame's growth, because following by ResizeObserver is one frame behind by construction and that frame is never painted.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Opus 5 via Claude Code — implementation, tests, and this description, under human direction and review. Affected commits carry Generated-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Three writers moved `scrollTop` in the chat transcript — Astryx's
auto-follow lock and spring, Maka's height-delta compensation and
`scrollIntoView`, and the browser's own anchoring — and none of them held
the answer to "where should the viewport be". They avoided each other
through flags, effect ordering and heuristics reconstructed from DOM
signals, and each heuristic had more than one cause.
Collapse the policy to one boolean. `pinned` means growth writes
`scrollTop = scrollHeight`; not pinned means nothing here writes it, ever,
and `overflow-anchor: auto` — already the initial value — keeps the reader
where they were reading for free. Around it sit three one-shot commands:
return to the tail, jump to a turn, and compensate earlier history at
`scrollTop === 0`, the one place native anchoring declines to help. Every
command releases the pin first, so a command can never race the policy.
Being the only writer is what makes the state exact. A write flags itself,
so an unflagged `scroll` event is the reader by construction — and because
`scroll` does not bubble, a gesture a nested scroller consumed never
reaches the authority at all.
Astryx's scroll layer is turned off per call site through a new
`scrollOwner` prop rather than globally: the workhub surfaces render no
`ChatView` and still want stock auto-follow. The growth signal reuses the
turn virtualizer's existing `ResizeObserver`; no new observer is added.
The patch's scroll-related surface shrinks from six files and ten hunks to
three files and five hunks — `conversationKey`, `unlockAutoFollow` and
`resetInitialFill` all disappear, replaced by forwarding one `autoScroll`
flag into the hook's existing `enabled` switch.
Generated-by: Claude Opus 5 via Claude Code
@coderabbitai

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five Playwright assertions over the real Electron window, one per property
the single scroll authority is supposed to have: a streaming answer keeps
the tail on screen, content arriving after the reader scrolls up does not
pull them back, a gesture a nested scroller consumed does not release the
tail, the dock affordance returns the reader to the tail, and earlier
history lands above the turn the reader is on.
They assert element positions rather than pixel deltas. Where lag has to
be measured at all it is self-calibrating — the worst frame's lag is
compared against that frame's own growth, because following by
`ResizeObserver` is one frame behind by construction and that frame is
never painted. A fixed pixel budget would encode the machine it was
written on.
Generated-by: Claude Opus 5 via Claude Code
@Astro-Han
Astro-Hanforce-pushed the refactor/chat-scroll-single-authority branch from 9ddbeb8 to ecd3290CompareAugust 29, 2026 03:46
@Astro-Han

Copy link
Copy Markdown
OwnerAuthor

Superseded: this work is being merged into apache#4105 directly, since that PR is the teardown and this is the rebuild — they cannot land independently.

@Astro-Han
Astro-Han deleted the refactor/chat-scroll-single-authority branch August 29, 2026 04:02
Sign up for freeto 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.

1 participant

@Astro-Han