Skip to content

feat(chat): turn navigator, back-to-bottom button and scroller under the composer - #225

Merged
lioensky merged 5 commits into
lioensky:mainfrom
RoxyAsahi:pr/zcode-turn-navigation
Oct 3, 2026
Merged

lioensky merged 5 commits into
lioensky:mainfrom
RoxyAsahi:pr/zcode-turn-navigation

Conversation

@RoxyAsahi

@RoxyAsahi RoxyAsahi commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

概述

共五个提交,可以逐个审阅:

  1. fix(chat):显式释放底部跟随,用于搜索跳转。 修复一个现有 bug:在长话题里点击消息搜索结果,永远看不到命中的那条消息。
    • selectTopic 会重新打开底部跟随。100ms 后 scrollIntoView({ behavior: 'smooth' }) 开始滚动,途经的 content-visibility 消息被渲染后变高,跟随用的 ResizeObserver 又把视图拉回底部。
    • 在一个 22 条提问的话题里复现:scrollTop 一直停在最大值,命中的消息不在屏幕内。
    • ui-helpers.js 新增 releaseChatScrollFollow():递增代号、取消排队中的滚到底部,并关闭跟随。滚回底部时照旧重新打开。
    • 跟随状态的切换统一经过一个 setter,并在滚动容器上派发 vcp-chat-follow-change 事件。
  2. feat(chat):提问导航条。 聊天区左侧的竖条,每条提问一根短横条,悬停显示预览卡片,点击跳转。纯 DOM 实现,不依赖框架。
    • 聊天区宽度不足 864px 时隐藏,不挤占消息;话题少于两条提问时也隐藏。横条平时 8px,悬停放大到约 31 / 20 / 15px。
    • 设置 > 界面与外观 新增「显示提问导航条」(enableTurnNavigator,默认打开)。关闭时导航条连同它的观察器一起卸载;renderer.js 在 global-settings-updated 时按新值挂载或卸载。
    • 跳转目标不在底部时,先调用 releaseChatScrollFollow()。
    • 超过 1.5 个视口高度的跳转直接到位,只有近距离跳转才带动画。长距离平滑滚动会逐个渲染途经的 content-visibility 消息,卡住渲染进程,最后停在过时的位置。
    • 落点之后,由消息列表上的 ResizeObserver(在布局之后、绘制之前回调)把这条提问固定在原位。浏览器自带的滚动锚定做不到这一点:它选中的锚点正是上方那条回答,而回答渲染时是向下变高的。静止 1.2 秒、在聊天区滚轮或触摸、或任何指针 / 键盘输入时结束固定(所以回到底部和发送都不会被拉回去)。
    • 长话题:当前横条用二分查找确定(每帧测量 O(log n) 个元素),加载更早的历史时按 key 复用横条,只重设状态有变化的横条。在 3000 条消息上,单次刷新从 262ms 降到 13ms,每批历史的开销从 619ms 降到 23ms。
  3. feat(chat):回到底部按钮。 28px 圆形向下箭头按钮,水平居中,位于输入框上方 8px。
    • 跟随关闭且话题有消息时显示。
    • 点击调用 resetChatScrollFollow() + scrollToBottom({ force: true })。
    • 只通过事件读取跟随状态,ui-helpers.js 仍是跟随状态的唯一持有者。
  4. feat(chat):消息滚动区延伸到输入框下方。 以前消息列表止于输入区顶部,滚动条也停在那里。现在滚动区一直延伸到窗口底部,输入区浮在它最后一段之上。
    • chat-composer-inset.js 用 ResizeObserver 测量输入区,把输入区高度和滚动条宽度作为 CSS 变量写到 <main> 上,并加上 .vcp-chat-composer-overlay。
    • chat-composer-inset.css 把输入区叠在滚动区上,给滚动区补上输入区高度加 20px 渐隐的内边距,并遮住输入区后面的消息,上沿有一小段渐隐。滚动条那一列不遮,所以滚动条能一直到窗口底部。
    • 没有这个脚本时布局保持原样。
    • 输入框变高(多行输入)时,跟随底部的视图保持贴底;已经往上翻的用户停在原处。
    • 提问导航条把这段内边距视为视口之外,当前横条与输入框上方实际可见的内容一致。
  5. fix(chat):输入区让出滚动条那一列。 第 4 个提交里输入区横跨整个宽度,盖住了它旁边那段滚动条,那一段点不到也拖不动。现在输入区右侧让出测得的滚动条宽度。

关于跟随 API 的设计说明

目前跟随只会在用户输入时关闭(向上滚轮、触摸、拖动滚动条),主动滚动的代码没法表明自己的意图。这里没有去伪造用户输入(比如合成一个滚轮事件),而是在现有的 resetChatScrollFollow() 旁边加了一个显式的释放接口:导航在滚动前释放跟随,回到底部时重新打开。如果你更倾向别的命名或形式,我可以改。

测试

  • node --test tests/settings-schema-render.test.mjs tests/settings-value-golden.test.mjs tests/global-settings-save.test.mjs tests/conversation-turn-navigator.test.mjs tests/ui-helper-chat-scroll-follow.test.js tests/chat-back-to-bottom.test.mjs tests/chat-composer-inset.test.mjs:65 个通过
  • 实机,1000 条消息的话题:在第 10 到第 470 轮之间跳转,逐帧记录。每一帧提问都停在距顶部 12px 处,历史分批加载期间也一样。
  • 门禁(check:ui-system 的各步骤逐一运行,加上 chat kernel / contract 与 settings-ownership 检查),对比 upstream/main 的 8d9a8656:没有新增失败;check:chat-contracts 通过(每个提交都重新生成了 chat-event-graph.json)。guard:next-delta、guard:ui-system、test:appearance-engine、guard:chat-kernel-consumers 和 check:settings-ownership 在 main 上同样失败,输出完全一致。
  • 实机:22 条提问的话题里,导航条位置、悬停卡片和点击跳转正常。
  • 实机:搜索结果跳转在释放跟随后停在命中的消息上。
  • 实机:设置开关能卸载和重新挂载导航条,重启后设置保留。
  • 实机:聊天区窄于 864px 时导航条隐藏,变宽后重新出现。
  • 实机:按钮在输入卡片上方居中,间距 8px,没有被遮挡。
  • 实机:点击按钮后的滚动动画,深色主题。
  • 实机:输入框旁边那段滚动条可以点击和拖动。修复前在那一列命中的是输入区;修复后命中滚动区,在 94 条消息的话题里按住滑块上拖 200px,scrollTop 从 22004 降到 15887。
  • 实机,滚动区延伸到输入框下方:输入框变高、滚动条到达窗口底部、回到底部按钮的位置、深色主题。

🤖 Generated with Claude Code

Clicking a message-content search result re-opens the topic, which turns
bottom-follow back on, then scrolls the hit into view 100ms later. In a long
topic the content-visibility messages the smooth scroll passes grow, the
follow ResizeObserver sees a taller list and pulls the view back to the
bottom, so the hit is never shown. Follow currently only turns off on user
input (wheel up, touch, scrollbar drag), and a programmatic jump has no way
to say "I am leaving the bottom on purpose".

ui-helpers.js gains releaseChatScrollFollow(): it bumps the scroll
generation, cancels any queued bottom scroll and turns follow off. Reaching
the bottom again re-enables follow exactly as before. searchManager calls it
right before scrollIntoView.

Follow flips are now funnelled through one setter that dispatches
`vcp-chat-follow-change` (exported as CHAT_FOLLOW_CHANGE_EVENT) on the
scroll container, so UI that depends on follow state can subscribe instead
of re-deriving it from geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RoxyAsahi
RoxyAsahi force-pushed the pr/zcode-turn-navigation branch from 0f7fa3f to b315833 Compare October 2, 2026 17:48
RoxyAsahi and others added 3 commits October 2, 2026 19:48
A thin rail on the left of the main chat with one short bar per user
question. Hovering a bar magnifies it and its neighbours and shows a
card with the question and the start of the answer; clicking jumps to
that turn. The rail hides when the chat column is narrower than 864px
instead of squeezing in next to the messages; it also hides when the
topic has fewer than two questions. Idle bars are 8px; hovered ones
magnify to about 31 / 20 / 15px.

Settings > 界面与外观 gains 显示提问导航条 (enableTurnNavigator, on by
default). Turning it off disposes the navigator with all its observers;
renderer.js mounts or disposes it on global-settings-updated, which
fires after settings load and save.

Plain DOM plus styles/ui-system/turn-navigator.css. Entries come
straight from the message DOM: each .message-item.user is one turn.
The rail mounts under <main>, so it carries .vcp-ui-scope itself and
takes its colours from the vcp-ui tokens; its pointer-driven bar scale
and measured offsets are allowlisted in check-ui-system.mjs.

A jump that lands away from the bottom first calls
releaseChatScrollFollow() (passed in as releaseFollow), otherwise the
content-visibility messages that grow while the smooth scroll passes
them let bottom-follow pull the view back. Jumps farther than 1.5 viewports land at once and only nearby ones
animate. After landing, a ResizeObserver on the message list keeps the
question at its position before each paint. That stops when the view
has been still for 1.2s or on user wheel, touch, pointer or key input.

Long topics stay cheap. The active bar is found by binary search over
the questions, which sit in DOM order (O(log n) rects per frame; a test
checks it against the linear reference). Bars are reused by key while
older history batches prepend, and only bars whose state changed are
restyled. In the app's renderer, on 3000 synthetic messages, refresh
went from 262ms to 13ms and the cost per history batch from 619ms to
23ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the reader leaves the bottom of a long conversation there is no
quick way back to the latest message. A small round arrow-down button
now sits centred 8px above the input card whenever bottom-follow is off
and the topic has messages; clicking it re-enables follow and scrolls to
the bottom.

28px outlined circle, 16px arrow, shown when follow is off and the topic has messages. The button does not
measure scroll geometry itself; it listens for the
`vcp-chat-follow-change` event from ui-helpers.js and calls
resetChatScrollFollow() + scrollToBottom({ force: true }), so follow
state keeps a single owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The message list used to stop at the top of the input area, so the
scrollbar ended there too. The scroller now runs to the bottom of the
window and the input area floats over its last stretch.

chat-composer-inset.js measures the input area with a ResizeObserver and
publishes its height (and the scrollbar width) as CSS variables on
<main>, then adds .vcp-chat-composer-overlay. chat-composer-inset.css
lays the input area over the scroller, pads the scroller by the
composer height plus a 20px fade, and masks the messages out behind the
composer with a short fade above it. The scrollbar column is left
unmasked. Without the script the layout is unchanged.

When the composer grows (multi-line input) a view that follows the
bottom stays pinned; a reader scrolled up is left where they are. The
turn navigator now treats the padded stretch as outside the viewport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RoxyAsahi RoxyAsahi changed the title feat(chat): turn navigator rail, back-to-bottom button and explicit follow release feat(chat): turn navigator, back-to-bottom button and scroller under the composer Oct 2, 2026
@RoxyAsahi
RoxyAsahi marked this pull request as ready for review October 2, 2026 18:21
The composer overlay spanned the full width of the chat area, so the
stretch of scrollbar beside it received no pointer events and could not
be grabbed or clicked. Inset the composer by the measured scrollbar width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lioensky
lioensky merged commit 6da3c4d into lioensky:main Oct 3, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants