Skip to content

fix: open modals on the top layer instead of in document flow - #219

Merged
yujiezhang-ops merged 1 commit into
mainfrom
fix/dialogs-use-top-layer
Aug 19, 2026
Merged

yujiezhang-ops merged 1 commit into
mainfrom
fix/dialogs-use-top-layer

Conversation

@yujiezhang-ops

Copy link
Copy Markdown
Collaborator

问题

点击命令行 Agent 的「启动」按钮后,选择启动目录的弹窗出现在页面最底部并把页面挤开,而不是浮在窗口中央。装的 Agent 越多越明显。

根因是 <dialog open> 并不是模态框。open 属性只让元素可见,元素仍留在正常文档流的静态位置上,既没有 ::backdrop,也没有焦点约束和 Esc。而每个弹窗都写在拥有其状态的那一行/那一页内部,所以启动弹窗渲染在它所属的 Agent 行的位置上 —— Agent 一多就落到折叠线以下,并把自身高度加进列表。.app-window 的 overflow: hidden 又会裁掉任何只靠定位往外跑的元素。

在应用实际使用的两个引擎里实测同一个元素,同一份 DOM:

<dialog open>(修复前) showModal()(修复后)
position absolute fixed
top(视口高 600px) 1310px 254px
在视口内可见 false true
:modal false true

Chromium 与 WebKit 结果完全一致。

排查范围

按要求统一排查了所有浮层,结论分三类:

  • 4 处 <dialog open>:AgentManageRow(用户报告的启动目录)、TransferPage 的密码 / 导出设置 / 确认导入。均为上述缺陷。
  • 2 处手写 fixed 遮罩:MCPPage、SkillsPage。位置本身正确,但 z-index: 20 与任务中心的 z-index: 20 相等,谁在上面只由 DOM 顺序决定;两者也都完全没有 Esc。
  • 锚定下拉(SelectField、ModelPicker 等):贴着触发器定位、z-index: 40 在任务中心之上,行为正确,不在改动范围内。

改动

六处弹窗统一收敛到一个 ModalDialog,内部调用 showModal()。顶层(top layer)把居中、遮盖、::backdrop、焦点约束和 Esc 一次性都给到,且位于所有 stacking context 之上、overflow: hidden 的裁剪之外,因此不再需要任何 z-index,相等 z-index 抢层的问题随之消失。

几个值得说明的决定:

  • Esc 经 onCancel 回调给调用方,而不是直接关闭元素。 渲染弹窗的 state 是唯一事实来源;若让 Esc 直接关掉元素,state 仍认为「打开」,弹窗就变成一个看不见又无法再次打开的死状态。
  • 用 useLayoutEffect 而非 useEffect。 普通 effect 会留下一帧:元素已在 DOM 中但尚未进入顶层,于是先在静态位置画一次 —— 正是本次要消除的闪动。这一点也是实测发现的:改动初期全量测试约 1/4 概率随机失败,正是这一帧造成的(详见下方)。
  • CSS 宽度补显式小窗口保护。 弹窗成为顶层元素后,min(620px, 100%) 里的 100% 从原先带 padding 的容器格改为按视口解析,等于失去保护,故改为 calc(100vw - 48px)。
  • .mcp-modal 补 color。 UA 样式表会给 <dialog> 设自己的 color,而 UA 声明优先于继承;不补的话深色主题下会是黑字压深色背景。已在深色 + 420px 下实测确认。

验证

  • pnpm run test:429 passed / 56 files
  • pnpm run build(含 tsc --noEmit)、go vet ./...、go test ./...、python3 scripts/check-docs.py 全部通过
  • 真实浏览器(e2e/wails-server.mjs 跑真实 Go 后端):确认弹窗 :modal 为 true、position: fixed、双轴居中、在视口内、::backdrop 真正绘制、焦点被限制在弹窗内;requestClose() → onCancel → state → 卸载 的闭环正常,无残留 dialog 元素
  • 响应式 / 深色:420×700 下弹窗宽 372px、左右各留 24px、无横向溢出,深色配色正确
  • 跨引擎:Chromium 与 WebKit(打包后 macOS 实际使用的引擎)均复现修复前后的差异

关于测试稳定性

改动过程中全量测试出现约 1/4 概率的随机失败(TransferPage :: keeps the saved key when the file carries none)。已定位并修掉,不是遗留问题:showModal() 原本放在 useEffect 里,会在渲染后一拍才执行,中间存在「元素已在 DOM、但还没有 open 属性」的窗口,getByRole("dialog") 在这个窗口里取不到元素,机器负载高时断言正好落进去。改用 useLayoutEffect 后连续 12 次全量测试全绿(修复前约 1/4 失败)。

防回归

  • ModalDialog.test.tsx:断言 showModal() 被调用、可访问名正确、Esc 只上报给调用方而不自行关闭、调用方停止渲染时元素被关闭
  • styles/modal-layer.test.ts:源码级守卫 —— 除 ModalDialog 外任何文件都不得再渲染 <dialog>,且不得再出现 <dialog open> 与竞争性 fixed 遮罩。该守卫已做变异测试验证:故意还原一处 <dialog open> 后确实失败。
  • jsdom 30 完全没有实现 HTMLDialogElement 的任何方法(show / showModal / close / requestClose 全是 undefined),已在 test/setup.ts 中补齐,并注明位置正确性由浏览器层验证、不由 jsdom 保证。

…itting in it

`<dialog open>` is visible but not modal: the element stays in normal document
flow at its static position, with no ::backdrop, no focus containment and no
Esc. Every modal is authored inside the row or page owning its state, so the
launch dialog rendered wherever its Agent row happened to sit -- with many
Agents installed, far below the fold, adding its own height to the list rather
than covering the window. Measured in both engines the app ships against, the
dialog landed at top 1310px in a 600px viewport, off-screen, where showModal()
puts it at 254px and centred.

Two more modals hand-rolled a fixed overlay instead, and its `z-index: 20` only
tied the task centre's 20, leaving DOM order to break the tie.

Both shapes are now one ModalDialog calling showModal(), which promotes the
element above every stacking context and outside `.app-window`'s
`overflow: hidden`, and brings the backdrop, focus trap and Esc along with it.
Esc is routed through onCancel to the caller's state rather than closing the
element directly, so a dialog cannot go invisible while its owner still
believes it is open.

jsdom 30 implements no HTMLDialogElement methods at all, so the test setup
polyfills them; a source guard keeps `<dialog open>` from coming back.
@yujiezhang-ops
yujiezhang-ops requested a review from a team August 19, 2026 08:21
@yujiezhang-ops
yujiezhang-ops merged commit 7dbf3d0 into main Aug 19, 2026
4 checks passed
@yujiezhang-ops
yujiezhang-ops deleted the fix/dialogs-use-top-layer branch August 19, 2026 08:29
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.

1 participant