fix: restore consistent controls, usable overlays and narrow layouts

Resolve 15 shared-control, overlay and responsive-layout issues plus the MCP editor being hidden behind settings. Restore theme contrast, danger and disabled states, visible keyboard focus, and consistent Continue fields. Keep the workbar add button reachable and model protocol values readable in narrow containers.

Make nested MCP and image dialogs usable above their parents with focus restoration, keep project Escape handling within the active modal, release native browser views for overlapping help, and restore keyboard access and Escape in sandboxed HTML previews without relaxing sandbox permissions. Add unit and real Electron regression coverage and update design and validation documentation.

Release note: 修复深浅主题下控件边框、危险操作、禁用状态和键盘焦点显示不一致的问题。MCP 编辑器与图片预览可在父弹窗上正常操作,HTML 全屏预览支持键盘进入和 Escape 返回;窄工作栏的添加按钮保持可用,模型连接协议文字完整可读。

Validation: the full suite recorded 5309 passed, 3 failed and 83 skipped. The ProjectSwitcher initial-focus regression was then fixed, and App, ProjectSwitcher and overlay Electron tests passed all 331 cases. The other two HeartbeatCenter failures were reproduced on isolated HEAD 11facf0 and remain pre-existing. Typecheck and lint passed after the fix; staged whitespace checks passed. The full suite was not rerun after the focused fix.

Limits: Electron evidence is from Windows development runs, with fixtures for unrelated services and native occlusion throttling disabled in some drivers. No production build, packaging, macOS/Linux, remote Agent host or external MCP connection validation; skipped tests and the documented scaling and native-select coverage gaps remain.
This commit is contained in:
mesalogo
2026-09-27 21:57:35 +08:00
parent 11facf0392
commit 2713a81bd9
49 changed files with 1868 additions and 223 deletions
+9 -2
View File
@@ -116,6 +116,7 @@
- 业务组件通过 `--font-family-ui` 与字体尺寸令牌继承字体,不创建页面专属字体栈。
- 表单按钮、输入框、选择框和文本域必须继承界面字体,避免回退为原生控件字体。
- 文本表单复用 `.field` 下的共享输入规则;不适合增加字段包装层的独立输入或文本域使用 `.field-control`。文本输入规则排除 Checkbox 和 Radio,保留全局 `:focus-visible` 轮廓,不清除 Switch 焦点环。
- 连续阅读内容使用 `14px`,持久辅助信息不得小于 `11px`。
- 本地兜底字体资源必须随生产包交付,并同时包含 Inter 与 Noto Sans SC 的 OFL 许可证。
- 页面内不得通过同时放大字号、加粗和使用强调色制造多个同级主标题。
@@ -360,6 +361,7 @@
- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。不可关闭实例不渲染
关闭按钮,也不响应关闭快捷命令。
“+”位于滚动容器外并占用固定宽度;滚动箭头限定在剩余的页签区域内,不能覆盖添加入口。
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
@@ -416,13 +418,13 @@
绘制,避免被 Modal 或滚动容器遮挡。菜单和帮助挂在触发按钮所属 Modal 内,通知跟随当前活动
Modal,无 Modal 时挂在 `body`;不能只提高 `z-index` 或解除背景的 `inert`。
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。
浏览器 viewport;非模态对话框、菜单、同行帮助 tooltip、局部确认或应用通知与浏览器区域相交时也释放。
浮层关闭后只恢复当前活动实例的 viewport,不关闭、重建或刷新网页。接线与测试见
[浏览器多实例技术设计](./docs/features/assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport)。
- 遮罩必须 `inset: 0` 铺满整窗,并由 `styles.css` 中统一的遮罩选择器声明
`-webkit-app-region: no-drag`,否则 `.brand` 与 `.topbar` 的原生拖动区域会截获指针,
使遮罩上的控件无法悬停和点击。新增整窗遮罩时扩展该共享选择器,不要在各自规则里复制
`no-drag`,也不要用 `inset: 38px 0 0` 之类的偏移绕开标题栏。
`no-drag`,也不要用 `inset: 38px 0 0` 之类的偏移绕开标题栏。遮罩回归按 fixed/inset 声明发现,不能只按 `backdrop` 或 `modal` 类名后缀筛选;知识引用的 `-dialog` 同样适用。
- 当前会话存在 Task 时,Task 上下文栏位于标题栏下方并承载任务摘要与详情;没有 Task 时
整行隐藏。折叠状态保持为 `46px` 单行表面,与右侧工作栏标签栏等高,左右分隔线必须水平
对齐。该栏不重复放置新建入口。
@@ -505,6 +507,7 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
- `footer` 使用共享操作区,按钮整体右对齐,次要取消在前、主要保存在后。放弃修改确认沿用同一操作区,继续编辑在前、危险操作在后。
- header、滚动 body、footer 是 surface 的三个直接子区域。沿用共享 Grid 的 `auto minmax(0, 1fr) auto`,只有 body 滚动,短窗口仍可见标题、关闭和提交操作;窄窗口沿用共享外边距、内边距和主题令牌。
- X、取消、Escape 和遮罩关闭共用业务关闭检查,不绕过未保存确认或提交锁定。确认中的 Escape 或 X 返回编辑;继续复用 `activateModalFocus`、`trapTabFocus`,保留背景隔离、初始焦点和关闭后的焦点恢复。保存失败保留草稿及可恢复错误。
- 从已有 Modal 打开的子 Modal 同样复用 `--z-dialog` 和 `activateModalFocus`,以较后挂载的 body Portal 绘制在父层上方,并隔离父层;不能只手动聚焦或使用旧的页面层级常量。子层关闭后恢复父层的隔离状态与触发按钮焦点,Escape 不继续关闭父层。
## 7. 交互状态
@@ -521,6 +524,8 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
异步提交成功后更新内容并通过统一应用通知提供明确反馈。失败时保留用户输入和筛选上下文。
共享控件的状态规则须在容器组合中生效:菜单通用按钮样式不能覆盖危险按钮,普通图标悬停不能覆盖危险图标或作用于禁用按钮,页签悬停保留当前选中态。深色主题通过令牌提供控件颜色,避免用页面祖先下的通用输入规则替换控件边框。验证时读取最终 computed style;共享令牌对比度与源码规则存在性检查不能代替容器内、body Portal 和伪状态回归。修复与验证记录见[共享控件审查](docs/quality/shared-controls-review-2026-09-27.md)。
## 8. 范围与数据语义
### 8.1 页面范围
@@ -659,6 +664,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则,
并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容
或保留旧预览。
Tab 和 Shift+Tab 必须能进入及离开 iframe;焦点在预览内部时 Escape 仍关闭当前全屏层并返回入口。空 sandbox 与禁脚本 CSP 保持不变,跨文档键盘接线见[HTML 渲染技术设计](docs/features/conversation-html-rendering/technical-design.md#ui-与无障碍)。
- Mermaid 大图支持缩放和拖动;图形超过画布时,四个边缘都必须能通过滚动到达,不能因居中而裁掉顶部或左侧。
弹窗工具栏提供“导出 PNG”,保存完整图形,不受当前缩放和滚动位置影响;背景使用当前主题的画布底色。
导出按原图四周留出 `16px`,以 2 倍分辨率绘制,任一边最多 `4096px`。导出期间禁用重复点击,失败在按钮旁提示重试。
@@ -815,6 +821,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
- 设置中的连接列表与详情共用 `.model-connection-manager`,按容器可用宽度响应:不超过 `640px` 时上下排列,列表内部滚动高度最多 `180px`;更宽时恢复左右排列。LLM、向量连接和角色编辑复用此规则,不以整窗宽度代替表单宽度。接口协议的中英文当前值须完整可读。回归范围见[窄容器布局修复记录](docs/quality/responsive-controls-review-2026-09-27.md)。
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
@@ -81,12 +81,14 @@ Record<ConversationId, Record<BrowserTabId, BrowserLiveState>>
`BrowserViewport` 统一观察 body Portal 的插入、移除和可见属性变化,以及浮层尺寸、
窗口尺寸、滚动和过渡结束。`browser-viewport-occlusion.ts` 按共享语义识别
`aria-modal="true"`、dialog、alertdialog、menu 和应用通知:可见应用级 Modal 始终释放
`aria-modal="true"`、dialog、alertdialog、menu、tooltip 和应用通知:可见应用级 Modal 始终释放
viewport,其余浮层只在与浏览器区域相交时释放。隐藏节点和隐藏祖先不阻塞浏览器。
最后一个遮挡消失后,只有当前活动实例恢复同一 viewport 租约;网页、历史和页面状态不变。
终端关闭确认复用此机制,不再维护单独的隐藏条件。页面无需逐个登记弹窗,也不能靠提高
CSS `z-index` 覆盖原生子视图。此逻辑仅涉及 Desktop Renderer 的显示租约,不改变
Runtime、MCP 或远端 Agent 的请求、导航与生命周期。
同行帮助仍使用 `role="tooltip"`;相交判定使用其内容矩形,不使用铺满窗口的
`FloatingPortal` 宿主。真实 App 的相交与非相交验证见[浮层审查修复记录](../../quality/overlay-review-2026-09-27.md)。
浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。
切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B
+1 -1
View File
@@ -234,7 +234,7 @@ Runtime
从当前聊天右栏迁移时,Task Center 和工作区作为默认打开、单实例且不可关闭的基础应用保留;
浏览器保留一个默认打开实例,但注册为可关闭的多实例应用;成果保持默认打开、单实例且可
关闭;终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动,
不能自动隐藏或缩写到不可辨认,“+”紧随最后一个 Tab 并随标签滚动,溢出时两端提供固定滚动箭头。Task Center 继续作为 Task 的现有
不能自动隐藏或缩写到不可辨认。“+”固定在滚动区外的行末,溢出时页签区两端提供固定滚动箭头,箭头不覆盖“+”。Task Center 继续作为 Task 的现有
入口;审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和
完整配置统一归属“智能心跳”菜单入口。当前阶段不新增独立自动化中心。
@@ -1,5 +1,14 @@
# 工作栏实现与验证进度
## 2026-09-27:窄栏添加入口
“+”已移出页签滚动容器;左右箭头和溢出测量使用扣除添加按钮宽度后的页签区域。
Windows Electron 回归覆盖 `300px` 工作栏、默认四页签和 16 个含长标题的页签,验证
原生鼠标打开应用目录、创建浏览器实例、Escape 返回、箭头滚动及 Home/End 激活。
窄栏扩到 `900px` 后,默认页签的溢出箭头消失,缩回后恢复。
中英文、浅深主题均通过,模型调用 0 次。范围与命令见
[窄容器布局修复记录](../../quality/responsive-controls-review-2026-09-27.md)。
## 2026-09-27 Continue Update Status
The shared Continue adapter now skips the update service before it emits the
@@ -180,9 +180,9 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
- 已打开实例位于一个语义化 `tablist` 中。
- 每个实例使用独立 `tab` 和 `tabpanel`。
- “+”固定在 Tab 行末尾,但位于 `tablist` 之外。
- Tab 过多时实例区域单行横向滚动,不换行、不自动隐藏实例。“+”紧随最后一个标签并随标签一起滚动,滚到末尾时可见。
- 标签全部可见时隐藏左右滚动按钮;溢出时左右箭头透明浮动于工作区两端,不占标签布局宽度。鼠标移入标签栏时显示可滚动方向的箭头,移出后隐藏;键盘聚焦箭头时显示,无悬停能力的设备保持可滚动方向的箭头可见。悬停箭头时显示轻微底色。点击滚动约一屏,边界方向禁用并隐藏,避免与末尾“+”重叠,且不拦截下方标签的指针操作。切换或新增激活标签时将其滚入可见区域。未激活标签使用低对比度灰蓝底色和细边框,激活标签保留亮底与强调色顶线。
- “+”固定在 Tab 行末尾,位于 `tablist` 和页签滚动容器之外,占用独立宽度。
- Tab 过多时实例区域单行横向滚动,不换行、不自动隐藏实例。“+”在 `300px` 工作栏内也保持可见和可点击。
- 标签全部可见时隐藏左右滚动按钮;溢出时左右箭头透明浮动于页签区域两端,不占标签布局宽度,也不进入“+”的点击区域。鼠标移入标签栏时显示可滚动方向的箭头,移出后隐藏;键盘聚焦箭头时显示,无悬停能力的设备保持可滚动方向的箭头可见。悬停箭头时显示轻微底色。点击滚动约一屏,边界方向禁用并隐藏,且不拦截下方标签的指针操作。切换或新增激活标签时将其滚入可见区域。未激活标签使用低对比度灰蓝底色和细边框,激活标签保留亮底与强调色顶线。
- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、
工作目录和 Shell。用户可以重命名终端。
- 任务中心、工作区、成果及新建终端的默认标题随界面语言切换,恢复布局时也使用当前语言。
@@ -565,7 +565,7 @@ Schema、假数据终端或不可用入口的中间产品。
- [x] 连续选择浏览器会创建页面、导航状态和元素引用相互独立的浏览器 Tab。
- [x] 连续选择终端会创建多个互相独立的终端 Tab。
- [ ] 关闭可关闭实例、切换和重排实例后,“+”仍保持可用。
- [ ] Tab 溢出时单行横向滚动,两端箭头固定,“+”跟随最后一个 Tab,滚到末尾可见,不把 Tab 换成下拉框。
- [ ] Tab 溢出时单行横向滚动,页签区两端箭头固定,“+”在滚动区外持续可见,不把 Tab 换成下拉框。
- [x] 关闭可关闭的活动 Tab 后激活正确的相邻 Tab 并恢复焦点。
- [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停
设备上出现,且不引发布局位移。
@@ -1,5 +1,10 @@
# 会话 HTML 渲染实现进度
2026-09-27 浮层审查修复:全屏 iframe 已纳入共享焦点循环,跨文档 Escape 通过受限
Main/Preload 通知返回外层。真实 App 中的带链接及纯正文预览均已验证键盘进入和 Escape,
带链接预览另验证正反向 Tab 返回;没有开放 sandbox 权限或执行预览脚本。
实现与测试范围见[浮层审查修复记录](../../quality/overlay-review-2026-09-27.md)。
## 已实现
- [x] 应用级持久开关,默认开启,并提供旧设置迁移。
@@ -56,6 +56,17 @@ iframe 无法按内容自适应高度,因为 `sandbox=""` 不含 `allow-same-o
嵌入预览完全相同的清理后文档,不额外放宽 Sandbox 或 CSP。切换回复时,源码面板与全屏
预览按当前 source 派生开合状态,不使用会引发级联渲染的重置 effect。
全屏 iframe 提供 `tabIndex=0`,共享焦点候选包含 iframe。关闭按钮的正向 Tab 显式进入
预览,支持没有链接的长文;iframe 后的焦点守卫将原生 Tab 返回到后续 Modal 控件或关闭
按钮。守卫本身不作为焦点循环的首尾,通知关闭按钮仍参与循环。
iframe 内的 Escape 不会冒泡到父文档。`registerWindowIpcHandlers` 只在主窗口直接子
`about:srcdoc` frame 获得焦点且收到 Escape keyDown 时,向主 Renderer 发送
`window:preview-escape`。Preload 暴露无参数订阅 `app.onPreviewEscape`;全屏组件仅在
自己的 iframe 当前聚焦且所属 Modal 未被 inert 隔离时关闭。组件卸载移除订阅,窗口 IPC
释放时移除输入监听。此路径不执行预览脚本、不授予同源权限,也不暴露原始键盘事件或
Electron 对象;iframe 的 Sandbox/CSP 保持原值。
## 验证范围
- 应用设置默认值、关闭持久化和版本 10 迁移。
@@ -65,4 +76,5 @@ iframe 无法按内容自适应高度,因为 `sandbox=""` 不含 `allow-same-o
- 源码按钮的 `aria-expanded` 切换,以及全屏预览的初始焦点、背景 inert、Escape 关闭和
焦点恢复。
- 主 Agent、子 Agent、用户、推理和流式状态边界。
- 真实 Electron 中带链接与纯正文 iframe 的进入、正反向 Tab 返回和内部 Escape;通过生产 Main/Preload 事件订阅完成关闭。
- 从设置 Switch 到当前会话重新渲染的应用级集成路径。
@@ -81,6 +81,49 @@
## 已验证证据
### 2026-09-27 MCP 编辑器遮挡修复
真实 Electron App 经“设置 → 能力与工具 → MCP → 自定义 MCP → 添加 Server”复现。
修复前编辑器层级为 `70`、设置为 `130`,输入框中心命中设置页签容器;名称输入框虽已
获得焦点,父设置仍为 `inert=false`。添加和编辑现接入共享 Modal 层级与背景隔离,
遮罩关闭保留触发按钮焦点。没有修改 Runtime、Agent 或 MCP 传输实现。
`overlay-layering.electron.test.ts` 新增真实 App 用例,复用生产 preload、IPC、
`CapabilityService`、设置存储与 `BrowserService`。已通过的场景:
| 场景 | 验证结果 |
| --- | --- |
| 无浏览器,浅色 `1280 × 900`、深色 `960 × 420` | 添加/编辑鼠标命中、文字输入、父设置隔离、Tab/Shift+Tab 循环通过 |
| 右侧原生浏览器打开,浅色 `1280 × 900`、深色 `1280 × 420` | 设置及 MCP 期间原生视图隐藏;设置关闭后恢复相同 webContents ID、URL 和页面内标记,无重建或刷新 |
| 关闭与焦点 | X、取消、Escape、遮罩、保存成功返回原触发按钮;关闭子层保留设置,关闭设置返回其入口 |
| 持久化 | 四次 UI 保存经生产 IPC 写入临时配置,新建 `CapabilityService` 重读四条记录;编辑重开回显名称 |
原有 overlay Electron 用例也通过,继续覆盖解析菜单、二级结果、通知、应用中心、本机
推理监控和原生 `dialog`。回归禁用 Windows 原生窗口遮挡节流,避免其他桌面窗口暂停
测试的帧等待;未改变产品窗口选项。浏览器页面使用本地 HTTP 服务;MCP 测试配置保存为
停用状态,不启动外部 MCP 命令。真实模型调用 **0 次**。
证据范围为 Windows x64 开发态 Electron。无关启动服务使用 harness 替身,未配置的
向量模型与 Runtime 原生能力服务会返回不可用;未验收这些服务、外部 MCP 连接、远程
Agent 或其他操作系统。短窗口从已打开设置调整尺寸;宽度跨越工作栏自动收起阈值的路径
不作为浏览器保持打开的验收场景。
最终 focused 集合为 6 文件、215 项通过;`npm run typecheck`、`npm run lint` 和
`git diff --check` 通过。本轮未运行全仓 `npm test` 或打包。验证命令:
```text
npx vitest run tests/overlay-layering.electron.test.ts src/renderer/src/McpSettingsSection.test.tsx src/renderer/src/SettingsPanel.test.tsx src/renderer/src/FloatingPortal.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx src/renderer/src/RightAssistantSidebar.resize.test.tsx
npm run typecheck
npm run lint
git diff --check
```
调试期间的失败记录:旧代码在真实鼠标命中断言失败;接入共享焦点后补修遮罩关闭的默认
焦点转移。harness 的帧等待曾受 Windows 窗口遮挡节流影响,浏览器导航曾在创建或输入
状态提交前触发;分别关闭测试进程的遮挡节流,并等待可用控件与实际页面加载后验证。
这些失败未计为产品验收通过。中文文档扫描无阻断项;本节的并列项是实测关闭入口和覆盖
组件清单,保留以便核对。
### 2026-09-03 `v0.12.0` 失败标签恢复
- 不可变标签工作流 `33704483505` 的 Windows x64/ARM64 与 macOS x64/ARM64 托管 Python
@@ -279,6 +279,14 @@ stdio MCP:
Tab、不渲染占位面板。
- 页签直接包装层使用 `flex: 0 0 auto`,并由 CSS 回归测试保证在滚动设置布局中不可收缩。
`McpSettingsSection` 的添加与编辑共用 body Portal,遮罩使用 `--z-dialog`。
打开前记录并聚焦触发按钮,再由 `activateModalFocus` 隔离父设置、聚焦名称并在关闭时
恢复原隔离状态与焦点;键盘事件在子层停止传播。遮罩的 `mousedown` 关闭分支阻止默认
焦点转移,避免恢复到触发按钮后又失焦。原生浏览器继续复用
`browser-viewport-occlusion.ts`,无需 MCP 专用隐藏条件。
真实 App 回归位于 `tests/overlay-layering.electron.test.ts`,使用生产 preload、IPC、
配置存储及 `BrowserService`,验证添加、持久化、编辑、父层隔离与原生浏览器恢复。
### 11.2 工具环境状态与 IPC
Renderer 接收:
@@ -113,6 +113,12 @@ MCP 面板
- MCP 原有四个内层页签继续保留,外层和内层使用不同的 `aria-label` 与 `idPrefix`。
- 窄屏时外层页签保持单行并允许水平滚动,不折成多行,不替换为下拉框。
“MCP / 自定义 MCP”的添加和编辑共用设置上方的子 Modal。打开后聚焦名称输入框,
父设置与工作区保持不可交互;Tab 和 Shift+Tab 在编辑器内循环。X、取消、Escape、
遮罩及保存成功均返回原添加或编辑按钮,Escape 只关闭编辑器。保存期间禁止关闭,
失败保留输入。右侧浏览器沿用应用级 Modal 的视图释放规则,编辑器关闭后仍由父设置
保持隔离,设置关闭后恢复网页。
## 4. 工具下载源
使用原生 Radio Group:
@@ -1,5 +1,15 @@
# 模型连接请求定制进度
## 2026-09-27:窄容器协议显示
连接管理布局改用命名容器查询,不超过 `640px` 时列表和表单上下排列;移除原先仅在
窗口不超过 `720px` 时切换的规则。LLM、向量连接及角色编辑共用同一布局样式。
Windows Electron 验证覆盖 `680/720/721/740/960/1180px` 窗宽、短窗口、中英文及浅深
主题;另在 `1180px` 窗口中单独缩窄设置面板,确认布局由实际容器宽度决定。
协议文字完整、表单无横向溢出,原生 Tab 与方向键可选择并还原协议。没有保存配置或发送
模型请求。验证命令、截图说明和未测范围见
[窄容器布局修复记录](../../quality/responsive-controls-review-2026-09-27.md)。
## 2026-09-20:模型设置可用性修复
- 模型接口 URL 保存失败时按连接名称显示错误,URL 字段同步提供 `aria-invalid` 和就地
@@ -22,6 +22,9 @@
`aria-invalid` 并关联就地错误,避免只显示无法定位的“Invalid URL”。
- LLM 模型连接的分类标题和模型类型分段页签在内容滚动时共同粘附在内容区顶部;左侧连接
列表仍可独立滚动。
- 连接列表和编辑表单按自身容器宽度切换左右或上下排列,断点及列表滚动上限遵循
[设置中心布局规则](../../../UI-DESIGN.md#138-设置中心)。窄窗口中收起的接口协议选择框
保留完整中英文当前值,不能依赖展开选项才能辨认协议。
- 每个已保存或正在编辑的 LLM 连接提供“复制连接”。复制会生成新的连接名称和 ID,保留
地址、模型、协议、请求定制、认证方式及当前草稿 API Key。已保存的 API Key 通过 Main
进程的加密凭据复制路径保存,不回传到 Renderer;原连接和默认连接不改变。
+62
View File
@@ -0,0 +1,62 @@
# 浮层审查修复记录
日期:2026-09-27。本阶段处理全局样式审查中的五项浮层问题。既有 MCP 子 Modal 修复和
[共享控件阶段](shared-controls-review-2026-09-27.md)保持原实现,菜单危险色覆盖未重复修改。
## 修复与验证
| 入口与问题 | 实现 | 实际验证 |
| --- | --- | --- |
| 附件解析结果打开大图,图片被父遮罩盖住 | 图片遮罩复用 `--z-dialog`;解析正文及图片回调保持稳定,关闭后原触发节点仍在 | 完整 App、浅深主题;读取窗口合成截图中大图中心红色像素,检查父层 inert,Escape 返回图片入口,再关闭解析层 |
| 同行帮助被原生浏览器盖住 | 共享 occlusion selector 增加 tooltip,内容矩形沿用相交检测及 ResizeObserver | 运行记录页真实 InlineHelp;1600px 窗口与工作栏最小宽度下不相交,1000px 窗口重开工作栏并调至最大宽度后相交;检查真实 WebContentsView 隐藏/恢复,实例 ID 与页面内标记不变 |
| HTML 全屏无法键盘进入 iframe | iframe 加入共享焦点候选,显式进入及返回守卫;内部 Escape 使用受限窗口通知 | 完整 App、生产 Main/Preload;带链接预览正反向进入/返回及内部 Escape,纯正文进入、PageDown 和内部 Escape;保留空 sandbox 与禁脚本 CSP |
| 窄侧栏项目 Modal 的 Escape 同时关闭两层 | ProjectSwitcher 接入 `activateModalFocus`,忽略已处理或被隔离弹层的按键 | 760px 窗口侧栏中新建项目,一次 Escape 仅关闭项目且焦点回到项目入口,第二次才关闭侧栏 |
| 知识引用遮罩仍是原生拖动区域 | 加入共享 no-drag;自动发现测试按 fixed/inset 扫描,覆盖 `-dialog` 类名 | 完整 App 点击知识引用;Windows 原生命中返回 HTCLIENT,临时清除 no-drag 的对照返回 HTCAPTION |
解析大图路径另暴露了焦点恢复问题:ReactMarkdown 的图片组件在 App Context 更新时被
重建。稳定 `SectionContent` 与图片页签回调后,返回焦点断言通过,没有改为聚焦无关按钮。
HTML 键盘桥只转发主窗口直接 srcdoc 子 frame 的 Escape keyDown。窗口级单测另检查
主 frame、普通网页、间接子 frame、Tab、keyUp 不转发,以及监听释放后不再发送。
它不为预览授予 scripts、same-origin、forms 或 navigation 权限。技术接线由
[HTML 渲染技术设计](../features/conversation-html-rendering/technical-design.md#ui-与无障碍)维护。
## 测试组织
真实路径位于 [overlay-paths-main.ts](../../tests/support/overlay-paths-main.ts),由
[overlay-layering.electron.test.ts](../../tests/overlay-layering.electron.test.ts)启动。App、
preload、窗口事件 IPC、BrowserService 及 viewport 调用使用生产实现;解析结果及图片是
受控数据,其他无关启动服务使用 fixture。测试使用独立 profile/SQLite 和本地 HTTP 网页,
不启动模型,模型请求尝试为 0。
设置 `GB_OVERLAY_PATHS_ARTIFACTS` 可保留浅深大图、两种帮助布局的 PNG 与结果 JSON。
原生命中在 Windows 使用 WM_NCHITTEST;其他平台只执行其余行为和 computed no-drag 检查。
测试启动器退出后删除隔离 profile 和构建目录。
此前漏测的原因分别是:只检查 Modal DOM 存在、原生遮挡用例未枚举 tooltip、HTML 用例
未用真实 Tab 穿越 frame、项目和侧栏单独测试,以及遮罩自动发现依赖类名后缀。本次回归
对应加入组合路径与原生断言。
## 验证边界
本机验证为 Windows、Electron 43.2.0,DPR 1。未把解析 fixture 当作真实 OCR 验收;未执行
macOS/Linux 原生输入、全页面缩放矩阵;本阶段之后的全仓结果见
[最终验证汇总](responsive-controls-review-2026-09-27.md#全部样式修复最终验证)。
已执行的定向回归:
- Renderer 共享控件、浮层、浏览器租约、Markdown、ProjectSwitcher、DocumentResultPreview 及窗口事件:9 文件、176 项通过。
- Main IPC、窗口事件、Preload sandbox 及画布 IPC:4 文件、197 项通过,1 项原有跳过。旧 WebContents fixture 补充了事件订阅/移除方法,没有给生产代码添加 mock 专用兼容分支。
- App Modal 与侧栏筛选:分别 4 项和 15 项通过,其余由名称筛选排除。
- `tests/overlay-layering.electron.test.ts`:3 项通过,包含原有菜单/通知、MCP 完整 App 以及本阶段五条路径。MCP 保留四个浅深/短窗口/浏览器场景及生产保存重读断言。
- `tests/shared-controls.electron.test.ts`、`tests/inline-help.electron.test.ts`、`tests/magic-notes-capture.electron.test.ts`:通过,保留前一阶段 338 组 computed style 与危险按钮回归。
- 最终调整项目初始焦点与 iframe 事件归属断言后,`MarkdownRenderer.test.tsx`、`ProjectSwitcher.test.tsx`、`window-ipc.test.ts` 共 53 项通过;整个 overlay Electron 文件再次 3 项通过。
- `npm run typecheck`、`npm run lint`、`git diff --check`:通过。新增质量记录中文审校无阻断项;键盘事件枚举保留为技术测试条件。
最终测试已通过 `GB_OVERLAY_PATHS_ARTIFACTS` 导出 PNG 与结果 JSON,并人工查看浅深图片
Modal 和帮助相交截图。浏览器可见性以 Main 中实际 WebContentsView 状态为准,Renderer
截图不作为原生视图已隐藏的替代证据。
扩大验证曾发现窗口先销毁再执行 IPC dispose 的清理错误,已改为使用注册时捕获的
WebContents 引用,单测及 MCP Electron 复跑通过。测试脚本也修正了异步窗口尺寸/工作栏
展开的等待条件和旧 fixture 接口,未放宽像素、焦点、命中或浏览器可见性断言。
+29 -2
View File
@@ -57,11 +57,14 @@ AI 列 280。测试同步原生输入与实际状态后独立复跑通过,保
[UI 设计系统](../../UI-DESIGN.md)。
**防复发**:`WorkspacePrimitives.test.tsx` 的
"releases the native drag region for every full-window overlay" 从 Renderer 顶层全部 CSS 文件自动发现
全部 `position: fixed` 遮罩并逐个校验,新增遗漏会直接失败。
"releases the native drag region for every full-window overlay" 递归扫描 Renderer CSS,按
`position: fixed` 与 `inset` 声明发现遮罩,排除不接收指针的透明宿主,并校验共享 no-drag。
**历史**:`e9679fe`、`ca4a58c` 各修了一部分被人工点到的弹窗,同一批改动又新增了两个未覆盖
的遮罩;后续复查改为共享规则加自动发现测试。
2026-09-27 复查发现自动发现仍依赖 `backdrop/modal` 后缀,漏掉知识引用的
`.knowledge-citation-dialog`。补入共享规则并移除后缀限制;真实 App 的 Windows
`WM_NCHITTEST` 回归验证修复后为 `HTCLIENT`,临时清除该规则的对照为 `HTCAPTION`。
## 共享控件样式依赖页面祖先
@@ -80,6 +83,15 @@ AI 列 280。测试同步原生输入与实际状态后独立复跑通过,保
**历史**:2026-09-27 同次检查确认草稿确认及上述四处调用均受同一作用域错误影响。
同日后续全局审查发现,控件已有全局定义仍会被容器与状态规则覆盖:深色通用输入规则替换
控件边框,普通图标 hover 覆盖危险图标,菜单按钮规则覆盖危险确认;`.field input` 还将
Radio 与 Switch 当成文本字段。共享规则现已排除二元控件,恢复全局焦点轮廓,降低菜单
默认样式特异性,并移除深色控件兜底覆盖。Continue 独立字段通过 `.field-control` 复用
输入规则。`shared-controls.electron.test.ts` 检查浅深主题、设置容器、body Portal、菜单
组合、默认/悬停/禁用/选中态及原生 Tab 焦点,并渲染实际 Continue 组件。令牌测试同时检查
主按钮 hover 白字与控件背景的对比度。[审查修复记录](shared-controls-review-2026-09-27.md)
保留本次范围、验证结果和未测项。
## 网页浮层遗漏原生浏览器视图
**症状**:知识库实例、项目设置等 Modal 的右侧被浏览器网页覆盖,通知或菜单也可能
@@ -97,6 +109,9 @@ AI 列 280。测试同步原生输入与实际状态后独立复跑通过,保
**历史**:先前浏览器多实例修正只覆盖了终端关闭确认;2026-09-13 用户在知识库实例
Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
2026-09-27 同行帮助的 tooltip 仍未进入选择器。现已纳入同一相交判定和尺寸观察;
Electron 回归从运行记录页打开真实 InlineHelp,并通过工作栏分隔条形成相交与非相交布局,
检查实际 WebContentsView 可见性及恢复后的页面标记,确认网页没有重建或刷新。
## Modal 内的菜单与通知被遮挡或隔离
@@ -117,6 +132,18 @@ Modal,兼顾 top layer 绘制、辅助技术的模态归属及焦点循环;
**历史**:2026-09-19 同次检查确认解析测试菜单和应用级通知两处挂载机制错误,后者影响
多个全局 Modal;统一宿主后补完整 App 的真实保存通知与 TXT 解析复验。
2026-09-27 在真实 App 的“设置 → 能力与工具 → MCP → 自定义 MCP → 添加 Server”
再次复现子层遮挡:MCP body Portal 沿用 `z-index: 70`,低于设置的 `130`,输入框中心
实际命中父设置页签。编辑器仅手动聚焦,父设置未设为 `inert`。添加与编辑统一接入
`--z-dialog` 和 `activateModalFocus`;遮罩关闭阻止默认鼠标焦点转移,Escape 停在子层。
新增 Electron 回归检查真实鼠标命中、输入、Tab 循环、各关闭入口、生产 IPC 保存及重读,
并检查浏览器在父子 Modal 期间隐藏、最后恢复同一未刷新的网页。子 Modal 应使用共享模态
机制;上述 `FloatingPortal` 仍用于非模态菜单与通知。
同次审查确认解析结果打开图片时也沿用低于父层的 `z-index: 80`,现复用 `--z-dialog`。
解析正文使用稳定的组件与图片回调,避免 App 打开大图时重建触发节点而丢失返回焦点。
窄侧栏打开项目 Modal 也已接入共享背景隔离,避免一次 Escape 同时关闭两层。
本阶段实现、键盘边界和 Electron 证据见[浮层审查修复记录](overlay-review-2026-09-27.md)。
## Flex 滚动内容压缩页签和分段控件
**症状**:知识库来源选择被长参数表单压成窄缝,标签和按钮文字被裁掉。
@@ -0,0 +1,114 @@
# 窄容器布局审查修复记录
日期:2026-09-27。范围为全局样式审查第三阶段的工作栏添加入口和模型设置布局。
此前 MCP、共享控件、浮层和深色主按钮对比度修复均保留。
## 修复
工作栏原先将“+”放在页签滚动区内,`300px` 栏宽下默认四个页签就会将按钮卷出可见区。
现在添加按钮独立占用行末宽度,页签及两端滚动箭头放在剩余区域内;溢出测量也读取该区域。
没有改动应用目录、实例策略、持久化或终端生命周期。
模型设置原先按整窗 `720px` 断点切换单列。`721px` 窗口仍保留分类、连接列表和表单三列,
协议选择框宽度只有约 `136px`。现在 `.model-connection-manager` 自身作为命名容器,
可用宽度不超过 `640px` 时让列表及详情各占整行,列表保留 `180px` 的内部滚动上限。
更宽时沿用原双栏。LLM、向量连接和角色编辑共用这条规则,没有增加页面专属断点。
产品规则见 [UI-DESIGN.md](../../UI-DESIGN.md)、
[工作栏页签 PRD](../features/assistant-workbar/terminal-tabs-prd.md#71-tab-行)和
[模型连接 UI 设计](../features/model-connections/ui-design.md)。工作栏两份 PRD 中“+ 随页签
滚动”的旧说明已纠正。
## Electron 证据
[workbar-settings-layout.electron.test.ts](../../tests/workbar-settings-layout.electron.test.ts)
在隔离 profile 中加载当前生产 `WorkbarShell`、`SettingsPanel`、共享样式及字体。
测试使用受控 IPC 配置数据,不访问模型服务。Windows Electron 共记录 32 组结果,包含:
- 中文与英文、浅色与深色主题。
- `300px` 工作栏的默认四个页签、16 个含长标题的页签,以及从目录新建第 17 个实例。
检查“+”位于可见栏内且中心点可命中,原生鼠标打开目录、创建浏览器实例;Escape 返回
添加按钮。滚动箭头不改变活动页签,Home/End 激活并显示首末页签。
- 默认页签下将工作栏扩到 `900px` 再缩回 `300px`,检查溢出箭头退出及恢复。
- 设置内容区窗口为 `680×560`、`720×560`、`721×560`、`740×560`、`960×560`、
`1180×760`。另在 `1180×560` 窗口中将设置面板限定为 `740px`,检查同一窗口内的
容器响应,而非仅检查媒体断点。
- 长连接名称下,协议当前值的真实字体测量宽度小于控件扣除内边距及箭头后的可用宽度。
设置内容区、连接管理区及详情无横向溢出,协议框可命中;原生 Tab 进入后,方向键改变
协议,Home 还原。`721px` 窗宽下控件 clientWidth 为 `370px`,英文协议文字约 `233px`。
通过 `GB_LAYOUT_ARTIFACTS` 可输出截图和 `result.json`。已查看英文深色 `721px` 设置、
英文宽窗口窄容器及中英文工作栏截图。测试退出后删除专用 profile 与 Vite 缓存。
## 验证命令
```text
npm test -- src/renderer/src/WorkbarShell.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx src/renderer/src/SettingsPanel.test.tsx src/renderer/src/EmbeddingSettingsSection.test.tsx src/renderer/src/RolePromptSettingsSection.test.tsx src/renderer/src/RightAssistantSidebar.resize.test.tsx tests/workbar-settings-layout.electron.test.ts tests/shared-controls.electron.test.ts
npm run typecheck
npm run lint
```
Focused 回归 8 文件、238 项通过;typecheck 和 lint 通过。前阶段共享控件 Electron
回归的 338 组状态测量仍通过。模型调用 0 次,未提交代码。
补强页签焦点与选中态同步断言后,新增 Electron 回归连续三次完整通过,每次 32 组结果。
新驱动调试期间修正了目录按钮选择器、环境变量类型声明和 Electron 方向键名称。
原生 select 弹出菜单的关闭时序曾使驱动超时,最终使用原生 Tab 与方向键验证字段操作,
并保留命中、文字宽度和溢出断言;没有放宽产品断言或增加生产代码补丁。
后续联合运行曾停在调整窗口后的动画帧等待。驱动补充了超时脚本记录,并在测试进程中关闭
Windows 原生遮挡节流,等待窗口焦点和尺寸后再同步绘制及发送鼠标输入。该设置不进入产品。
最终连续三次回归通过;超时记录不足以单独证明此前每次停顿的唯一原因。
## 限制
这是当前生产组件的真实 Electron 布局与输入回归,IPC 数据使用 fixture;不证明完整 App
的配置保存或模型调用链路。未覆盖原生 select 弹出菜单的鼠标选项选择、macOS/Linux、
系统缩放、200% 字号、原生窗口遮挡节流和全部设置类别。向量连接和角色编辑运行了组件回归,但本次 Electron
矩阵仅验证 LLM 连接表单。本阶段定向验证未运行全仓测试或生产构建;后续全仓结果见下节。
## 全部样式修复最终验证
2026-09-27 在 Windows 开发环境完成最终验证。范围为共享控件 8 项、浮层 5 项、
窄容器 2 项,共 15 项样式问题,以及 MCP 子 Modal 修复。先读取根目录 `AGENTS.md`,
检查未提交源码和文档 diff;原有 37 个已跟踪修改文件、12 个未跟踪文件均保留,暂存区为空。
本次新增的生产修正仅为 `ProjectSwitcher.tsx` 的初始字段选择器一行,未改测试断言。
| 命令与执行范围 | 实际结果 |
| --- | --- |
| `npm test`,当前工作树,全仓且无 bail | 463 文件:450 通过、2 失败、11 跳过;5395 项:5309 通过、3 失败、83 跳过。耗时 696.22 秒 |
| `npm test -- src/renderer/src/HeartbeatCenter.test.tsx`,隔离 HEAD `11facf0` | 1 文件失败;40 项通过、2 项失败,耗时 4.86 秒,与当前工作树同一行、同一中英文断言失败 |
| `npm test -- src/renderer/src/App.test.tsx src/renderer/src/ProjectSwitcher.test.tsx tests/overlay-layering.electron.test.ts`,焦点修复后 | 3 文件、331 项通过,耗时 80.57 秒 |
| `npm run typecheck` | 全仓测试后及焦点修复后均通过,覆盖 node、agent、web 三份 TypeScript 配置 |
| `npm run lint` | 全仓测试后及焦点修复后均通过 |
| `git diff --check` | 通过;Git 提示已有 LF 将转换为 CRLF,没有空白错误 |
完整 `npm test` 只启动一次,超时设为 1,200,000 毫秒,等待正常结束,没有 bail、
中止或重复启动。修复后只复跑相关集合,未把定向通过改写成全仓通过。
### 失败归因与处理
`App.test.tsx:8921` 是本次回归。项目 Modal 新接入的初始焦点逻辑只查询 input,
覆盖了通道项目说明 textarea 的原有自动聚焦,实际焦点落到后面的默认工作目录。
确认组件结构和失败输出后,选择器改为按 DOM 顺序查询未禁用的 input、textarea、select,
无字段时仍退回按钮。原有说明字段焦点断言保持不变,App 全文件及项目切换组件、真实
Electron 浮层回归共 331 项通过,包含窄侧栏 Escape 隔离与 MCP 保存重读路径。
`HeartbeatCenter.test.tsx:213` 的中文、英文两项失败为既有问题。测试要求全页只有
1 个 tablist,并要求内容面板内没有 tablist;但图谱有事件数据时,
`SupervisorWorkspace.tsx:677` 已渲染事件、实体、关系的内层 `PageTabs`,实际总数为 2。
未带本轮改动的 HEAD `11facf0` 使用相同依赖独立执行该文件,也得到 40 通过、2 失败。
本次保留组件与断言,后续需按图谱导航设计确认测试范围;没有将预期数量直接改为 2。
### Electron 结果与未测范围
全仓运行中,共享控件输出 338 组 computed style 测量,窄容器输出 32 组布局和输入结果。
overlay 文件 3 项通过,包含 MCP 四种主题/窗口/浏览器场景、生产 IPC 保存与重新读取,
以及大图、iframe、项目 Modal、tooltip、知识引用五条路径;焦点修正后该文件再次通过。
这些样式与 MCP 驱动的模型请求尝试为 0。全仓另有隔离的 403 fixture 输出,每种凭据来源
各记录 1 次请求;这不作为真实模型生成证据,也不据此声称全仓外部调用总数为 0。
剩余失败为上述两项 HeartbeatCenter 既有断言。全仓跳过的 11 文件、83 项仍未形成通过证据。
本次没有运行生产构建、打包、macOS/Linux 验证、远程 Agent 实机或外部 MCP 连接;
未覆盖强制颜色、完整系统缩放/200% 字号矩阵、原生 select 弹出菜单的鼠标选项选择,
也未将组件 fixture 当作全部 App 业务入口验收。部分 Electron 驱动关闭了 Windows
原生遮挡节流,不能据此宣称产品在原生遮挡状态下也已覆盖。没有 commit 或 push。
@@ -0,0 +1,55 @@
# 共享控件审查修复记录
日期:2026-09-27。范围为全局样式审查第一阶段,包含原审查六项、会话菜单危险按钮覆盖及
深色主按钮 hover 对比度。原有 MCP 子 Modal 修复保留,本阶段没有改动其组件或业务逻辑。
## 修复范围
| 问题与触发位置 | 共享修复 | 验证 |
| --- | --- | --- |
| 深色设置输入框、次按钮边框被兜底规则覆盖 | 删除通用输入覆盖及次按钮边框覆盖,使用主题 `--border-control` | 设置容器和 body Portal 读取最终边框;输入边框与实际背景至少 3:1 |
| `.field` 与浏览器地址框丢失焦点环;Radio 被拉高 | 文本字段规则排除 Checkbox/Radio,删除局部 outline 清除和未定义 `--focus-ring` 引用 | 原生 Tab 后轮廓为 2px 强调色;Switch 为 38×22px,Checkbox/Radio 为 15×15px |
| 图标按钮、分段控件禁用后仍像可操作 | 共享禁用透明度和光标;普通图标 hover 排除禁用项 | 禁用时光标为 not-allowed,透明度降低,hover 不恢复可用颜色 |
| Continue 删除图标丢失危险颜色 | 普通图标 hover 排除危险修饰类,移除深色普通图标覆盖 | 组合类及实际 Continue 删除按钮在两种主题下保留危险前景和悬停背景 |
| Continue Rule/Prompt 输入回退原生样式 | 五个独立文本字段使用 `.field-control`,复用原表单规则 | 实际组件字段使用共享边框、字体、内边距和至少 38px 高度,并保留键盘焦点 |
| 默认 PageTabs hover 覆盖 active | hover 只作用于未选中项 | 当前项悬停前后背景及文字颜色不变 |
| 会话菜单 button 覆盖 danger-button | 会话与工作区文件菜单的通用规则降低特异性,删除多余文件菜单危险补丁 | 实际 `DestructiveConfirmActions` 从入口进入确认后保留危险实心背景、反白文字;禁用危险图标不高亮 |
| 深色主按钮 hover 白字对比度约 4.31:1 | `--accent-solid-hover` 调整为 `#1a77c5` | 白字对比度 4.68:1;令牌及真实按钮默认/hover 均检查至少 4.5:1 |
回归另发现浅色 `--border-control` 在 `--surface-subtle` 上只有约 2.90:1。
共享边框令牌由 `#7f95aa` 调整为 `#748a9f`,对白底、subtle、muted 背景分别为
3.57:1、3.35:1、3.02:1。没有按页面另设边框颜色。
实现主要位于 [styles.css](../../src/renderer/src/styles.css);Continue 接线位于
[RuntimeCustomizationSection.tsx](../../src/renderer/src/RuntimeCustomizationSection.tsx)。
共享规范由 [UI-DESIGN.md](../../UI-DESIGN.md) 维护。
## 回归证据
[shared-controls.electron.test.ts](../../tests/shared-controls.electron.test.ts) 使用当前源码组件、
共享样式和主题函数。Continue 数据使用受控 IPC fixture,菜单使用实际
`DestructiveConfirmActions`。测试在真实 Electron/Chromium 中读取 computed style,
覆盖浅深主题、设置容器和真实 body Portal,共记录 338 组状态测量,包含 PageTabs 的
default 与 segmented 变体。焦点通过原生 Tab
进入;hover 通过 CDP 强制伪状态检查,不作为原生鼠标命中证据。模型调用为 0。
本机环境为 Windows、Electron 43.2.0、Chromium 150,内容区 1280×800,DPR 1。
已打开审阅浅深 Continue 字段及深色 Portal 的原尺寸 PNG;共享输入边框、删除图标和
焦点轮廓符合计算值。截图与完整 computed JSON 可通过 `GB_SHARED_CONTROLS_ARTIFACTS`
指定目录输出,测试专用 profile 与 Vite 缓存在退出后删除。
已执行:
- `npx vitest run tests/shared-controls.electron.test.ts tests/overlay-layering.electron.test.ts src/renderer/src/WorkspacePrimitives.test.tsx src/renderer/src/SettingsPanel.test.tsx src/renderer/src/WorkspaceFilesPanel.test.tsx`:最终 5 文件、181 项通过。
- MCP 浮层回归的 4 个场景通过:浅深主题、短窗口、原生鼠标命中与焦点循环、生产 IPC 保存及重读;有浏览器的场景保持 Modal 期间隐藏、关闭后恢复同一未刷新的网页。模型请求尝试为 0。
- `npm run typecheck` 通过;`npm run lint` 通过。
首轮新回归在对话框输入的浅色边框对比度断言失败,修正共享令牌后通过。测试启动器曾有
环境变量对象类型错误,增加 `NodeJS.ProcessEnv` 标注后类型检查通过。未放宽视觉断言。
## 剩余边界
本阶段上述八类问题已修复。共享控件测试属于组件视觉层,不证明完整 App 的全部业务入口;
MCP 原有集成回归独立验证其真实保存与浏览器隔离路径。本阶段未执行全仓测试、macOS/Linux、
强制颜色、200% 缩放及全页面四档窗口矩阵。后续全部 15 项样式修复与 MCP 的
[最终验证汇总](responsive-controls-review-2026-09-27.md#全部样式修复最终验证)记录全仓结果及剩余失败。
+27 -27
View File
@@ -102,7 +102,7 @@ describe('terminal IPC boundary', () => {
})
it('validates terminal requests and routes them through the trusted renderer owner', async () => {
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
id: 73,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
@@ -1065,7 +1065,7 @@ describe('registerIpcHandlers update source routing', () => {
})
it('uses the persisted source for checks and the download page', async () => {
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
@@ -1172,7 +1172,7 @@ describe('registerIpcHandlers model download source routing', () => {
})
it('uses the persisted source and rejects stale renderer requests', async () => {
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
@@ -1421,7 +1421,7 @@ describe('registerIpcHandlers DSH runtime extensions', () => {
})
it('validates extension actions, reloads the Runtime, and trusts only the renderer', async () => {
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
@@ -1568,7 +1568,7 @@ describe('registerIpcHandlers SSH hosts', () => {
})
it('validates narrow host requests and restricts them to the trusted renderer', async () => {
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
@@ -2302,7 +2302,7 @@ describe('registerIpcHandlers SSH hosts', () => {
setProjectArchived: vi.fn(),
deleteProject: vi.fn()
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
@@ -2667,7 +2667,7 @@ describe('registerIpcHandlers lifecycle tracking', () => {
const workspace = await mkdtemp(
join(tmpdir(), 'goodbuddy-ipc-settings-')
)
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -2879,7 +2879,7 @@ describe('registerIpcHandlers lifecycle tracking', () => {
.mockRejectedValueOnce(activationError)
.mockRejectedValueOnce(rollbackError)
const repairs = vi.fn()
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -3041,7 +3041,7 @@ describe('registerIpcHandlers lifecycle tracking', () => {
})
const repairs = vi.fn(() => [])
const reportRepairs = vi.fn()
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -3179,7 +3179,7 @@ describe('registerIpcHandlers knowledge snapshot ontology', () => {
tasks: []
}))
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -3267,7 +3267,7 @@ describe('registerIpcHandlers knowledge embedding index', () => {
knowledgeEmbeddingApiKeyConfigured: false
}))
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -3361,7 +3361,7 @@ describe('registerIpcHandlers knowledge task actions', () => {
cancelTask: vi.fn(async () => true),
retryTask: vi.fn(async () => undefined)
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -3432,7 +3432,7 @@ describe('registerIpcHandlers model ZIP dialogs', () => {
})
it('imports and exports speech and OCR ZIPs through trusted dialogs', async () => {
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -3665,7 +3665,7 @@ describe('registerIpcHandlers document parsing', () => {
listPendingConversationQueueIds: vi.fn(() => []),
createInlineArtifact
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -3811,7 +3811,7 @@ describe('registerIpcHandlers connection tests', () => {
dispose: vi.fn(async () => undefined)
}
const getResolvedSettings = vi.fn(async () => resolvedSettings)
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -3934,7 +3934,7 @@ describe('registerIpcHandlers connection tests', () => {
getStatus: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -4029,7 +4029,7 @@ describe('registerIpcHandlers Runtime config actions', () => {
continueConfigPath: configPath
}
}))
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -4160,7 +4160,7 @@ describe('registerIpcHandlers window controls', () => {
it('restricts custom chrome controls to the trusted main window', async () => {
let maximized = false
const listeners = new Map<string, () => void>()
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -4259,7 +4259,7 @@ describe('registerIpcHandlers workspace files', () => {
listPendingConversationQueueIds: vi.fn(() => []),
getProject: vi.fn(() => ({ id: projectId, rootPath }))
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
}
@@ -4348,7 +4348,7 @@ describe('registerIpcHandlers workspace files', () => {
updatedAt: '2026-08-01T00:00:00.000Z'
}))
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
}
@@ -4424,7 +4424,7 @@ describe('registerIpcHandlers token usage', () => {
listPendingConversationQueueIds: vi.fn(() => []),
getExecutionStatsAsync: vi.fn(async () => summary)
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
}
@@ -4470,7 +4470,7 @@ describe('registerIpcHandlers token usage', () => {
listPendingConversationQueueIds: vi.fn(() => []),
getTokenUsageSummary: vi.fn(() => summary)
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: {
url: 'file:///goodbuddy/index.html'
},
@@ -4575,7 +4575,7 @@ describe('registerIpcHandlers local conversation persistence', () => {
clear: vi.fn(),
remove: vi.fn()
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: {
url: 'file:///goodbuddy/index.html'
},
@@ -4709,7 +4709,7 @@ describe('registerIpcHandlers local conversation persistence', () => {
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: {
url: 'file:///goodbuddy/index.html'
},
@@ -4829,7 +4829,7 @@ describe('registerIpcHandlers Runtime customization', () => {
detail: 'OpenCode manages native context'
}
}
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
@@ -5321,7 +5321,7 @@ describe('registerIpcHandlers agent terminal state', () => {
rootPath: 'C:\\ProjectWorkspace'
}))
}
const webContents = {
const webContents = { on: vi.fn(),
id: 9,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
@@ -12728,7 +12728,7 @@ describe('registerIpcHandlers Magic Notes analysis', () => {
runtimeFactoryMocks.createDefaultModelRuntime.mockReturnValue(
analysisRuntime
)
const webContents = {
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
+2 -1
View File
@@ -3257,7 +3257,7 @@ export function registerIpcHandlers(
false
)
registerWindowIpcHandlers(registerHandler, window)
const disposeWindowIpc = registerWindowIpcHandlers(registerHandler, window)
registerHandler(ipcChannels.appClearLocalData, (event) => {
assertTrustedSender(event, window)
@@ -7374,6 +7374,7 @@ export function registerIpcHandlers(
registerKnowledgeIpcHandlers(registerHandler, window, knowledgeService, settingsStore)
return async () => {
disposeWindowIpc()
shuttingDown = true
if (nativeClientCoordinator) window.webContents.removeListener('destroyed', closeNativeClients)
await nativeClientCoordinator?.closeOwner(nativeClientOwnerId)
@@ -65,6 +65,7 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', ()
let api: DesktopApi['magicNotes']
let applicationSettings: ApplicationSettingsStore
const webContents = { id: 91, mainFrame: { url: 'file:///canvas-test/index.html' },
on: vi.fn(), removeListener: vi.fn(),
getURL: () => 'file:///canvas-test/index.html', isDestroyed: () => false, send: vi.fn() }
const window = { webContents, isDestroyed: () => false, isMaximized: () => false,
on: vi.fn(), removeListener: vi.fn() }
+40
View File
@@ -0,0 +1,40 @@
import { EventEmitter } from 'node:events'
import type { BrowserWindow, ipcMain } from 'electron'
import { expect, it, vi } from 'vitest'
import { ipcChannels } from '../shared/ipc-channels'
import { registerWindowIpcHandlers } from './window-ipc'
vi.mock('electron', () => ({ clipboard: {} }))
vi.mock('./window', () => ({ showWindow: vi.fn() }))
it('relays only direct srcdoc Escape keydowns and removes the listener on disposal', () => {
const mainFrame = { url: 'file:///app/index.html', parent: null }
const contents = Object.assign(new EventEmitter(), {
mainFrame, focusedFrame: mainFrame as { url: string; parent: unknown } | null, send: vi.fn()
})
let destroyed = false
const window = { get webContents() {
if (destroyed) throw new Error('Object has been destroyed')
return contents
} }
const dispose = registerWindowIpcHandlers(vi.fn() as typeof ipcMain.handle, window as unknown as BrowserWindow)
const input = (key = 'Escape', type = 'keyDown'): void => { contents.emit('before-input-event', {}, { key, type }) }
input()
contents.focusedFrame = null
input()
contents.focusedFrame = { url: 'https://example.com', parent: mainFrame }
input()
contents.focusedFrame = { url: 'about:srcdoc', parent: {} }
input()
contents.focusedFrame = { url: 'about:srcdoc#section', parent: mainFrame }
input('Tab')
input('Escape', 'keyUp')
expect(contents.send).not.toHaveBeenCalled()
input()
expect(contents.send).toHaveBeenCalledExactlyOnceWith(ipcChannels.windowPreviewEscape)
destroyed = true
dispose()
input()
expect(contents.send).toHaveBeenCalledTimes(1)
expect(contents.listenerCount('before-input-event')).toBe(0)
})
+12 -1
View File
@@ -22,7 +22,17 @@ export function registerClipboardIpcHandlers(
export function registerWindowIpcHandlers(
registerHandler: typeof ipcMain.handle,
window: BrowserWindow
): void {
): () => void {
const contents = window.webContents
// Sandboxed srcdoc keyboard events cannot bubble to the renderer's modal.
const previewEscape = (_event: Electron.Event, input: Electron.Input): void => {
const frame = contents.focusedFrame
if (input.type === 'keyDown' && input.key === 'Escape' &&
frame?.parent === contents.mainFrame && frame.url.startsWith('about:srcdoc')) {
contents.send(ipcChannels.windowPreviewEscape)
}
}
contents.on('before-input-event', previewEscape)
registerHandler(ipcChannels.appShow, (event) => {
assertTrustedSender(event, window)
showWindow(window)
@@ -56,4 +66,5 @@ export function registerWindowIpcHandlers(
assertTrustedSender(event, window)
return window.isMaximized()
})
return () => contents.removeListener('before-input-event', previewEscape)
}
+5
View File
@@ -250,6 +250,11 @@ const desktopApi: DesktopApi = {
handler
)
},
onPreviewEscape: (listener) => {
const handler = (): void => listener()
ipcRenderer.on(ipcChannels.windowPreviewEscape, handler)
return () => ipcRenderer.removeListener(ipcChannels.windowPreviewEscape, handler)
},
onBeforeQuit: (listener) => {
const handler = (
_event: Electron.IpcRendererEvent,
+1
View File
@@ -227,6 +227,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
toggleMaximize: vi.fn(async () => {}),
close: vi.fn(async () => {}),
isMaximized: vi.fn(async () => false),
onPreviewEscape: vi.fn(() => () => undefined),
onMaximizedChanged: vi.fn((listener) => {
maximizedChangedListener = listener;
return removeMaximizedChangedListener;
+8 -6
View File
@@ -1,4 +1,4 @@
import { useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import DOMPurify from 'dompurify'
@@ -44,7 +44,7 @@ function ResultImage({ resultId, image, onLocate, label }: {
</div>
}
function SectionContent({ content, result, onImage }: {
const SectionContent = memo(function SectionContent({ content, result, onImage }: {
content: string; result: DocumentResult; onImage: (id: string) => void
}): React.JSX.Element {
const pieces = useMemo(() => content.split(/(<table\b[\s\S]*?<\/table>)/giu), [content])
@@ -61,7 +61,7 @@ function SectionContent({ content, result, onImage }: {
const image = images.get(id)
return image ? <><ResultImage resultId={result.id} image={image} /><button type="button" className="secondary-button" onClick={() => onImage(id)}>第 {image.pageNumber} 页图片 · 在图片中查看</button></> : <span>图片资源不可用</span>
} }}>{piece.replace(/<\/?(?:div|p|span|h[1-6])\b[^>]*>/giu, '\n').replace(/<br\s*\/?\s*>/giu, '\n\n')}</ReactMarkdown>)}</div>
}
})
export function DocumentResultPreview({ resultId, allowAddImages = false, conversationId, showOpenOriginal = true, imageActionsInImagesTab = false }: { resultId: string; allowAddImages?: boolean; conversationId?: string; showOpenOriginal?: boolean; imageActionsInImagesTab?: boolean }): React.JSX.Element {
const conversationContext = useContext(DocumentConversationContext)
@@ -109,6 +109,10 @@ export function DocumentResultPreview({ resultId, allowAddImages = false, conver
return () => { active = false; unsubscribe() }
}, [allowAddImages, target])
const focusTarget = useRef<string | undefined>(undefined)
const showImage = useCallback((id: string) => {
focusTarget.current = `${prefix}-image-${id}`
setTab('images')
}, [prefix])
useEffect(() => {
let active = true
void window.goodbuddy.documentParsing!.getResult(resultId).then(
@@ -138,9 +142,7 @@ export function DocumentResultPreview({ resultId, allowAddImages = false, conver
<section className="document-result-panel" role="tabpanel" id={`${prefix}-panel-${tab}`} aria-labelledby={`${prefix}-tab-${tab}`}>
{tab === 'text' && result.sections.map((section, index) => <article key={index}>
<h3 tabIndex={-1} id={`${prefix}-section-${index}`}>{section.locator}</h3>
<SectionContent content={section.content} result={result} onImage={(id) => {
focusTarget.current = `${prefix}-image-${id}`; setTab('images')
}} />
<SectionContent content={section.content} result={result} onImage={showImage} />
</article>)}
{tab === 'images' && <><p>已保存 {result.images.length} 张 · 未保存 {result.missingImages.length} 张</p>
{!result.images.length && <p>此结果没有提取图片;原生解析器可能只提取文字。</p>}
+17 -1
View File
@@ -24,6 +24,7 @@ const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn()
}))
const previewEscape = vi.fn<() => void>()
vi.mock('mermaid', () => ({
default: mermaidMock
@@ -31,6 +32,12 @@ vi.mock('mermaid', () => ({
describe('MarkdownRenderer', () => {
beforeEach(() => {
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
app: { onPreviewEscape: vi.fn((listener: () => void) => {
previewEscape.mockImplementation(listener)
return () => previewEscape.mockReset()
}) }
} })
mermaidMock.render.mockResolvedValue({
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
<rect id="safe-node" width="100" height="50" />
@@ -178,18 +185,27 @@ const ready = true
name: '关闭全屏预览'
})
expect(closeButton).toHaveFocus()
act(() => previewEscape())
expect(dialog).toBeInTheDocument()
expect(
document.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(true)
const frame = screen.getByTitle('Agent 回复 HTML 全屏静态预览')
expect(frame).toHaveAttribute('sandbox', '')
expect(frame).toHaveAttribute('tabindex', '0')
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer')
expect(frame.getAttribute('srcdoc') ?? '').toContain(
'Full screen page'
)
fireEvent.keyDown(dialog, { key: 'Escape' })
fireEvent.keyDown(closeButton, { key: 'Tab' })
expect(frame).toHaveFocus()
dialog.setAttribute('inert', '')
act(() => previewEscape())
expect(dialog).toBeInTheDocument()
dialog.removeAttribute('inert')
act(() => previewEscape())
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(viewerButton).toHaveFocus()
})
+7 -7
View File
@@ -31,7 +31,7 @@ import type {
RuntimeTarget,
WebSearchTestResult
} from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { PageTabs } from './WorkspacePrimitives'
const configurableMcpTargets: RuntimeTarget[] = [
@@ -177,10 +177,10 @@ export function McpSettingsSection({
if (!editorOpen) {
return
}
const frame = requestAnimationFrame(() =>
editorNameRef.current?.focus()
return activateModalFocus(
() => editorNameRef.current,
() => editorTriggerRef.current ?? null
)
return () => cancelAnimationFrame(frame)
}, [editorOpen])
const run = async (
@@ -388,6 +388,7 @@ export function McpSettingsSection({
trigger: HTMLButtonElement
): void => {
editorTriggerRef.current = trigger
trigger.focus()
setError(undefined)
setEditor(nextEditor)
}
@@ -396,16 +397,14 @@ export function McpSettingsSection({
if (busy === 'save') {
return
}
const trigger = editorTriggerRef.current
editorTriggerRef.current = undefined
setError(undefined)
setEditor(undefined)
requestAnimationFrame(() => trigger?.focus())
}
const handleEditorKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>
): void => {
event.stopPropagation()
if (event.key === 'Escape') {
event.preventDefault()
closeEditor()
@@ -1214,6 +1213,7 @@ export function McpSettingsSection({
className="mcp-editor-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault()
closeEditor()
}
}}
+12 -1
View File
@@ -41,7 +41,7 @@ import type {
import type {
RemoteProjectRecoveryState
} from '../../shared/remote-project-recovery-contracts'
import { trapTabFocus } from './dialog-focus'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import {
getDefaultRuntimeSelection
} from './runtime-selection'
@@ -812,6 +812,15 @@ export function ProjectSwitcher({
return () => cancelAnimationFrame(focusFrame)
}, [directoryPickerOpen])
useEffect(() => {
if (!dialogMode) return
return activateModalFocus(
() => dialogRef.current?.querySelector<HTMLElement>('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)') ??
dialogRef.current?.querySelector<HTMLElement>('button:not(:disabled)') ?? null,
() => restoreFocusTarget.current === 'create' ? createButtonRef.current : projectPickerButtonRef.current
)
}, [dialogMode])
useEffect(() => {
if (!dialogMode) {
if (restoreFocusTarget.current === 'create') {
@@ -823,6 +832,7 @@ export function ProjectSwitcher({
return
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.defaultPrevented || dialogRef.current?.closest('[inert]')) return
if (directoryPickerOpen) {
if (event.key === 'Escape') {
event.preventDefault()
@@ -833,6 +843,7 @@ export function ProjectSwitcher({
return
}
if (event.key === 'Escape' && !archiving && !deleting) {
event.preventDefault()
if (remoteProjectsEnabled && remoteSaving) {
void window.goodbuddy.projects.remote
.cancelCurrent()
@@ -1989,7 +1989,7 @@ describe('RightAssistantSidebar resizing', () => {
bounds.mockRestore()
}
})
it.each(['menu', 'dialog', 'notification'])('hides the native view only while an overlapping %s is visible', async (kind) => {
it.each(['menu', 'dialog', 'tooltip', 'notification'])('hides the native view only while an overlapping %s is visible', async (kind) => {
const popup = document.createElement('section')
if (kind === 'notification') popup.className = 'app-notification'
else popup.setAttribute('role', kind)
@@ -882,6 +882,7 @@ export const RuntimeCustomizationSection = forwardRef<
aria-label={t(
'runtime.customization.continue.ruleName'
)}
className="field-control"
maxLength={
runtimeCustomizationLimits.nameCharacters
}
@@ -947,6 +948,7 @@ export const RuntimeCustomizationSection = forwardRef<
'runtime.customization.continue.ruleContent',
{ name: rule.name }
)}
className="field-control"
maxLength={
runtimeCustomizationLimits.contentCharacters
}
@@ -1014,6 +1016,7 @@ export const RuntimeCustomizationSection = forwardRef<
aria-label={t(
'runtime.customization.continue.promptName'
)}
className="field-control"
maxLength={
runtimeCustomizationLimits.nameCharacters
}
@@ -1057,6 +1060,7 @@ export const RuntimeCustomizationSection = forwardRef<
'runtime.customization.continue.promptDescription',
{ name: prompt.name }
)}
className="field-control"
maxLength={
runtimeCustomizationLimits.descriptionCharacters
}
@@ -1084,6 +1088,7 @@ export const RuntimeCustomizationSection = forwardRef<
'runtime.customization.continue.promptContent',
{ name: prompt.name }
)}
className="field-control"
maxLength={
runtimeCustomizationLimits.contentCharacters
}
+5
View File
@@ -5911,6 +5911,7 @@ describe('SettingsPanel runtime files', () => {
const addServer = await screen.findByRole('button', {
name: '添加 Server'
})
const settingsBackdrop = addServer.closest<HTMLElement>('.settings-backdrop')!
fireEvent.click(addServer)
const dialog = screen.getByRole('dialog', {
name: '添加 MCP Server'
@@ -5921,6 +5922,8 @@ describe('SettingsPanel runtime files', () => {
const saveButton = within(dialog).getByRole('button', {
name: '保存 MCP Server'
})
expect(settingsBackdrop.inert).toBe(true)
expect(dialog.parentElement?.parentElement).toBe(document.body)
closeButton.focus()
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })
expect(saveButton).toHaveFocus()
@@ -5961,6 +5964,7 @@ describe('SettingsPanel runtime files', () => {
)
)
await waitFor(() => expect(addServer).toHaveFocus())
expect(settingsBackdrop.inert).toBe(false)
expect(
screen.queryByRole('dialog', { name: '添加 MCP Server' })
).not.toBeInTheDocument()
@@ -5975,6 +5979,7 @@ describe('SettingsPanel runtime files', () => {
}
fireEvent.mouseDown(backdrop)
await waitFor(() => expect(addServer).toHaveFocus())
expect(settingsBackdrop.inert).toBe(false)
expect(
screen.queryByRole('dialog', { name: '添加 MCP Server' })
).not.toBeInTheDocument()
+24 -1
View File
@@ -3,6 +3,7 @@ import { Code2, FileCode2, Maximize2, X } from 'lucide-react'
import {
memo,
useEffect,
useEffectEvent,
useId,
useMemo,
useRef,
@@ -10,7 +11,7 @@ import {
} from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { activateModalFocus, getModalFocusableElements, trapTabFocus } from './dialog-focus'
const forbiddenTags = [
'base',
@@ -108,11 +109,18 @@ function StaticHtmlViewer({
const hintId = useId()
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const frameRef = useRef<HTMLIFrameElement>(null)
const closePreview = useEffectEvent(onClose)
useEffect(
() => activateModalFocus(() => closeRef.current),
[]
)
useEffect(() => window.goodbuddy.app.onPreviewEscape(() => {
if (document.activeElement === frameRef.current && !dialogRef.current?.closest('[inert]')) {
closePreview()
}
}), [])
return createPortal(
<div
@@ -131,9 +139,15 @@ function StaticHtmlViewer({
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
onClose()
return
}
if (event.key === 'Tab' && !event.shiftKey && document.activeElement === closeRef.current) {
event.preventDefault()
frameRef.current?.focus()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
@@ -160,11 +174,20 @@ function StaticHtmlViewer({
</button>
</header>
<iframe
ref={frameRef}
referrerPolicy="no-referrer"
sandbox=""
srcDoc={documentSource}
tabIndex={0}
title={t('markdown.htmlViewerFrame')}
/>
{/* Native Tab leaving a cross-origin frame needs a focus target in this document. */}
<span className="sr-only" tabIndex={0} data-focus-guard onFocus={() => {
if (!dialogRef.current || !frameRef.current) return
const targets = getModalFocusableElements(dialogRef.current)
const next = targets[targets.indexOf(frameRef.current) + 1] ?? targets[0]
next?.focus()
}} />
</section>
</div>,
document.body
+3 -2
View File
@@ -158,8 +158,9 @@ describe('WorkbarShell', () => {
fireEvent.scroll(scroll)
expect(previous).toBeDisabled()
const add = screen.getByRole('button', { name: '打开工作栏应用' })
expect(add.parentElement).toBe(strip)
expect(list.nextElementSibling).toBe(add)
expect(add.parentElement).toBe(row.parentElement)
expect(row.nextElementSibling).toBe(add)
expect(scroll).not.toContainElement(add)
expect(row.firstElementChild).toBe(previous)
expect(row.lastElementChild).toBe(next)
rowWidth = 800
+106 -104
View File
@@ -402,98 +402,114 @@ export function WorkbarShell({
aria-label={t('sidebar.workbar.ariaLabel')}
className={joinClassNames('workbar-shell', className)}
>
<div className="workbar-shell__tab-row" ref={tabRowRef}>
{tabOverflow.visible && (
<button
aria-label={t('sidebar.workbar.scrollLeft')}
title={t('sidebar.workbar.scrollLeft')}
className="workbar-shell__scroll-button"
disabled={!tabOverflow.left}
onClick={() => scrollTabs(-1)}
type="button"
>
<ChevronLeft aria-hidden="true" />
</button>
)}
<div className="workbar-shell__tab-scroll" ref={tabScrollRef} onScroll={updateTabOverflow}>
<div className="workbar-shell__tab-strip" ref={tabListRef}>
<div
aria-label={t('sidebar.workbar.tablist')}
className="workbar-shell__tablist"
role="tablist"
>
{instances.map((instance, index) => {
const selected =
!catalogOpen && instance.id === activeInstanceId
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
const closable =
appDefinitions.find(
(definition) => definition.id === instance.appId
)?.closable === true
return (
<div
className={joinClassNames(
'workbar-shell__tab-item',
selected && 'workbar-shell__tab-item--active'
)}
key={instance.id}
>
<button
aria-controls={panelId(instance.id)}
aria-selected={selected}
className={joinClassNames(
'workbar-shell__tab',
selected && 'workbar-shell__tab--active'
)}
id={tabId(instance.id)}
onClick={() => {
setCatalogOpen(false)
setTargetError(null)
onActiveInstanceChange(instance.id)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, index)
}
role="tab"
tabIndex={selected ? 0 : -1}
title={
appDefinitions.find(
(application) =>
application.id === instance.appId
)?.description ?? instance.title
}
type="button"
>
<span
aria-hidden="true"
className="workbar-shell__tab-icon"
<div className="workbar-shell__tab-row">
<div className="workbar-shell__tab-navigation" ref={tabRowRef}>
{tabOverflow.visible && (
<button
aria-label={t('sidebar.workbar.scrollLeft')}
title={t('sidebar.workbar.scrollLeft')}
className="workbar-shell__scroll-button"
disabled={!tabOverflow.left}
onClick={() => scrollTabs(-1)}
type="button"
>
<ChevronLeft aria-hidden="true" />
</button>
)}
<div className="workbar-shell__tab-scroll" ref={tabScrollRef} onScroll={updateTabOverflow}>
<div className="workbar-shell__tab-strip" ref={tabListRef}>
<div
aria-label={t('sidebar.workbar.tablist')}
className="workbar-shell__tablist"
role="tablist"
>
{instances.map((instance, index) => {
const selected =
!catalogOpen && instance.id === activeInstanceId
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
const closable =
appDefinitions.find(
(definition) => definition.id === instance.appId
)?.closable === true
return (
<div
className={joinClassNames(
'workbar-shell__tab-item',
selected && 'workbar-shell__tab-item--active'
)}
key={instance.id}
>
<AppIcon />
</span>
<span className="workbar-shell__tab-label">
{instance.title}
</span>
{renderTabAdornment?.(instance)}
</button>
{closable ? (
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
) : null}
</div>
)
})}
<button
aria-controls={panelId(instance.id)}
aria-selected={selected}
className={joinClassNames(
'workbar-shell__tab',
selected && 'workbar-shell__tab--active'
)}
id={tabId(instance.id)}
onClick={() => {
setCatalogOpen(false)
setTargetError(null)
onActiveInstanceChange(instance.id)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, index)
}
role="tab"
tabIndex={selected ? 0 : -1}
title={
appDefinitions.find(
(application) =>
application.id === instance.appId
)?.description ?? instance.title
}
type="button"
>
<span
aria-hidden="true"
className="workbar-shell__tab-icon"
>
<AppIcon />
</span>
<span className="workbar-shell__tab-label">
{instance.title}
</span>
{renderTabAdornment?.(instance)}
</button>
{closable ? (
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
) : null}
</div>
)
})}
</div>
</div>
</div>
{tabOverflow.visible && (
<button
aria-label={t('sidebar.workbar.scrollRight')}
title={t('sidebar.workbar.scrollRight')}
className="workbar-shell__scroll-button"
disabled={!tabOverflow.right}
onClick={() => scrollTabs(1)}
type="button"
>
<ChevronRight aria-hidden="true" />
</button>
)}
</div>
<button
aria-expanded={catalogOpen}
aria-label={t('sidebar.workbar.add')}
@@ -510,20 +526,6 @@ export function WorkbarShell({
>
<span aria-hidden="true">+</span>
</button>
</div>
</div>
{tabOverflow.visible && (
<button
aria-label={t('sidebar.workbar.scrollRight')}
title={t('sidebar.workbar.scrollRight')}
className="workbar-shell__scroll-button"
disabled={!tabOverflow.right}
onClick={() => scrollTabs(1)}
type="button"
>
<ChevronRight aria-hidden="true" />
</button>
)}
</div>
<div className="workbar-shell__content">
+10 -4
View File
@@ -164,13 +164,15 @@ describe('WorkspacePrimitives', () => {
// region, otherwise the native titlebar keeps the pointer and its controls
// stop responding to hover and clicks.
const overlays = [
...readdirSync(join(process.cwd(), 'src', 'renderer', 'src'))
...readdirSync(join(process.cwd(), 'src', 'renderer', 'src'), { recursive: true, encoding: 'utf8' })
.filter((name) => name.endsWith('.css'))
.map((name) => readFileSync(join(process.cwd(), 'src', 'renderer', 'src', name), 'utf8'))
.join('\n').matchAll(
/^(\.[\w-]*(?:backdrop|modal))\s*\{([^}]*)\}/gmu
/^\s*(\.[\w-]+)\s*\{([^}]*)\}/gmu
)
].filter(([, , body]) => /position:\s*fixed/u.test(body!))
].filter(([, selector, body]) => /position:\s*fixed/u.test(body!) &&
(/inset\s*:/u.test(body!) || /(?:backdrop|modal|dialog)$/u.test(selector!)) &&
!/pointer-events:\s*none/u.test(body!))
expect(overlays.length).toBeGreaterThan(10)
for (const [, selector, body] of overlays) {
expect(covered, `${selector} must release the drag region`).toContain(
@@ -425,7 +427,7 @@ describe('WorkspacePrimitives', () => {
/\.field > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field input,\s*\.field textarea,\s*\.field select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
/\.field input:not\(:where\(\[type='checkbox'\], \[type='radio'\]\)\),\s*\.field textarea,\s*\.field select,\s*\.field-control\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.field small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
@@ -822,6 +824,7 @@ describe('WorkspacePrimitives', () => {
['success', 'success-subtle'],
['danger', 'danger-subtle'],
['text-on-accent', 'accent-solid'],
['text-on-accent', 'accent-solid-hover'],
['text-on-accent', 'danger-solid'],
['text-on-accent', 'danger-solid-hover']
] as const
@@ -834,5 +837,8 @@ describe('WorkspacePrimitives', () => {
expect(
contrast(tokens['border-control']!, tokens['surface-raised']!)
).toBeGreaterThanOrEqual(3)
expect(
contrast(tokens['border-control']!, tokens['surface-subtle']!)
).toBeGreaterThanOrEqual(3)
})
})
@@ -27,10 +27,17 @@ describe('native browser overlay detection', () => {
const { host, viewport } = setup({ 'aria-modal': 'true' }, false)
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
})
it.each(['dialog', 'alertdialog', 'menu'])('detects overlapping %s without requiring modal focus setup', role => {
it.each(['dialog', 'alertdialog', 'menu', 'tooltip'])('detects overlapping %s without requiring modal focus setup', role => {
const { host, viewport } = setup({ role })
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
})
it('ignores non-overlapping and hidden tooltips', () => {
const { host, overlay, viewport } = setup({ role: 'tooltip' }, false)
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
vi.spyOn(overlay, 'getBoundingClientRect').mockReturnValue(viewport)
overlay.hidden = true
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
})
it.each(['hidden', 'display', 'visibility', 'ancestor-display'])('ignores a modal hidden by %s', kind => {
const { host, parent, overlay, viewport } = setup({ 'aria-modal': 'true' })
if (kind === 'hidden') parent.hidden = true
@@ -3,6 +3,7 @@ export const browserOverlaySelector = [
'[role="dialog"]',
'[role="alertdialog"]',
'[role="menu"]',
'[role="tooltip"]',
'.app-notification'
].join(', ')
+8 -4
View File
@@ -5,7 +5,13 @@ type TabKeyEvent = {
}
const focusableSelector =
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), iframe:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])'
export function getModalFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
.filter((element) => !element.hasAttribute('data-focus-guard') &&
!element.closest('[hidden], [inert], [aria-hidden="true"]'))
}
export function trapTabFocus(
event: TabKeyEvent,
@@ -14,9 +20,7 @@ export function trapTabFocus(
if (event.key !== 'Tab' || !container) {
return
}
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(focusableSelector)
).filter((element) => !element.closest('[hidden], [inert], [aria-hidden="true"]'))
const focusable = getModalFocusableElements(container)
if (focusable.length === 0) {
event.preventDefault()
container.focus()
+46 -51
View File
@@ -11,7 +11,7 @@
--text-disabled: #718398;
--text-on-accent: #fff;
--border-default: #cedce7;
--border-control: #7f95aa;
--border-control: #748a9f;
--border-subtle: #e2eaf1;
--accent: #086fc7;
--accent-solid: #0875cf;
@@ -675,7 +675,6 @@
.custom-task-dialog__field
:is(input, textarea, select):focus-visible {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.custom-task-dialog__field
@@ -3917,6 +3916,7 @@ textarea:focus-visible {
.document-parsing-diagnostic-backdrop,
.mcp-editor-backdrop,
.knowledge-confirm-dialog-backdrop,
.knowledge-citation-dialog,
.knowledge-library-dialog-backdrop,
.knowledge-dialog-backdrop,
.external-knowledge-modal
@@ -3992,7 +3992,7 @@ button > svg {
scrollbar-gutter: stable;
}
.icon-button:hover {
.icon-button:hover:where(:not(:disabled, .danger-ghost)) {
background: var(--accent-subtle);
color: var(--accent);
}
@@ -4121,6 +4121,13 @@ button > svg {
gap: var(--space-1);
}
.workbar-shell__tab-navigation {
position: relative;
display: flex;
min-width: 0;
flex: 1;
}
.workbar-shell__tab-scroll {
min-width: 0;
flex: 1;
@@ -5315,7 +5322,6 @@ button > svg {
padding: 0 var(--space-2);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
outline: none;
background: var(--surface-raised);
color: var(--text-primary);
font-family: var(--font-family-ui);
@@ -5324,7 +5330,6 @@ button > svg {
.assistant-sidebar__browser-address input:focus-visible {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.assistant-sidebar__browser-go {
@@ -7209,7 +7214,7 @@ button > svg {
.image-viewer-backdrop {
position: fixed;
z-index: 80;
z-index: var(--z-dialog);
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
@@ -9942,6 +9947,7 @@ details.settings-section > :not(summary) + :not(summary) {
.model-connection-manager {
display: grid;
container: model-connections / inline-size;
min-width: 0;
align-items: start;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
@@ -9988,6 +9994,16 @@ details.settings-section > :not(summary) + :not(summary) {
overflow-y: auto;
}
@container model-connections (max-width: 640px) {
.model-connection-manager > * {
grid-column: 1 / -1;
}
.model-connection-list > [role='list'] {
max-height: 180px;
}
}
.model-connection-list [role='listitem'] + [role='listitem'] {
border-top: 1px solid var(--border-subtle);
}
@@ -11260,35 +11276,38 @@ details.settings-section > :not(summary) + :not(summary) {
font-weight: 650;
}
.field input,
.field input:not(:where([type='checkbox'], [type='radio'])),
.field textarea,
.field select {
.field select,
.field-control {
width: 100%;
min-height: 38px;
padding: 0 11px;
border: 1px solid var(--border-control);
border-radius: 9px;
outline: 0;
background: var(--surface-raised);
color: var(--text-primary);
font-size: var(--font-body);
}
.field input:focus,
.field input:not(:where([type='checkbox'], [type='radio'])):focus,
.field textarea:focus,
.field select:focus {
.field select:focus,
.field-control:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 16%, transparent);
}
.field input:disabled,
.field input:not(:where([type='checkbox'], [type='radio'])):disabled,
.field textarea:disabled,
.field select:disabled {
.field select:disabled,
.field-control:disabled {
background: var(--surface-muted);
color: var(--text-muted);
}
.field textarea {
.field textarea,
textarea.field-control {
padding-top: 9px;
padding-bottom: 9px;
resize: vertical;
@@ -12247,7 +12266,7 @@ details.settings-section > :not(summary) + :not(summary) {
.mcp-editor-backdrop {
position: fixed;
z-index: 70;
z-index: var(--z-dialog);
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
@@ -12486,8 +12505,8 @@ details.settings-section > :not(summary) + :not(summary) {
font-size: var(--font-body);
}
.conversation-actions button,
.workspace-files__menu button {
/* Menu defaults must not override shared button states. */
:where(.conversation-actions, .workspace-files__menu) button {
display: flex;
min-height: 30px;
align-items: center;
@@ -12501,8 +12520,7 @@ details.settings-section > :not(summary) + :not(summary) {
text-align: left;
}
.conversation-actions button:hover,
.workspace-files__menu button:hover {
:where(.conversation-actions, .workspace-files__menu) button:where(:hover:not(:disabled)) {
background: var(--accent-subtle);
color: var(--accent);
}
@@ -13089,7 +13107,7 @@ details.settings-section > :not(summary) + :not(summary) {
white-space: nowrap;
}
.page-tabs__tab:hover {
.page-tabs__tab:hover:where(:not(.page-tabs__tab--active)) {
background: var(--surface-subtle);
color: var(--text-primary);
}
@@ -13151,6 +13169,12 @@ details.settings-section > :not(summary) + :not(summary) {
box-shadow: var(--shadow-card);
}
.icon-button:disabled,
.segmented-control__option:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.empty-state {
display: grid;
place-content: center;
@@ -16298,7 +16322,7 @@ details.settings-section > :not(summary) + :not(summary) {
--border-subtle: #20384b;
--accent: #72c5ff;
--accent-solid: #1469b5;
--accent-solid-hover: #1c7dce;
--accent-solid-hover: #1a77c5;
--accent-hover: #9bd7ff;
--accent-selected: #28668f;
--accent-subtle: #123b5b;
@@ -16366,8 +16390,7 @@ details.settings-section > :not(summary) + :not(summary) {
.heartbeat-center__plans .heartbeat-settings,
.heartbeat-settings__item,
.capability-card,
.quick-actions button,
.secondary-button
.quick-actions button
) {
border-color: var(--border-subtle);
background: var(--surface-raised);
@@ -16380,20 +16403,6 @@ details.settings-section > :not(summary) + :not(summary) {
background: var(--surface-subtle);
}
:root[data-theme='dark'] :where(
.app-shell,
.sidebar,
.topbar,
.assistant-sidebar,
.settings-panel,
.project-create-card,
.workspace-panel-scroll
) :where(input, textarea, select) {
border-color: var(--border-default);
background: var(--surface-subtle);
color: var(--text-primary);
}
:root[data-theme='dark'] :where(
.app-shell,
.sidebar,
@@ -16481,7 +16490,6 @@ details.settings-section > :not(summary) + :not(summary) {
:root[data-theme='dark'] :where(
.user-card,
.icon-button,
.assistant-sidebar__row,
.welcome__description,
.tool-activity,
@@ -16489,7 +16497,6 @@ details.settings-section > :not(summary) + :not(summary) {
.settings-tabs button,
.credential-state,
.capability-card p,
.secondary-button,
.check-field,
.token-usage th,
.token-usage td,
@@ -16526,8 +16533,6 @@ details.settings-section > :not(summary) + :not(summary) {
}
:root[data-theme='dark'] :where(
.icon-button:hover,
.icon-button--active,
.user-card:hover,
.secondary-button:hover:not(:disabled),
.settings-tabs button[aria-selected='true']
@@ -16948,14 +16953,6 @@ details.settings-section > :not(summary) + :not(summary) {
flex-direction: column;
}
.model-connection-manager {
grid-template-columns: 1fr;
}
.model-connection-list > [role='list'] {
max-height: 180px;
}
.capability-list--tools {
grid-template-columns: 1fr;
}
@@ -18091,8 +18088,6 @@ details.settings-section > :not(summary) + :not(summary) {
box-shadow: var(--shadow-dialog);
}
.workspace-files__menu button { min-height: 32px; justify-content: flex-start; font-size: var(--font-body); }
.workspace-files__menu .danger-ghost { color: var(--danger); }
.workspace-files__menu .danger-ghost:hover { color: var(--danger-strong); background: var(--danger-subtle); }
.workspace-files__menu [role="separator"] { border-top: 1px solid var(--border-subtle); }
.workspace-files__path { overflow-wrap: anywhere; }
.workspace-files__directory-picker { display: grid; min-width: 0; gap: var(--space-2); }
+1
View File
@@ -1783,6 +1783,7 @@ export type DesktopApi = {
close: () => Promise<void>
isMaximized: () => Promise<boolean>
onMaximizedChanged: (listener: (maximized: boolean) => void) => () => void
onPreviewEscape: (listener: () => void) => () => void
onBeforeQuit: (listener: () => Promise<void>) => () => void
clearLocalData: () => Promise<void>
onNewConversation: (listener: () => void) => () => void
+1
View File
@@ -23,6 +23,7 @@ export const ipcChannels = {
windowClose: 'window:close',
windowIsMaximized: 'window:is-maximized',
windowMaximizedChanged: 'window:maximized-changed',
windowPreviewEscape: 'window:preview-escape',
appRendererPersistenceReady: 'app:renderer-persistence-ready',
appRendererPersistenceRequest: 'app:renderer-persistence-request',
appRendererPersistenceComplete: 'app:renderer-persistence-complete',
+63 -1
View File
@@ -1,9 +1,11 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { copyFile, mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
@@ -36,3 +38,63 @@ it('keeps menus and notifications interactive above real Electron modals', async
} finally { clearTimeout(timeout) }
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 120000)
it.each([
['opens the MCP editor from App settings with native browser isolation and real persistence', 'mcp-overlay-main.ts'],
['keeps nested previews, sandbox keyboard, project modals and native overlays usable', 'overlay-paths-main.ts']
])('%s', async (_name, entry) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-mcp-overlay-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react({ include: /\/(src|tests)\/.*\.[jt]sx?$/ }), { name: 'mcp-app', configureServer(server) {
server.middlewares.use('/mcp.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/mcp.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/magic-notes-capture-renderer.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null },
optimizeDeps: { entries: ['tests/support/magic-notes-capture-renderer.tsx'], include: ['fabric', 'quill', 'quill/blots/block', 'quill-delta', 'jspdf', 'pdfjs-dist', 'react', 'react-dom/client', 'react-i18next'] } })
try {
const driver = join(directory, 'main.mjs')
await build({ entryPoints: [`tests/support/${entry}`], outfile: driver,
bundle: true, platform: 'node', format: 'esm', external: ['electron'], plugins: [
{ name: 'isolated-external-services', setup(build) {
build.onResolve({ filter: /channels\/channel-env$/ }, () => ({ path: 'channels', namespace: 'mcp' }))
build.onResolve({ filter: /agent\/create-runtime$/ }, () => ({ path: 'runtime', namespace: 'mcp' }))
build.onLoad({ filter: /.*/, namespace: 'mcp' }, ({ path }) => ({ contents: path === 'channels'
? 'export const startEnvironmentChannels=()=>[];'
: 'export const createDefaultModelRuntime=()=>{globalThis.overlayModelAttempts=(globalThis.overlayModelAttempts??0)+1;throw new Error("Model calls forbidden")}; export const createModelProfileRuntime=createDefaultModelRuntime;' }))
} },
{ name: 'external-packages', setup(build) {
build.onResolve({ filter: /^[^./]/ }, async args => {
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
const result = await build.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true }
})
} }
] })
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
await build({ entryPoints: ['src/main/execution-stats-worker.ts'], outfile: join(directory, 'out/main/execution-stats-worker.js'), bundle: true, platform: 'node', format: 'cjs' })
await server.listen()
const env: NodeJS.ProcessEnv = { ...process.env, GB_MCP_DIRECTORY: directory, GB_MCP_URL: server.resolvedUrls!.local[0] + 'mcp.html' }
delete env.ELECTRON_RUN_AS_NODE
const bootstrap = join(directory, 'bootstrap.cjs')
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(error => { console.error(error); require('electron').app.exit(1) })`)
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 90000)
try {
expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0)
} finally { clearTimeout(timeout) }
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
process.stdout.write(`${entry} Electron evidence: ${JSON.stringify(result)}\n`)
expect(result).toMatchObject({ modelAttempts: 0 })
if (entry === 'mcp-overlay-main.ts') {
expect(result.persistedAfterReload).toBe(true)
expect(result.scenarios).toHaveLength(4)
} else {
expect(result).toMatchObject({ image: true, iframe: true, project: true, tooltip: true, citation: true })
}
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 180000)
+48
View File
@@ -0,0 +1,48 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('preserves shared control states in real Chromium across themes and containers', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-shared-controls-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'shared-controls', configureServer(server) {
server.middlewares.use('/controls.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/controls.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/shared-controls-regression.tsx"></script></body></html>'))
})
} }],
optimizeDeps: { entries: ['tests/support/shared-controls-regression.tsx'] },
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/shared-controls-driver.mjs'), driver)
const env: NodeJS.ProcessEnv = { ...process.env, GB_CONTROLS_DIRECTORY: directory,
GB_CONTROLS_URL: server.resolvedUrls!.local[0] + 'controls.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 90000)
try {
expect(await new Promise((resolve, reject) => {
child.once('exit', resolve)
child.once('error', reject)
}), output).toBe(0)
process.stdout.write(output)
} finally { clearTimeout(timeout) }
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 120000)
+228
View File
@@ -0,0 +1,228 @@
import { app, BrowserWindow, safeStorage, WebContentsView } from 'electron'
import assert from 'node:assert/strict'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createServer } from 'node:http'
import { registerIpcHandlers } from '../../src/main/ipc'
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
import { CapabilityService } from '../../src/main/capabilities/capability-service'
import { BrowserService } from '../../src/main/browser/browser-service'
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
const directory = process.env.GB_MCP_DIRECTORY!
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
// Other desktop windows must not suspend the frame-based input synchronization.
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app.whenReady().then(async () => {
console.log('MCP overlay: Electron ready')
const win = new BrowserWindow({ show: true, width: 1280, height: 900, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false,
backgroundThrottling: false, preload: join(directory, 'preload.cjs') } })
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
await applicationSettings.update({ checkUpdatesOnStartup: false })
const cipher = { isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (value: string) => safeStorage.encryptString(value), decrypt: (value: Buffer) => safeStorage.decryptString(value) }
const capabilities = new CapabilityService(join(directory, 'capabilities.json'),
join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher)
const browser = new BrowserService({ parentWindow: win })
const settings = new RuntimeSettingsStore(join(directory, 'runtime.json'), cipher, {})
// Only unrelated startup dependencies are substituted; MCP, browser, preload and IPC are production.
const dispose = registerIpcHandlers(win,
{ capability: 'text', getStatus: async () => ({ available: true, name: 'Overlay test', capability: 'text' }) } as never,
'CommandOrControl+Shift+Space', settings,
capabilities, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
database, { clear() {} } as never, {} as never, async () => {}, undefined, browser,
undefined, undefined, applicationSettings, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
const errors: string[] = []
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const js = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
const settle = (): Promise<unknown> => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const wait = async (code: string): Promise<void> => {
for (let i = 0; i < 600; i++) {
if (await js(code)) return
await new Promise(resolve => setTimeout(resolve, 25))
}
throw new Error(`Timeout: ${code}\nFocus: ${await js('document.activeElement?.tagName + " " + document.activeElement?.className')}\n${await js('document.body.innerText')}\n${errors.join('\n')}`)
}
const click = async (selector: string): Promise<void> => {
win.focus(); win.webContents.focus()
await wait(`!!document.querySelector(${JSON.stringify(selector)})`)
await js(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'nearest', inline:'nearest'})`)
await settle()
const point = await js<{ x: number; y: number; hit: boolean }>(`(() => {
const e = document.querySelector(${JSON.stringify(selector)}); e.scrollIntoView({block:'nearest'});
const r = e.getBoundingClientRect(), x = Math.round(r.x+r.width/2), y = Math.round(r.y+r.height/2);
return {x,y,hit:e.contains(document.elementFromPoint(x,y))}; })()`)
assert(point.hit, `Occluded: ${selector} ${JSON.stringify(point)} ${await js(`document.elementFromPoint(${point.x},${point.y})?.className`)}`)
win.focus(); win.webContents.focus()
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
await settle()
}
const key = async (keyCode: string, modifiers: Electron.KeyboardInputEvent['modifiers'] = []): Promise<void> => {
win.focus(); win.webContents.focus()
win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
await settle()
}
const evidence: Record<string, unknown> = {}
const pageServer = createServer((_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end('<html><body style="background:#f80"><h1>MCP overlay browser fixture</h1><input id="browser-input"></body></html>')
})
await new Promise<void>(resolve => pageServer.listen(0, '127.0.0.1', resolve))
const pageAddress = pageServer.address() as { port: number }
const nativeView = (): WebContentsView | undefined => win.contentView.children.find(view => view instanceof WebContentsView) as WebContentsView | undefined
const waitNative = async (visible: boolean): Promise<void> => {
for (let i = 0; i < 200; i++) {
if (nativeView()?.getVisible() === visible) return
await new Promise(resolve => setTimeout(resolve, 25))
}
assert.equal(nativeView()?.getVisible(), visible, 'Native browser visibility ' + JSON.stringify(await js(`({
overlays:[...document.querySelectorAll('[aria-modal=true],[role=dialog],[role=menu],.app-notification')].map(e=>[e.className,e.getBoundingClientRect().toJSON()]),
viewport:document.querySelector('.assistant-sidebar__browser-viewport')?.getBoundingClientRect().toJSON(),
workbar:document.querySelector('.assistant-sidebar-toggle')?.outerHTML,
inert:[...document.querySelectorAll('[inert]')].map(e=>e.className || e.id)})`)))
}
try {
console.log('MCP overlay: loading App')
await win.loadURL(process.env.GB_MCP_URL!)
console.log('MCP overlay: App loaded')
await wait('!!document.querySelector(".sidebar")')
console.log('MCP overlay: App mounted')
const add = '#mcp-settings-panel-custom .settings-content-actions button'
const scenarios: unknown[] = []
for (const withBrowser of [false, true]) {
if (withBrowser) {
await click('.assistant-sidebar-toggle[aria-expanded=false]')
await wait('!!document.querySelector(".assistant-sidebar-toggle[aria-expanded=true]")')
await js('document.querySelector(".workbar-shell__tab[aria-selected=true]").focus()')
await key('Right')
await key('Right')
await wait('document.querySelector(".workbar-shell__tab[aria-selected=true]").textContent.startsWith("Browser")')
await waitNative(true)
await wait('!document.querySelector(".assistant-sidebar__browser-address input").disabled')
await click('.assistant-sidebar__browser-address input')
await key('A', ['control'])
await win.webContents.insertText(`http://127.0.0.1:${pageAddress.port}/`)
await wait(`document.querySelector('.assistant-sidebar__browser-address input').value === 'http://127.0.0.1:${pageAddress.port}/' &&
!document.querySelector('.assistant-sidebar__browser-go').disabled`)
await click('.assistant-sidebar__browser-go')
for (let i = 0; i < 200; i++) {
if (await nativeView()!.webContents.executeJavaScript('document.querySelector("h1")?.textContent === "MCP overlay browser fixture"')) break
await new Promise(resolve => setTimeout(resolve, 25))
}
assert.equal(await nativeView()!.webContents.executeJavaScript('document.querySelector("h1")?.textContent'), 'MCP overlay browser fixture',
'Browser page ' + nativeView()!.webContents.getURL() + ' ' + await nativeView()!.webContents.executeJavaScript('document.body?.innerText'))
await nativeView()!.webContents.executeJavaScript('window.overlayDocumentToken = "preserve-page"')
}
for (const theme of ['light', 'dark']) {
console.log('MCP overlay scenario:', { withBrowser, theme })
const width = theme === 'light' || withBrowser ? 1280 : 960
const height = theme === 'light' ? 900 : 420
await js(`document.documentElement.dataset.theme = '${theme}'`)
await click('button[aria-label="Settings"]')
await click('#settings-tab-capabilities')
await click('#capabilities-settings-tab-mcp')
await click('#mcp-settings-tab-custom')
win.setContentSize(width, height)
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
await settle()
if (withBrowser) await waitNative(false)
const browserId = nativeView()?.webContents.id
await click(add)
await wait('document.activeElement === document.querySelector(".mcp-editor input")')
if (withBrowser) await waitNative(false)
assert(await js(`document.querySelector('.settings-backdrop').inert && document.querySelector('.app-shell').inert &&
!document.querySelector('.mcp-editor').closest('[inert]')`), 'Only the child modal may be interactive')
await click('.mcp-editor input')
await win.webContents.insertText('Keyboard draft')
await wait('document.querySelector(".mcp-editor input").value === "Keyboard draft"')
// Programmatic background focus must also be rejected by Chromium inert handling.
await js('document.querySelector("#settings-tab-appearance").focus()')
assert(await js('!!document.activeElement.closest(".mcp-editor")'))
await click('.mcp-editor__header button')
await wait(`!document.querySelector('.mcp-editor') && document.activeElement === document.querySelector('${add}')`)
for (const close of ['escape', 'cancel', 'backdrop']) {
await click(add)
await wait('document.activeElement === document.querySelector(".mcp-editor input")')
await key('Tab', ['shift'])
assert(await js('document.activeElement.matches(".mcp-editor__header button")'))
await key('Tab', ['shift'])
assert(await js('document.activeElement.matches(".mcp-editor__actions .primary-button")'))
await key('Tab')
assert(await js('document.activeElement.matches(".mcp-editor__header button")'))
if (close === 'escape') await key('Escape')
else if (close === 'cancel') await click('.mcp-editor__actions .secondary-button')
else {
win.webContents.sendInputEvent({ type: 'mouseDown', x: 2, y: 2, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: 2, y: 2, button: 'left', clickCount: 1 })
}
await wait(`!document.querySelector('.mcp-editor') && document.activeElement === document.querySelector('${add}')`)
assert(await js('!!document.querySelector(".settings-panel") && !document.querySelector(".settings-backdrop").inert && document.querySelector(".app-shell").inert'))
if (withBrowser) await waitNative(false)
}
await click(add)
await click('.mcp-editor input')
const name = `Overlay ${withBrowser}-${theme}`
await win.webContents.insertText(name)
await click('.mcp-editor input[aria-label]')
await win.webContents.insertText('overlay-test-command')
// Disable the test configuration so no MCP process is launched by runtime refresh.
await click('.mcp-editor input[role="switch"]')
await click('.mcp-editor__actions .primary-button')
await wait(`!document.querySelector('.mcp-editor') && document.activeElement === document.querySelector('${add}')`)
const saved = (await capabilities.getSnapshot()).mcpServers.find(server => server.name === name)!
assert(saved && !saved.enabled && saved.transport === 'stdio' && saved.command === 'overlay-test-command', 'Production IPC must persist the form')
const edit = '#mcp-settings-panel-custom button[aria-label="Edit ' + name + '"]'
await click(edit)
await wait(`document.querySelector('.mcp-editor input')?.value === '${name}'`)
await click('.mcp-editor input')
await key('Escape')
await wait(`!document.querySelector('.mcp-editor') && document.activeElement === document.querySelector('${edit}')`)
win.setContentSize(1280, 900)
await wait('innerWidth === 1280 && innerHeight === 900')
await settle()
await key('Escape')
await wait('!document.querySelector(".settings-panel") && !document.querySelector(".app-shell").inert')
assert(await js('document.activeElement.matches("button[aria-label=Settings]")'), 'Settings must restore its own trigger')
if (withBrowser) {
await waitNative(true)
assert.equal(nativeView()!.webContents.id, browserId, 'Modal must not recreate browser')
assert.equal(nativeView()!.webContents.getURL(), `http://127.0.0.1:${pageAddress.port}/`)
assert.equal(await nativeView()!.webContents.executeJavaScript('window.overlayDocumentToken'), 'preserve-page', 'Modal must not reload browser')
}
scenarios.push({ withBrowser, theme, width, height, hitTesting: true, focusCycle: true, parentIsolation: true,
closeMethods: ['X', 'Escape', 'Cancel', 'backdrop', 'save'], savedId: saved.id, edit: true,
...(withBrowser ? { nativeHiddenDuringModals: true, nativeRestoredWithoutReload: true } : {}) })
}
win.setContentSize(1280, 900)
await wait('innerWidth === 1280 && innerHeight === 900')
await settle()
}
const reloaded = new CapabilityService(join(directory, 'capabilities.json'),
join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher)
assert.equal((await reloaded.getSnapshot()).mcpServers.length, 4)
evidence.scenarios = scenarios
evidence.persistedAfterReload = true
evidence.modelAttempts = (globalThis as typeof globalThis & { overlayModelAttempts?: number }).overlayModelAttempts ?? 0
await writeFile(join(directory, 'result.json'), JSON.stringify(evidence))
assert.equal(evidence.modelAttempts, 0)
assert.deepEqual(errors, [])
} finally {
win.destroy()
await dispose()
await browser.dispose()
pageServer.close()
database.close()
}
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
+247
View File
@@ -0,0 +1,247 @@
import { app, BrowserWindow, ipcMain, nativeImage, safeStorage, screen, WebContentsView } from 'electron'
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { createRequire } from 'node:module'
import { createServer } from 'node:http'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { registerIpcHandlers } from '../../src/main/ipc'
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
import { BrowserService } from '../../src/main/browser/browser-service'
import { CapabilityService } from '../../src/main/capabilities/capability-service'
import { defaultRuntimeSettings } from '../../src/shared/contracts'
import { ipcChannels } from '../../src/shared/ipc-channels'
const directory = process.env.GB_MCP_DIRECTORY!
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, frame: false, width: 1280, height: 800, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false,
backgroundThrottling: false, preload: join(directory, 'preload.cjs') } })
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const project = database.listProjects()[0]!
const resultId = randomUUID()
const html = '<html><body><a href="#end">Jump to end</a>' + '<p>Long preview text</p>'.repeat(80) +
'<h2 id="end">End</h2><script>window.parent.previewUnsafe=true</script><img src="https://example.invalid/leak"></body></html>'
database.saveLocalConversations([{ header: { id: randomUUID(), projectId: project.id, title: 'Overlay paths', updatedAt: Date.now() },
messages: [{ id: randomUUID(), role: 'assistant', state: 'complete', createdAt: Date.now(), content: '```html\n' + html + '\n```',
attachments: [{ id: randomUUID(), kind: 'text', name: 'review.pdf', size: 20, preview: 'Saved result', resultId }],
sourceReferences: [{ libraryId: randomUUID(), libraryName: 'Review', documentName: 'Review source', sourceName: 'Fixture',
snippet: 'Saved citation', rank: 1, external: { kind: 'external', provider: 'fastgpt', instanceId: randomUUID(), remoteKnowledgeBaseId: 'review' } }]
}, { id: randomUUID(), role: 'assistant', state: 'complete', createdAt: Date.now(),
content: '```html\n<html><body><h1>Plain keyboard preview</h1>' + '<p>Long plain content</p>'.repeat(80) + '</body></html>\n```' }] }])
const settings = { ...defaultRuntimeSettings, workspacePath: directory, modelProfiles: [], embeddingConnections: [] }
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
await applicationSettings.update({ checkUpdatesOnStartup: false })
const browser = new BrowserService({ parentWindow: win })
const cipher = { isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (value: string) => safeStorage.encryptString(value), decrypt: (value: Buffer) => safeStorage.decryptString(value) }
const capabilities = new CapabilityService(join(directory, 'capabilities.json'), join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher)
const dispose = registerIpcHandlers(win,
{ capability: 'text', getStatus: async () => ({ available: true, name: 'Overlay fixture', capability: 'text' }) } as never,
'CommandOrControl+Shift+Space', { getResolvedSettings: async () => settings, getPublicSettings: async () => settings } as never,
capabilities, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
database, { clear() {} } as never, {} as never, async () => {}, undefined, browser,
undefined, undefined, applicationSettings, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
// Parsing payloads are fixtures; App, preload, window keyboard IPC and browser services are production.
const redImage = nativeImage.createFromBitmap(Buffer.from([0, 0, 255, 255]), { width: 1, height: 1 }).resize({ width: 200, height: 100 }).toDataURL()
ipcMain.removeHandler(ipcChannels.documentParsingResult)
ipcMain.removeHandler(ipcChannels.documentParsingImage)
ipcMain.handle(ipcChannels.documentParsingResult, () => ({ id: resultId, fileName: 'review.pdf', sourceFormat: '.pdf',
sections: [{ locator: 'Page 1', content: '![Review](asset:image)' }],
images: [{ id: 'image', pageNumber: 1, width: 200, height: 100, size: 100, mimeType: 'image/png' }],
missingImages: [], warnings: [], completeness: 'complete', parsedAt: new Date().toISOString(), durationMs: 1, settings: {} }))
ipcMain.handle(ipcChannels.documentParsingImage, () => redImage)
const js = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
const settle = (): Promise<unknown> => js('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
const wait = async (code: string): Promise<void> => {
for (let i = 0; i < 400; i++) {
if (await js(code)) return
await new Promise(r => setTimeout(r, 25))
}
throw new Error(`Timeout: ${code}\nFocus: ${await js('document.activeElement?.outerHTML')}\n${await js('document.body.innerText')}`)
}
const click = async (selector: string): Promise<void> => {
await wait(`!!document.querySelector(${JSON.stringify(selector)})`)
await js(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'nearest'})`)
await settle()
const p = await js<{ x: number; y: number; hit: boolean }>(`(()=>{const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect(),x=Math.round(r.x+r.width/2),y=Math.round(r.y+r.height/2);return {x,y,hit:e.contains(document.elementFromPoint(x,y))}})()`)
assert(p.hit, `Occluded ${selector}`)
win.focus()
win.webContents.sendInputEvent({ type: 'mouseDown', x: p.x, y: p.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: p.x, y: p.y, button: 'left', clickCount: 1 })
await settle()
}
const key = async (keyCode: string, modifiers: Electron.KeyboardInputEvent['modifiers'] = []): Promise<void> => {
if (!win.isFocused()) win.focus()
// Do not refocus WebContents here: it would reset a focused child frame.
win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
await settle()
}
const evidence: Record<string, unknown> = {}
const artifacts = process.env.GB_OVERLAY_PATHS_ARTIFACTS
if (artifacts) await mkdir(artifacts, { recursive: true })
const screenshot = async (name: string): Promise<void> => {
if (artifacts) await writeFile(join(artifacts, name + '.png'), (await win.webContents.capturePage()).toPNG())
}
const server = createServer((_q, r) => { r.setHeader('Content-Type', 'text/html'); r.end('<h1>Native overlay page</h1>') })
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}/`
const native = (): WebContentsView | undefined => win.contentView.children.find(v => v instanceof WebContentsView) as WebContentsView | undefined
const waitNative = async (visible: boolean): Promise<void> => {
for (let i = 0; i < 400; i++) {
if (native()?.getVisible() === visible) return
await new Promise(r => setTimeout(r, 25))
}
assert.equal(native()?.getVisible(), visible)
}
try {
await win.loadURL(process.env.GB_MCP_URL!)
await wait('!!document.querySelector(".message-html-preview")')
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme='${theme}'`)
await click('[aria-label="查看解析结果:review.pdf"]')
await click('.document-result-image button')
await wait('!!document.querySelector(".image-viewer-dialog")')
assert(await js(`getComputedStyle(document.querySelector('.image-viewer-backdrop')).zIndex === getComputedStyle(document.querySelector('.document-parsing-diagnostic-backdrop')).zIndex && document.querySelector('.document-parsing-diagnostic-backdrop').inert`))
const imageRect = await js<{ x: number; y: number }>(`(()=>{const r=document.querySelector('.image-viewer-dialog img').getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`)
const pixel = (await win.webContents.capturePage({ ...imageRect, width: 1, height: 1 })).toBitmap()
assert(pixel[2]! > 240 && pixel[1]! < 10 && pixel[0]! < 10, 'Image pixels must paint above the inert parent')
await screenshot(theme + '-nested-image')
await key('Escape')
await wait('!document.querySelector(".image-viewer-dialog") && !document.querySelector(".document-parsing-diagnostic-backdrop").inert')
await wait('document.activeElement.matches(".document-result-image button")')
await key('Escape')
await wait('!document.querySelector(".document-parsing-diagnostic")')
}
evidence.image = true
await click('.message-html-preview__actions button:last-child')
await wait('!!document.querySelector(".html-preview-viewer")')
assert(await js(`document.querySelector('.html-preview-viewer iframe').getAttribute('sandbox') === '' && document.querySelector('.html-preview-viewer iframe').srcdoc.includes("script-src 'none'")`))
await key('Tab')
await wait('document.activeElement.matches(".html-preview-viewer iframe")')
assert(await js(`!document.querySelector('.html-preview-viewer iframe').srcdoc.includes('<script>') && !document.querySelector('.html-preview-viewer iframe').srcdoc.includes('https://example.invalid/leak')`))
for (let i = 0; i < 6 && !await js('document.activeElement.matches(".html-preview-viewer__header button")'); i++) {
await key('Tab')
}
await wait('document.activeElement.matches(".html-preview-viewer__header button")')
await key('Tab', ['shift'])
await wait('document.activeElement.matches(".html-preview-viewer iframe")')
for (let i = 0; i < 6 && !await js('document.activeElement.matches(".html-preview-viewer__header button")'); i++) {
await key('Tab', ['shift'])
}
await wait('document.activeElement.matches(".html-preview-viewer__header button")')
await key('Tab')
await wait('document.activeElement.matches(".html-preview-viewer iframe")')
await key('Escape')
await wait('!document.querySelector(".html-preview-viewer")')
assert(await js('document.activeElement.matches(".message-html-preview__actions button:last-child")'))
assert(await js('window.previewUnsafe !== true'))
await click('.message-html-preview:has(iframe[srcdoc*="Plain keyboard preview"]) .message-html-preview__actions button:last-child')
await wait('!!document.querySelector(".html-preview-viewer")')
await key('Tab')
await wait('document.activeElement.matches(".html-preview-viewer iframe")')
await key('PageDown')
await key('Escape')
await wait('!document.querySelector(".html-preview-viewer")')
evidence.iframe = true
win.setContentSize(760, 700)
await wait('document.querySelector(".sidebar").inert')
await click('.sidebar-toggle')
await click('.project-switcher .icon-button')
await wait('!!document.querySelector(".project-create-card") && document.querySelector(".app-shell").inert')
await key('Escape')
await wait('!document.querySelector(".project-create-card")')
assert(await js('!document.querySelector(".sidebar").inert && document.activeElement.matches(".project-switcher .icon-button")'))
await key('Escape')
await wait('document.querySelector(".sidebar").inert')
evidence.project = true
win.setContentSize(1280, 800)
await click('.sidebar-toggle')
await click('.message-citations summary')
await click('.message-citations__actions button')
await wait('!!document.querySelector(".knowledge-citation-dialog")')
assert.equal(await js('getComputedStyle(document.querySelector(".knowledge-citation-dialog")).webkitAppRegion'), 'no-drag')
if (process.platform === 'win32') {
const koffi = createRequire(join(process.cwd(), 'package.json'))('koffi') as typeof import('koffi')
const send = koffi.load('user32.dll').func('intptr_t __stdcall SendMessageW(void *hWnd, uint32_t Msg, uintptr_t wParam, intptr_t lParam)')
const hwnd = koffi.decode(win.getNativeWindowHandle(), 'void *')
await js('document.querySelector(".knowledge-citation-dialog").style.webkitAppRegion="initial"')
const bounds = win.getBounds()
const hit = (x: number): number => {
const point = screen.dipToScreenPoint({ x: bounds.x + x, y: bounds.y + 12 })
return Number(send(hwnd, 0x84, 0, (point.y << 16) | (point.x & 0xffff)))
}
let captionX: number | undefined
for (let attempt = 0; attempt < 40 && captionX === undefined; attempt++) {
for (let x = 20; x < 1200; x += 20) if (hit(x) === 2) { captionX = x; break }
if (captionX === undefined) await new Promise(r => setTimeout(r, 25))
}
assert.notEqual(captionX, undefined, 'Control must exercise an actual native drag region')
await js('document.querySelector(".knowledge-citation-dialog").style.removeProperty("-webkit-app-region")')
for (let attempt = 0; attempt < 40 && hit(captionX!) !== 1; attempt++) await new Promise(r => setTimeout(r, 25))
assert.equal(hit(captionX!), 1, 'Citation overlay must receive HTCLIENT, not HTCAPTION')
evidence.nativeHit = 'HTCLIENT; negative control HTCAPTION'
}
await key('Escape')
evidence.citation = true
await click('.assistant-sidebar-toggle[aria-expanded=false]')
await js('document.querySelector(".workbar-shell__tab[aria-selected=true]").focus()')
await key('Right'); await key('Right')
await wait('document.querySelector(".workbar-shell__tab[aria-selected=true]").textContent.startsWith("Browser")')
await waitNative(true)
await wait('!document.querySelector(".assistant-sidebar__browser-address input").disabled')
await click('.assistant-sidebar__browser-address input')
await key('A', ['control'])
await win.webContents.insertText(origin)
await click('.assistant-sidebar__browser-go')
for (let i = 0; i < 400 && native()!.webContents.getURL() !== origin; i++) await new Promise(r => setTimeout(r, 25))
await native()!.webContents.executeJavaScript('window.overlayToken="same-document"')
const nativeId = native()!.webContents.id
await js(`[...document.querySelectorAll('.primary-nav button')].find(e=>e.textContent.includes('Run history')).click()`)
await wait('!!document.querySelector(".activity-panel .inline-help")')
for (const width of [1600, 1000]) {
win.setContentSize(width, 800)
await wait(`innerWidth === ${width}`)
if (width === 1000) {
await wait('document.querySelector(".assistant-sidebar-toggle").getAttribute("aria-expanded") === "false"')
await click('.assistant-sidebar-toggle')
await waitNative(true)
}
await js('document.querySelector(".assistant-sidebar__resize-handle").focus()')
await key(width === 1000 ? 'End' : 'Home')
await wait(`(()=>{const e=document.querySelector('.assistant-sidebar');return Math.abs(e.getBoundingClientRect().width-parseFloat(e.style.getPropertyValue('--assistant-sidebar-width')))<=2})()`)
await click('.activity-panel .inline-help')
await wait('!!document.querySelector(".inline-help__content")')
const overlaps = await js<boolean>(`(()=>{const a=document.querySelector('.inline-help__content').getBoundingClientRect(),b=document.querySelector('.assistant-sidebar__browser-viewport').getBoundingClientRect();return a.right>b.left&&a.left<b.right&&a.top<b.bottom&&a.bottom>b.top})()`)
assert.equal(overlaps, width === 1000, 'Real help/viewport layout ' + JSON.stringify(await js(`['.inline-help__content','.assistant-sidebar__browser-viewport','.assistant-sidebar__resize-handle'].map(s=>{const e=document.querySelector(s);return [s,e.getBoundingClientRect().toJSON(),e.getAttribute('aria-valuenow')]})`)))
await waitNative(!overlaps)
await screenshot(width + '-browser-help')
await key('Escape')
await waitNative(true)
assert.equal(native()!.webContents.id, nativeId)
assert.equal(await native()!.webContents.executeJavaScript('window.overlayToken'), 'same-document')
}
evidence.tooltip = true
evidence.modelAttempts = (globalThis as typeof globalThis & { overlayModelAttempts?: number }).overlayModelAttempts ?? 0
assert.equal(evidence.modelAttempts, 0)
await writeFile(join(directory, 'result.json'), JSON.stringify(evidence))
if (artifacts) await writeFile(join(artifacts, 'result.json'), JSON.stringify(evidence, null, 2))
} finally {
await dispose()
await browser.dispose()
win.destroy()
server.close()
database.close()
}
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
+212
View File
@@ -0,0 +1,212 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import process from 'node:process'
import console from 'node:console'
import { setTimeout } from 'node:timers/promises'
app.setPath('userData', join(process.env.GB_CONTROLS_DIRECTORY, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
const artifacts = process.env.GB_SHARED_CONTROLS_ARTIFACTS
const observations = []
const errors = []
const contrast = (a, b) => {
const luminance = color => color.match(/[\d.]+/g).slice(0, 3).map(Number)
.map(v => v / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
.reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0)
const values = [luminance(a), luminance(b)].sort((a, b) => a - b)
return (values[1] + 0.05) / (values[0] + 0.05)
}
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const js = code => win.webContents.executeJavaScript(code)
const wait = async code => {
for (let i = 0; i < 200; i++) {
if (await js(code)) return
await setTimeout(25)
}
throw new Error(`Timeout: ${code}`)
}
const settle = () => js(`Promise.all(document.getAnimations().filter(a => a.effect.getTiming().iterations !== Infinity)
.map(a => a.finished.catch(() => {}))).then(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))`)
const measure = async (selector, state) => {
await settle()
const style = await js(`(() => {
const e = document.querySelector(${JSON.stringify(selector)}), s = getComputedStyle(e), r = e.getBoundingClientRect();
const rgb = name => { const span = document.createElement('span'); span.style.color = s.getPropertyValue(name); e.parentElement.append(span);
const value = getComputedStyle(span).color; span.remove(); return value };
return { color:s.color, background:s.backgroundColor, border:s.borderTopColor, borderStyle:s.borderTopStyle,
radius:s.borderRadius, padding:s.padding, minHeight:s.minHeight, width:r.width, height:r.height,
font:s.fontFamily, opacity:s.opacity, cursor:s.cursor, outline:s.outlineStyle, outlineWidth:s.outlineWidth,
outlineColor:s.outlineColor, focusVisible:e.matches(':focus-visible'), disabled:e.matches(':disabled'),
tokens:Object.fromEntries(['--accent','--accent-solid','--accent-solid-hover','--text-on-accent','--text-secondary',
'--text-muted','--danger','--danger-solid','--danger-solid-hover','--danger-subtle','--border-control','--surface-raised']
.map(name=>[name,rgb(name)])) };
})()`)
observations.push({ theme: await js('document.documentElement.dataset.theme'), selector, state, ...style })
return style
}
const hover = async (selector, enabled) => {
const { root } = await win.webContents.debugger.sendCommand('DOM.getDocument')
const { nodeId } = await win.webContents.debugger.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector })
await win.webContents.debugger.sendCommand('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: enabled ? ['hover'] : [] })
}
const disabled = (selector, value) => js(`document.querySelector(${JSON.stringify(selector)}).disabled = ${value}`)
const focus = async selector => {
await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); e.scrollIntoView({block:'center'});
const start = document.createElement('button'); start.id='focus-start'; start.textContent='Focus start'; e.before(start); start.focus(); })()`)
win.focus(); win.webContents.focus()
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
await wait(`document.activeElement === document.querySelector(${JSON.stringify(selector)})`)
await js('document.getElementById("focus-start").remove()')
const s = await measure(selector, 'native-tab-focus')
assert.equal(s.focusVisible, true, selector)
assert.equal(s.outline, 'solid', selector)
assert.equal(s.outlineWidth, '2px', selector)
assert.equal(s.outlineColor, s.tokens['--accent'], selector)
}
const screenshot = async name => {
await settle()
if (artifacts) await writeFile(join(artifacts, `${name}.png`), (await win.webContents.capturePage()).toPNG())
}
try {
if (artifacts) await mkdir(artifacts, { recursive: true })
await win.loadURL(process.env.GB_CONTROLS_URL)
await wait('!!document.querySelector(".runtime-customization-item textarea")')
await js('document.fonts.ready')
win.webContents.debugger.attach('1.3')
await win.webContents.debugger.sendCommand('DOM.enable')
await win.webContents.debugger.sendCommand('CSS.enable')
for (const theme of ['light', 'dark']) {
await js(`document.getElementById('theme-${theme}').click()`)
for (const container of ['settings', 'portal']) {
await js(`document.getElementById('portal-controls').style.display = '${container === 'portal' ? 'block' : 'none'}'`)
const base = `[data-controls="${container}"]`
const classes = ['primary-button', 'secondary-button', 'danger-solid', 'danger-button', 'danger-button danger-button--quiet',
'danger-ghost', 'icon-button', 'danger-ghost icon-button', 'icon-button icon-button--active']
for (const className of classes) {
const selector = `${base} [data-button="${className}"]`
const normal = await measure(selector, 'default')
if (className === 'secondary-button') assert.equal(normal.border, normal.tokens['--border-control'])
if (className.includes('danger-ghost')) assert.equal(normal.color, normal.tokens['--danger'])
if (className === 'icon-button icon-button--active') assert.equal(normal.color, normal.tokens['--accent'])
await hover(selector, true)
const hovered = await measure(selector, 'hover')
if (className.includes('danger-ghost')) {
assert.equal(hovered.color, hovered.tokens['--danger'])
assert.equal(hovered.background, hovered.tokens['--danger-subtle'])
}
if (['primary-button', 'danger-solid'].includes(className)) {
for (const s of [normal, hovered]) assert(contrast(s.color, s.background) >= 4.5, `${theme} ${className} contrast`)
}
await hover(selector, false)
await disabled(selector, true)
const inactive = await measure(selector, 'disabled')
assert.equal(inactive.cursor, 'not-allowed', selector)
assert(Number(inactive.opacity) < 1, selector)
await hover(selector, true)
const inactiveHover = await measure(selector, 'disabled-hover')
assert.equal(inactiveHover.background, inactive.background, selector)
assert.equal(inactiveHover.color, inactive.color, selector)
await hover(selector, false)
await disabled(selector, false)
await focus(selector)
}
for (const kind of ['text', 'select', 'textarea', 'switch', 'radio', 'checkbox', 'address', 'dialog']) {
const selector = `${base} [data-input="${kind}"]`
const s = await measure(selector, 'default')
if (['text', 'select', 'textarea', 'address', 'dialog'].includes(kind)) {
assert.equal(s.border, s.tokens['--border-control'], selector)
assert(contrast(s.border, s.background) >= 3, selector)
} else {
assert.equal(s.height, kind === 'switch' ? 22 : 15, selector)
assert.equal(s.width, kind === 'switch' ? 38 : 15, selector)
}
await focus(selector)
}
for (const variant of ['default', 'segmented']) {
const tab = `${base} .page-tabs--${variant} .page-tabs__tab--active`
const selected = await measure(tab, 'active')
await hover(tab, true)
const selectedHover = await measure(tab, 'active-hover')
assert.equal(selectedHover.background, selected.background)
assert.equal(selectedHover.color, selected.color)
await hover(tab, false)
await focus(tab)
}
for (const suffix of ['--active', ':not(.segmented-control__option--active)']) {
const selector = `${base} .segmented-control__option${suffix}`
await disabled(selector, true)
const s = await measure(selector, 'disabled-segment')
assert.equal(s.cursor, 'not-allowed')
assert(Number(s.opacity) < 1)
await disabled(selector, false)
await focus(selector)
}
for (const menu of ['conversation-actions', 'workspace-files__menu']) {
const menuGhost = `${base} .${menu} [data-menu-ghost]`
await disabled(menuGhost, true)
await hover(menuGhost, true)
const disabledGhost = await measure(menuGhost, 'menu-disabled-danger-hover')
assert.equal(disabledGhost.color, disabledGhost.tokens['--text-muted'])
assert.equal(disabledGhost.background, 'rgba(0, 0, 0, 0)')
await hover(menuGhost, false)
await disabled(menuGhost, false)
const trigger = `${base} .${menu} .danger-button--quiet`
const triggerStyle = await measure(trigger, 'menu-danger-trigger')
assert.equal(triggerStyle.color, triggerStyle.tokens['--danger'])
await js(`document.querySelector(${JSON.stringify(trigger)}).click()`)
const confirm = `${base} .${menu} .danger-confirm .danger-button`
await wait(`!!document.querySelector(${JSON.stringify(confirm)})`)
const confirmStyle = await measure(confirm, 'menu-danger-confirm')
assert.equal(confirmStyle.color, confirmStyle.tokens['--text-on-accent'])
assert.equal(confirmStyle.background, confirmStyle.tokens['--danger-solid'])
await hover(confirm, true)
const confirmHover = await measure(confirm, 'menu-danger-confirm-hover')
assert.equal(confirmHover.background, confirmStyle.background)
assert.equal(confirmHover.color, confirmStyle.color)
await hover(confirm, false)
await js(`document.querySelector('${base} .${menu} .danger-confirm .secondary-button').click()`)
await wait(`!document.querySelector(${JSON.stringify(confirm)})`)
}
await screenshot(`${theme}-${container}`)
}
await js("document.getElementById('portal-controls').style.display='none'")
const controls = await js(`Array.from(document.querySelectorAll('.runtime-customization-item .field-control')).map((e,i)=>{e.dataset.actualControl=i;return '[data-actual-control="'+i+'"]'})`)
assert.equal(controls.length, 5)
for (const selector of controls) {
const s = await measure(selector, 'actual-continue-field')
assert.equal(s.borderStyle, 'solid')
assert.equal(s.border, s.tokens['--border-control'])
assert.equal(s.radius, '9px')
assert(s.height >= 38)
assert(s.font.includes('Inter Variable'))
await focus(selector)
}
const danger = '.runtime-customization-item .danger-ghost.icon-button'
await hover(danger, true)
const s = await measure(danger, 'actual-continue-danger-hover')
assert.equal(s.color, s.tokens['--danger'])
assert.equal(s.background, s.tokens['--danger-subtle'])
await hover(danger, false)
await js("document.querySelector('.runtime-preset-editor').scrollIntoView({block:'start'})")
await screenshot(`${theme}-continue`)
}
assert.deepEqual(errors, [])
if (artifacts) await writeFile(join(artifacts, 'computed.json'), JSON.stringify({ observations, errors, modelCalls: 0 }, null, 2))
console.log(`Shared controls: ${observations.length} computed observations; light/dark, settings/body Portal, native Tab, actual Continue; model calls: 0`)
app.exit(0)
} catch (error) {
console.error(error)
if (artifacts) {
await screenshot('failure')
await writeFile(join(artifacts, 'failure.json'), JSON.stringify({ observations, errors, failure: String(error.stack) }, null, 2))
}
app.exit(1)
}
})
@@ -0,0 +1,73 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { createPortal } from 'react-dom'
import { Trash2 } from 'lucide-react'
import { RuntimeCustomizationSection } from '../../src/renderer/src/RuntimeCustomizationSection'
import { DestructiveConfirmActions, PageTabs, SegmentedControl } from '../../src/renderer/src/WorkspacePrimitives'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import { applyAppearanceTheme } from '../../src/renderer/src/theme'
import type { DesktopApi } from '../../src/shared/contracts'
import '../../src/renderer/src/styles.css'
Object.defineProperty(window, 'goodbuddy', { value: {
runtimeCustomization: {
getSettings: async () => ({ opencode: {}, continue: { defaultPresetId: 'preset', presets: [{
id: 'preset', name: 'Fixture preset',
rules: [{ id: 'rule', name: 'Fixture rule', content: 'Rule content', enabled: true }],
prompts: [{ id: 'prompt', name: 'Fixture prompt', description: 'Prompt description', prompt: 'Prompt content' }]
}] } }),
getNativeSnapshot: async () => ({ provider: 'continue', available: true, inventoryStatus: 'available',
agents: [], tools: [], commands: [], lsp: [], formatters: [], mcpServers: [], skills: [], rules: [], prompts: [], resources: [],
context: { strategy: 'goodbuddy-summary', manualCompact: true, detail: 'Fixture' } })
}
} as unknown as DesktopApi })
function Controls({ name }: { name: string }): React.JSX.Element {
const [tab, setTab] = useState('first')
const [confirming, setConfirming] = useState<string>()
return <section data-controls={name} style={{ padding: 12 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{['primary-button', 'secondary-button', 'danger-solid', 'danger-button', 'danger-button danger-button--quiet', 'danger-ghost', 'icon-button', 'danger-ghost icon-button', 'icon-button icon-button--active'].map(className =>
<button type="button" key={className} className={className} data-button={className}>{className.includes('icon-button') ? 'X' : className}</button>)}
</div>
<div className="field">
<label>Text<input data-input="text" defaultValue="Fixture" /></label>
<label>Choice<select data-input="select" defaultValue="one"><option value="one">One</option></select></label>
<label>Content<textarea data-input="textarea" defaultValue="Fixture" /></label>
<label className="toggle-row"><input data-input="switch" role="switch" type="checkbox" defaultChecked /><span>Switch</span></label>
<label className="check-field"><input data-input="radio" type="radio" defaultChecked /><span>Radio</span></label>
<label className="check-field"><input data-input="checkbox" type="checkbox" defaultChecked /><span>Checkbox</span></label>
</div>
<label className="assistant-sidebar__browser-address">Address<input data-input="address" defaultValue="example.test" /></label>
<label className="custom-task-dialog__field">Dialog field<input data-input="dialog" defaultValue="Fixture" /></label>
<PageTabs ariaLabel={`${name} tabs`} idPrefix={name} value={tab} onChange={setTab}
tabs={[{ id: 'first', label: 'First' }, { id: 'second', label: 'Second' }]} />
<PageTabs ariaLabel={`${name} segmented tabs`} idPrefix={`${name}-segmented`} value={tab} onChange={setTab}
variant="segmented" tabs={[{ id: 'first', label: 'First' }, { id: 'second', label: 'Second' }]} />
<SegmentedControl ariaLabel={`${name} segments`} value="first" onChange={() => {}}
options={[{ value: 'first', label: 'First' }, { value: 'second', label: 'Second' }]} />
{['conversation-actions', 'workspace-files__menu'].map(menu => <div key={menu} className={menu} style={{ position: 'static' }}>
<button type="button" aria-label="Delete icon" className="danger-ghost icon-button" data-menu-ghost><Trash2 size={14} aria-hidden="true" /></button>
<DestructiveConfirmActions confirming={confirming === menu} confirmLabel="Delete fixture" triggerLabel="Delete"
onRequestConfirm={() => setConfirming(menu)} onCancel={() => setConfirming(undefined)} onConfirm={() => {}} />
</div>)}
</section>
}
function Fixture(): React.JSX.Element {
return <>
<div className="app-shell" style={{ display: 'block', overflow: 'auto' }}>
<button id="theme-light" onClick={() => applyAppearanceTheme('light')}>Light</button>
<button id="theme-dark" onClick={() => applyAppearanceTheme('dark')}>Dark</button>
<div className="settings-panel" style={{ display: 'block', height: 'auto', maxHeight: 'none', width: '100%' }}>
<Controls name="settings" />
<RuntimeCustomizationSection provider="continue" />
</div>
</div>
{createPortal(<div id="portal-controls" style={{ position: 'fixed', inset: 0, overflow: 'auto', display: 'none', background: 'var(--surface-raised)' }}>
<Controls name="portal" />
</div>, document.body)}
</>
}
createRoot(document.getElementById('root')!).render(<UiLocaleProvider initialPreference="zh-CN"><Fixture /></UiLocaleProvider>)
@@ -0,0 +1,153 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import process from 'node:process'
import { setTimeout } from 'node:timers/promises'
import { setTimeout as startTimeout } from 'node:timers'
const directory = process.env.GB_LAYOUT_DIRECTORY
const artifacts = process.env.GB_LAYOUT_ARTIFACTS
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
// Keep test frames advancing during native window resize/focus transitions.
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1000, height: 720, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
const errors = [], observations = []
let pending = 'startup'
startTimeout(async () => {
await writeFile(join(directory, 'result.json'), JSON.stringify({ passed: false, pending, observations, errors }))
app.exit(1)
}, 80000)
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const js = code => { pending = code; return win.webContents.executeJavaScript(code) }
const wait = async code => {
for (let i = 0; i < 400; i++) { if (await js(code)) return; await setTimeout(25) }
throw new Error(`Timeout: ${code}\n${errors.join('\n')}`)
}
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const point = selector => js(`(() => {
const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw new Error('Missing control: '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();
return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2))};
})()`)
const click = async selector => {
win.focus(); win.webContents.focus()
await wait('document.hasFocus()')
await settle()
const p = await point(selector)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 0, y: 0 })
win.webContents.sendInputEvent({ type: 'mouseMove', x: p.x, y: p.y })
await wait(`(() => { const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect();return e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)) })()`)
win.webContents.sendInputEvent({ type: 'mouseDown', x: p.x, y: p.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: p.x, y: p.y, button: 'left', clickCount: 1 })
await settle()
}
const key = async keyCode => {
win.focus(); win.webContents.focus()
await wait('document.hasFocus()')
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode })
await settle()
}
const screenshot = async name => {
if (artifacts) {
await writeFile(join(artifacts, name + '.png'), (await win.webContents.capturePage()).toPNG())
}
}
const addVisible = async () => {
assert(await js(`(() => { const e=document.querySelector('.workbar-shell__add'),r=e.getBoundingClientRect(),s=e.closest('.assistant-sidebar').getBoundingClientRect();
return !e.closest('.workbar-shell__tab-scroll') && r.left>=s.left && r.right<=s.right && e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)); })()`), 'Add must remain visible and hit-testable outside the scroll viewport')
}
try {
if (artifacts) await mkdir(artifacts, { recursive: true })
for (const locale of ['zh-CN', 'en-US']) {
for (const theme of ['light', 'dark']) {
win.setContentSize(1000, 720)
await win.loadURL(`${process.env.GB_LAYOUT_URL}?locale=${locale}&theme=${theme}`)
await wait('!!document.querySelector(".workbar-shell__scroll-button")')
await js('document.fonts.ready')
await settle()
const scroll = "document.querySelector('.workbar-shell__tab-scroll')"
for (const many of [false, true]) {
if (many) { await click('#many-tabs'); await wait('document.querySelectorAll("[role=tab]").length===16') }
await addVisible()
await click('.workbar-shell__add')
await wait('!!document.querySelector(".workbar-shell__catalog") && document.activeElement.matches("[data-workbar-app-choice]")')
await key('Escape')
await wait('!document.querySelector(".workbar-shell__catalog") && document.activeElement.matches(".workbar-shell__add")')
const active = await js('document.querySelector("[role=tab][aria-selected=true]").id')
await click('.workbar-shell__scroll-button:last-child')
await wait(`${scroll}.scrollLeft > 0`)
assert.equal(await js('document.querySelector("[role=tab][aria-selected=true]").id'), active)
await addVisible()
await js('document.querySelector("[role=tab][aria-selected=true]").focus()')
await key('End')
await wait('document.activeElement === document.querySelector(".workbar-shell__tab-item:last-child [role=tab][aria-selected=true]")')
assert(await js(`(() => {const r=document.activeElement.parentElement.getBoundingClientRect(),s=${scroll}.getBoundingClientRect();return r.left>=s.left-1 && r.right<=s.right+1})()`), 'End must reveal the last tab')
await addVisible()
await screenshot(`${locale}-${theme}-workbar-${many ? 'many' : 'default'}`)
await key('Home')
await wait(`${scroll}.scrollLeft === 0 && document.activeElement === document.querySelector('.workbar-shell__tab-item:first-child [role=tab][aria-selected=true]')`)
if (!many) {
await js("document.querySelector('.assistant-sidebar').style.width='900px';document.querySelector('.assistant-sidebar').style.flexBasis='900px'")
await wait('!document.querySelector(".workbar-shell__scroll-button")')
await addVisible()
await js("document.querySelector('.assistant-sidebar').style.width='300px';document.querySelector('.assistant-sidebar').style.flexBasis='300px'")
await wait('!!document.querySelector(".workbar-shell__scroll-button")')
}
}
await click('.workbar-shell__add')
await wait('!!document.querySelector(".workbar-shell__catalog")')
await click('.workbar-shell__catalog-choice:has(.workbar-shell__app-icon--browser)')
await wait('document.querySelectorAll("[role=tab]").length===17 && !document.querySelector(".workbar-shell__catalog")')
await addVisible()
observations.push({ locale, theme, workbar: 'default and 16 long tabs; native add/catalog/create, arrows, Home/End' })
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
await wait('!!document.querySelector(".model-connection-detail select")')
await js('document.fonts.ready')
for (const [width, height, panelWidth] of [[680, 560], [720, 560], [721, 560], [740, 560], [960, 560], [1180, 760], [1180, 560, 740]]) {
win.setContentSize(width, height)
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
await settle()
await js(`document.querySelector('.settings-panel').style.width=${JSON.stringify(panelWidth ? `${panelWidth}px` : '')}`)
await js("document.querySelector('.model-connection-detail select').scrollIntoView({block:'center'})")
const result = await js(`(() => {
const e=document.querySelector('.model-connection-detail select'),s=getComputedStyle(e),r=e.getBoundingClientRect();
const c=document.createElement('canvas').getContext('2d');c.font=s.font;
const manager=document.querySelector('.model-connection-manager'),list=manager.querySelector('.model-connection-list').getBoundingClientRect(),detail=manager.querySelector('.model-connection-detail').getBoundingClientRect();
return {containerWidth:manager.clientWidth,stacked:detail.top>=list.bottom-1,selectWidth:e.clientWidth,
textWidth:c.measureText(e.selectedOptions[0].textContent).width,available:e.clientWidth-parseFloat(s.paddingLeft)-parseFloat(s.paddingRight)-20,
overflow:['.settings-panel__content','.model-connection-manager','.model-connection-detail'].filter(sel=>{const n=document.querySelector(sel);return n.scrollWidth>n.clientWidth+1}),
hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),value:e.value};
})()`)
assert.equal(result.stacked, result.containerWidth <= 640, JSON.stringify(result))
assert.deepEqual(result.overflow, [], JSON.stringify(result))
assert(result.textWidth <= result.available, 'Protocol truncated: ' + JSON.stringify(result))
assert(result.hit, 'Protocol is occluded')
await js("document.querySelector('.model-connection-detail select').closest('.field').previousElementSibling.querySelector('input').focus()")
await key('Tab')
await wait('document.activeElement.matches(".model-connection-detail select")')
await key('Home'); await key('Down')
await wait('document.querySelector(".model-connection-detail select").value!=="openai-chat-completions"')
await key('Home')
await wait('document.querySelector(".model-connection-detail select").value==="openai-chat-completions"')
observations.push({ locale, theme, width, height, panelWidth, ...result })
await screenshot(`${locale}-${theme}-settings-${width}${panelWidth ? '-narrow-container' : ''}`)
}
}
}
assert.deepEqual(errors, [])
const result = { passed: true, observations, errors, modelCalls: 0 }
await writeFile(join(directory, 'result.json'), JSON.stringify(result))
if (artifacts) await writeFile(join(artifacts, 'result.json'), JSON.stringify(result, null, 2))
app.exit(0)
} catch (error) {
const result = JSON.stringify({ passed: false, error: String(error.stack), observations, errors })
await writeFile(join(directory, 'result.json'), result)
if (artifacts) { await screenshot('failure'); await writeFile(join(artifacts, 'result.json'), result) }
app.exit(1)
}
})
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { SettingsPanel } from '../../src/renderer/src/SettingsPanel'
import { DEFAULT_WORKBAR_INSTANCES, WorkbarShell } from '../../src/renderer/src/WorkbarShell'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import { changeUiLocale } from '../../src/renderer/src/i18n'
import { applyAppearanceTheme } from '../../src/renderer/src/theme'
import { installBundledUiFonts } from '../../src/renderer/src/fonts'
import { defaultRuntimeSettings, type DesktopApi } from '../../src/shared/contracts'
import type { WorkbarTabInstance } from '../../src/shared/workbar-contracts'
import '../../src/renderer/src/styles.css'
const query = new URLSearchParams(location.search)
const locale = query.get('locale') === 'en-US' ? 'en-US' : 'zh-CN'
const longName = locale === 'en-US'
? 'Organization shared model connection for document processing and analysis'
: '\u7ec4\u7ec7\u5185\u90e8\u6587\u6863\u5904\u7406\u4e0e\u5206\u6790\u4e13\u7528\u5171\u4eab\u6a21\u578b\u8fde\u63a5'
const profileId = '10000000-0000-4000-8000-000000000001'
Object.defineProperty(window, 'goodbuddy', { value: {
settings: {
getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [{
id: profileId, name: longName, baseUrl: 'http://fixture.invalid/v1', modelName: 'fixture-model',
protocol: 'openai-chat-completions', authentication: 'none', imageGenerationQuality: 'auto',
apiKeyConfigured: false, credentialSource: 'none'
}], defaultModelProfileId: profileId, embeddingConnections: [],
opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' },
deepseekHarnessModelSource: { kind: 'platform' } }),
detectAgentRuntimes: async () => ({})
},
runtimeCustomization: {
getSettings: async () => ({ opencode: {}, continue: { presets: [] } }),
getNativeSnapshot: async () => ({ provider: 'opencode', available: false, inventoryStatus: 'unavailable',
agents: [], tools: [], commands: [], lsp: [], formatters: [], mcpServers: [], skills: [], rules: [], prompts: [], resources: [],
context: { strategy: 'goodbuddy-summary', manualCompact: false, detail: 'Fixture' } })
}
} as unknown as DesktopApi })
function WorkbarFixture(): React.JSX.Element {
const [instances, setInstances] = useState<readonly WorkbarTabInstance[]>(DEFAULT_WORKBAR_INSTANCES.map((item, index) => ({
...item, title: locale === 'en-US' ? ['Task center', 'Workspace', 'Browser', 'Results'][index]! : item.title
})))
const [active, setActive] = useState<string | null>(instances[0]!.id)
return <>
<button id="many-tabs" onClick={() => setInstances(current => [...current, ...Array.from({ length: 12 }, (_, index) => ({
id: `extra-${index}`, appId: 'browser' as const, title: `${longName} ${index + 1}`
}))])}>More tabs</button>
<aside className="assistant-sidebar assistant-sidebar--open" style={{ width: 300, flexBasis: 300, height: 480 }}>
<WorkbarShell instances={instances} activeInstanceId={active} onActiveInstanceChange={setActive}
onCreateInstance={({ appId }) => {
const id = `created-${instances.length}`
setInstances(current => [...current, { id, appId, title: `${appId} ${instances.length}` }])
setActive(id)
}}
onCloseInstance={instance => setInstances(current => current.filter(item => item.id !== instance.id))}
onResolveTerminalTarget={() => ({ type: 'local' })} renderPanel={instance => <p>{instance.title}</p>} />
</aside>
</>
}
function Fixture(): React.JSX.Element {
return <UiLocaleProvider initialPreference={locale}>
{query.get('surface') === 'settings'
? <SettingsPanel open initialCategory="model" projects={[]} onClose={() => {}} onSaved={() => {}}
onUpdateProject={async () => { throw new Error('Unused fixture API') }} onClearLocalData={async () => {}} />
: <WorkbarFixture />}
</UiLocaleProvider>
}
installBundledUiFonts()
applyAppearanceTheme(query.get('theme') === 'dark' ? 'dark' : 'light')
void changeUiLocale(locale).then(() => createRoot(document.getElementById('root')!).render(<Fixture />))
@@ -0,0 +1,49 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('keeps workbar add reachable and model protocols readable at narrow container widths', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-responsive-controls-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'responsive-controls', configureServer(server) {
server.middlewares.use('/responsive.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/responsive.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/workbar-settings-layout.tsx"></script></body></html>'))
})
} }],
optimizeDeps: { entries: ['tests/support/workbar-settings-layout.tsx'] },
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/workbar-settings-layout-driver.mjs'), driver)
const env: NodeJS.ProcessEnv = { ...process.env, GB_LAYOUT_DIRECTORY: directory,
GB_LAYOUT_URL: server.resolvedUrls!.local[0] + 'responsive.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 90000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
const result = await readFile(join(directory, 'result.json'), 'utf8').catch(() => output)
expect(code, result).toBe(0)
const parsed = JSON.parse(result)
expect(parsed.passed).toBe(true)
process.stdout.write(`Responsive controls: ${parsed.observations.length} observations; Chinese/English, light/dark, native mouse and keyboard; model calls: 0\n`)
} finally { clearTimeout(timeout) }
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 120000)