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

Status: implemented

[English](2026-08-04-pointer-revealed-sidebar-scrollbars.md) | 中文

## 问题

侧边栏的会话列表只要有几个会话就会溢出，从那一刻起它的滚动条就一直画在那里——所处的这一列大部分时间都是静止的，而列表行自己的操作按钮只在悬停时才出现。它是侧边栏里唯一始终常驻的构件，而在有人真的伸手去操作它之前，它不提供任何可操作性。产品诉求（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](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)）；重新绑定的只是颜色，那份预留始终有效，所以滑块出现在列表本就为它留出的空间里。

选择这组间接变量而不是给列表加规则，是因为这组变量正是 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-scrollbar` 用 `display: 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 的情况下隐藏滚动条（即设置面板那种形态），落回盒子之内的则取消待触发的隐藏。它还在拖尾进行中卸载组件并断言没有定时器存活——待触发的隐藏落到已销毁的组件上，正是这种写法容易犯的错。事件用的是带 `relatedTarget` 的 `pointerover`／`pointerout`，因为 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](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)记录过的陷阱。

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

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