Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ the previous hook reveals while held; the fixed hook stays hidden and opens at
the measured tail after release, including remount. Unit coverage retains cached
reading positions, user escape, composer resize suppression and row growth.

A later defect could leave that hidden viewport stuck after destination rows
were already measured. First end-restore is not the follow lock; see
[initial conversation reveal](../bug-fix/2026-09-18-conversation-initial-reveal-follow-lock.md).

## Cold virtualizer blank on open

Waiting for the measured destination hides the viewport, and a virtualizer that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ Virtua 已挂载、测量目标行;此时显示视口会暴露空白或中间
已经显示,修复后保持隐藏,放行测量后直接显示末尾;重新挂载也覆盖。
单元回归保留历史阅读位置、用户脱离吸附、输入框高度抑制和行高增长检查。

后续有缺陷会在目标行已经测量之后仍让隐藏视口卡住。首次滚到底不是跟随锁;
见 [会话首次显示](../bug-fix/2026-09-18-conversation-initial-reveal-follow-lock.zh.md)。

## 冷虚拟器导致的打开空白

等待目标行测量会隐藏视口,而从未测量过该会话的虚拟器需要多次提交才能到达
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,7 @@ a resize delivery landing between a user's scroll-to-bottom re-lock and its
commit can release that intent; the reader scrolls once more and re-locks. No
timers or frame retries were added, per the scrolling invariants in
`packages/components/src/hooks/AGENTS.md`.

Initial end-restore is not this follow lock: observers may keep correcting to
the real bottom until the conversation is revealed. See
[initial conversation reveal](2026-09-18-conversation-initial-reveal-follow-lock.md).
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,6 @@ isNearBottom`)。虚拟列表 spacer 抖动或折叠都可能产生这种收
resize 回调恰好落在用户滚到底部的重新上锁与其提交之间,该意图会被释放;
读者再滚动一次即可重新上锁。按 `packages/components/src/hooks/AGENTS.md`
的滚动不变量,未引入任何定时器或逐帧重试。

首次滚到底不是这条跟随锁:在会话显示之前,观察者可以继续纠正到真正底部。
见 [会话首次显示](2026-09-18-conversation-initial-reveal-follow-lock.zh.md)。
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Initial conversation reveal is not the follow lock

Status: implemented
Translation: current

[中文](2026-09-18-conversation-initial-reveal-follow-lock.zh.md)

## Abstract

Opening a Chat Session could leave the conversation pane permanently black while
the message DOM was fully present. The viewport stays `visibility: hidden` until
`initialScrollRestored`, and that flag never flipped: the ready check used the
scroll library's ~70px near-bottom lock as "following" and required the last
row's box to sit within 2px of the viewport bottom, while the follow-lock fix
for group toggles stopped observers from correcting to the real bottom until a
commit snapshot said the reader was already following. First end-restore is now
one-time positioning, not follow-lock. The ready check uses restore intent and
viewport distance from the DOM bottom. After reveal, observers still must not
re-arm follow.

## Root cause

`SessionChatStreamView` hides `[data-message-selection-scroll]` until
`initialWindowReady && initialScrollRestored`. `settleInitialLayout` called
`isInitialScrollLayoutReady(..., state.isAtBottom)`. `state.isAtBottom` is the
library follow lock and includes ~70px of near-bottom tolerance, but the ready
function treated it as "flush with the last row" and compared
`getBoundingClientRect` of that row to `clientHeight - paddingBottom` within
2px. A scroller that is itself `visibility: hidden` with `contain: strict` can
make descendant boxes a bad flush signal; a restore that lands inside the 70px
band also fails the 2px test.

Independently, [worked-group follow-lock](2026-09-17-worked-group-toggle-scroll-jump.md)
made geometry observers call `stopScroll()` and skip `scrollToRealBottom()` when
`wasFollowingRef` was false. That is the right rule after reveal. During the
first end-restore it undoes the one-time positioning the 2px check still needed,
and hooks rules forbid a settle timer, so the pane stayed hidden.

The visibility gate itself stays. Ablation in
[windowed conversation reads](../architecture/2026-09-10-windowed-reader-integration.md)
showed that removing it exposes an empty or uncorrected window on a cold
virtualizer.

## Contract

- Until `initialScrollRestored`, an end restore (cached position is not
`offset`) may keep writing the real DOM bottom from observer and layout
paths while the lock has never been held (`hadFollowedRef` is false) or
the commit snapshot is still following. A reader who already followed and
then escaped is not pulled back, even while the viewport is hidden.
Geometry observers suppress `stopScroll()` only for that first end-restore.
Offset restore still releases a same-delivery re-lock before reveal, so a
shrink into the near-bottom band cannot pull the cached reading position to
the end or rewrite the cache as `end`.
- Ready uses restore intent, not `state.isAtBottom`. Offset restores never
take the flush-to-bottom branch. End restores require
`getScrollElementDistanceFromBottom <= 2` and a mounted destination row
that is not Virtua-unmeasured (`style.visibility === 'hidden'`).
- Visible-bottom item lookup subtracts both viewport paddings (or equivalent
Virtua item-offset space). The scroller's `padding-top` includes
`--conversation-top-inset`.
- After reveal, the group-toggle contract is unchanged: observers must not
re-arm follow; only scroll events and explicit `scrollToBottom` may.

## Alternatives

A timeout or animation-frame retry to force `visible` was rejected: scrolling
invariants forbid settle timers, and it would only delay the same flash the
gate exists to hide.

Removing `visibility: hidden` was rejected by the windowed-reader ablation.

Using `opacity: 0` instead of `visibility` was rejected: the same stuck
predicate would still never flip, and the subtree would remain in the
accessibility tree.

Reverting the group-toggle follow-lock fix was rejected: expanding a finished
"Worked for …" header would again yank a reader to the session end.

## Evidence and verification

`packages/components/tests/use-sticky-scroll.test.ts` covers the ready
function (end restore with a last-row box 50px off still reveals when
`scrollTop` is at the DOM bottom; near-bottom offset restore reveals without
a 2px flush; unmeasured destination stays hidden; visible-bottom lookup
subtracts top and bottom padding) and the hook (same two restore cases; an
unrestored offset restore stays at the cached offset through a shrink into
the near-bottom band, and the cache is not rewritten as `end`).
`packages/components/tests/sticky-scroll-virtua.test.tsx` still asserts that
a collapse shrink cannot re-arm follow for an escaped reader after reveal,
and that a following reader stays at the end.

No timer was added. Device-scale cold-open flash remains the capture in
`e2e/scripts/capture-conversation-open-flicker.mjs`; this change does not
re-measure it. Shipped in https://github.com/LodyAI/Lody/pull/803.

## Trade-offs and limits

An end restore can still sit hidden while the last row is unmeasured, which
is the original empty-window protection. If Virtua's `scrollOffset` and the
DOM `scrollTop` disagree by more than 1px, reveal still waits. A reader who
restored to an offset inside the library's near-bottom band is shown at that
offset rather than flushed to the end; they can scroll to re-lock follow.
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 会话首次显示不是跟随锁

Status: implemented
Translation: current

[English](2026-09-18-conversation-initial-reveal-follow-lock.md)

## 摘要

打开 Chat Session 时,消息 DOM 已经齐全,对话区域却可能一直纯黑。视口在
`initialScrollRestored` 之前保持 `visibility: hidden`,而这个标志可能永远
不会翻开:就绪判定把滚动库大约 70px 的近底锁当成「跟底」,并要求最后一行
的盒子贴住视口底边(误差 ≤ 2px);同时为折叠 “Worked for …” 做的跟随锁修复
让观察者在提交快照显示未跟随时不再把列表纠正到真正底部。首次滚到底现在是
一次性定位,不是跟随锁。就绪判定改用恢复意图和视口距 DOM 底部的距离。
显示之后,观察者仍然不能重新武装跟随。

## 根因

`SessionChatStreamView` 在 `initialWindowReady && initialScrollRestored` 之前
把 `[data-message-selection-scroll]` 设为 `visibility: hidden`。
`settleInitialLayout` 调用 `isInitialScrollLayoutReady(..., state.isAtBottom)`。
`state.isAtBottom` 是库的跟随锁,包含约 70px 近底容忍,就绪函数却把它当成
「最后一行已经贴底」,并用该行的 `getBoundingClientRect` 去对齐
`clientHeight - paddingBottom`,误差必须 ≤ 2px。滚动容器自身在显示前就是
`visibility: hidden` 且 `contain: strict`,子孙盒子不能当贴底信号;落在 70px
带内的恢复也会过不了 2px 检查。

另一方面,
[worked-group 跟随锁](2026-09-17-worked-group-toggle-scroll-jump.zh.md)
让几何观察者在 `wasFollowingRef` 为 false 时 `stopScroll()` 并跳过
`scrollToRealBottom()`。这在显示之后是对的。首次滚到底时,它会撤掉 2px
检查仍需要的一次性定位;hooks 规则又禁止 settle timer,于是面板一直隐藏。

可见性门本身保留。
[窗口读取对话历史](../architecture/2026-09-10-windowed-reader-integration.zh.md)
里的消融表明,拆掉它会在冷虚拟器上露出空窗或未修正的滚动位置。

## 契约

- 在 `initialScrollRestored` 之前,末尾恢复(缓存位置不是 `offset`)可以在
跟随锁从未持有(`hadFollowedRef` 为 false)或提交快照仍在跟随时,从观察者
和 layout 路径继续写入真正的 DOM 底部。已经跟随过再逃逸的读者即使视口仍
隐藏也不会被拉回去。几何观察者只在这次首次末尾恢复时抑制 `stopScroll()`。
offset 恢复在显示前仍会解除同批次重新上锁,避免内容收缩进入近底带时把缓存
阅读位置拽到底部,或把缓存改写成 `end`。
- 就绪用恢复意图,不用 `state.isAtBottom`。offset 恢复绝不走贴底分支。
末尾恢复要求 `getScrollElementDistanceFromBottom <= 2`,且目标行已挂载、
不是 Virtua 未测量状态(`style.visibility === 'hidden'`)。
- 可见底边的条目查找要减去视口上下 padding(或等价的 Virtua item-offset
空间)。滚动容器的 `padding-top` 包含 `--conversation-top-inset`。
- 显示之后,折叠切换的契约不变:观察者不能重新武装跟随;只有滚动事件和
显式 `scrollToBottom` 可以。

## 备选

用超时或 animation frame 强制 `visible` 被拒绝:滚动不变量禁止 settle
timer,也只是把闪一下换成晚一点闪。

拆掉 `visibility: hidden` 被窗口读取消融拒绝。

改成 `opacity: 0` 被拒绝:同一条卡住的谓词仍然不会翻开,子树还会留在
无障碍树里。

回滚折叠跟随锁修复被拒绝:展开已完成的 “Worked for …” 头会再次把读者
拽到会话末尾。

## 证据与验证

`packages/components/tests/use-sticky-scroll.test.ts` 覆盖就绪函数(末尾
恢复时最后一行盒子偏离 50px,只要 `scrollTop` 已在 DOM 底部就显示;近底
offset 恢复不必 2px 贴底;未测量目标行保持隐藏;可见底边查找减去上下
padding)以及 hook(同样两种恢复;未显示的 offset 恢复在收缩进入近底带后
仍停在缓存偏移,缓存不会被改写成 `end`)。
`packages/components/tests/sticky-scroll-virtua.test.tsx` 仍断言显示之后
折叠收缩不能为已脱离的读者重新上锁,跟随中的读者仍停在末尾。

没有加入 timer。设备级冷开闪屏仍以
`e2e/scripts/capture-conversation-open-flicker.mjs` 为准;本次未重测。
随 https://github.com/LodyAI/Lody/pull/803 提交。

## 取舍与限制

末尾恢复在最后一行尚未测量时仍会隐藏,这是原来的空窗保护。若 Virtua 的
`scrollOffset` 与 DOM `scrollTop` 相差超过 1px,显示仍会等待。恢复到库
近底带内某个 offset 的读者会停在该 offset,而不是被贴到末尾;他们可以
再滚动以重新锁定跟随。
31 changes: 16 additions & 15 deletions packages/components/src/hooks/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ Parent AGENTS apply. Edit `AGENTS.md`, not its `CLAUDE.md` symlink. Background:

## Conversation scrolling

- Reveal only after the first window AND its destination rows are measured and
positioned by Virtua; a DOM scroll write alone is not readiness. Transient
visible-range reports must not redirect the initial lease. Later loads never hide the view. Restore before paint;
- Reveal only after the first window AND destination rows are measured and
positioned by Virtua; a DOM scroll write is not readiness. Until reveal,
end-restore keeps correcting to the bottom even if the commit snapshot is
not-following. Ready uses restore intent and viewport distance, not the
near-bottom lock. Transient visible-range reports must not redirect the
initial lease. Later loads never hide the view. Restore before paint;
hydration re-anchors only while following, using DOM extent, not evictable indices.
- Correct content measurements in ResizeObserver before paint, even with unchanged
row counts; no RAF deferral. Correct Virtua spacer-height commits in MutationObserver
before deferred resize delivery. Observe spacer height and mounted row geometry
(which may overflow it), never message subtrees/text or scroll pointer styles. Respect the live follow
lock and explicit jump suppression.
- Correct content measurements in ResizeObserver before paint; no RAF deferral.
Correct Virtua spacer-height commits in MutationObserver before deferred resize
delivery. Observe spacer height and mounted row geometry, never message
subtrees/text or scroll pointer styles.
- Virtua owns rows, measurement and index navigation; `use-sticky-scroll.ts` adapts
`use-stick-to-bottom` to its viewport/content. No content-token effects or upward
distance thresholds: real upward wheel, touch, selection or scrollbar movement
Expand All @@ -23,15 +25,14 @@ Parent AGENTS apply. Edit `AGENTS.md`, not its `CLAUDE.md` symlink. Background:
- Follow-lock truth is `state.isAtBottom`: the returned `isAtBottom` includes tolerance;
`escapedFromLock` records escape history and survives explicit re-locking.
- Handle viewport HEIGHT changes through ResizeObserver; ignore width-only records.
No resize-event pumps, guessed transition durations or stop timers. Before a composer
inline-height write, set a one-shot ref consumed only by the next viewport height
No resize-event pumps, guessed durations or stop timers. Before a composer
inline-height write, set a one-shot ref consumed by the next viewport height
resize, without `scrollToRealBottom`; keep it separate from jump suppression.
- Group toggles never scroll. Observer deliveries must never re-arm the follow
lock — only scroll events may — so the content ResizeObserver releases a
same-delivery re-lock while the commit-time snapshot says not-following. No
frame retries/settle timers.
- Preserve per-session restoration, search/expansion suppression and viewport resizing
for keyboards and terminal docks.
lock — only scroll events may — except the first end-restore. Offset restore
still releases a same-delivery re-lock while not-following. No frame
retries/settle timers.
- Preserve per-session restoration, search/expansion suppression and viewport resizing.

## Session, auth, and app shell

Expand Down
23 changes: 16 additions & 7 deletions packages/components/src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,22 @@ bottom correction and visibly jitters the conversation.

First-window data readiness does not imply viewport readiness. A DOM `scrollTop`
write can reach the estimated bottom while Virtua still has no destination rows,
or has hidden unmeasured rows. Initial reveal waits for the virtualizer's offset,
measured destination and visible-row geometry to agree. Direct row ResizeObserver
records and spacer/row geometry commits drive this check without a settle timer.
Those row records also correct following before the spacer's deferred resize;
programmatic corrections use the library's scroll setter to preserve user-intent
tracking. Only mounted rows are observed, and normal window loads never hide a
previously revealed conversation.
or has hidden unmeasured rows. Initial reveal waits for the virtualizer's offset
and a measured destination row. End-restore intent uses viewport distance from
the DOM bottom, not last-row `getBoundingClientRect` and not the library's
near-bottom lock (`state.isAtBottom` includes ~70px of tolerance). Until that
reveal, observers keep correcting an end restore to the real bottom even when
the commit snapshot is not-following — first positioning is not the follow lock
— and they skip `stopScroll()` only for that end restore. An offset restore
still releases a same-delivery re-lock, so a shrink into the near-bottom band
cannot pull the cached reading position to the end. After reveal, observers
must not re-arm follow. Direct row
ResizeObserver records and spacer/row geometry commits drive this check without
a settle timer, including when the row count is unchanged. Those row records also correct following before the spacer's
deferred resize; programmatic corrections use the library's scroll setter to
preserve user-intent tracking. Only mounted rows are observed, and normal window
loads never hide a previously revealed conversation. See
[the stuck-hidden reveal note](../../../../.agents/notes/implemented/bug-fix/2026-09-18-conversation-initial-reveal-follow-lock.md).

The composer one-shot ref preserves the reader's position while typing without
changing keyboard, terminal, or window-resize follow behavior, which is why it is
Expand Down
Loading
Loading