deepseek-harness/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md
2026-08-22 13:39:00 +08:00

9 KiB
Raw Blame History

Agent Note:反馈备注编辑器以浮层悬浮在对话记录上方

Status: implemented

English | 中文

Problem

消息反馈的 Web 界面(#2262)把控件贡献给 conversation.chat.assistant-actions,该槽位渲染在已定稿助手消息共享的 IconActions 行内。那一行是单条固定高度的 flex 线,flex-wrap 保持初始值 nowrap 且 height: 28px,按 28px 图标加一个时钟来定尺寸。备注编辑器作为一个内联组挂进去,内含 width: 260px 的 textarea 加 Save 与 Cancel。

一个 260px 输入框加两个按钮在任何窗口尺寸下都装不进那条线。对着已构建产物实测,编辑器打开时该行的可滚动溢出在 1680px 视口下是 168px,在 600px 下是 444px——这个缺陷从来不是窄窗口的边缘情况,在全屏桌面下就已存在。flex 溢出会溢出到线的末端之外,因此按 flex 顺序排在编辑器之后的项被挤出会话列:branch 操作在 600px 时离开列,时钟及其运行时长/TTFT/吞吐读数在 900px 时离开列。这些控件在不可见的同时仍可命中测试,所以没有任何行为断言发现它;已交付的 e2e 覆盖评分、备注、reload 与撤回,而 24 个 UI 快照是与宽度无关的 DOM。

同一张样式表还引用了四个主题并未定义的 --dsw-alias-* token:border-secondary、bg-primary、interactive-bg-primary 与 label-inverse。未定义的自定义属性会让其所在的整条声明在 computed-value 阶段失效,因此 textarea 交付时既无边框也无底色,Save 既无填充也无可读标签——编辑器读起来像是浮在对话记录里的散落文本,而不是一个输入框。

Decision

备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 document.body,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 order 或换行。portal 出会话列也逃出了列的 overflow 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 ui-primitives/Menu 为锚定菜单所用的同一套 portal 机制(ui-subagent 的 catalog popover 就构建在它之上):面板 position: fixed,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。ui-primitives/useAnchoredPosition 负责共享的「测量—偏移—钳制—跟随」行为。Menu 保留自己的 effect,因为它的定位还要解析 side/align 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。

操作条。 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 button(aria-haspopup="dialog",打开时 aria-expanded),在没有备注时显示「补充说明」,已有备注时显示备注文本。

浮层。 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 role="dialog",其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 border-l2 与 bg-layer-1,Save 用 button-primary-fill 配 label-primary-foreground 并加 button-primary-hover 状态;面板表面复用 Menu 卡片的配方(--dsw-specific-menu、--dsw-shadow-lv3、反色发丝线 --dsw-alias-border-inverted、border-radius: 12px)。

失败提示按人的视线所落之处拆分。 评分或列表加载失败显示在按钮旁的图标行里,无论浮层是否打开都清晰可读。备注保存失败显示在浮层内、Save/Cancel 旁,且面板保持打开,以便草稿留存待修正。

Alternatives considered

**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行。**否决:这种做法能修正几何(行在 1680px 到 600px 报告零溢出),但会让 branch 操作与末尾时钟在编辑器打开时换行到编辑器下方,使行占据三行,并让交互与行本就占满的横向条带争空间。Issue #2561记录了由此产生的错位,并要求采用 chat 界面已有的浮层模式。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。

不 portal 出列的绝对定位浮层 — 否决:会话列是 overflow-y: auto 的滚动容器,因此在列内布局的面板会被滚动边缘裁掉,且不随列滚动而跟住消息。portal 到 document.body 并从触发按钮矩形做固定定位,才让浮动面板可行,正如 Menu 的 portal 模式与 subagent catalog popover 已然做到的那样。

在 MessageIconActions 上新增 belowActions 接缝,把编辑器作为该行的兄弟节点渲染在下方 — 否决:slot 契约明确记载 assistant-actions 渲染在消息 IconActions 行内部,且单个条目无法在不为一个展示细节拓宽 Host 契约的前提下提供两个渲染点,而 portal 出的浮层无需触碰 Host 就表达了该细节。

Consequences

编辑器打开时,操作行保持单条 28px 线,在 1680px 到 600px 的每一档视口都零溢出、零项落在列外——因为编辑器本就不在行里。面板悬浮于对话记录之上、位于视口内,并保持锚定其触发按钮,逃出列溢出裁剪。编辑器在两种主题下都能被辨认为输入框。

apps/web/tests/message-feedback-layout.e2e.ts 在编辑器打开时扫描六个视口,并在每一档钉住:行报告单行零溢出、面板位于会话列之外(证明它逃出裁剪)、面板落在视口内(证明钳制有效)、面板紧贴其触发按钮。已提交的 golden 记录这些关系;回退到行内(或去掉 portal)会让几何断言失败。packages/client/ui-message-feedback/tests/styles.client.spec.ts 校验 token 与主题已提交的源一致、面板为 position: fixed、且不带任何 flex sizing(因此不会重新加入行),并校验大括号平衡,沿用 ui-settings-models styles spec 的先例。单元 spec 覆盖评分、备注、reload、撤回,外加浮层的 portal 到 body、Escape/外部点击关闭、以及浮层内部点击保持打开。

ui-message-feedback 包新增 @types/react-dom,使 createPortal 用法能通过类型检查,与 ui-primitives 一致。

有若干已知限制在此接受而非修复。面板打开时点击评分会关闭它,而关闭路径把焦点归还给备注触发按钮,而不是留在用户刚按下的评分按钮上;外部点击落在另一个可聚焦控件上时同理——浏览器先把焦点给该控件,随后关闭路径又把它拉回触发按钮。指针用户对两者都无感,键盘用户会察觉焦点移动。钳制假定面板放得下:面板高于视口时,上界 innerHeight - height - margin 会小于 margin,于是 top 变为负值、被裁掉的是面板顶部而非底部。面板里的三行 textarea 带 resize: vertical,用户可以拖过这个尺寸,因此 .notePanel 把自身高度限制在 calc(100vh - 24px) 并自行滚动内容——这是既有 max-width 的对应项,用的是与钳制相同的 12px 边距。编辑器打开时若评分消失,面板会因 rating !== undefined 守卫卸载,但 noteOpen 仍为 true,因此 document 级的 Escape 与 pointer-down 监听继续挂着;若该 item 之后经 resync 重新出现,浮层会带着上一次的草稿回来且不重新聚焦 textarea。该窗口只有一次点击或一次 Escape 那么宽,而它可能遮住的保存失败已经有行内回退,因此保持现状。若失败在面板关闭并重开后才到达,不会写入新会话的面板:其草稿已按已存备注重新播种,旧尝试的错误会误标新草稿,而未提交的内容本就不存在——该失败被丢弃而不展示。以及,定位虽然会在滚动、窗口缩放与面板自身尺寸变化时重放,但 jsdom 没有布局,因此真实几何由浏览器场景证明,单测则通过 ResizeObserver stub 覆盖其接线。

520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。ui-agent-preset、ui-conversation、ui-jobs、ui-settings-plugins 与 ui-tool 仍引用未定义的 design token;仓库没有拒绝此类引用的门禁。