fix: keep browser overlays and knowledge controls usable

Native browser pages could cover application dialogs and overlapping menus
or notifications. Suspend only the viewport while covered and restore the
same active page afterward, including nested dialogs and terminal prompts.

Keep knowledge source controls at their full height, constrain long library
cards and titles, and update automatic names when switching remote targets
without overwriting edited or saved names. Restore packaged release-note
loading now that the history exceeds its previous size limit.

Add focused regressions and real Electron UI evidence. Clean up temporary
Harness fixture directories at test completion instead of accumulating them.

Release note: 修复内嵌浏览器遮挡弹窗和浮层、知识库来源选项被压缩及长名称显示异常;切换远端知识库时自动名称正确更新,并恢复更新说明显示。
This commit is contained in:
mesalogo
2026-09-13 19:46:37 +08:00
parent a0456f687a
commit 1c22e1a06d
21 changed files with 408 additions and 22 deletions
+6
View File
@@ -210,6 +210,8 @@
- 选项数量为 2 至 4 个。
- 使用带 `aria-pressed` 的按钮组语义,支持方向键切换。
- 不能用于多选筛选、页面导航或执行即时命令。
- 与 `PageTabs` 一样,共享控件根节点必须 `flex: 0 0 auto`,不能被同级长表单或滚动区域
压缩高度。横向空间不足时保留控件自身滚动,不把标签裁成一条窄缝。
- 控件宽度由内容决定,除移动窄屏外不默认等分整行。
### 6.3 筛选工具栏
@@ -395,6 +397,10 @@
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响;
保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。
浮层关闭后只恢复当前活动实例的 viewport,不关闭、重建或刷新网页。接线与测试见
[浏览器多实例技术设计](./docs/features/assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport)。
- 遮罩必须 `inset: 0` 铺满整窗,并由 `styles.css` 中统一的遮罩选择器声明
`-webkit-app-region: no-drag`,否则 `.brand` 与 `.topbar` 的原生拖动区域会截获指针,
使遮罩上的控件无法悬停和点击。新增整窗遮罩时扩展该共享选择器,不要在各自规则里复制
@@ -77,8 +77,16 @@ Record<ConversationId, Record<BrowserTabId, BrowserLiveState>>
非活动 Browser Tab 保持页面和状态,但隐藏 `WebContentsView` 并停止非必要画面采集。工作栏
收起、切换主页面和窗口隐藏时采用相同规则,不销毁 Tab。
普通导航、快照与页面操作只同步轻量导航状态,不自动截图、等待画面或传输 JPEG;只有显式
`browser_screenshot` 调用生成图片。侧栏内终端关闭确认显示期间,同样隐藏原生浏览器视图,
确认或取消后恢复当前实例,避免原生子视图遮挡 HTML 确认按钮。
`browser_screenshot` 调用生成图片。
`BrowserViewport` 统一观察 body Portal 的插入、移除和可见属性变化,以及浮层尺寸、
窗口尺寸、滚动和过渡结束。`browser-viewport-occlusion.ts` 按共享语义识别
`aria-modal="true"`、dialog、alertdialog、menu 和应用通知:可见应用级 Modal 始终释放
viewport,其余浮层只在与浏览器区域相交时释放。隐藏节点和隐藏祖先不阻塞浏览器。
最后一个遮挡消失后,只有当前活动实例恢复同一 viewport 租约;网页、历史和页面状态不变。
终端关闭确认复用此机制,不再维护单独的隐藏条件。页面无需逐个登记弹窗,也不能靠提高
CSS `z-index` 覆盖原生子视图。此逻辑仅涉及 Desktop Renderer 的显示租约,不改变
Runtime、MCP 或远端 Agent 的请求、导航与生命周期。
浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。
切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B
@@ -1,5 +1,38 @@
# 工作栏实现与验证进度
## 2026-09-13 原生浏览器与应用浮层修复
- 用户在“添加实例”中复现原生网页盖住弹窗。原先只有终端关闭确认释放 viewport,
现在所有应用 Modal 与相交浮层共用
[浏览器遮挡机制](./browser-tabs-technical-design.md#4-renderer-与-viewport)。
新增五个组件回归在修复前全部失败,修复后通过;独立检测器另有十项回归。
- Windows 当前 Main、Preload、Renderer,通过 `electron-vite dev` 和独立 profile
启动完整 App,不是旧安装包或仅有 Renderer 的 fixture。浏览器导航到本机合成页后,
实例管理与项目设置均验证原生视图 `visible → hidden → visible`;视图 ID 不变且未销毁。
本次不修改 Runtime、Agent、MCP 或远程生产路径。
- 实例编辑器在浅深主题、目标 1280×800、960×720、720×640、640×420 下完成八组
几何、关闭按钮命中和原生视图隐藏断言。Windows 缩放产生不足 1 CSS px 的取整差异,
记录实际尺寸而非把目标尺寸当测量值。PNG 实际审阅包括浅色 1280、720 和深色 640;
其余尺寸执行程序化检查,不将其描述为全部 PNG 人工审阅。
- 临时驱动曾出现新浏览器 target 抢占自动化目标、隐藏窗口截图阻塞及严格像素比较
误判;修正驱动后重新取证,不把这些失败改写为产品修复。最终使用 pinned renderer、
独立测试窗口和 CDP PNG;原生视图状态单独从 Main 读取,避免网页截图漏掉原生视图。
- 顺带复现已发布更新说明资源为 136,554 字节,超过原 128 KiB 读取上限。上限提高到
1 MiB,仍使用有界读取;回归直接读取实际打包资源并确认当前版本可展示、可确认。
完整 App 已显示并关闭 0.13.0 更新说明,重启后不再次显示。
- 首轮 `npm test -- --maxWorkers=1`:4,108 通过、66 跳过,608.30 秒;
`npm run typecheck`、`npm run lint` 通过。补长名称修正后的全量执行为 4,105 通过、
66 跳过、3 失败,586.51 秒;失败全部位于并行修改中的子代理进度测试。
未修改这些文件,随后定向重跑 ACP 与 OpenCode 子代理共 66 项全部通过。
全仓类型与 lint 检查期间先后遇到并行子代理/存储升级改动的缺失字段和未使用导入,
这些改动不纳入本次提交,不能把早先的通过结果当作当前并行工作区全绿。
- 两组 Harness 单测补 `onTestFinished` 回收临时目录。最后定向四文件 73 项通过,
运行前后检查新增残留目录为 0。测试清理不改变 deployed Runtime。
- 本次修改文档的 66 个相对链接目标存在,暂存差异无空白错误;最终发布仍需对确定的、
不再变动的候选提交执行发布验证,不能将未提交的并行改动混入候选。
- 临时脚本、截图和隔离构建按用户要求在提交后清理;这里只保留脱敏结论。知识库布局、
合成服务保存验证与调用计数见[知识库进度](../knowledge-base/progress.md#2026-09-13-知识库布局与自动名称修复)。
## 2026-09-13 OpenCode 事件流关闭顺序修复
候选检查中的 lifecycle 失败已稳定复现,4 路并行后 `openEventBodies` 为 3 而不是 0。
@@ -68,6 +68,10 @@ Renderer 丢弃已失效的目录、详情和测试响应,不把切换前的
## 5. 创建与编辑
新建绑定时,用户尚未编辑的显示名称跟随当前目录选择更新;更换实例或手工输入另一个
目标 ID 时清除旧自动名称。用户手动填写过名称后保留该草稿。编辑或重新绑定已有库时,
始终保留已保存的本地名称,除非用户主动修改;此修正不自动重命名已有知识库。
流程为选择 `local | dify | fastgpt | ragflow`,外部类型再选择实例、目录项或手工 ID、
显示名称、配置与测试查询。表单测试成功后允许保存;服务保存前再次执行同一查询验证,
检查实例、绑定和唯一性是否在等待期间改变,再写入数据库。
@@ -31,6 +31,12 @@
| 检索结果 | 外部组件显示总耗时、返回数量、片段、定位、实际 ID 和原始评分,包括 FastGPT 评分数组;不显示本地全文、向量、图谱通道或本地上下文操作 |
| 聊天与引用 | 统一选择器显示 Provider、实例与状态;外部引用打开保存片段,显示实例 ID 和远端定位,不请求本地上下文或打开原始来源 |
创建类型分段控件复用全局不收缩规则,不能被目录和参数表单压扁;列表长名称的宽度约束见
[知识库 UI 设计](./ui-design.md#2-信息架构)。新建时自动名称跟随目录选择,手动改名和已保存
名称不被覆盖,具体规则见[逻辑设计](./external-knowledge-logic-design.md#5-创建与编辑)。
实例 Modal 和其他浮层统一参与[原生浏览器遮挡处理](../assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport),
不再仅验证没有浏览器的独立 Portal。
原 `knowledge-field` 的控件样式依赖知识库工作区祖先,实例 Modal 经 `createPortal` 挂到
body 后丢失该作用域。当前 `Field` 使用全局 `.field` 和标签 span,密码也复用同一字段
结构,确保 Portal 内的字体、输入边框、背景和焦点样式与应用一致。弹窗宽度为
+20
View File
@@ -16,6 +16,26 @@
## 验证证据
### 2026-09-13 知识库布局与自动名称修复
- 创建类型的共享分段控件被长表单压缩,修复共享不收缩规则,不添加页面专属高度。
长名称卡片增加零最小宽度的 Grid 轨道,名称单行省略、图标保持 15px;详情名称允许换行。
合成长名称实测列表横向溢出为 0;详情修复前溢出 658px,修复后为 0。
- 连续选择两个目录项时,未修改过的自动名称原先停留在第一个目标。回归先失败再修复,
现在跟随选择,手动名称和已保存名称不自动覆盖。规则见
[创建与编辑](./external-knowledge-logic-design.md#5-创建与编辑)。
- 当前完整 App 的真实 UI → Preload → Main → SQLite 路径完成实例保存、两个目录项
切换、零结果检索后添加绑定、详情展示和配置草稿保留。上游是 loopback Dify 合成服务,
不是真实知识平台;没有调用模型,也不能据此声称真实检索质量或远端验收。
- 本轮合成服务共收到 8 次请求:1 次初始目录、2 次初始详情、2 次检索(测试与保存)、
2 次后续详情、1 次后续目录;真实模型和真实知识服务请求均为 0。
- 创建类型在浅深主题、目标 1280×800、960×720、720×640、640×420 的八组尺寸中
检查控件高度至少 34px,并保存 PNG。实际审阅浅色 1280、深色 640 及最终添加后的
长名称详情 PNG,确认 Tab 不再成为窄缝,卡片边框和图标完整,详情无横向滚动条。
- 同期修复和通用验证结果见[工作栏进度](../assistant-workbar/progress.md#2026-09-13-原生浏览器与应用浮层修复)。
临时脚本与合成 profile 在提交后清理,不把它们作为长期依赖;用户在另一测试窗口
手工创建的实例与绑定不属于本轮合成数据,不随清理删除。
### 2026-09-13 检索路径与绑定查询简化
对 `3b560c9..ca4a58c` 的复查发现三处与本功能相关的实现问题,已按 KISS 与性能优先修正:
@@ -25,6 +25,11 @@
标题区提供“用于当前对话”“检索测试”、编辑和删除。新建知识库是页面主操作。
“高级设置”承载问答效果入口、向量索引、分块与图谱配置,不把工程参数放在首次路径。
本地与外部库共用的列表以 `minmax(0, 1fr)` 限制条目宽度,再对名称单行省略;选中卡片
的边框必须完整留在列表内,Provider/实例等辅助文字允许换行。不得用列表外层裁切
掩盖被长名称撑宽的卡片,库图标不能随名称一起收缩。外部详情的长名称和远端名称允许
换行,不能撑出横向滚动条。创建类型分段控件与详情页签保持固定内容高度,不随长表单收缩。
## 3. 首次使用
没有知识库时,页面空状态显示“新建知识库”主操作。创建时先选择类型;本地类型默认
+31
View File
@@ -26,6 +26,37 @@
**历史**:`e9679fe`、`ca4a58c` 各修了一部分被人工点到的弹窗,同一批改动又新增了两个未覆盖
的遮罩;后续复查改为共享规则加自动发现测试。
## 网页浮层遗漏原生浏览器视图
**症状**:知识库实例、项目设置等 Modal 的右侧被浏览器网页覆盖,通知或菜单也可能
被压在原生视图下面。
**原因**:`WebContentsView` 不参与 Renderer 的 CSS 层叠。先前只在关闭终端确认时
显式隐藏 viewport,没有覆盖其他页面的 Portal 和浮层。
**规则**:`BrowserViewport` 共用 `browser-viewport-occlusion.ts`,按 Modal 语义和
浮层相交状态释放显示租约,不销毁网页会话。不得再增加逐个弹窗的特殊隐藏条件。
**防复发**:组件回归覆盖 body Modal、嵌套 Modal、已打开 Modal、隐藏或非相交浮层、
通知、终端确认和非活动页签;实际 Electron 验证同时检查原生视图可见性,不能只看 DOM
的遮罩或截图。
**历史**:先前浏览器多实例修正只覆盖了终端关闭确认;2026-09-13 用户在知识库实例
Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
## Flex 滚动内容压缩页签和分段控件
**症状**:知识库来源选择被长参数表单压成窄缝,标签和按钮文字被裁掉。
**规则**:`PageTabs` 与 `SegmentedControl` 在共享样式上固定 `flex: 0 0 auto`;
直接包装层也必须保留高度。不要按页面复制高度常量。
**防复发**:`WorkspacePrimitives.test.tsx` 检查共享分段控件不收缩,已有平台功能、
能力和 MCP 页签回归继续保留;Electron 复验必须包含长表单与短窗口。
**历史**:设置页签曾补过包装层不收缩规则;2026-09-13 知识库创建类型的共享
`SegmentedControl` 又出现同类问题。
## 逐行查询代替主键查询
**症状**:列表渲染或校验函数随数据量线性变慢,且慢在单次操作内重复发生。
@@ -1,9 +1,9 @@
import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import {
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
DEEPSEEK_HARNESS_CONTROL_VERSION,
@@ -33,6 +33,7 @@ async function fixture() {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-launcher-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const workspace = join(root, 'workspace')
const dshHome = join(root, 'home')
const hostPath = join(
+6 -2
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, onTestFinished } from 'vitest'
import {
createBoundedNdJsonStream,
installHarnessDiagnosticGuard,
startControlledDeepSeekHarnessHost
} from './deepseek-harness-host'
import { vi } from 'vitest'
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import type {
Agent,
CreateAgentOptions
@@ -65,6 +65,7 @@ describe('controlled DeepSeek Harness host', () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-host-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const inbound = new TransformStream<
Record<string, unknown>,
Record<string, unknown>
@@ -125,6 +126,7 @@ describe('controlled DeepSeek Harness host', () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-alias-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const alias = join(root, '..', basename(root))
const inbound = new TransformStream<
Record<string, unknown>,
@@ -157,6 +159,7 @@ describe('controlled DeepSeek Harness host', () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-extensions-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const brokenEntrypoint = join(root, 'broken.mjs')
await writeFile(
brokenEntrypoint,
@@ -202,6 +205,7 @@ describe('controlled DeepSeek Harness host', () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-skill-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const skillDirectory = join(root, 'web-3d-game')
await mkdir(skillDirectory)
await writeFile(
+18 -2
View File
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -70,6 +70,22 @@ afterEach(async () => {
})
describe('ReleaseNotesService', () => {
it('reads and acknowledges the actual packaged release history', async () => {
const { version } = JSON.parse(
await readFile(join(process.cwd(), 'package.json'), 'utf8')
) as { version: string }
const { filePath, service } = await createService(version)
await writeFile(
filePath,
await readFile(join(process.cwd(), 'resources', 'release-notes.json'))
)
await expect(service.getPending()).resolves.toMatchObject({
currentVersion: version,
releases: [{ version }]
})
await service.acknowledge({ version })
await expect(service.getPending()).resolves.toMatchObject({ releases: [] })
})
it('shows only the current release on a fresh installation', async () => {
const { service } = await createService('0.8.18')
@@ -135,7 +151,7 @@ describe('ReleaseNotesService', () => {
it('rejects an oversized release-notes resource with a bounded read', async () => {
const { filePath, service } = await createService('0.8.18')
await writeFile(filePath, ' '.repeat(128 * 1024 + 1), 'utf8')
await writeFile(filePath, ' '.repeat(1024 * 1024 + 1), 'utf8')
await expect(service.getPending()).rejects.toThrow(
'Release notes exceed the size limit'
+1 -1
View File
@@ -8,7 +8,7 @@ import {
import type { ApplicationSettingsStore } from './application-settings-store'
import { compareStrictSemVer } from './version-checker'
const maximumReleaseNotesBytes = 128 * 1024
const maximumReleaseNotesBytes = 1024 * 1024
export class ReleaseNotesService {
private releases?: ReleaseNote[]
@@ -32,6 +32,21 @@ function selectInstance(index = 0): void {
}
describe('external knowledge workflows', () => {
it('updates an automatic name when selecting another library but preserves an edited name', async () => {
bridge.externalCatalogList.mockResolvedValueOnce({
items: [{ id: 'remote-1', name: 'Handbook' }, { id: 'remote-2', name: 'Second library' }],
hasMore: false
})
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
selectInstance()
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
expect(screen.getByLabelText('Name')).toHaveValue('Handbook')
fireEvent.click(screen.getByRole('button', { name: /Second library/ }))
expect(screen.getByLabelText('Name')).toHaveValue('Second library')
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'My chosen name' } })
fireEvent.click(screen.getByRole('button', { name: /Handbook/ }))
expect(screen.getByLabelText('Name')).toHaveValue('My chosen name')
})
it('navigates the rich instance menu, skips disabled items and restores focus', async () => {
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'disabled', name: 'Disabled service', enabled: false }, { ...instance, id: 'other', name: 'Other service' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
const trigger = screen.getByRole('button', { name: 'External instance' })
@@ -97,6 +112,7 @@ describe('external knowledge workflows', () => {
it('keeps external tabs and filters from shrinking in the scrolling workspace', () => {
const css = readFileSync('src/renderer/src/external-knowledge.css', 'utf8')
expect(css).toMatch(/\.external-knowledge > \.page-tabs\s*\{[^}]*flex: 0 0 auto/)
expect(css).toMatch(/\.external-knowledge > header\s*\{[^}]*overflow-wrap: anywhere/)
expect(css).toMatch(/\.external-knowledge__filters\s*\{[^}]*flex: 0 0 auto/)
expect(css).toMatch(/\.external-knowledge-modal__surface\s*\{[^}]*width: min\(560px, 100%\)/)
expect(css).toMatch(/\.external-knowledge__footer\s*\{[^}]*position: sticky/)
+5 -4
View File
@@ -187,6 +187,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
const [instanceId, setInstanceId] = useState(binding?.instanceId ?? '')
const [remoteId, setRemoteId] = useState(binding?.remoteKnowledgeBaseId ?? '')
const [name, setName] = useState(library?.name ?? '')
const [nameEdited, setNameEdited] = useState(Boolean(library))
const [detail, setDetail] = useState<ExternalKnowledgeCatalogItem>()
const [common, setCommon] = useState(binding?.commonConfig ?? commonDefaults)
const [config, setConfig] = useState(binding?.providerConfig ?? externalProviderDefaults(provider))
@@ -265,10 +266,10 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
<div className="external-knowledge__columns">
<section className="external-knowledge__fields">
<h3>{t('external.source')}</h3>
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); if (!nameEdited) setName(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
<button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button>
<SegmentedControl ariaLabel={t('external.remote')} value={manual ? 'manual' : 'catalog'} options={[{ value: 'catalog', label: t('external.browse') }, { value: 'manual', label: t('external.manual') }]} onChange={value => setManual(value === 'manual')} />
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value) }} /></Field> : <>
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value); if (!nameEdited) setName('') }} /></Field> : <>
<Field label={t('external.search')}><input type="search" maxLength={512} value={search} onChange={event => { setSearch(event.currentTarget.value); setPage(1) }} /></Field>
<div className="external-knowledge__actions"><button type="button" className="secondary-button" aria-label={t('external.refresh')} title={t('external.refresh')} onClick={() => { invalidate(); setRefresh(value => value + 1) }}><RefreshCw size={15} /></button>
{parentId && <button type="button" className="secondary-button" onClick={() => { setParentId(null); setPage(1) }}>{t('external.root')}</button>}</div>
@@ -280,7 +281,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
const bound = libraries.some(candidate => candidate.id !== library?.id && candidate.external?.instanceId === instanceId && candidate.external.remoteKnowledgeBaseId === item.id)
return <button type="button" className="external-knowledge__target" key={item.id} aria-pressed={item.kind === 'folder' ? undefined : remoteId === item.id} disabled={bound || busy} onClick={() => {
if (item.kind === 'folder') { setParentId(item.id); setPage(1); return }
invalidate(); setRemoteId(item.id); setDetail(item); if (!name) setName(item.name)
invalidate(); setRemoteId(item.id); setDetail(item); if (!nameEdited) setName(item.name)
}}>{item.kind === 'folder' ? <FolderOpen size={15} /> : <Database size={15} />}<span><strong>{item.name}</strong><small>{item.id}{bound ? ` · ${t('external.alreadyBound')}` : ''}</small></span></button>
})}
<div className="external-knowledge__actions"><button type="button" className="secondary-button" disabled={page === 1} onClick={() => setPage(value => value - 1)}>{t('external.previous')}</button><span>{page}</span><button type="button" className="secondary-button" disabled={!catalog.hasMore} onClick={() => setPage(value => value + 1)}>{t('external.next')}</button></div>
@@ -288,7 +289,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
</>}
{remoteId && <p className="external-knowledge__identifier">{remoteName}<br />{remoteId}</p>}
{duplicate && <p role="alert" id={`${errorId}-duplicate`}>{t('external.alreadyBound')}</p>}
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => setName(event.currentTarget.value)} /></Field>
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => { setName(event.currentTarget.value); setNameEdited(true) }} /></Field>
<Field label={t('external.query')}><textarea required maxLength={4000} value={query} onChange={event => { invalidate(); setQuery(event.currentTarget.value) }} /></Field>
</section>
<ExternalConfig common={common} config={config} detail={detail} onCommon={value => { invalidate(); setCommon(value) }} onConfig={value => { invalidate(); setConfig(value) }} />
@@ -1472,6 +1472,99 @@ describe('RightAssistantSidebar resizing', () => {
expect(browserA).toHaveAttribute('aria-selected', 'true')
})
it('releases the native browser for portalled modals until the last modal closes', async () => {
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
width: 400, x: 900, y: 100, toJSON: () => ({})
})
const modal = document.createElement('section')
modal.setAttribute('role', 'dialog')
modal.setAttribute('aria-modal', 'true')
const nested = modal.cloneNode() as HTMLElement
try {
renderSidebar({ tab: 'browser', activeConversationId: 'modal-browser' })
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
))
const lease = browserApi.setViewport.mock.calls.at(-1)![0].leaseToken
document.body.append(modal)
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
document.body.append(nested)
modal.remove()
await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease })
nested.remove()
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'modal-browser', leaseToken: lease, bounds: expect.any(Object) })
))
expect(browserApi.closeTab).not.toHaveBeenCalled()
expect(browserApi.createTab).toHaveBeenCalledTimes(1)
} finally {
modal.remove()
nested.remove()
bounds.mockRestore()
}
})
it.each(['menu', 'dialog', '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)
popup.hidden = true
let overlapping = true
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const left = this === popup && !overlapping ? 100 : 900
return {
bottom: 500, height: 400, left, right: left + 400, top: 100,
width: 400, x: left, y: 100, toJSON: () => ({})
}
})
try {
document.body.append(popup)
renderSidebar({ tab: 'browser', activeConversationId: 'popup-browser' })
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
))
const lease = browserApi.setViewport.mock.calls.at(-1)![0].leaseToken
popup.hidden = false
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
overlapping = false
fireEvent(window, new Event('resize'))
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
))
overlapping = true
fireEvent(window, new Event('resize'))
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
popup.hidden = true
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
))
} finally {
popup.remove()
bounds.mockRestore()
}
})
it('does not acquire a native view behind an already open modal or restore an inactive tab', async () => {
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
width: 400, x: 900, y: 100, toJSON: () => ({})
})
const modal = document.createElement('section')
modal.setAttribute('aria-modal', 'true')
try {
document.body.append(modal)
renderSidebar({ tab: 'browser', activeConversationId: 'covered-browser' })
await waitFor(() => expect(browserApi.setViewport).toHaveBeenCalled())
expect(browserApi.setViewport.mock.calls.every(([request]) => !('bounds' in request))).toBe(true)
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
modal.remove()
await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
expect(browserApi.setViewport.mock.calls.every(([request]) => !('bounds' in request))).toBe(true)
} finally {
modal.remove()
bounds.mockRestore()
}
})
it('releases the native browser viewport while confirming another terminal close', async () => {
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
+35 -9
View File
@@ -43,6 +43,10 @@ import type {
} from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { SegmentedControl } from './WorkspacePrimitives'
import {
browserOverlaySelector,
isBrowserViewportOccluded
} from './browser-viewport-occlusion'
import {
findTaskSchedule,
TaskScheduleActions
@@ -504,10 +508,11 @@ function BrowserViewport({
let animationFrame: number | undefined
let lastRequest = ''
let resizeObserver: ResizeObserver | undefined
const observedOverlays = new Set<Element>()
const panel = host.closest<HTMLElement>('[role="tabpanel"]')
const syncViewport = (): void => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
return
}
animationFrame = requestAnimationFrame(() => {
animationFrame = undefined
@@ -517,7 +522,8 @@ function BrowserViewport({
Boolean(conversationId) &&
panel?.hidden !== true &&
rect.width >= 1 &&
rect.height >= 1
rect.height >= 1 &&
!isBrowserViewportOccluded(host, rect)
const bounds = isVisible
? {
x: Math.max(0, Math.round(rect.left)),
@@ -545,6 +551,7 @@ function BrowserViewport({
if (panel?.hidden !== false) {
resizeObserver?.disconnect()
resizeObserver = undefined
observedOverlays.clear()
syncViewport()
return
}
@@ -552,17 +559,34 @@ function BrowserViewport({
resizeObserver = new ResizeObserver(syncViewport)
resizeObserver.observe(host)
}
if (resizeObserver) {
const overlays = new Set(document.querySelectorAll(browserOverlaySelector))
for (const overlay of observedOverlays) {
if (!overlays.has(overlay)) {
resizeObserver.unobserve(overlay)
observedOverlays.delete(overlay)
}
}
for (const overlay of overlays) {
if (!observedOverlays.has(overlay)) {
resizeObserver.observe(overlay)
observedOverlays.add(overlay)
}
}
}
syncViewport()
}
const mutationObserver = new MutationObserver(updateObservation)
if (panel) {
mutationObserver.observe(panel, {
attributes: true,
attributeFilter: ['hidden']
})
}
mutationObserver.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['hidden', 'aria-modal', 'role', 'class', 'style', 'open']
})
window.addEventListener('resize', syncViewport)
window.addEventListener('scroll', syncViewport, true)
document.addEventListener('transitionend', syncViewport, true)
document.addEventListener('animationend', syncViewport, true)
updateObservation()
return () => {
if (animationFrame !== undefined) {
@@ -572,6 +596,8 @@ function BrowserViewport({
mutationObserver.disconnect()
window.removeEventListener('resize', syncViewport)
window.removeEventListener('scroll', syncViewport, true)
document.removeEventListener('transitionend', syncViewport, true)
document.removeEventListener('animationend', syncViewport, true)
void browserApi.setViewport({ leaseToken }).catch(() => undefined)
}
}, [conversationId, tabId, visible])
@@ -2397,7 +2423,7 @@ export function RightAssistantSidebar({
: undefined
}
tabId={browserTabIds[instance.id]}
visible={open && instance.id === activeWorkbarInstanceId && !terminalCloseConfirmation}
visible={open && instance.id === activeWorkbarInstanceId}
/>
</section>
)}
@@ -111,6 +111,27 @@ describe('WorkspacePrimitives', () => {
expect(platformFeaturesTabs).toBeDefined()
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
})
it('keeps shared segmented controls from collapsing beside scrollable content', () => {
const segmentedStyles = stylesheet.match(
/\.segmented-control,\s*\.page-tabs--segmented\s*\{(?<rules>[^}]*)\}/u
)?.groups?.rules
expect(segmentedStyles).toBeDefined()
expect(segmentedStyles).toMatch(/flex:\s*0 0 auto;/u)
})
it('bounds knowledge library cards before truncating long names', () => {
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-list\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/u
)
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-button\s*\{[^}]*min-width:\s*0;/u
)
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-meta\s*\{[^}]*overflow-wrap:\s*anywhere;/u
)
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-identity > svg\s*\{[^}]*flex:\s*0 0 auto;/u
)
})
it('keeps Capabilities and tools tabs visible in scrollable Settings', () => {
const capabilitiesTabs = stylesheet.match(
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
afterEach(() => {
document.body.replaceChildren()
vi.restoreAllMocks()
})
function setup(attributes: Record<string, string>, overlaps = true) {
const host = document.createElement('div')
const parent = document.createElement('div')
const overlay = document.createElement('section')
for (const [name, value] of Object.entries(attributes)) {
overlay.setAttribute(name, value)
}
parent.append(overlay)
document.body.append(host, parent)
const viewport = new DOMRect(600, 100, 400, 500)
vi.spyOn(overlay, 'getBoundingClientRect').mockReturnValue(
new DOMRect(overlaps ? 700 : 10, 200, 200, 100)
)
return { host, parent, overlay, viewport }
}
describe('native browser overlay detection', () => {
it('blocks an application modal even when its surface does not overlap the page', () => {
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 => {
const { host, viewport } = setup({ role })
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
})
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
if (kind === 'display') overlay.style.display = 'none'
if (kind === 'visibility') overlay.style.visibility = 'hidden'
if (kind === 'ancestor-display') parent.style.display = 'none'
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
})
it('does not treat inline statuses and error messages as overlays', () => {
const { host, viewport } = setup({ role: 'alert' })
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
})
it('does not let a surrounding non-modal container hide its own browser', () => {
const { host, overlay, viewport } = setup({ role: 'dialog' })
overlay.append(host)
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
})
})
@@ -0,0 +1,35 @@
export const browserOverlaySelector = [
'[aria-modal="true"]',
'[role="dialog"]',
'[role="alertdialog"]',
'[role="menu"]',
'.app-notification'
].join(', ')
export function isBrowserViewportOccluded(
host: HTMLElement,
viewport: DOMRect
): boolean {
// WebContentsView is above the renderer, regardless of CSS z-index.
// Release its viewport, not its session, while renderer overlays need it.
return Array.from(
host.ownerDocument.querySelectorAll<HTMLElement>(browserOverlaySelector)
).some((overlay) => {
if (overlay.contains(host) || overlay.closest('[hidden]')) return false
const rect = overlay.getBoundingClientRect()
if (rect.width < 1 || rect.height < 1) return false
const style = getComputedStyle(overlay)
if (style.visibility === 'hidden' || style.visibility === 'collapse') {
return false
}
for (let element: HTMLElement | null = overlay; element; element = element.parentElement) {
if (getComputedStyle(element).display === 'none') return false
}
return overlay.getAttribute('aria-modal') === 'true' || (
rect.left < viewport.right &&
rect.right > viewport.left &&
rect.top < viewport.bottom &&
rect.bottom > viewport.top
)
})
}
+1
View File
@@ -41,6 +41,7 @@
.external-knowledge dt { color: var(--text-secondary); }
.external-knowledge dd { margin: 0; overflow-wrap: anywhere; }
.external-knowledge > header h2 { margin: 0 0 var(--space-2); font-size: var(--font-section-title); }
.external-knowledge > header { overflow-wrap: anywhere; }
.external-knowledge > .page-tabs { flex: 0 0 auto; }
.external-knowledge__fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4); }
.external-knowledge__filters { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3); flex: 0 0 auto; }
+8
View File
@@ -12731,6 +12731,7 @@ details.settings-section > :not(summary) + :not(summary) {
.segmented-control,
.page-tabs--segmented {
display: inline-flex;
flex: 0 0 auto;
max-width: 100%;
padding: 3px;
border: 1px solid var(--border-control);
@@ -13125,6 +13126,7 @@ details.settings-section > :not(summary) + :not(summary) {
.knowledge-workspace__library-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
padding: 0;
margin: 0;
gap: 7px;
@@ -13133,6 +13135,7 @@ details.settings-section > :not(summary) + :not(summary) {
.knowledge-workspace__library-button {
width: 100%;
min-width: 0;
padding: 11px;
border: 1px solid transparent;
border-radius: var(--radius-control);
@@ -13155,13 +13158,18 @@ details.settings-section > :not(summary) + :not(summary) {
}
.knowledge-workspace__library-identity strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-workspace__library-identity > svg {
flex: 0 0 auto;
}
.knowledge-workspace__library-meta {
display: block;
overflow-wrap: anywhere;
margin-top: 5px;
color: var(--text-muted);
font-size: var(--font-body);