资讯动态

CloudCLI(Claude Code UI)会话滚动机制全解:五个写入者如何在一个滚动容器上协调不打架

发布时间:2026/9/15 1:29:55 来源:尧图企业网站定制
CloudCLIClaude Code UI会话滚动机制全解五个写入者如何在一个滚动容器上协调不打架【免费下载链接】claudecodeuiUse Claude Code, OpenCode, Cursor CLI, and Codex on mobile and web with CloudCLI (aka Claude Code UI). CloudCLI is a free open source webui/GUI that helps you manage your Claude Code session and projects remotely.项目地址: https://gitcode.com/GitHub_Trending/cl/claudecodeui导读在 CloudCLIClaude Code UI的聊天界面里长会话的转录区transcript会出现流式输出自动贴底、用户上滑阅读时绝不抢滚动、翻页/搜索跳转后位置精确恢复等行为而这背后并没有一个全局滚动控制器或状态机——整个转录区只有一个滚动div却有五段互相独立的代码在写它的scrollTop。本文基于仓库架构文档 docs/architecture/05-scrolling.md结合 useChatSessionState.ts、ChatMessagesPane.tsx、transcriptScrollOwnership.test.tsx 等源码与测试完整拆解这套用少量 ref 而非状态机协调写入者的设计谁写滚动、何时写、写之前必须检查什么以及每一处阈值和 claim ref 背后的失败历史。读完后你将能快速定位 CloudCLI 聊天滚动相关的任何问题并理解为什么改错一个常量会立刻引发回归。核心心智模型八条必须先记住的规则整个滚动子系统的设计可以用一句话概括转录区是一个可滚动的div五段代码分别写它的scrollTop它们之间没有控制器和状态机只靠一小组 ref 互相让路。唯一的共享决策量是isUserScrolledUp它只由输入事件重算从不由高度变化重算。以下是必须先建立的心理模型一个元素拥有转录区的位置。即 ChatMessagesPane.tsx 渲染的div.chat-messages-pane。部分行内自带独立的受限滚动器bash 输出、文件列表、问题面板它们从不移动转录区而且原生的scroll事件不冒泡因此永远不会到达handleScroll。pane 组件本身不持有滚动状态。它只接收scrollContainerRef、onWheel、onTouchMove作为 props。对scrollTop的每一次写入、每一个阈值、每一个 claim ref都位于 useChatSessionState.ts。如果你想在ChatMessagesPane.tsx里找滚动逻辑你找错了文件。isUserScrolledUp是唯一的共享决策且它恰好只有三个读者。追加跟随 effect、useLayoutEffect中的 tab 重新激活分支以及 ChatInterface.tsx 中的跳到底部按钮。由此可以预测如果该标志为true不会发生任何自动滚动且圆形下箭头按钮会出现在屏幕上。该标志只从输入事件重算。handleScroll在scroll、wheel、touchmove上运行只应用一条测试——scrollHeight - scrollTop - clientHeight 50。在折叠线下方增长的内容不会移动scrollTop不会发出事件因此会让标志保持过期状态。延迟滚动必须在触发时重新读取意图。isUserScrolledUpRef镜像 state使 50 ms 或 200 ms 前武装的定时器能再次询问用户是否已经滚走。任何不带该检查的定时滚动都会重新引入 transcriptScrollOwnership.test.tsx 存在的目的就是要抓住的 bug。跟随 effect 依赖三件事而非一件。其依赖是chatMessages.length、isUserScrolledUp和isLoadingMoreMessages。所以新行会重新跟随对已有行的流式原地改写不会而标志翻转回false也会武装一次滚动——这正是你往下滚回时最后几像素被吸到位的机制。claim ref 压制其他写入者。pendingInitialScrollRef、pendingScrollRestoreRef、searchScrollActiveRef外加列表顶部的两个闩锁topLoadLockRef与wasNearTopRef。会话切换用一个 effect 统一清空或重新武装这五个。行几何不会在用户背后发生变化。懒加载行在内容卸载时保留测量到的高度React key 从消息内在字段派生而非对象身份contain-intrinsic-size: auto让浏览器记住每行最后一次渲染的尺寸。测试文件的开头注释印证了第 1 点的五处写入论断transcriptScrollOwnership.test.tsxThe transcripts scroll position is written from five places coordinated by refs and timers rather than by one owner.部件清单每个文件在滚动系统里的角色文件角色src/modules/chat/hooks/useChatSessionState.ts拥有滚动位置。全部五个写入者、isNearBottom、handleScroll、所有 claim ref、搜索跳转。src/modules/chat/transcript/ChatMessagesPane.tsx渲染唯一滚动元素绑定 ref 与传入的 wheel/touch 处理函数首次提交时立即挂载最新的INITIAL_MOUNTED_TAIL_ROWS行。src/modules/chat/ChatInterface.tsx把 hook 接到 pane 上将handleScroll作为onWheel/onTouchMove传入渲染跳到底部按钮。src/modules/chat/hooks/useChatComposerState.tshandleSubmit清空isUserScrolledUp并在 100 ms 时滚动到底部。src/modules/chat/transcript/LoadAllMessagesOverlay.tsx用户到达顶部时出现的加载全部胶囊按钮。src/modules/chat/transcript/LazyMessageRow.tsx把行的内容换成等高的占位符同时保留一个始终可寻址的包装 div。src/modules/chat/hooks/useLazyRowObserver.ts每个 pane 一个共享的IntersectionObserverroot 为滚动容器LAZY_ROW_VIEWPORT_MARGIN_PX 1200。src/modules/chat/utils/searchTargetLocator.tsfindSearchTargetIndex把侧栏命中解析到已加载数据resolveSearchWindowSize计算渲染窗口大小。src/modules/chat/utils/messageKeys.tsgetIntrinsicMessageKey—— 稳定的渲染 key使 prepend 不会重挂载其下方的行。src/index.css.chat-messages-pane/.chat-message的 containment、移动端touch-action、文档级 overscroll containment、.search-highlight-flash。src/modules/project-workspace/hooks/useVisualViewportKeyboardOffset.ts发布--keyboard-height让 shell 在 iOS 键盘上方收缩。src/shared/ui/ScrollArea.tsx聊天不使用它。只有FileTree.tsx和SidebarContent.tsx在用。src/modules/chat/tests/transcriptScrollOwnership.test.tsx钉住两个所有权 bug——延迟滚动与跨会话搜索跳转。src/modules/chat/tests/lazyMessageRow.test.tsx钉住占位高度与隐藏 tab 的零矩形场景。src/modules/chat/tests/searchTargetLocator.test.ts钉住 snippet 优先解析、时间戳回退与窗口大小。谁在写scrollTop五个写入者全部位于useChatSessionState.ts。仓库内对scrollTop 、scrollTop 、scrollIntoView和scrollTo(的全库 grep 找不到其他转录区写入者——剩下的命中是 composer 的 textarea 高亮覆盖层、命令菜单和工作区 tab 条。单一滚动容器规则只有一个元素滚动转录区渲染它的组件不持有任何滚动状态。ChatMessagesPane渲染一个单一的div带ref{scrollContainerRef}、onWheel{onWheel}、onTouchMove{onTouchMove}以及类名chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden其内部是一个max-w-[54.25rem]的行列。其中的导出菜单是sticky right-4 top-3这正是列表移动时它保持不动的原因。pane 被memo化且它、MessageComponent以及任何工具视图都不读写滚动偏移。确实存在三个行级滚动器——BashCommandDisplay.tsxmax-h-80 overflow-auto、FileListContent.tsx和AskUserQuestionPanel.tsxmax-h-48 overflow-y-auto。它们是无害的原生scroll事件不冒泡所以 pane 的scroll监听器永远看不到它们。它们的wheel和touchmove事件会冒泡进handleScroll后者读取的是 pane 自己的scrollTop/scrollHeight于是只是重新测量了一下未变化的转录区。src/shared/ui/ScrollArea.tsx是另一个带嵌套内层滚动器和touchAction: pan-y的组件。聊天不使用它。如果你在调试聊天滚动ScrollArea是条死路。为什么onWheel和onTouchMove要和一个真正的scroll监听器并排放置。它们看起来是多余的。ChatInterface.tsx中ChatMessagesPane调用点上的注释记录了原因第一页只有SESSION_MESSAGES_PAGE_SIZE 20行工具结果折叠进各自的调用且存在更多页时加载更早消息链接是隐藏的——所以一个短转录区常常根本不可滚动、永远不发出scroll。此时 wheel 和 touch 是用户到达顶部翻页器或加载全部覆盖层的唯一途径。停留在底部规则当折叠线下方剩余内容不足 50 px 时用户即位于底部。useChatSessionState.ts→isNearBottom返回scrollHeight - scrollTop - clientHeight 50容器不存在时返回false。handleScroll在每一次scroll、wheel、touchmove上调用它当 Chat tab 非激活时先直接退出写入setIsUserScrolledUp(!nearBottom)并把当前的{height, top}记录进scrollPositionRef供 tab 重新激活时恢复。一个独立的 effect 把 state 镜像进isUserScrolledUpRef——用 effect 而不是在每个 setter 旁赋值是因为setIsUserScrolledUp也会从 hook 返回并被 composer 调用。规则追加内容只在用户没有滚走时滚动且在移动前会重新检查。这就是自动跟随的全部。注意是什么重新触发它新行会重新跟随而每 100 ms 一次、对已有行做原地改写的流式 flush 不会见下文 gotchas。又因为isUserScrolledUp是依赖项跌回 50 px 带内会再武装一次滚动完成到最底部的最后一程。跟随与脱离Follow / DetachedSettling和Jumping是存在 ref 中的 claim而非标志的值。注意最后一条Jumping边当跳转解析为空时searchScrollActiveRef被清空但初始 settle 已经被消费于是转录区停在渲染时的位置标志仍为false。流中上滑以及如何回来规则回到底部的唯一途径是显式的——往下滚、按按钮、或发送消息。当isUserScrolledUp为true时ChatInterface.tsx会在 composer 上方渲染一个圆形的ArrowDownIcon悬浮按钮门控条件是isUserScrolledUp chatMessages.length 0。没有未读数、没有新消息指示器这个按钮就是全部 affordance。它的处理器是scrollToBottomAndReset不是scrollToBottom。区别很重要当allMessagesLoaded被设置用户拉取了整段转录它还会把visibleMessageCount降回INITIAL_VISIBLE_MESSAGES 100并清空allMessagesLoaded。跳到底部是有意丢弃加宽的渲染窗口——这个窗口的存在只是为了到达远处上方的内容。延迟滚动必须重新检查意图规则在定时器上武装的滚动必须在移动任何东西之前重新读取isUserScrolledUpRef。位置延迟重新检查追加跟随 effectuseChatSessionState.ts50 ms是——if (!isUserScrolledUpRef.current)外部更新刷新同文件externalMessageUpdateeffect200 ms是——同一守卫且仅在重取之前isNearBottom()成立时武装Composer 发送useChatComposerState.ts→handleSubmit100 ms否——它自己先把标志置false然后无条件调用scrollToBottom()前两个过去是无条件触发的。提交a1a42774描述了失败模式延迟期间向上滚动被静默撤销而且由于程序化滚动本身会发出scroll事件随之而来的handleScroll把isUserScrolledUp重置为false并隐藏了跳到底部按钮。用户被拽回底部并且失去了本可解释这一切的那个控件。transcriptScrollOwnership.test.tsx在 fake timers 上钉住了这两个方向驱动真实 hook 配合一个手工构造的容器jsdom 没有布局所以scrollHeight/clientHeight被 stubscrollTop的写入被记录does not yank the view back down when the user scrolls up inside the delay—— 追加一行、翻转标志、推进 200 ms、断言零次写入。still sticks to the bottom when the user has not scrolled away—— 同样的设置但不翻转标志断言一次scrollHeight的写入。测试刻意把requestAnimationFramestub 成 no-op初始 settle 循环是另一个写入者否则它就会替定时器满足断言。源码中isUserScrolledUpRef的注释也印证了这一设计两个延迟滚动到底部的调用都是在用户位于底部时武装、数十到数百毫秒后触发若不在触发时重读延迟窗口内的上滑会被静默撤销。Composer 发送则是故意不设防的——用户按了 Enter意图是新鲜的。代价是发送后 100 ms 内的上滑会被撤销。这一逻辑在 useChatComposerState.ts 的handleSubmit中清晰可见setIsUserScrolledUp(false); setTimeout(() scrollToBottom(), 100);到达顶部翻页器、闩锁与覆盖层规则顶部 100 px 是触发区且每次访问最多触发一次。handleScroll的后半段计算scrolledNearTop container.scrollTop 100并基于它运行两个独立的闩锁。wasNearTopRef对覆盖层做防抖它只在用户首次到达顶部时出现一次而不是每次滚动事件都出现。一旦scrolledNearTop变false就被清空。hook 中的 2500 ms 隐藏定时器与 LoadAllMessagesOverlay.tsx 自身的loadAllOverlayAutoFade 2500ms动画精确匹配于是胶囊淡出的时刻正是 state 清空的时刻。topLoadLockRef阻止一页加载立即触发下一页。一次成功的 prepend 后恢复逻辑会让你再次靠近顶部这会在紧接着的下一个事件上满足scrolledNearTop。锁只在container.scrollTop 20时释放——用户必须主动离开最顶部才会抓取下一页。注意这种不对称进入触发区是 100离开锁是 20。loadAllMessages覆盖层按钮走的是另一条路径以limit: null拉取整段转录把visibleMessageCount设为Infinity先捕获滚动锚点然后显示 2500 ms 的绿色全部已加载胶囊。Prepend 后恢复位置规则前置行绝不能把内容从用户眼前挪走。位置从锚点元素恢复而不是从滚动偏移恢复。loadOlderMessages在 fetch之前调用captureScrollRestoreState(container)。它记录四样东西scrollHeight、scrollTop、一个锚点元素、以及该锚点相对容器顶边的偏移。锚点是第一个getBoundingClientRect().bottom到达或越过容器顶边的.chat-message——通俗地讲就是第一行尚未完全滚出视野的行。当sessionStore.fetchMore报告prependedCount 0后捕获的状态被停放进pendingScrollRestoreRefvisibleMessageCount增加SESSION_MESSAGES_PAGE_SIZE。一个useLayoutEffect——在绘制之前——排空它情形修正方式锚点仍在 DOM 中anchor.isConnected且偏移已记录scrollTop newAnchorOffset - oldAnchorOffset没有锚点或它已消失scrollTop oldTop max(newScrollHeight - oldHeight, 0)锚点路径是精确路径高度差路径是回退方案。让锚点在 prepend 之后存活的是稳定的 keyChatMessagesPane每次渲染都从getIntrinsicMessageKey构建messageKeyMap碰撞时用出现次数下标消歧。其注释说明了原因——服务端刷新会用等价的新对象替换源记录所以对象身份在分页或水合过程中不能作为持久的 React key。key 依次回退经过id、messageId、toolId、toolCallId、blobId、rowid、sequence最后才轮到时间戳加内容前缀的字符串。这一实现可在 messageKeys.ts 中完整看到。同一机制被loadAllMessages复用。当pendingScrollRestoreRef被设置时追加跟随 effect 直接拒绝执行——prepend 绝不能被误认为 append——并且useLayoutEffect的恢复分支提前返回所以一个待处理的恢复在同一个提交中也能胜过 tab 重新激活恢复。打开会话、切换会话、回到会话规则每一次程序化滚动都押注一个 claim其他每个写入者都检查它。触发条件机制Claim ref打开会话rAF settle 循环pendingInitialScrollRef回到 Chat tabuseLayoutEffect重新激活分支—前置了更早的一页useLayoutEffect恢复分支pendingScrollRestoreRef侧栏搜索命中scrollIntoView重试链searchScrollActiveRef展开工具视图什么都没有——纯布局变化—打开会话过去一个 200 ms 的scrollToBottom()就够了。其实不够markdown 块、代码高亮和图片在那个窗口之后才完成渲染scrollHeight会增长却没有任何东西重新锚定——tab 视觉上滚得老高最新的 assistant 消息在屏幕外。当前 effect 运行一个requestAnimationFrame循环每一帧都设置scrollTop scrollHeight计数帧数和连续稳定高度在**连续 3 个稳定帧或 60 帧约 1 秒**时停止先到者为准然后清空pendingInitialScrollRef。循环被 effect 自己的 cleanup 通过cancelAnimationFrame取消会话切换 effect重新武装pendingInitialScrollRef为true而不是清空它。它得力于ChatMessagesPane.tsx中的INITIAL_MOUNTED_TAIL_ROWS 30最新的 30 行在首次提交时就以真实内容挂载于是循环在底部测量的是真实高度而非占位估算。如果searchScrollActiveRef被设置循环直接放弃——从搜索命中打开的会话不应该落在底部。切换会话会话切换 effect以selectedProject?.projectId和selectedSession?.id为 key清空待处理的搜索定时器清空searchScrollActiveRef与searchTarget置空pendingScrollRestoreRef清空topLoadLockRef与wasNearTopRef重新武装pendingInitialScrollRef把visibleMessageCount重置为INITIAL_VISIBLE_MESSAGES并将isUserScrolledUp设为false。它的注释记录了一个承重的顺序从新选会话上读取__searchTargetSnippet的 effect 在这个effect 之后运行所以从搜索结果打开的会话会立即重新武装。回到 Chat tab当另一个工作区 tab 激活时聊天树仍然挂载在 Tailwind 的hiddendisplay: none后面WorkspaceMain.tsx负责传递isActive。一个无依赖数组的 effect 在 tab 激活时于每次渲染后把{height, top}记录进scrollPositionRefuseLayoutEffect的重新激活分支——通过wasChatActiveRef识别——在脱离状态下恢复scrollPositionRef.current.top在跟随状态下恢复container.scrollHeight。隐藏的 tab 绝不能重置分页或滚动handleScroll、恢复分支和 settle 循环都会在!isActive时退出。跳到搜索命中规则先在数据里解析目标只有在那之后才去找它的行而且到最后一试之前只接受精确时间戳匹配。侧栏Sidebar.tsxonConversationResultClick把__searchTargetSnippet和__searchTargetTimestamp放到选中的会话对象上。跳转过程如下设置searchScrollActiveRef——初始 settle 与追加跟随同时让路。武装 effect 要求 snippet 字符串非空没有它就没有跳转。把整段转录取进 storelimit: null使旧命中可达但不渲染全部。对已加载数据而非 DOM 解析索引searchTargetLocator.ts →findSearchTargetIndex。snippet 是权威——经过归一化、去省略号、转小写MIN_SNIPPET_LENGTH 10、MAX_SNIPPET_LENGTH 80对displayText、content、字符串型toolInput和字符串型 tool-result 内容匹配。只有当 snippet 未命中时时间戳才接管而且它返回的是按时间最近的消息不是精确匹配。-1——因此完全不动——只发生在 snippet 未命中且没有有限时间戳时。searchTargetLocator.test.ts钉住了两半a snippet that matches nothing reports a miss instead of guessing和the timestamp is only a fallback when the snippet misses。用resolveSearchWindowSize(count, index, SEARCH_TARGET_CONTEXT_MESSAGES 20)加宽渲染窗口以Math.max(previous, required)应用窗口永不变小。visibleMessages是尾切片所以覆盖索引 N 意味着渲染它之后的所有内容。等 150 ms 让 React 提交然后按data-message-timestamp查找行并调用scrollIntoView({ block: center, behavior: smooth })加上一个 4000 ms 后移除的search-highlight-flash类。重试预算。SEARCH_SCROLL_RETRIES 20是retriesLeft的起始值且首次尝试前有一个前置setTimeout所以整条链是间隔SEARCH_SCROLL_RETRY_DELAY_MS 150的 21 次尝试——总计约 3.15 秒。预算这么长是因为加宽窗口可能一次提交数千行每行都要跑 markdown 管线。为什么存在allowNearest。findRenderedMessageElement以allowNearest (retriesLeft 0)调用所以除最后一试外每次尝试都只接受精确时间戳匹配。最后一试放宽到按时间最近因为折叠工具组内第二次及之后的调用没有自己的行groupConsecutiveTools给组条目盖上第一个消息的时间戳toolGrouping.ts所以组行永远是最近匹配绝不可能是精确匹配。展开工具视图什么都不滚动。src/modules/chat/transcript/或工具渲染器下没有任何scrollIntoView。展开是浏览器自身滚动锚定吸收的布局变化如果行随后卸载LazyMessageRow会先记录展开后的高度。懒加载行与高度稳定性规则卸载行的内容绝不能改变滚动几何。LazyMessageRow把每一行转录都包在一个永久的轻量div中携带data-message-timestamp。昂贵的子树只在行距视口LAZY_ROW_VIEWPORT_MARGIN_PX 1200之内时挂载由每个 pane 一个、root 在滚动容器上的共享IntersectionObserver追踪useLazyRowObserver.ts。有三个细节纯粹是为了保护滚动位置卸载前测量。handleNearViewportChange在内容仍在 DOM 中时读取offsetHeight然后以精确高度渲染占位符。你已看过的行在滚回去时零几何成本。包装器保持可寻址。搜索跳转查询[data-message-timestamp]无论内容是否挂载包装器都携带它。这不延伸到 prepend 锚点captureScrollRestoreState查询.chat-message而这个类在MessageComponent/ToolGroupContainer上位于包装器子级的内部——所以锚点扫描只会挑到当前已挂载的行。这在实践中可行因为视口附近的行恰恰就是已挂载的那些。零尺寸矩形被忽略。隐藏的 Chat tabdisplay: none报告isIntersecting: false且矩形为0x0。把它当作滚走了会抹掉每一行的挂载状态和测量高度。lazyMessageRow.test.tsx将这一点钉为ignores the zero-rect non-intersections a hidden tab reports。该守卫在useLazyRowObserver.ts中可直接看到width 0 height 0的非相交条目被continue跳过。从未测量过的行回退到ESTIMATED_ROW_HEIGHT_PX 100并依赖浏览器自身的滚动锚定在其稳定期间维持位置。CSS 在 src/index.css 中对同一想法做了第二遍实现.chat-messages-pane { contain: layout style paint; } .chat-message { contain: layout style paint; content-visibility: auto; contain-intrinsic-size: auto 180px; } .chat-message.assistant { contain-intrinsic-size: auto 240px; } .chat-message.user, .chat-message.tool, .chat-message.error { contain-intrinsic-size: auto 96px; }contain-intrinsic-size中的auto关键字让浏览器记住每行最后一次渲染的尺寸所以跳过屏幕外行的渲染不会改变它的尺寸。仓库从不设置overflow-anchor所以浏览器默认值auto对 JS 未显式恢复的一切保持生效——这正是工具卡片展开或代码块完成高亮被吸收的方式。移动端、键盘与 CSS规则文档永不滚动只有 pane 滚动。规则文件原因html, body { overflow: hidden; overscroll-behavior-y: contain; }src/index.cssshell 是fixed inset-0容器所以文档永远不需要滚动。裁掉它就去掉了幻影全高滚动条并禁用移动端下拉刷新。* { touch-action: manipulation; }max-width: 768px下src/index.css消除 300 ms 点击延迟——但也会杀掉滚动这正是下面两条规则存在的原因。.overflow-y-auto { touch-action: pan-y; -webkit-overflow-scrolling: touch; }src/index.css为 pane 重新声明纵向平移和动量惯性。.chat-message { touch-action: pan-y; }src/index.css同上为行本身。--keyboard-height来自visualViewport.resizeuseVisualViewportKeyboardOffset.tsProjectWorkspaceShell.tsx以style{{ bottom: var(--keyboard-height, 0px) }}应用它使固定 shell 在 iOS 键盘上方收缩而不是被覆盖。media (prefers-reduced-motion: reduce) { scroll-behavior: auto !important; }src/index.css仓库中唯一的scroll-behavior声明。CSS 中没有设置smooth。scrollToBottom是一次即时的scrollTop scrollHeight赋值。转录区中唯一的平滑滚动是搜索跳转显式的behavior: smooth。pane 的底部 padding 依据hasActivityIndicator在pb-12 sm:pb-14和pb-3 sm:pb-4之间切换——composer 的浮动活动/停止 tab 与 pane 重叠所以 padding 为它预留空间。这个切换改变了 pane 的可用高度却不发出滚动事件。Gotchas代码为什么长这样流式文本不会重新跟随但第一次 flush 会。useSessionStore.ts 中的updateStreaming以众所周知的 id__streaming_sessionId写入一行。第一次 flush 追加它所以chatMessages.length变化一次跟随 effect 运行。之后每次 flush 替换同一个数组槽位长度不变effect 保持安静。在一个流式块内部pane 由浏览器而不是这段代码把持。stream_end也不会重新跟随。finalizeStreaming原地改写同一个槽位只改 id、kind和rolestream_delta和 assistanttext都在normalizedToChatMessages中映射到恰好一行。长度从不移动effect 不重跑。真正触发重新跟随的是下一个行——一次工具调用或下一个流式块后者会分配一个全新的__streaming_id。isUserScrolledUp可能过期。它只从scroll、wheel和touchmove重算。折叠线下方增长的内容不移动scrollTop不触发事件标志保持false跳到底部按钮即使最新内容在屏幕外也保持隐藏。键盘打开以及活动指示器的 padding 切换同理。程序化滚动会发出scroll事件。每一次scrollTop写入都通过handleScroll反馈并重写标志。这就是延迟写入者要自我守卫的原因——未守卫的写入既移动了用户又抹掉了他们曾滚走的证据。翻页器的两个阈值刻意不同。进入触发区是scrollTop 100但只有scrollTop 20才释放topLoadLockRef。如果两者都是 100prepend 后的恢复——它按设计落在顶部附近——会立刻抓取下一页长会话会在一个手势内被抽干。loadEarlierMessages没有滚动恢复。它只是对已加载消息做setVisibleMessageCount(prev 100)chatMessages.length从不变化所以跟随 effect 和恢复useLayoutEffect都不会运行。在那里只有浏览器原生滚动锚定维持位置。loadOlderMessages和loadAllMessages都会捕获锚点这一个不会。一个跨会话仍然武装的搜索跳转曾经可见地错两次。新会话在中间位置打开跳转待处理时初始 settle 放弃然后重试用完、allowNearest介入后它滚到了新会话中一条无关消息并给它打上高亮。transcriptScrollOwnership.test.tsx →does not follow the user into the next session精确驱动了这个场景在容器中种下一行会话 B 的行让跳转开始对会话 A 重试切换会话推进整个重试预算断言零次scrollIntoView调用、零个.search-highlight-flash元素。最近行回退不是偷懒。它从早期尝试中被移除提交0a19ad8a因为未提交的窗口会让它滚到一条任意消息——这正是这次重写要消除的失败。它只在最后一试幸存因为那正是把折叠工具组内的命中映射到组行的方式。初始滚动是循环不是超时。一次 200 ms 的scrollToBottom()输给了迟到的 markdown、高亮和图片布局。3 稳定帧 / 60 帧上限的 rAF 循环就是修复。懒加载行是为内存而生并为此在滚动正确性上付出代价。提交f537a3a9在长会话上加载全部曾经一次性提交数千个 markdown/tool 子树把 tab 撑向 1 GB。使用 29k 行 fixture现在 tab 以几十个挂载行维持约 112 MB而不是以七千个挂载行维持约 1 GB。LazyMessageRow中的每一条几何保证——卸载前测量、永久包装器、零矩形过滤——都是为了让这个权衡不可见。content-visibility: auto在导出时被覆盖。buildTranscriptHtml.tsx 发出.chat-message { content-visibility: visible !important; contain-intrinsic-size: auto !important; }注释为屏幕外跳过是滚动优化在打印文档中它会留下空白页。在IntersectionObserver不存在的地方jsdom每一行都保持挂载。useLazyRowObserver返回nullLazyMessageRow把它当作总是挂载。需要懒路径的测试会安装一个 stub observer 并手工驱动它。修改清单动哪里还要检查哪里如果你动了还要检查isNearBottom中的 50 px 阈值跟随 effect、tab 重新激活分支和跳到底部按钮读的是同一个标志。 100顶部区或 20锁释放topLoadLockRef必须仍要求显式离开顶部否则分页会失控。chatMessages的形状或身份跟随 effect 和恢复/重新激活useLayoutEffect都以chatMessages.length为 key原地行改写对两者都不可见。任何新增的延迟滚动它必须在触发时重读isUserScrolledUpRef否则transcriptScrollOwnership.test.tsx应该失败。getIntrinsicMessageKey或ChatMessagesPane中的 key mapprepend 恢复需要锚点元素存活不稳定的 key 会重挂载行并把它降级到高度差回退。LazyMessageRow占位高度、.chat-message类的放置、或 1200 px observer marginprepend 锚点扫描、搜索跳转行查找以及lazyMessageRow.test.tsx。SEARCH_SCROLL_RETRIES、重试延迟或findRenderedMessageElement跨会话取消测试和searchTargetLocator.test.tsallowNearest必须只留在最后一试。.chat-messagecontainment 或content-visibilitybuildTranscriptHtml.tsx 中的导出覆盖镜像这些声明。useChatSessionState.ts中的会话加载或分页pendingScrollRestoreRef、pendingInitialScrollRef、searchScrollActiveRef、topLoadLockRef和wasNearTopRef都由会话切换 effect 统一处理——见 the message store and lazy loading。Composer 发送或活动指示器handleSubmit强制isUserScrolledUp为false并在 100 ms 无条件滚动指示器改变 pane 的 padding 却不发滚动事件。工具卡片展开/折叠今天什么都不滚动——见 tool views。在那里添加scrollIntoView会引入一个没有 claim ref 的第六个写入者。延伸阅读01-websocket-transport.md所有帧的传输管道与重连/重放语义。02-realtime-stream.md一次 run 的完整旅程——流式行如何到达以及为什么行数不变在滚动语境里如此关键。03-conversation-handoff.md应用会话 id 与 provider 会话 id 的交接点。04-message-store-and-lazy-loading.md消息存储、分页与懒加载——滚动恢复依赖的锚点机制的数据来源。06-tool-view.md工具调用如何变成 UI以及为什么工具展开不需要任何滚动代码。docs/architecture/README.md六篇架构文档的阅读顺序与整体图景。【免费下载链接】claudecodeuiUse Claude Code, OpenCode, Cursor CLI, and Codex on mobile and web with CloudCLI (aka Claude Code UI). CloudCLI is a free open source webui/GUI that helps you manage your Claude Code session and projects remotely.项目地址: https://gitcode.com/GitHub_Trending/cl/claudecodeui创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价