Skip to content

perf(desktop): bound rendering within oversized turns - #4259

Open
liugddx wants to merge 26 commits into
apache:mainfrom
liugddx:fix/oversized-turn-render-bounds
Open

perf(desktop): bound rendering within oversized turns#4259
liugddx wants to merge 26 commits into
apache:mainfrom
liugddx:fix/oversized-turn-render-bounds

Conversation

@liugddx

@liugddxliugddx commented Aug 30, 2026

Copy link
Copy Markdown
Member

Summary

Bound render-active work inside one oversized transcript Turn with nested Chromium content-visibility boundaries at the answer, Processing, reasoning, and tool blocks (5 marker sites, one CSS rule). The Turn stays the persistence and paging unit; component identity, disclosure, selection and accessibility are unchanged.

The measured win is hover, not scrolling. Astryx's :where(parent:hover *) rules invalidate the whole Turn subtree on a mouse sweep, and sub-turn containment keeps that off the ~15k-element oversized Turn (per @Astro-Han's A/B: hover p95 9ms vs 59ms, 0 vs ~20 LoAF > 50ms, hover recalc ~100ms vs ~1270ms). Scrolling is already cheap under production motion.

Fixes#4256

Scope note (#4269)

This PR no longer touches the scroll authority. main has since landed a more precise #4269 fix — the unexplained-delta guard in transcript-scroll-authority, which separates a reader move from content-driven offset changes within a rounding band — so this branch takes main's transcript-scroll-authority.tsx and its tests wholesale and keeps only the sub-turn containment. #4269 is handled on main, not here.

Verification

  • npm run format:check / lint / typecheck / build — passed
  • @maka/core + @maka/ui build clean; the transcript-scroll-authority suite (main's) — 13 passed
  • e2e/oversized-turn-render.spec.ts — the content-visibility skip regression for an oversized Turn
  • hosted GitHub CI test — validated on the pushed head

AI use

  • Generative tooling made a substantive contribution

Tool(s) and scope: authored the sub-turn containment, its fixture and skip regression, adopted main's scroll-authority fix on merge, and ran verification under maintainer direction.

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

@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Aug 30, 2026
@liugddx
liugddxforce-pushed the fix/oversized-turn-render-bounds branch from 19cd319 to 73bb817CompareAugust 30, 2026 11:27

@hqhq1025hqhq1025 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.

Found one important issue: the new oversized-Turn performance harness does not enforce the stated 50 ms limit, and the exact head exceeded that limit in two consecutive local runs while the test still passed. The containment behavior itself passed focused functional, accessibility, scroll, and build checks.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Comment threadapps/desktop/e2e/native-transcript-perf.spec.ts Outdated

@hqhq1025hqhq1025 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.

Reviewed exact head c0f66317387a01a3151d3925296ede26dbb0a13b. The previous harness-coverage issue is fixed: the opt-in benchmark now asserts that no Long Animation Frame exceeds 50 ms. I found one remaining performance failure on the exact head and left it inline. The functional oversized-Turn and live-tail behavior passed focused tests.

Validation: npm run build:test, full workspace typecheck, UI 276/276, the two oversized-Turn functional Electron tests, changed-file Biome, git diff --check, and a synthetic merge with current main passed. The opt-in performance test failed on Linux/Xvfb with 9 frames over 50 ms and a 76.9 ms maximum. The hosted test check was still running at publication.

Unable to determine: I could not reproduce the author’s macOS arm64 environment, so the cross-platform performance envelope remains uncertain.

Result: NO-GO for the current stated 50 ms acceptance criterion until the benchmark passes on the supported target matrix or the intended benchmark environment is explicitly scoped.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Comment threadapps/desktop/e2e/native-transcript-perf.spec.ts Outdated

@Astro-HanAstro-Han 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.

Thanks for the focused performance work, and for turning the 50 ms Long Animation Frame criterion into a real failing gate on the latest head.

I reviewed exact head c0f66317387a01a3151d3925296ede26dbb0a13b. The oversized-Turn problem and the nested rendering boundary are well motivated. I found one separate P2 inline in the normal keyboard/assistive-navigation path. It does not challenge the value of the performance change, but I think the tail-follow authority needs to account for focus-driven materialization.

Please do push back if focus navigation through transcript activity cards is intentionally outside the supported interaction contract; the finding is based on Chromium materializing skipped focus targets and the current ResizeObserver tail-write path.

中文对照

感谢把超大 Turn 的性能问题和 50 ms 门槛做成可执行验证。当前 head 的性能方向成立;另有一个独立 P2:键盘或辅助功能聚焦被跳过的 activity card 时会触发布局展开,而当前 tail-follow authority 仍可能把视图写回尾部。若 transcript 内的焦点导航不属于支持范围,也欢迎补充上下文。

AI-assisted review disclosure: Codex ran an independent analysis lane; Astro-Han independently verified the exact head, Chromium focus/materialization path, scroll-authority composition, and severity, and owns this review.

Comment threadapps/desktop/src/renderer/styles/chat-message.css

@hqhq1025hqhq1025 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.

Reviewed exact head 895cb38bff24aaab82fc4fcab27c0b45174dc8c7. I found no remaining P0-P3 issues.

The two prior blockers are addressed on this head. The 50 ms Long Animation Frame assertion is now explicitly scoped to the calibrated native macOS arm64 environment, while unsupported Linux/Xvfb execution reports a skip instead of a misleading pass or failure. The focus-navigation path now releases live-tail following when focus enters a skipped offscreen transcript block, and the real keyboard regression preserves both the focused control and viewport position through subsequent transcript growth.

Validation: clean npm ci, npm run build:test, full workspace typecheck, UI 276/276, renderer architecture 62/62, oversized-Turn Electron tests 3/3, the keyboard-focus test repeated 3/3, transcript-scroll Electron tests 10/10, full lint and format checks, git diff --check, a clean synthetic merge with current main, and hosted test / windows_recovery all passed. The opt-in performance command on Linux/Xvfb produced the expected explicit skip.

Unable to determine: this Linux host cannot independently reproduce the native macOS arm64 50 ms benchmark, so the author's five-run macOS measurements remain platform-specific evidence rather than independently verified results here.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

@Astro-HanAstro-Han 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.

Thanks for the focused follow-up. I confirmed that the platform-scoped performance gate and the offscreen-focus case from the earlier review are both addressed. I found one separate focus/growth interleaving below. This was an AI-assisted review using Codex; I independently checked the scroll authority, ChatLayout composition, regression coverage, and exact-head CI. Please push back if Chromium or the layout enforces an ordering I have overlooked.

Comment threadpackages/ui/src/transcript-scroll-authority.tsx Outdated
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Aug 31, 2026

@hqhq1025hqhq1025 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.

Reviewed exact head d12b06162ecb657ed8b88fd129793f42e5d695a7. I found one P2 and do not recommend merging this head yet.

This follow-up scopes focus handling to the transcript and compares scrollTop with the authority's last write before deciding that focus moved the reader. The intended pending-growth case passes in isolated Linux/Xvfb runs, but the exact-head hosted test job failed the new regression with the viewport 804 px from the tail. The remaining race is described inline.

Validation: clean install, npm run build:test, full workspace typecheck, UI 276/276, renderer architecture 62/62, the oversized-Turn file 4/4, the new pending-growth case 10/10 in isolated repeats, transcript-scroll Electron tests 10/10, lint, format, git diff --check, and a clean synthetic merge with current main passed locally. Hosted windows_recovery passed; hosted test failed this exact focus/growth regression. A known 96 px assertion fluctuation in the older offscreen-focus test reproduced on both 895cb38 and this head, so I did not attribute that separate flake to this delta.

Unable to determine: this Linux host cannot independently validate the native macOS arm64 performance gate, and the hosted-only focus failure did not reproduce in ten isolated local repeats.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Comment threadpackages/ui/src/transcript-scroll-authority.tsx Outdated
liugddxand others added 4 commits August 31, 2026 14:19
… estimate
A 96px contain-intrinsic-block-size placeholder on multi-line terminal/tool
blocks forces a large scrollHeight correction as the reader approaches them,
which the live-tail-release layer then compensates for. A closer first-paint
estimate lets native overflow-anchor absorb most of the correction. Estimate
only (auto <n>px still grows to real size); no content-visibility change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@Astro-HanAstro-Han 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.

Third pass, on efe3acb2. The new head only changes CSS (intrinsic-size estimate 96px to 320px), so the focus/growth interleaving from last round was closed on the previous head; I re-read it and agree it is closed in code. @maka/ui is green locally (278/278). Two things to fix before this can go in, one direction question, then a rebase note.

The regression I asked for last round is not what the E2E tests.visible composer focus during pending growth keeps the live tail focuses COMPOSER_INPUT. The composer lives in the layout's bottom dock, outside .maka-chat-message-list, so onFocusIn returns at the TRANSCRIPT_SELECTOR guard before any of the new logic runs. Delete onFocusOut, incomingFocus and the pre-focus visibility check and that test stays green. The half I asked about, an already-visible transcript control focused while growth is pending, is covered only by the fake-DOM unit test, whose closest() and contains() return true unconditionally. Point the E2E at a visible tool-card header in the fixture instead.

The boundary selector list now exists in four places, and two are already out of sync.chat-message.css (5 selectors), FOCUS_VISIBILITY_BOUNDARY in transcript-scroll-authority.tsx (same 5, copied across the package boundary), SEGMENT in oversized-turn-render.spec.ts (3, missing .maka-deep-thinking and .maka-tool-activity-card) and the perf spec (3). The next boundary someone adds in CSS silently escapes focus classification and the E2E. The nearest seam is a data-* attribute set where the blocks are rendered; CSS and TS both select on it and the four lists become one.

Direction: release is keyed to input devices.onScroll still returns on moved without touching pinned, which is the #4269 ambiguity, and this PR opens two bypasses for it, wheel and focusin. PageUp/Home/arrow keys with focus in the transcript (which the E2E itself sets up via Shift+Tab), scrollbar drags and scrollIntoView are still dragged back to the tail while streaming. This PR widens that window: one upward gesture used to trigger at most one Turn-level materialization, now it can trigger many inside one Turn (the E2E asserts 80+ boundaries). Rather than a third device listener, I would have onScroll account for its own writes within the delivery window and treat the remainder as reader movement. If you go that way, the wheel and focusin listeners, FOCUS_VISIBILITY_BOUNDARY, the incomingFocus state and two of the three new unit tests fold into that one ledger, and the production diff roughly halves. Happy to hear why not.

Smaller, take or leave: the deltaY > 0 branch in nestedScrollerConsumesWheel is unreachable (both callers return on deltaY >= 0); || target in focusEventRoot exists only for the fake root, give the fake an ownerDocument; the new overscroll-behavior: contain rule changes history paging for the pannable mermaid viewport (correct, but undeclared); isOutsideViewport uses the scroll container's border box, so a control hidden under the composer counts as visible; the second CSS block repeats four selectors to override one property, one rule plus one exception is enough; the perf gate needs MAKA_TRANSCRIPT_PERF=1 and macOS arm64, so CI never runs it, and skippedSegments in it is logged, not asserted.

Rebase: git merge-tree against main conflicts only in native-transcript-perf.spec.ts, where #4523 moved the perf skip to declaration time. Do the same for the platform gate, so the Electron window is not built just to skip.

Evidence boundary: static read of efe3acb2 against maincdb29399, @maka/ui unit suite run; Playwright not run; the non-wheel release path is derived from the onScroll control flow, not reproduced in Electron.

AI-assisted review: drafted with Maka; I verified the composer guard, the four selector lists and the merge conflict myself.

简体中文

新 head 只改了 CSS,上轮的 focus/growth 问题在前一个 head 已关。两条合并前要修:E2E 用 composer 做焦点目标,它在 .maka-chat-message-list 之外,onFocusIn 在守卫处就返回了,没测到被要求的那半边,改用 fixture 里可见的工具卡头;边界选择器列表现在有四份,两份已不同步,建议渲染处打 data-* 属性统一。方向问题:释放规则按输入设备开旁路,键盘、拖滚动条等仍会被拽回尾部,且本 PR 放大了这个窗口,建议在 onScroll 里做位移对账,能把生产 diff 减半。rebase 只在 perf spec 与 #4523 有一处机械冲突。

composerRect.bottom > rootRect.top && composerRect.top < rootRect.bottom,
distance: element.scrollHeight - element.scrollTop - element.clientHeight,
};
}, COMPOSER_INPUT);

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.

The composer is in the bottom dock, outside .maka-chat-message-list, so onFocusIn returns at its first guard and none of the new focus logic runs for this test. Focus a visible tool-card header in the fixture instead; that is the case from last round.

const PIN_THRESHOLD_PX = 10;
const BUTTON_THRESHOLD_PX = 100;
const TRANSCRIPT_SELECTOR = '.maka-chat-message-list';
const FOCUS_VISIBILITY_BOUNDARY = [

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.

Copied from chat-message.css across the package boundary, and the two E2E copies already dropped two of these. A data-* attribute where the blocks are rendered lets CSS and this select on one fact.

if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue;
if (target.scrollHeight <= target.clientHeight) continue;
if (event.deltaY < 0 && target.scrollTop > 0) return true;
if (

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.

Unreachable: both callers return on deltaY >= 0 before getting here.

// content movement, so release synchronously while the input ownership
// is still unambiguous. A wheel consumed by a nested scroller is not an
// outer-transcript gesture and leaves the pin untouched.
const onWheel = (event: WheelEvent): void => {

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.

This and focusin are the only two inputs that can release while geometry is moving; keyboard paging, scrollbar drags and scrollIntoView still hit the moved return in onScroll and get pulled back. If onScroll subtracted its own writes for the delivery window, both listeners could go.

@liugddx

Copy link
Copy Markdown
MemberAuthor

Addressed the September 2 review on exact head ffc698b42, merged with current main72eb982d5.

  • The pending-growth E2E now focuses an already-visible .maka-tool-activity-card header inside the transcript, rather than the composer outside .maka-chat-message-list.
  • Renderers now set data-maka-transcript-boundary at answer, Processing, reasoning, tool-card, and linked-agent boundaries. CSS, focus classification, functional E2E, and the perf probe all select that one fact.
  • Added a real PageUp + same-delivery geometry-growth Electron regression. Upward wheel, transcript keyboard navigation (ArrowUp, PageUp, Home, Shift+Space), non-overlay scrollbar interaction, and focus reveal now release while input ownership is known. Nested scrollers retain ownership.
  • I tested the suggested pure onScroll ledger direction. In real streaming E2E, intrinsic geometry correction moved both scrollTop and scrollHeight without reader input and was misclassified as reader movement, leaving the transcript 400+ px from the tail. The final implementation therefore keeps geometry-changing scroll events neutral and handles ambiguous reader inputs synchronously. The normal streaming-tail and nested-scroller regressions pass on this form.
  • Removed the unreachable downward branch, moved the macOS arm64 perf gate to declaration time, and made skippedSegments an assertion.
  • Rebased/merged current main; the perf-spec conflict follows ci: cut single-job validation time by up to 40% #4523's declaration-time skip shape.

Verification on the final tree:

  • npm --workspace @maka/desktop run build:with-deps passed, including renderer entry and third-party notice checks.
  • @maka/ui full suite: 310/310 passed.
  • oversized-Turn Electron suite: 5/5 passed.
  • targeted transcript-scroll regressions for ordinary upward release, nested-scroller consumption, and the history boundary: 3/3 passed.
  • UI and Desktop typechecks, changed-file Biome, and git diff --check passed.

The required hosted test check is currently running. @Astro-Han, please re-review this head when it completes.

@Astro-HanAstro-Han 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.

Good round. The boundary is now one fact (data-maka-transcript-boundary on five render sites, one CSS rule, one selector in the authority and both specs), the unreachable deltaY > 0 branch is gone, the perf spec skips at declaration time and asserts skippedSegments, and merge-tree against main is clean. @maka/ui 315/315, desktop typecheck, lint, format and the architecture check pass locally. Your case against the onScroll ledger, that intrinsic geometry corrections would read as reader movement, is concrete and I accept it.

What I cannot accept yet is the test evidence for the focus subsystem. Last round's two unit tests were the ones where closest() and contains() always returned true; this round deletes them and adds nothing in their place, and the new E2E (visible transcript focus during pending growth keeps the live tail) goes focus, blur, growth, focus on the same header. Blur to body carries no in-transcript relatedTarget, so incomingFocus is undefined at the second focus, readerMoved is false, and the handler does not release. Without onFocusIn and onFocusOut at all the pin also stays. Removing the whole focus block (about 28 lines) leaves every test green. The regression that would prove the early return is a Tab from one visible transcript control to the next visible one, so focusout carries a real relatedTarget, with growth appended in the same task.

The PageUp regression has a related problem: it sets tabIndex = -1 on the scroller and focuses it, which takes the event.target === target branch. The production scroller has no tabindex, so the only reachable path is focus on a card header inside the list, the way the Shift+Tab test already does it. Same fix.

Not blocking, but please say two things in the body. The keydown and pointerdown listeners make release an explicit enumeration of input devices, which is outside #4269 and leaves touch drag still pulled back by onScroll, a pre-existing behaviour whose window this PR widens. And the overscrollBehaviorY check changes history paging on pannable mermaid viewports: scrolling up at the top no longer reaches requestEarlier(). Correct, but undeclared and untested.

Two lines to drop: || target in focusEventRoot has no consumer now that the fake-DOM tests are gone, and nestedScrollerConsumesWheel only forwards composedPath(). And onWheel and onKeyDown still walk getComputedStyle over the composed path after the pin is already released; an early if (!pinned) return saves a forced style recalc per wheel event in exactly the gesture the perf gate measures.

Evidence boundary: static read of ffc698b4 against main92fa5281; @maka/ui unit tests, desktop typecheck, lint, format and architecture check run locally; Playwright not run, the focus reasoning is from the handler control flow, not an Electron run.

AI-assisted review: drafted with Maka; I verified the boundary attribute, the E2E focus sequence and the PageUp focus target myself.

简体中文

这轮边界收敛成一个 data 属性、死分支删了、perf spec 声明期 skip 并断言,merge 干净,本地全绿;对 ledger 方案的反驳有具体依据,我接受。还差的是 focus 子系统的测试证据:上轮两个假 DOM 单测删了没有替代,新 E2E 的 focus→blur→growth→focus 序列在有无监听器时结果一样,整块删掉测试仍绿。需要一条 Tab 到下一个可见控件(带真实 relatedTarget)并在同一 task 追加 growth 的回归。PageUp 回归给 scroller 设 tabIndex=-1 走的是生产不可达分支,改成聚焦卡片 header。非阻塞:正文声明按输入设备枚举释放(触屏拖动仍被拽回)和 mermaid 可平移视口的历史分页变化;两处一行可删;释放后 wheel/keydown 早返回省一次 style recalc。

await root.evaluate((element) => {
const list = element.querySelector('.maka-chat-message-list');
if (!list) throw new Error('the transcript content box is missing');
element.tabIndex = -1;

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.

The production scroller has no tabindex, so event.target === target is unreachable there. Focus a visible card header as the Shift+Tab test does; that exercises the closest() branch users actually hit.

next.closest(TRANSCRIPT_SELECTOR) !== null && isOutsideViewport(next),
};
};
const focusEventRoot = target.ownerDocument || target;

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.

|| target existed for the fake-DOM tests that this round deleted; nothing consumes it now.

…rection
- oversized-turn PageUp regression now focuses a visible tool-card header
inside the list, exercising the `closest('.maka-chat-message-list')` branch
users actually hit; the production scroller carries no tabindex, so the old
`event.target === root` path was unreachable.
- inline `nestedScrollerConsumesUpwardInput` at both wheel call sites and drop
the `nestedScrollerConsumesWheel` forwarder that only wrapped `composedPath()`;
drop the `|| target` fallback in `focusEventRoot` that only the removed
fake-DOM tests needed.
- return early from `onWheel`/`onKeyDown` once released, skipping the
composed-path `getComputedStyle` walk in the exact gesture the perf gate
measures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
liugddxand others added 7 commits September 4, 2026 07:08
…tion
Reshape the oversized-turn fix to the guard change apache#4269 actually needs,
per maintainer review. The regression is that a `moved` scroll event —
geometry changed since the last one — refused to touch the pin, so under a
patch storm the position-based release never ran and the ResizeObserver
re-pinned the reader to the tail every frame ("grabs the scrollbar").
Leaving the tail is monotonic and readable from position alone, so a moved
event now releases the pin when distance > PIN_THRESHOLD_PX; only re-pinning
still needs the stable-geometry path or an explicit pinToTail(). This drops
the wheel/keydown/pointerdown/focusin/focusout enumeration the previous shape
added (which could not close touch-drag or macOS overlay scrollbars, and left
two wheel listeners on the scroller), and restores the by-construction header.
Keep the sub-turn content-visibility containment and its boundary markers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ance
CI showed the pure-distance release regressed two base contracts in
transcript-scroll.spec.ts (content-grows-is-followed, gesture-nested-consumed
keeps-tail): benign growth fires a moved scroll event whose distance to the
now-larger tail exceeds the threshold before writeToTail catches up, and
releasing on distance alone dropped the follow the reader never left.
Distance cannot separate growth from the reader, but direction can. This
authority only ever writes the offset toward the tail, so an offset now above
its last write is the reader having moved up — the one thing growth alone never
produces. Release the pin only for a moved event whose offset is above
lastWrittenTop (and past PIN_THRESHOLD_PX); growth at or past the last write
keeps the pin. This still fixesapache#4269 (an upward gesture that also grows
scrollHeight is read by direction, not refused) without releasing on growth.
Restores the base "growth that outruns the write" unit contract and adds the
upward-under-churn release case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	apps/desktop/e2e/fixtures.ts

@Astro-HanAstro-Han 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.

c3ad91387 is the right predicate. Distance cannot separate growth from the reader; direction can, and the property you wrote down — this authority only ever writes toward the tail, so an offset above its last write is the reader — is what makes it safe. Verified at e0a96652d: all 10 transcript-scroll-authority tests pass, including the two contracts you restored.

The rest of this is one point, plus the smaller things that follow from it.

1. #4206's gates do not cover what this PR changes, and this PR has to bring its own

#4206 landed the same class of change and set the bar for it: a 7-pair interleaved A/B on production builds, 7/7 wins with median/P95/P99/IQR, and — the part that matters here — predeclared secondary gates, with scroll anchoring named among them and shown not to regress.

This PR uses #4206's harness (native-transcript-perf.spec.ts, which #4206 created) but not its gate discipline. The new case asserts three things: LoAF is supported, loafOver50Ms === 0, skippedSegments > 0. There is no secondary gate at all.

That gap is not hypothetical. [measured] One upward traversal of your own fixture moves scrollHeight from 29,044 to 47,283 — +63%. Same traversal with inner boundaries disabled drifts 0. What the reader sees is the scrollbar thumb shrinking and jumping for the whole gesture.

Three reasons the existing gates let this through, and all three say the gate has to come from this PR:

  • #4206's anchoring gate is turn-level by construction.transcript-scroll.spec.ts asserted Math.abs(turnTop(anchor) - anchorTop) <= 4 for growth arriving below a stationary reader. This PR moves the boundary inside the Turn, so the anchored Turn's own height now changes while the reader scrolls up through it. Under #4206 that could not happen — one boundary per Turn, auto 280px — so nothing ever asserted on it.
  • The file that held those gates was deleted at 08:23 today by #4741, 28 minutes before c3ad91387. Your 07:59 predicate was caught by exactly two of its cases (content that grows outside the turn wrappers is followed too, a gesture a nested scroller consumed does not release the tail) — the gate worked, and then the 09:24 merge removed it from this branch. The Verification section still lists it as passing coverage.
  • The perf spec never runs in CI.MAKA_TRANSCRIPT_PERF appears nowhere under .github/. #4206's A/B lived in its PR body, not in code, so the only durable gates were the e2e ones that are now gone.

I do not think the answer is to correct the two estimates. [measured] They are 10× off in opposite directions — default gives assistant answers 96px against a real 1022px, large gives collapsed tool cards 320px against a real 24–32px, and the classification is inverted (Astryx renders tool detail as {isDetailOpen && (…)}, so collapsed it is not in the DOM at all). Better constants would shrink the drift, but they would still be four guesses where #4206 had one measured-harmless guess at a coarser grain, and the PR still would not be able to say the geometry is stable.

What I would want instead is the assertion: geometry does not drift materially across the traversal. The harness already computes distance = scrollHeight - clientHeight to size the gesture and then never looks at it again — sampling it at both ends is a couple of lines. How you then make that assertion pass is yours to choose; #2237 solved the same symptom ("the document grows chunk by chunk and the scrollbar keeps moving") with a measured turn-size-index rather than constants, before #2975 removed it, so the constants-free route has a precedent here too.

2. The win is real and bigger than the body claims

[measured] Same build, same fixture, same gesture; only variable is whether inner boundaries are active. Control arm overrides .maka-chat-message-list [data-maka-transcript-boundary] { content-visibility: visible }. Fixed gesture delta for both arms — at the tail scrollHeight - clientHeight reads 28,384 with boundaries on vs 51,116 off, so sizing the gesture from it would make the arms travel different distances. 480 frames × 47,000px.

ONOFF (≈main)
TaskDuration1108 ms8117 ms7.3×
RecalcStyleDuration304 ms3729 ms12.3×
LoAF > 50 ms024
worst frame25.1 ms358.4 ms14×

Single-armed, the body only supports "the post-change value is under a threshold", which badly undersells this. #4206's interleaved form would state it properly.

One wording note: loafOver50Ms: frames.loafDurations.filter((duration) => duration > 50).length — LoAF only emits entries above 50 ms, so the filter is near-tautological and the assertion is really "zero long animation frames". That is a fine gate; "explicitly calibrated" is not what it is. The Xvfb question is answered now, and the comment at native-transcript-perf.spec.ts:27 is enough.

3. The fixture does not contain the reported case

oversizedTurnMessages emits only user, assistant text, tool_call, tool_result — no reasoning records at all. #4256 reports ~98.8k reasoning characters, and reasoning is the opposite of tool detail: astryx-chat-reasoning.tsx keeps {children} mounted and only swaps the wrapper class, so a collapsed body stays in the DOM and keeps costing layout. The .maka-deep-thinking boundary — plausibly the largest win here — is never exercised.

Also toolOutput = 'synthetic output line\n'.repeat(600) × 48: most of the fixture's 736,799 characters is collapsed tool stdout that never enters the DOM, so "exceeds the 512 KiB budget" is calibrated largely on free bytes.

4. The body no longer matches the code

  • "Behaviour notes (September 2 follow-up)" still describes the wheel/keydown/pointerdown enumeration and the touch-drag gap; 496f2014b removed all of it.
  • Verification still lists targeted e2e/transcript-scroll.spec.ts coverage … 3 passed for a file that is no longer in this branch.

The containment is worth landing and the predicate is right, so this is not a request to change the approach. It is that a perf change of this shape has, in this repo, come with a declared no-regression gate, and the one dimension this PR newly destabilises is the one no existing gate covers. §4 is a quick fix; §1 is the one I would want settled before merge.

@liugddx

Copy link
Copy Markdown
MemberAuthor

@Astro-Han thanks, and agreed on the predicate — direction, not distance, is what makes the release safe.

Since your review at e0a96652d:

  • The body now matches the code. Removed the September-2 "input-device enumeration" note and the touch-drag / mermaid overscrollBehaviorY paragraphs — all of that went with 496f2014b, so there is no longer a wheel/keydown/pointerdown/focus listener to describe. Dropped the stale transcript-scroll.spec.ts line from Verification (removed by test(desktop): remove the layout-tier E2E specs that block main #4741). Fixed the loafOver50Ms wording: LoAF only emits entries over 50 ms, so it is "zero long-animation-frames", not a separately calibrated bound.
  • Merged latest main; the only conflict was the two fixture entries. The hosted test job is green on the merged head, and the 10 transcript-scroll-authority tests pass — including the "growth that outruns the write" contract I restored and the new upward-under-changing-geometry release.

On §1 (the geometry-drift gate) you're right, and I don't want to hand-wave it. The honest constraint is that both the drift and any fix for it are only observable on native macOS arm64: the probe is nativePerformanceTest, and Linux/CI skips it, so I cannot validate a stabilisation mechanism from CI. The right shape is what you describe — sample scrollHeight - clientHeight at both ends of the traversal and assert no material drift, backed by a per-instance measured intrinsic-size (your #2237turn-size-index precedent) rather than the current per-type estimates — and it needs your environment to confirm the drift actually lands under the gate. The same work should extend the fixture with reasoning records so the .maka-deep-thinking boundary (§3) is exercised, since that changes what the gate measures.

Given the containment win is real and independent (your A/B: 7.3× TaskDuration, 12.3× RecalcStyle, 0 vs 24 LoAF > 50 ms) and you noted this isn't a request to change the approach: would you be open to landing the containment + direction-based release now, with the geometry-drift gate + measured intrinsic-size + reasoning fixture tracked as a scoped follow-up (I'll file it and reference the #2237 / #4206 gate discipline)? If you'd rather it stay in this PR, I'll build the measured mechanism and lean on your macOS run to confirm the assertion, since CI can't.

Astro-Han added a commit that referenced this pull request Sep 4, 2026
The rebuild in the previous commit is a verbatim port, and #4761 warns that
porting the eleven tests as they stood reproduces a blind spot: every one of
them watches content arriving at a reader who stays put. None watches a
reader travelling up through content-visibility placeholders as those
materialise.
Measured, that traversal is not still. A Turn off screen is laid out at
contain-intrinsic-block-size: auto 280px and swaps to its real height on the
way past, so fifteen upward steps drift by up to 163px and the transcript
converges about 8% shorter. That is the cost #4206 accepted rather than a
regression, so the story asserts a bound instead of stillness:
- no single step throws the reader past a whole Turn — 163px against a Turn
of 293px;
- the whole traversal stays within 15%. #4259 moves the boundaries inside
the Turn and was measured there at 63%.
The bound is what #4206 buys: one estimate to correct per Turn, so the
correction scales with Turns crossed rather than with what is inside them.
Also fixes the CI failure the previous commit shipped. StreamingTailFollow
waited out a stream paced by setInterval, which left it on the edge of
waitFor's default one-second window — passing locally, failing on the
runner. The stream is paced by frames now, in step with the per-frame
sampler, and the play function stops it once the growth the assertion needed
is behind it. 3.0s to 2.1s, and 8.9s under an 8x CPU throttle.
Refs #4761.
Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor

The drift gate doesn't need a Mac. native-transcript-perf.spec.ts:25 gates on MAKA_TRANSCRIPT_PERF === '1' — an opt-in env var, not a platform check — and what it genuinely needs a real machine for is frame intervals, LoAF and heap counters over CDP. Sampling scrollHeight - clientHeight at both ends of a traversal is layout, not compositing, so it runs wherever Chromium lays out, CI included. It can be an ordinary gate rather than a probe.

On keeping it in this PR: measured intrinsic sizes don't validate the constants, they delete them. chat-message.css currently carries three first-paint constants — auto 280px at the Turn level from #4206, auto 96px on [data-maka-transcript-boundary], auto 320px on ="large" — and the split is thin, with ="default" at one application site (chat-turn.tsx:1191) against four for ="large". Per-instance measurement removes all three and the default/large distinction with them, leaving a bare marker. As a follow-up, its first job would be undoing part of this PR.

The numbers point the same way: the answer block, the only ="default" site, measures ~1022px against a 96px estimate, and collapsed tool/activity cards measure 24–32px against 320px — inverted, ~10× each way.

One more that subtracts: does the Turn-level content-visibility at chat-message.css:60-61 still earn its place? Your comment on the new rule says the outer boundary stops helping once any part of the Turn is relevant, and a Turn fully out of range has its blocks skipped anyway. If the A/B holds without it, this lands as finer boundaries replacing a coarse one and 280px goes too.

Yes to extending the fixture with reasoning records for .maka-deep-thinking — it changes what the gate measures.

Two of my earlier notes are closed: 496f2014b leaves use-chat-scroll.ts:170 as the only wheel listener, and your LoAF wording is right.

简体中文

漂移 gate 不需要 Mac。native-transcript-perf.spec.ts:25 的门是 MAKA_TRANSCRIPT_PERF === '1',一个 opt-in 环境变量,不是平台判断;它真正需要真机的是帧间隔、LoAF 和走 CDP 的 heap 计数。在遍历两端采样 scrollHeight - clientHeight 是 layout,不是合成,Chromium 在哪儿排版就在哪儿能跑,CI 也能。它可以是一道普通的 gate,不必做成 probe。

关于留在这个 PR:实测 intrinsic size 不是验证常量,是删掉常量。chat-message.css 现在有三个首帧常量——#4206 的 turn 级 auto 280px[data-maka-transcript-boundary]auto 96px="large"auto 320px——而这个区分很薄,="default" 只有一个应用点(chat-turn.tsx:1191),="large" 四个。逐实例测量会把三个常量连同 default/large 的区分一起去掉,只剩一个裸标记。做成 follow-up 的话,它第一件事就是撤销这个 PR 的一部分。

数字也指向同一处:答案块——唯一的 ="default" 应用点——实测约 1022px,估值 96px;折叠态工具/活动卡实测 24–32px,估值 320px。方向相反,各差约 10×。

还有一条能减的:chat-message.css:60-61 的 turn 级 content-visibility 还挣得到位置吗?你给新规则写的注释说了,turn 里任何一部分相关时外层边界就不再帮忙;完全在范围外的 turn,里面的块本来也全被跳过。如果去掉之后 A/B 仍然成立,这个 PR 就是用更细的边界换掉粗的,280px 也一起走。

同意扩展 fixture 加 reasoning 记录覆盖 .maka-deep-thinking——它会改变 gate 量的东西。

我早先的两条已经关掉:496f2014b 之后 use-chat-scroll.ts:170 是唯一的 wheel 监听器;LoAF 那条措辞你改对了。

@liugddx

Copy link
Copy Markdown
MemberAuthor

Right on both counts — the drift sample is layout, so I'll wire it as an ordinary gate that runs in CI, not behind the native probe, and I'll delete the three first-paint constants and the default/large split rather than retune them.

Before I do, one thing I want to get right with you, because it changes what "measured" can deliver here. content-visibility: auto skips layout of content that has never been rendered, so on a cold upward traversal — moveToTail() then scroll up, which is what the probe does and where the +63% shows — the blocks above the reader have no real size to measure yet; their placeholder is whatever we seeded. Recording real heights (your #2237turn-size-index) removes the drift on the second traversal and on revisits, because the sizes were captured on an earlier pass — but #2237 still fell back to an estimate on a first-ever visit, and here the whole oversized Turn is first-seen. So "measured" and "delete the constants" only fully meet on a warmed pass; the cold first scroll-up can only be tightened by a per-instance derived size (from each block's own content), which is still a computed value rather than a measurement.

So, to size the work correctly:

  1. Should the drift gate assert on a cold traversal, or on a warmed one? A warmed pass is driftless with no new code at all — one full traversal lets contain-intrinsic-size: auto remember each block's real size, so the second pass holds. If the gate is warmed, the win is free; if it's cold, it needs derived per-instance sizes.
  2. If cold: do you want per-instance derived intrinsic sizes (computed from content, accepting they are derived not measured), or a recorded size-index seeded from the warmup pass the probe already runs before sampling?
  3. What drift do you count as material — a fraction of clientHeight, an absolute px, a ratio of the traversal distance?

I'll build to whichever you pick; I just don't want to reintroduce a removed 500-line index if a warmed gate is what settles it.

Astro-Han added a commit that referenced this pull request Sep 4, 2026
…#4766)
#4741 deleted `transcript-scroll.spec.ts` (774 lines) and `prompt-rail.spec.ts`
(622) because they asserted scroll offsets and bounding boxes against a
compositor settling on its own schedule, and blocked `main` while doing it.
That was the right call for the required check, and it left transcript
scrolling and the prompt rail with no automated coverage at all — including
the three regressions the rail spec existed for, each of which shipped: the
rail laid out across the whole conversation and scrolled off screen (#2161),
parked under macOS's overlay scrollbar where every tick rendered and none
could be clicked (#2338), and computed to 0x0 bars, invisible in 0.1.9 and
0.1.10 (#2580).
The instability was never in the readings. It was in asserting them under four
Xvfb workers sharing OS focus and throttled frames. These assertions need a
real layout engine; none of them needs Electron. Storybook renders in the same
Chromium, settles per story rather than per application launch, and has
nothing competing for focus.
Rebuilds 18 of the 22 deleted tests as Storybook `play` functions, in eighteen
stories, plus one that is new. No production code changes. Nothing had to be
extracted first: `ChatView` already takes the transcript, the history seam, the
growth signal and the rail's landmark index as props, so a story reaches every
one without a fake backend. The rail's 120-prompt seeded session was not needed
either — the transcript carries the Host's bounded active range and
`transcriptTurnIndex` carries the rest of the landmarks, so the rail gets its
full 64 ticks against 10 mounted Turns, which is what production does.
The nineteenth story is not a restoration. Every deleted test watches content
arriving at a reader who stays put; none watches a reader travelling *up*
through `content-visibility` placeholders as they materialise, which is where
#4259 measured one traversal moving `scrollHeight` by 63%. Measuring first
changed what it could assert: the traversal is not still and cannot be, since a
Turn off screen is laid out at its `contain-intrinsic-block-size` estimate and
swaps to its real height on the way past. So the story bounds it — no single
step past a whole Turn, the whole traversal within 15%, and the reader can
still dock back. That bound is the property #4206 bought: one estimate to
correct per Turn, so the correction scales with Turns crossed rather than with
what is inside them.
Four of the deleted tests are not rebuilt, for three different reasons:
- *a gesture a nested scroller consumed does not release the tail* — a tier
decision. It turns on Chromium's own scroll chaining, which needs real wheel
input. Stays in E2E. The sibling case sinks because its guard reads
`composedPath()` and the overflow of what the wheel crossed, which is DOM
state a dispatched wheel reaches identically.
- *streaming deltas do not reconstruct the prompt rail observer* — uncovered
until it lands as a component test (#4761). It asserts no geometry, so a
layout engine buys it nothing.
- *switching Sessions restores a Turn anchor* and *switching sessions
reconstructs only the Host active range* — uncovered, waiting on #4582. Not a
tier decision: they need shell state `app-shell.tsx` holds, not a real window.
Three ported assertions are not byte-for-byte, and the description lists them
rather than leaving them to be discovered: the accessibility-tree half of the
offscreen-Turn test needs CDP and did not come across; the nested-scroller test
swapped its closing tail-follow check for a direct assertion that no history
was requested; and the rail-currency test asserts at five reading positions
plus a range-replacing jump with a MutationObserver, rather than walking all
120 prompts.
Twelve of the stories were mutation-tested against the real defect they name —
`display: inline` on the tick bar, `position: static` on the rail anchor, both
directions of the tail-follow ResizeObserver branch, dropping the wheel guard,
`contain-intrinsic-block-size: 60px`, `content-visibility: hidden` — and go red
for the right reason. One story was found genuinely flaky under review (4
failures in ~310 runs at 4x CPU throttle) because it read its anchor outside
any retry, on a layout the arriving Turn had not finished; fixed and re-checked
at 0 in 40 runs at 4x and 60 at 8x, still red under mutation.
One change outside the stories: `SMOKE_HEADED=1` on the visual smoke runner.
The #2338 guard is inert headless — headless Chromium paints no platform
scrollbar, and Linux's in-flow one moves the content column left instead of
overlaying it — so "run it locally on macOS" was an instruction that bought
nothing until there was a way to run it headed.
No migration or compatibility impact. Storybook gains eighteen stories that
exist to be asserted against rather than looked at.
Refs #4761.
Generated-by: Claude Code

@Astro-HanAstro-Han 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.

The sub-turn containment in this PR is solid and definitely worth landing. For the #4269 scroll release, I put together a working replacement that avoids the direction predicate entirely. Here is a concrete path forward.

1. Adopt fix/transcript-pin-owns-anchoring for #4269

The direction check misfires because overflow-anchor: auto acts as a competing writer to scrollTop. While pinned, the browser's anchor adjustments are overwritten on the next frame, but their scroll events still fire. The moved guard then swallows the reader’s wheel input along with that synthetic movement, snapping them back to the tail.

The fix is to let the pin own anchoring: turn it off while pinned, and hand it back on release:

  • Pinned: root.style.overflowAnchor = 'none'
  • Released: root.style.overflowAnchor = ''
  • In onScroll: if (moved && !pinned) return;
  • Drop the overflow-anchor CSS rule from apps/desktop/src/renderer/styles/chat-message.css.

Branch fix/transcript-pin-owns-anchoring (commit 49bf059693) implements this and passes the authority unit suite (10/10). Measurements from a real Desktop build on current main (CDP wheel, 40 ticks = 4800px up-scroll during streaming, 6 rounds):

GrowthBuildEscapedFurthest from tailSnap-backs
every 4msmain0/6193px3
every 4ms49bf0596936/610513px0
every 8msmain1/6169px7
every 8ms49bf0596936/69745px0

History paging is completely clean across both builds (7 loads, 6 evictions, 0 jumps >130px). Feel free to cherry-pick that commit directly.

Two existing tests changed with it: both construct "while pinned, something moved scrollTop that this authority did not write", which only anchoring could do, so the input is now unreachable. I replaced them with a #4269 regression test and one asserting the pin owns anchoring. If that input is in fact still reachable while pinned, the whole approach falls over — that is the part to attack.

2. Keep the sub-turn CSS, update the rationale

Keep the 5 marker sites and containment CSS, but reframe the justification in the commit message around hover performance rather than scrolling.

Under production motion (with e2e fixture overrides stripped), scrolling is already cheap. Containment earns its keep on mouse sweeps, where Astryx's 82 :where(parent:hover *) rules invalidate a 15,000-element subtree:

TierUp recalcHover p95Hover LoAF (>50ms)Hover recalc
Containment off2ms58.4 / 58.3ms201226 / 1219ms
Turn-level (main)2ms59.0 / 59.2ms20 / 211264 / 1277ms
Turn + sub-turn (this PR)96 / 90ms9.2 / 9.2ms0102 / 82ms
Sub-turn only108 / 83ms8.9 / 9.2ms090 / 102ms

Because sub-turn only matches Turn + sub-turn across every column, the sub-turn rules should replace #4206's Turn-level rule in chat-message.css rather than stack on it—pending one verification on the 300-small-Turn rail shape.

3. What to drop

  • The direction predicate and its unit test.
  • nativePerformanceTest('oversized single Turn upward scroll metrics'): the ~1900ms recalc was an artifact of the fixture's transition-duration: 0.01ms !important re-serializing * { scrollbar-color: ... }. Under production motion, every tier passes.
  • The two "releases the live tail" E2E specs (move these to the anchoring commit).
  • promptRailMotionWindow (unused in this diff).

Let me know if you see any reachable case where the pin owning anchoring breaks down, or if you'd prefer to adjust this breakdown.

简体中文

这里的子轮次(sub-turn)containment 改动非常扎实,完全值得合并。针对 #4269 的滚动释放问题,我整理了一个现成的替代方案,无需引入方向判断。以下是具体的推进路径。

1. 采纳 fix/transcript-pin-owns-anchoring 解决 #4269

方向判断失效的原因在于 overflow-anchor: auto 成了 scrollTop 的第二个写入者。处于 pinned 状态时,浏览器的每次锚定调整都会在下一帧被 authority 覆盖,但其产生的 scroll 事件依然会触发;moved 防卫逻辑随之把读者的滚轮输入一并吞掉,导致 ResizeObserver 将视口重新吸附到末尾。

正确的解法是让 pin 全权接管锚定:跟随输出时关闭锚定,释放后再交还:

  • Pinned: root.style.overflowAnchor = 'none'
  • Released: root.style.overflowAnchor = ''
  • onScroll 中: if (moved && !pinned) return;
  • 移除 apps/desktop/src/renderer/styles/chat-message.css 中的 overflow-anchor CSS 规则。

分支 fix/transcript-pin-owns-anchoring(commit 49bf059693)已完成该改动并通过 authority 单元测试(10/10)。在当前 main 真实桌面构建下的实测数据(CDP 滚轮,实时输出期间向上滚 40 tick = 4800px,各 6 轮):

增长频率构建成功逃逸距末尾最远距离回跳次数
每 4msmain0/6193px3
每 4ms49bf0596936/610513px0
每 8msmain1/6169px7
每 8ms49bf0596936/69745px0

历史分页在两个构建中均未受影响(7 次加载、6 次逐出、0 次 >130px 跳动)。可直接 cherry-pick 该提交。

它同时改了两个现有测试:两者构造的都是「pinned 时有东西移动了 scrollTop 而不是本 authority 写的」,而这件事只有锚定能做到,所以在新前提下不可达。我把它们换成了一条 #4269 的回归测试和一条断言 pin 拥有锚定的测试。如果那个输入在 pinned 状态下其实仍然可达,整个方案就不成立——这是最该被攻击的一点。

2. 保留子轮次 CSS,调整优化理由

保留 5 处标记点和 containment CSS,但提交信息中的理由建议从“优化滚动”转为“优化 hover”。

在真实运行动效下(剥离 E2E fixture 属性),向上滚动在各级别下的开销都极小。Containment 真正的价值在于鼠标悬停:Astryx 编译出的 82 条 :where(parent:hover *) 规则会导致包含 1.5 万个元素的子树整体失效:

层级上滚 recalcHover p95Hover LoAF (>50ms)Hover recalc
关闭 containment2ms58.4 / 58.3ms201226 / 1219ms
仅 Turn 级 (main)2ms59.0 / 59.2ms20 / 211264 / 1277ms
Turn + 子轮次 (当前 PR)96 / 90ms9.2 / 9.2ms0102 / 82ms
仅子轮次108 / 83ms8.9 / 9.2ms090 / 102ms

由于“仅子轮次”在所有指标上与“Turn + 子轮次”没有差异,子轮次规则应当在 chat-message.css 中直接替换掉 #4206 的 Turn 级规则,而非叠加(落地前需对 300 个小 Turn 的 rail 场景做一次验证)。

3. 需要移除的内容

  • 滚动 authority 中的方向判断逻辑及其单元测试。
  • nativePerformanceTest('oversized single Turn upward scroll metrics'):原先测出的 recalc 激增属于测试 fixture 的副作用(transition-duration: 0.01ms !important 导致频繁重新序列化 * { scrollbar-color: ... }),剥离后所有层级均能通过。
  • 两条“释放实时末尾”的 E2E 用例(迁移至锚定改动的提交中)。
  • 未被使用的 promptRailMotionWindow

如果在你的测试中存在任何能让锚定接管方案失效的边界情况,欢迎随时指出。

@Astro-Han

Copy link
Copy Markdown
Contributor

Small correction to my last comment — I opened the anchoring change as its own PR (#4793) rather than leaving it as a loose branch, since #4560 needs the same onScroll semantics settled first. So there is nothing to cherry-pick: once #4793 lands, just rebase and drop the direction predicate. Everything else in my previous comment stands.

The direction predicate misfired because `overflow-anchor: auto` is a second
writer of `scrollTop`. While pinned, the browser's anchor adjustments are
overwritten on the next frame, but the `scroll` events they emit still fire,
and the `moved` guard swallowed the reader's upward wheel along with that
synthetic movement — snapping them back to the tail.
Let the pin own anchoring instead: `overflow-anchor: none` while following the
tail, handed back on release. With no competing writer, any non-echo scroll
event while pinned is the reader, exactly, so the guard reduces to
`if (moved && !pinned) return;` and the direction comparison is gone. Drop the
static `overflow-anchor` rule from chat-message.css so it cannot fight the
authority. Diagnosis and the measured fix are @Astro-Han's
(fix/transcript-pin-owns-anchoring): 6/6 upward escapes vs 0/6 on main under
4ms/8ms streaming growth, history paging unaffected.
Drop what the anchoring fix makes moot: the two "releases the live tail" E2E
specs (the release path is now the unit-tested anchoring contract), the
`nativePerformanceTest` oversized case whose recalc spike was an e2e-fixture
artifact (`transition-duration: 0.01ms !important`), and the unused
`promptRailMotionWindow`. Sub-turn containment stays — its win is hover, not
scroll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@liugddx

Copy link
Copy Markdown
MemberAuthor

Adopted fix/transcript-pin-owns-anchoring at c033dbb5b.

  • The pin now owns overflow-anchor: none while following the tail, handed back on release; the guard is if (moved && !pinned) return;, and the direction predicate and its unit test are gone. Dropped the static overflow-anchor rule from chat-message.css.
  • Unit suite stays 10/10: kept the bug(ui): upward scroll can snap back to the live tail during geometry changes #4269 release-under-growth regression, added one asserting the pin owns anchoring (off while pinned, back on release, restored on detach). Removed the two now-unreachable "moved scrollTop while pinned" tests.
  • Dropped the two "releases the live tail" E2E specs, the nativePerformanceTest('oversized …') case (the recalc spike was the fixture's transition-duration: 0.01ms !important), and the unused promptRailMotionWindow.
  • Kept the 5 sub-turn markers and containment CSS; reframed the rationale around hover in the commit message and PR body.

On §2's other half — replacing #4206's Turn-level rule with the sub-turn rules rather than stacking — I left that out for now, since it wants the 300-small-Turn rail A/B you flagged and I can't run that here. Happy to fold it in if you'd like it in this PR, or file it as the follow-up. CI is running on c033dbb5b.

liugddxand others added 3 commits September 5, 2026 00:15
Under the pin-owns-anchoring guard, a geometry-changing scroll event while
pinned now falls through to the pin re-evaluation instead of returning early.
That path also notified the reader-scroll listeners, so apache#4766's
`TailFollowDoesNotAskForHistory` story caught a real regression: a short
transcript's tail sits inside the "near the start" band, so a `moved` event
under the pin fired `requestEarlier()` and loaded history while still
following.
Only a stable-geometry scroll is a clean reader signal, so gate the listener
notification on `!moved` — matching the base contract, where the early
`if (moved) return` meant the listeners only ever fired on stable geometry.
Release on an upward reader move (apache#4269) and the anchoring ownership are
unchanged. Adds a unit test locking that a moved event under the pin reaches
no reader-scroll listener.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
apache#4256 containment
main landed a more precise apache#4269 fix (the unexplained-delta guard in
transcript-scroll-authority), so this branch drops its own scroll-authority
change entirely and takes main's file and tests wholesale. What remains is the
sub-turn content-visibility containment for apache#4256.
@liugddx

Copy link
Copy Markdown
MemberAuthor

main has since landed the unexplained-delta #4269 guard in transcript-scroll-authority — a cleaner separation of reader vs content-driven offset moves than either the direction predicate or pin-owns-anchoring. So I dropped this branch's scroll-authority change entirely: the merge takes main's transcript-scroll-authority.tsx and its tests wholesale (13/13), and restores the overflow-anchor: auto rule its approach relies on.

#4259 is now just the sub-turn containment for #4256 — the 5 data-maka-transcript-boundary markers and the CSS — reframed around hover per your §2. Net diff vs main is 9 files, +208/-4, no scroll-authority code. The oversized-Turn content-visibility skip stays as oversized-turn-render.spec.ts; if you'd rather it live as an app-shell story alongside #4766's transcript coverage, say so and I'll move it. CI is running on 54fa258e.

@github-actionsgithub-actionsBot added effort/M Under 500 readable lines and removed effort/L Under 1000 readable lines labels Sep 5, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(ui): oversized single Turn defeats transcript render bounds

3 participants

@liugddx@Astro-Han@hqhq1025