fix(ui-chat): release bottom ownership when a jump starts

Clicking an unloaded rail mark from the pinned tail raced the pinned
scroll snap: the first prepend's compensation fires a non-reader scroll
delivery, the snap called toBottom, and toBottom cancels a pending jump
— so the jump silently stayed at the tail while history loaded. The
click now drops atBottom itself (jumping into history is leaving the
live tail), pinned by a jsdom regression and re-verified live: a
118-turn session lands on turn 1 in ~250ms from click.
This commit is contained in:
Yichen Jiang 2026-08-30 13:47:00 +08:00 • committed by imccyu
parent 422b603874
commit 62f707bb1d
5 changed files with 37 additions and 4 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
2026-08-30-web-turn-rail-outline-jump.md: 4984cd1325d3e44a9114b36b3245b98a34c92e99
2026-08-30-web-turn-rail-outline-jump.zh.md: 8a27d72493c49989d33f0e7df786ff1129e66dec
2026-08-30-web-turn-rail-outline-jump.md: eb4e7f09abd66135d0aef1175729e9493b9b6db2
2026-08-30-web-turn-rail-outline-jump.zh.md: 6851456667d0b9f6d565b941189385b7392a5503

View file

@ -16,7 +16,7 @@ Three cooperating pieces, each useful alone.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
**View: merge, jump, and the fixed-pitch rail.** `mergeTurnRailItems` (ui-chat, view layer — the conversation snapshot still never carries projection values) unions the outline with the loaded rail items into `TurnRailItem`s discriminated by `anchor: loaded(key) | unloaded(seq)`; loaded wins per turn, and the outline prompt fills a mid-turn window head's empty preview. Activating an unloaded mark holds the reader's place with the existing paging anchor, calls `loadThrough`, and lands after React commits — no height estimation: a mid-paging landing pins the target row as the paging anchor so later chunks and the `Load earlier` button's unmount cannot drift it, and the loader's completion runs one final correction unless the reader already scrolled off the target (settlement otherwise repages once per head movement, then falls back to the nearest rendered turn). The rail itself keeps a fixed 10px pitch: the ladder scrolls inside the old frame geometry behind a hidden scrollbar, gradient fades mark each still-scrollable end, the hover preview compensates the rail scroll, and the active mark keeps itself centred while the pointer is off the rail. Unloaded marks render short and dimmed with a `Load and jump to turn N` label and pulse while their jump pages.
**View: merge, jump, and the fixed-pitch rail.** `mergeTurnRailItems` (ui-chat, view layer — the conversation snapshot still never carries projection values) unions the outline with the loaded rail items into `TurnRailItem`s discriminated by `anchor: loaded(key) | unloaded(seq)`; loaded wins per turn, and the outline prompt fills a mid-turn window head's empty preview. Activating an unloaded mark releases bottom ownership on the click itself (jumping into history is leaving the live tail; otherwise the pinned-scroll snap racing the first prepend's compensation would call `toBottom` and cancel the jump), holds the reader's place with the existing paging anchor, calls `loadThrough`, and lands after React commits — no height estimation: a mid-paging landing pins the target row as the paging anchor so later chunks and the `Load earlier` button's unmount cannot drift it, and the loader's completion runs one final correction unless the reader already scrolled off the target (settlement otherwise repages once per head movement, then falls back to the nearest rendered turn). The rail itself keeps a fixed 10px pitch: the ladder scrolls inside the old frame geometry behind a hidden scrollbar, gradient fades mark each still-scrollable end, the hover preview compensates the rail scroll, and the active mark keeps itself centred while the pointer is off the rail. Unloaded marks render short and dimmed with a `Load and jump to turn N` label and pulse while their jump pages.
## Alternatives considered

View file

@ -16,7 +16,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
**视图:合并、跳转与固定间距导航栏。** `mergeTurnRailItems`(ui-chat,视图层——会话快照仍不携带投影值)把大纲与已加载条目并成以 `anchor: loaded(key) | unloaded(seq)` 判别的 `TurnRailItem`;同轮已加载者优先,大纲提示词填补窗口头部半轮的空预览。激活未加载刻度先用现有分页锚点稳住读者位置、调用 `loadThrough`、在 React 提交后落点——不做任何高度估算:分页中途的落点把目标行钉为分页锚点,后续分片与 `加载更早` 按钮的卸载都不会使落点漂移,加载器完结时再做一次最终校正,除非读者已主动滚离目标(settlement 否则按窗口头每前进一次重发一次分页,再兜底落到最近的已渲染轮次)。导航栏本身保持固定 10px 间距:阶梯在原外框几何内隐藏滚动条滚动,渐变淡出标示仍可滚动的端点,悬浮预览补偿导航栏滚动量,指针不在栏上时活跃刻度自动保持居中。未加载刻度以短而暗的形态呈现,标签为「加载并跳转到第 N 轮」,其跳转分页期间脉冲闪烁。
**视图:合并、跳转与固定间距导航栏。** `mergeTurnRailItems`(ui-chat,视图层——会话快照仍不携带投影值)把大纲与已加载条目并成以 `anchor: loaded(key) | unloaded(seq)` 判别的 `TurnRailItem`;同轮已加载者优先,大纲提示词填补窗口头部半轮的空预览。激活未加载刻度在点击当下即交出钉底所有权(跳进历史就是离开活跃尾部;否则钉底吸附与首个 prepend 补偿的竞态会触发 `toBottom` 取消跳转),再用现有分页锚点稳住读者位置、调用 `loadThrough`、在 React 提交后落点——不做任何高度估算:分页中途的落点把目标行钉为分页锚点,后续分片与 `加载更早` 按钮的卸载都不会使落点漂移,加载器完结时再做一次最终校正,除非读者已主动滚离目标(settlement 否则按窗口头每前进一次重发一次分页,再兜底落到最近的已渲染轮次)。导航栏本身保持固定 10px 间距:阶梯在原外框几何内隐藏滚动条滚动,渐变淡出标示仍可滚动的端点,悬浮预览补偿导航栏滚动量,指针不在栏上时活跃刻度自动保持居中。未加载刻度以短而暗的形态呈现,标签为「加载并跳转到第 N 轮」,其跳转分页期间脉冲闪烁。
## Alternatives considered

View file

@ -671,6 +671,12 @@ export function ChatView({
if (local === null) return
const el = scrollerOf(local)
if (item.anchor.kind === 'unloaded') {
// Jumping into history is leaving the live tail: release bottom
// ownership on the click itself, or the pinned-scroll snap (a
// non-reader scroll delivery during the first prepend's compensation)
// would call toBottom and cancel the jump.
atBottomRef.current = false
setAtBottom(false)
// Hold the reader's place through the paging chunks; the layout effect
// lands on the target once its rows commit.
const held = pagingAnchor(local, el)

View file

@ -632,6 +632,33 @@ describe('ChatView', () => {
expect(view.getByRole('button', { name: '跳转到第 3 轮' }).getAttribute('aria-current')).toBe('true')
})
it('a jump from the pinned tail releases bottom ownership so the follow snap cannot cancel it', async () => {
const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
const h = makeHarness({ nodes: later }, { hasMore: true })
h.setOutline({
turns: [
{ turn: 1, seq: 0, prompt: 'first prompt' },
{ turn: 3, seq: 8, prompt: 'third prompt' },
],
})
let releaseJump: (() => void) | undefined
h.loadThrough.mockImplementation(() => new Promise<void>((resolve) => { releaseJump = resolve }))
const view = render(<h.ChatView {...h.props} />)
// Pinned to the tail on open: the back-to-bottom control is absent.
expect(view.queryByRole('button', { name: '回到底部' })).toBeNull()
const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
fireEvent.click(first)
// The click itself leaves the tail...
expect(view.getByRole('button', { name: '回到底部' })).toBeTruthy()
// ...so a non-reader scroll delivery at the floor (the first prepend's
// compensation fires one) no longer snaps to the tail and cancel the jump.
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLElement
fireEvent.scroll(scroller)
expect(first.getAttribute('aria-busy')).toBe('true')
await act(async () => { releaseJump?.() })
})
it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => {
const h = makeHarness(
{ nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },