Skip to content

fix(ui): 消除对话页与系统设置切换时的高度跳变,并支持 ESC 返回 - #836

Merged
Loosand merged 2 commits into
Stack-Cairn:developfrom
AlphaCatMeow:fix/settings-chat-switch-height-jump
Sep 20, 2026
Merged

Loosand merged 2 commits into
Stack-Cairn:developfrom
AlphaCatMeow:fix/settings-chat-switch-height-jump

Conversation

@AlphaCatMeow

@AlphaCatMeow AlphaCatMeow commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Closes #835

Summary

对话页和系统设置之间切换时,整页内容会上下位移一次。

根因是设置浮层的开合被直接接到了标题栏的渲染开关上:<AppChrome standaloneTitleBar={settingsOpen}>AppChrome 是纵向 flex 列,浮层一开,列里就多出一个 h-8(32px)的 WindowsTitleBar。它是普通兄弟节点而非绝对定位,下面承载 ChatPageflex-1 容器因此当场少掉 32px;关掉再还回去。ChatPage 全程没有卸载,只是被压扁重排。

位移之所以刺眼,是因为它和 300ms 的淡入淡出错开了。visibleoverlay !== "closed")首帧即为 true,标题栏立刻插入,而 activeoverlay === "open")要等两个 rAF 后才置位、浮层才开始淡入,中间几帧用户看到的是一个没有任何遮挡的对话页在跳。关闭时标题栏要等 transitionend 才移除,那下 32px 的下坠正好落在动画播完、画面已经静止的时刻。

改法是让浮层自带标题栏,外层不再随开关增删。对话页所在的 flex-1 容器高度从此恒定,跳变从源头消失,而 ChatPage 的顶栏结构一行没动。

顺带修掉两处同源风险。macOS 的 --app-header-height 原先由 ChatPage 子树里的 MacOsTitleBarToggle 写入、卸载时 removeProperty,子树一旦重挂,header 高度就会掉回 48px 兜底;现在抽成 useMacOsAppHeaderHeight() 挂在 App 根,卸载写回默认值,tokens.css:root 也补了静态默认值。Web 端 gateway shell 与设置浮层各自堆叠了 h-100vh/h-100svh/[height:100dvh],谁生效取决于类名顺序,而 svhdvh 在移动端地址栏收起时并不等值,现在统一收敛到 --app-viewport-height,由 @supports 决定取 dvh 还是 vh

第二个 commit 给设置页加了 ESC 返回对话。监听挂在 window 而不是 document,这点是有意为之:设置页内部已有多处 ESC 消费方(Base UI 的 Dialog、下拉弹层、内联重命名输入框),它们都绑在 document 上,而同一目标上的监听器按注册顺序触发,设置页先于弹窗挂载。绑 document 会让本 hook 抢先执行,一次按键既关弹窗又退回对话。window 在冒泡路径上恒位于 document 之后,与订阅时机无关,把顺序从挂载巧合变成结构保证。这个缺陷是先写测试才暴露的,初版绑 document 时「被消费的 ESC 不应再关闭设置页」一例直接失败。

Change scope

  • Modules: agent-gui / agent-ui / agent-gateway(web)
  • Key paths:
    • crates/agent-gui/src/App.tsx — 删除 standaloneTitleBar 条件渲染与对应 prop,浮层内部渲染标题栏
    • crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx — 新增 useMacOsAppHeaderHeight()
    • crates/agent-ui/src/styles/tokens.css--app-header-height 静态默认值、--app-viewport-height
    • crates/agent-gateway/web/src/lib/webStyleClasses.ts — 视口高度收敛到单一变量
    • crates/agent-ui/src/lib/settings/useSettingsEscapeToClose.ts — 新增
    • crates/agent-ui/src/pages/settings/SettingsShell.tsx — 接入 ESC,返回按钮补 aria-keyshortcuts 与 Esc 角标
    • crates/agent-ui/src/i18n/translations/{zhCN,enUS}Settings.ts — 新增 settings.backToChatHint
    • crates/agent-gui/test/settings/settings-escape-to-close.test.mjs — 新增

Screenshots / preview

2026-09-20_13-57-11

Verification

类型检查,两端均通过:

cd crates/agent-gui && tsc --noEmit -p tsconfig.json
cd crates/agent-gateway/web && tsc --noEmit -p tsconfig.json

测试:test/settings 下 21 个用例全部通过,其中 settings-overlay.test.mjs 覆盖浮层状态机,新增的 settings-escape-to-close.test.mjs 覆盖 ESC 各分支(defaultPrevented 让位、repeatisComposing、卸载后不残留监听)与 SettingsShell 的集成链路和无障碍属性。

Web 端 vite build 通过,CSS 产物中 --app-viewport-height 的两条定义和 @supports 块正确生成,100svh 只剩 token 定义本身、没有选择器再引用。

手工验证:Windows 桌面端与 WebUI 窄屏各来回切换十余次,跳变消失。

test/chat/standard-overlays.test.mjstest/settings/style-token-contract.test.mjs 在本机失败,但 stash 掉全部改动、在干净基线上跑同样失败,原因是 load-ts-module.mjs 在 Windows 下把路径拼成了 D:\D:\...,与本 PR 无关。

macOS 的那处修复我只能做代码层面的推理,手上没有 mac 实机,劳烦 review 时留意。

Pre-submit checklist

  • A requirement issue is linked (or this is a trivial fix that needs no issue, as explained in the summary).
  • Synced with the target branch; no merge conflicts.
  • The change is focused, with no unrelated modifications.
  • No secrets, tokens, or personal data included.
  • Docs are updated for changes affecting user behavior, deployment, or configuration.

最后一项没有勾:ESC 返回是用户可见的新行为,但仓库里没有面向用户的快捷键清单文档可以更新。按键本身在返回按钮上有 Esc 角标和 aria-keyshortcuts 提示。排查与修复过程记在 docs/worklog/settings-chat-switch-height-jump.md。如果需要补一份快捷键文档,告诉我放哪个目录,我加上。

打开设置浮层时,AppChrome 会在 flex 列里额外插入一个 32px 的
WindowsTitleBar。它是普通兄弟节点而非绝对定位,下面承载 ChatPage 的
flex-1 容器因此当场少掉 32px;关闭时再还回去。ChatPage 全程未卸载,
只是被压扁重排,于是每次切换都能看到整页上下位移。

跳变还和 300ms 的淡入淡出错位:visible 首帧即为 true(标题栏立刻
插入),active 要等两个 rAF 后才置位(浮层才开始淡入);关闭时标题栏
要等 transitionend 才移除,位移正好落在动画播完、视野最清晰的一刻。

改为让浮层自带标题栏,外层不再随开关增删:
- 删除 AppChrome 的 standaloneTitleBar 条件渲染与对应 prop
- 浮层加 flex flex-col 并在内部渲染 WindowsTitleBar,另套一层
  min-h-0 flex-1 收住 SettingsShell 的 h-full(AppErrorBoundary
  无错时直接透传 children,否则设置内容会顶穿标题栏)

顺带修掉两处同源风险:
- macOS:--app-header-height 原先由 ChatPage 子树里的
  MacOsTitleBarToggle 写入、卸载时 removeProperty,子树一旦重挂
  header 高度就会掉回 48px 兜底。抽成 useMacOsAppHeaderHeight() 挂在
  App 根,卸载写回默认值;tokens.css 的 :root 补静态默认值
- Web:gateway shell 与设置浮层各自堆叠了 h-100vh/h-100svh/
  [height:100dvh],谁生效取决于类名顺序,而 svh 与 dvh 在移动端
  地址栏收起时不等值。统一收敛到 --app-viewport-height,
  由 @supports 决定取 dvh 还是 vh

验证:两端 tsc 通过;test/settings 三个浮层测试全绿;Web 端 vite build
产物中 100svh 仅剩 token 定义、无选择器引用。
test/chat/standard-overlays.test.mjs 在干净基线上同样失败(Windows 下
virtual-core 路径解析问题),与本次改动无关。
在 SettingsShell 里接入 useSettingsEscapeToClose,桌面端与 Web 端共用
同一条路径。返回按钮补 aria-keyshortcuts="Escape",桌面端侧栏按钮右侧
增加 Esc 角标与 title 提示。

监听挂在 window 而不是 document,这一点是有意为之。设置页内部已有多处
ESC 消费方——Base UI 的 Dialog、下拉弹层、内联重命名输入框——它们必须
先拿到按键。这些层都绑在 document 上,而同一目标上的监听器按注册顺序
触发,设置页先于弹窗挂载,因此绑 document 会让本 hook 抢先执行,一次
按键既关弹窗又退回对话。window 在冒泡路径上恒位于 document 之后,与
订阅时机无关,把顺序从挂载巧合变成结构保证。

这个缺陷是先写测试才暴露的:初版绑 document,"被消费的 ESC 不应再关闭
设置页"一例直接失败(closes 为 2)。

其余守卫:
- defaultPrevented:Base UI useDismiss 关闭未被取消时会 preventDefault,
  据此让位给嵌套层
- repeat:长按 ESC 不应按重复节拍反复触发
- isComposing:输入法组字期间的 ESC 用于取消组字,不退出页面

未排除文本输入框:设置项里按 ESC 语义即"我改完了",而确实需要独占该键的
字段(供应商模型搜索、内联重命名)自身已 stopPropagation。

测试覆盖 hook 的各分支、卸载后不再残留监听,以及 SettingsShell 的集成
链路与无障碍属性。test/settings 下 21 个用例全部通过。
@StackCairn
StackCairn marked this pull request as draft September 20, 2026 05:54
@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

PR governance checks passed. Awaiting human review.

@AlphaCatMeow
AlphaCatMeow marked this pull request as ready for review September 20, 2026 05:57
@Loosand
Loosand merged commit 315b030 into Stack-Cairn:develop Sep 20, 2026
11 of 12 checks passed
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.

2 participants