Files
deepseek-harness/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md
2026-08-12 02:16:39 +08:00

9.5 KiB
Raw Blame History

Agent Note: 侧边栏的滚动条跟随指针

Status: implemented

English | 中文

问题

侧边栏的会话列表只要有几个会话就会溢出从那一刻起它的滚动条就一直画在那里——所处的这一列大部分时间都是静止的而列表行自己的操作按钮只在悬停时才出现。它是侧边栏里唯一始终常驻的构件而在有人真的伸手去操作它之前它不提供任何可操作性。产品诉求2026-08-04是只在指针位于侧边栏内时才绘制它并留一小段拖尾避免指针路过时它一闪而灭。

决策

SidebarRoot 跟踪整列上的指针,只要指针不在列内就给根元素挂上 quietBars 类。该类选中的规则把 ui-theme 的那组间接变量——--dsh-scrollbar-thumb--dsh-scrollbar-thumb-hover——重新绑定为 transparent,于是嵌套在这一列下的每个滚动区域都不绘制滑块。今天这样的区域只有会话列表;将来新增的区域会直接继承这一行为,而不需要逐个接入。

拖尾是 SCROLLBAR_LINGER_MS = 2000:离开会启动一个定时器,进入会取消尚未触发的定时器,只有定时器真正触发才会把类加回去。指针越过列边界又折返时——绕过一个 portal 菜单,或是奔向某一行时冲过了头——不会看到滑块闪动。

进入用的是列自身的 pointerenter;离开则按列的盒子判定,由一个只在滚动条可见期间存在的 pointermove 监听完成。DOM 包含关系无法判定离开ui-settings 把整屏的设置面板渲染为这一列的 fixed 定位后代,指针移到该面板上——或在面板关闭后移到对话区——都不会在这里触发 pointerleave,滚动条就会继续画在一个没人指向的列上。元素自身的 leave 仍然保留,用于几何判定看不到的那一种情况:指针移出窗口后不再产生任何移动事件。

承载指针的是整列,而不是列表。奔向滚动条的指针会先经过 logo 行、New Session 胶囊和搜索框,所以只在列表上显示,会让滚动条等到指针已经落在行中间时才出现。

transparent 正是让这次显示不触发任何布局的原因。列表上的 scrollbar-gutter: stable 存在的意义就是让行永不移动(见空槽 Agent Note);重新绑定的只是颜色,那份预留始终有效,所以滑块出现在列表本就为它留出的空间里。

选择这组间接变量而不是给列表加规则,是因为这组变量正是 ui-theme 写明的重新绑定约定一次声明同时作用于两条渲染路径WebKit 伪元素与 Firefox 的 scrollbar-color),而自定义属性会继承——这正是让整列、而不是列内每个滚动区域,成为该状态所有者的原因。

这拓宽了重新绑定约定,因此它的门禁把新的形态明写出来,而不是默许通过:ui-theme/tests/scrollbar-styles.spec.ts 只接受两种重新绑定目标,即 l2 那一组或 transparent,并且判定的是整条规则而不是逐条声明——混合规则(thumb: transparent 与 l2 的 hover 并列)会在指针一碰到滚动条时重新上色,却能通过逐条检查。抬升那一半按整个值与这组变量的规范写法比对,这同时也拒绝了交叉绑定和被包在字面表达式里的 token绑回 l1 与裸颜色本来就在门外。

隐藏不再算作抬升:只有 l2 重绑才能让一张样式表免于「任何在抬升表面上滚动的样式表都必须重新绑定」。既隐藏滚动条又在抬升表面上滚动的样式表,仍然欠着那里真正绘制滑块时所需的 l2。

考虑过的替代方案

只用列上的 CSS :hover,不引入 JavaScript 状态。 整套机制只需一条规则,但它表达不出拖尾:指针越过边界的那一帧滑块就会消失,而那恰好是指针正奔向对话区或绕行 portal 菜单的时刻。诉求本身点名了拖尾,只有 hover 的版本读起来就是闪烁。

留在 CSS 里、用过渡拿到这段延迟,即通过 @property 注册 --dsh-scrollbar-thumb 让该自定义属性可动画,再用 transition-delay 把颜色按住。因代价与作用范围被否决:这项注册对每个读取这组变量的表面都是全局的,却只为一列的时序服务;而且这套调色板实际渲染所走的 WebKit 滚动条伪元素并不可靠地支持过渡——延迟会被声明在观察不到它的地方。

直接把滚动条藏掉——scrollbar-width: none,或对 ::-webkit-scrollbardisplay: none。被否决,因为这会连带取消那段预留:滚动条重新出现时会重新占走 8px使每一行都在触发其显示的指针下方横向移动而这正是当初加入空槽预留所修掉的回归。

在应用内自绘一个覆盖式滑块,并彻底隐藏原生滚动条,这是完全自定义淡入淡出所需要的做法。它换来任意样式,代价是命中测试、拖拽、滚轮、惯性以及两套调色板下的 hover 状态——在一个滚动条已由 token 统一主题化的客户端里,为观感付出的是一大片自持表面。

把显示范围收敛到滚动的列表而不是整列。 涉及的元素更少,却把显示的边界放错了位置:指针最后才到达行,滚动条会等到用户已经在读这些行时才出现;而且日后加入侧边栏的其他滚动区域都得手工接入。

滚动事件也触发显示,让键盘或触摸驱动的滚动同样显示滚动条。被否决,因为那是在为触发它的输入方式画一个它用不上的可供性;行本身已经说明列表移动过了。

后果

  • 用键盘或触摸拖动滚动的列表在拖尾结束后不显示滑块因为这两种方式都不会把指针留在列上。e2e 会钉住这一点,而不只是把它写下来。
  • 拖动滑块本身移出列不会在拖动中途把它隐藏:滚动条会接管指针捕获,按住按键期间页面收不到 pointermove。已在 Chromium 实测——指针拖到列右侧 900px 处、超过拖尾窗口后,滚动条依然绘制并继续滚动。
  • 冷启动时该列处于静默状态,直到指针第一次移到它上面为止。页面加载时就停在那里的指针在移动之前不会触发任何事件,这是浏览器的规则,而非这个外壳的。
  • 嵌套在列内、为自身抬升层级把这组变量重新绑定到 l2 的抬升表面,会覆盖静默状态并继续绘制自己的滚动条。今天侧边栏内没有这样的表面。
  • 外壳的 DOM 现在带有一个状态类,因此 ui-sidebar 的外壳快照会钉住 quietBars,默认状态出现回归时表现为快照 diff而不是需要有人从截图里看出来的东西。

测试

packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx 用假定时器把这个类走过各次跃迁:进入时显示,拖尾结束前 1 毫秒仍然显示,结束后 1 毫秒转为静默,以及窗口内折返会取消隐藏。另有两条覆盖几何判定的离开:落在列盒子之外的 pointermove 会在没有任何 DOM leave 的情况下隐藏滚动条(即设置面板那种形态),落回盒子之内的则取消待触发的隐藏。它还在拖尾进行中卸载组件并断言没有定时器存活——待触发的隐藏落到已销毁的组件上,正是这种写法容易犯的错。事件用的是带 relatedTargetpointeroverpointerout,因为 React 由它们合成 enter 与 leave而会忽略原生的那两个事件。

packages/client/ui-sidebar/tests/scrollbar-quiet-styles.client.spec.ts 直接读样式表:该规则必须写出这组变量的两半——只重新绑定静止态滑块,会让指针一碰到滚动条就露出 hover 颜色——并且不得出现 scrollbar-gutter,那属于滚动区域自己。

apps/web/tests/sidebar-scrollbar.e2e.ts 是两半在真实引擎里汇合的地方。它在每次读取颜色前先把指针停在列表上,因为一个从不移动鼠标的场景全程测到的都是静默状态,会在未实际验证目标行为的情况下通过。随后它自己的用例把指针移开,断言在 leave 当下滑块仍在绘制,轮询直到它解析为 rgba(0, 0, 0, 0),在该状态下重新测量几何以证明滚动条隐藏期间那份预留依然生效,并以编程方式滚动列表——键盘或触摸拖动所做的事——来钉住无指针滚动不绘制任何滑块。提交的 golden 记录了两套调色板下、两个指针位置上的滑块颜色。

这条 e2e 的对照是一次 mutation而它需要插件自己的产物quietBars 从外壳中去掉,先重新构建 @deepseek-ai/dsh-client-ui-sidebar、之后再跑 build:web,该用例会因为滑块解析为 rgb(229, 229, 229)、而期望 rgba(0, 0, 0, 0) 而变红。只重跑 build:web 用的是陈旧产物,即使改动已被删除也照样通过,这正是空槽 Agent Note记录过的陷阱。

拓宽后的门禁也有自己的对照,每个都是对真实样式表的一处声明改动:把 transparent 与 l2 的 hover 混用,以及把 l2 token 包进 color-mix(…),都会让这条成对断言变红。

演示这一行为的录制必须用有头浏览器。无头 Chromium 会预留那条带(offsetWidth - clientWidth 为 8却不会把滑块画进捕获帧——通过统计带内滑块色像素在显示前后的变化实测无头一直停在噪声水平有头则从 46 跳到 1466。