From 62f707bb1d131ac0ca1a85c5cec501824ddcbc56 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 13:47:00 +0800 Subject: [PATCH] fix(ui-chat): release bottom ownership when a jump starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +-- .../2026-08-30-web-turn-rail-outline-jump.md | 2 +- ...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +- .../ui-chat/src/client/chat/ChatView.tsx | 6 +++++ .../ui-chat/tests/chat-view.client.spec.tsx | 27 +++++++++++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml index 996c206469..d78900be1b 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md index 4984cd1325..eb4e7f09ab 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md index 8a27d72493..6851456667 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md @@ -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 diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 71cd1755de..0b422d0281 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -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) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 87231f9724..6be1416036 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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((resolve) => { releaseJump = resolve })) + const view = render() + // 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)] },