fix: keep full-window dialogs responsive to mouse input

Render feedback, parsing diagnostics, knowledge confirmations, WeChat binding and image preview overlays through body portals so native titlebar drag regions cannot intercept their controls. Preserve themed form styles and modal focus isolation.

Validation: 320 targeted tests, typecheck and lint passed. Native Windows hover and close checks passed for feedback and parsing diagnostics in light and dark themes at two sizes. Full suite timed out without a summary.

Release note: 修复反馈等弹窗与窗口标题栏重叠时,关闭按钮无法正常悬停和点击的问题。
This commit is contained in:
mesalogo
2026-09-13 14:19:46 +08:00
parent ea45533137
commit ca4a58c762
13 changed files with 74 additions and 50 deletions
+3
View File
@@ -393,6 +393,9 @@
`34px × 34px` 点击区域。
- 发布说明 Modal 与其他应用级 Modal 一样使用整窗遮罩;遮罩必须覆盖主侧栏、右侧内容和
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,并声明
`-webkit-app-region: no-drag`,避免页面层叠上下文中的遮罩受 Electron 原生拖动区域影响。
保留 React Context、关闭与焦点管理;表单样式不得依赖已移出的页面祖先选择器。
- 当前会话存在 Task 时,Task 上下文栏位于标题栏下方并承载任务摘要与详情;没有 Task 时
整行隐藏。折叠状态保持为 `46px` 单行表面,与右侧工作栏标签栏等高,左右分隔线必须水平
对齐。该栏不重复放置新建入口。
@@ -156,6 +156,8 @@ Renderer 不预览、不读取也不接收诊断内容或路径。勾选后详
### 3.4 可访问性
- 对话框使用 `role="dialog"`、`aria-modal="true"` 和可见标题。
- 整窗遮罩通过 React portal 挂到 `document.body`,遵循
[全局遮罩与原生拖动区域规则](../../../UI-DESIGN.md#610-应用顶栏与全局操作)。
- 复用 `activateModalFocus()` 与 `trapTabFocus()`,打开后聚焦反馈类型,关闭后恢复入口
按钮焦点。
- 非提交状态支持 Escape 关闭;提交中保持对话框打开,直到请求完成或到达有界超时。
+3 -2
View File
@@ -5693,6 +5693,7 @@ describe("App", () => {
const imageDialog = await screen.findByRole("dialog", {
name: "页面截图.png",
});
expect(imageDialog.parentElement?.parentElement).toBe(document.body);
expect(
within(imageDialog).getByRole("img", { name: "页面截图.png" }),
).toHaveAttribute("src", imageAttachment.contentUrl);
@@ -5700,7 +5701,7 @@ describe("App", () => {
name: "关闭图片查看器",
});
expect(closeViewer).toHaveFocus();
expect(document.querySelector("main")?.inert).toBe(true);
expect(document.querySelector<HTMLElement>(".app-shell")?.inert).toBe(true);
fireEvent.keyDown(closeViewer, { key: "Tab" });
expect(
within(imageDialog).getByRole("button", {
@@ -5712,7 +5713,7 @@ describe("App", () => {
expect(
screen.queryByRole("dialog", { name: "页面截图.png" }),
).not.toBeInTheDocument();
expect(document.querySelector("main")?.inert).toBe(false);
expect(document.querySelector<HTMLElement>(".app-shell")?.inert).toBe(false);
fireEvent.click(
within(userArticle).getByRole("button", {
name: "下载图片 页面截图.png",
+4 -2
View File
@@ -56,6 +56,7 @@ import {
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { knowledgeReferenceKey } from "../../shared/knowledge-reference";
import type { TFunction } from "i18next";
import type {
@@ -10928,7 +10929,7 @@ function App(): React.JSX.Element {
reference={citationDialog.reference}
/>
)}
{imageViewerItem && (
{imageViewerItem && createPortal(
<div
className="image-viewer-backdrop"
onMouseDown={(event) => {
@@ -10980,7 +10981,8 @@ function App(): React.JSX.Element {
<img alt={imageViewerItem.title} src={imageViewerItem.src} />
</div>
</section>
</div>
</div>,
document.body
)}
{customTaskDialog && activeProject?.kind === "user" && (
<CustomTaskDialog
@@ -363,6 +363,9 @@ describe('ChannelSettingsSection', () => {
const close = await screen.findByRole('button', {
name: '关闭微信绑定'
})
expect(screen.getByRole('dialog').parentElement?.parentElement).toBe(
document.body
)
expect(
screen.getByText(
'请在微信中依次打开“设置 → 插件 → ClawBot → 开始扫一扫”,扫描下方二维码。二维码不会发送到第三方页面。'
+4 -2
View File
@@ -6,6 +6,7 @@ import {
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createPortal } from 'react-dom'
import QRCode from 'qrcode'
import type {
ChannelConnectionTestResult,
@@ -512,7 +513,7 @@ function WeixinQrDialog({
)
: undefined
return (
return createPortal(
<div className="channel-qr-backdrop">
<section
aria-labelledby="channel-qr-title"
@@ -639,7 +640,8 @@ function WeixinQrDialog({
</div>
)}
</section>
</div>
</div>,
document.body
)
}
@@ -513,7 +513,7 @@ describe('DocumentParsingSettingsSection', () => {
})
it('runs a real-file diagnostic flow and displays its result', async () => {
render(<DocumentParsingSettingsSection />)
const { container } = render(<DocumentParsingSettingsSection />)
await screen.findByText('PP-OCRv6 Tiny')
const trigger = screen.getByRole('button', {
@@ -525,15 +525,13 @@ describe('DocumentParsingSettingsSection', () => {
const dialog = await screen.findByRole('dialog', {
name: '解析测试结果'
})
expect(dialog.parentElement?.parentElement).toBe(document.body)
expect(dialog).toHaveTextContent('扫描件识别正文')
const close = screen.getByRole('button', {
name: '关闭结果'
})
await waitFor(() => expect(close).toHaveFocus())
const backgroundSection = trigger.closest<HTMLElement>(
'.settings-section'
)
expect(backgroundSection?.inert).toBe(true)
expect(container.inert).toBe(true)
expect(
fireEvent.keyDown(dialog, { key: 'Tab' })
).toBe(false)
@@ -544,7 +542,7 @@ describe('DocumentParsingSettingsSection', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
await waitFor(() => expect(trigger).toHaveFocus())
expect(backgroundSection?.inert).toBe(false)
expect(container.inert).toBe(false)
expect(test).toHaveBeenCalledWith('chat-attachment')
expect(update).not.toHaveBeenCalled()
})
@@ -20,6 +20,7 @@ import {
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { createPortal } from 'react-dom'
import type {
DocumentParsingDiagnostic,
DocumentOcrModelCatalogViewEntry,
@@ -104,7 +105,7 @@ function DiagnosticDialog({
useEffect(() => {
return activateModalFocus(() => closeRef.current)
}, [])
return (
return createPortal(
<div
className="document-parsing-diagnostic-backdrop"
onMouseDown={(event) => {
@@ -190,7 +191,8 @@ function DiagnosticDialog({
</div>
)}
</section>
</div>
</div>,
document.body
)
}
+1
View File
@@ -198,6 +198,7 @@ describe('FeedbackDialog', () => {
const dialog = screen.getByRole('dialog', {
name: '提交反馈'
})
expect(dialog.parentElement).toBe(document.body)
const category = screen.getByLabelText('反馈类型')
expect(category).toHaveFocus()
fireEvent.change(screen.getByLabelText('标题'), {
+4 -2
View File
@@ -12,6 +12,7 @@ import {
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { createPortal } from 'react-dom'
import type { AppInfo } from '../../shared/contracts'
import {
feedbackCategories,
@@ -326,7 +327,7 @@ export function FeedbackDialog({
}
}
return (
return createPortal(
<div
aria-describedby={descriptionId}
aria-labelledby={titleId}
@@ -760,6 +761,7 @@ export function FeedbackDialog({
</form>
)}
</div>
</div>
</div>,
document.body
)
}
+11 -22
View File
@@ -385,6 +385,7 @@ describe('KnowledgeWorkspace', () => {
'将删除此来源的 1 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。'
)
expect(onRemoveSource).not.toHaveBeenCalled()
expect(screen.getByRole('alertdialog').parentElement).toBe(document.body)
fireEvent.click(
screen.getByRole('button', { name: '移除来源' })
)
@@ -1920,7 +1921,9 @@ describe('KnowledgeWorkspace', () => {
'将永久删除实体“GoodBuddy”及其 1 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。'
)
expect(onDeleteEntity).not.toHaveBeenCalled()
expect(screen.getByRole('alertdialog').parentElement).toBe(document.body)
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '删除关系 使用' })
@@ -2086,7 +2089,7 @@ describe('KnowledgeWorkspace', () => {
})
it('isolates and traps focus in the library edit dialog', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
const { container } = render(<KnowledgeWorkspace {...createProps()} />)
const trigger = screen.getByRole('button', { name: '编辑' })
trigger.focus()
@@ -2095,13 +2098,10 @@ describe('KnowledgeWorkspace', () => {
const dialog = screen.getByRole('dialog', {
name: '编辑知识库'
})
expect(dialog.parentElement).toBe(document.body)
const nameInput = screen.getByLabelText('名称')
expect(nameInput).toHaveFocus()
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(true)
expect(container.inert).toBe(true)
fireEvent.keyDown(nameInput, { key: 'Tab', shiftKey: true })
expect(
screen.getByRole('button', { name: '保存修改' })
@@ -2109,16 +2109,12 @@ describe('KnowledgeWorkspace', () => {
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus())
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
expect(container.inert).toBe(false)
})
it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn()
render(
const { container } = render(
<KnowledgeWorkspace
{...createProps({ onDeleteLibrary })}
/>
@@ -2131,19 +2127,12 @@ describe('KnowledgeWorkspace', () => {
const dialog = screen.getByRole('dialog', {
name: '删除知识库确认'
})
expect(dialog.parentElement).toBe(document.body)
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(true)
expect(container.inert).toBe(true)
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus())
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
expect(container.inert).toBe(false)
fireEvent.click(trigger)
expect(
screen.getByText(
+13 -8
View File
@@ -41,6 +41,7 @@ import {
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { createPortal } from 'react-dom'
import type { TFunction } from 'i18next'
import type {
KnowledgeDocumentItem as SharedKnowledgeDocumentItem,
@@ -890,7 +891,7 @@ function EditLibraryDialog({
}
}
return (
return createPortal(
<div
aria-label={t('edit.ariaLabel')}
aria-modal="true"
@@ -961,7 +962,8 @@ function EditLibraryDialog({
</button>
</div>
</form>
</div>
</div>,
document.body
)
}
@@ -998,7 +1000,7 @@ function DeleteLibraryDialog({
}
}
return (
return createPortal(
<div
aria-label={t('delete.ariaLabel')}
aria-modal="true"
@@ -1056,7 +1058,8 @@ function DeleteLibraryDialog({
</button>
</div>
</div>
</div>
</div>,
document.body
)
}
@@ -1097,7 +1100,7 @@ function RemoveSourceDialog({
}
}
return (
return createPortal(
<div
aria-describedby={descriptionId}
aria-labelledby={titleId}
@@ -1155,7 +1158,8 @@ function RemoveSourceDialog({
</button>
</div>
</section>
</div>
</div>,
document.body
)
}
@@ -1248,7 +1252,7 @@ function GraphDestructiveDialog({
}
}
return (
return createPortal(
<div
aria-describedby={descriptionId}
aria-labelledby={titleId}
@@ -1296,7 +1300,8 @@ function GraphDestructiveDialog({
</button>
</div>
</section>
</div>
</div>,
document.body
)
}
+18 -4
View File
@@ -6955,12 +6955,13 @@ button > svg {
}
.image-viewer-backdrop {
-webkit-app-region: no-drag;
position: fixed;
z-index: 80;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
inset: 0;
place-items: center;
}
@@ -9935,12 +9936,13 @@ details.settings-section > :not(summary) + :not(summary) {
}
.channel-qr-backdrop {
-webkit-app-region: no-drag;
position: fixed;
z-index: var(--z-dialog);
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
inset: 0;
place-items: center;
}
@@ -10060,6 +10062,7 @@ details.settings-section > :not(summary) + :not(summary) {
}
.feedback-dialog-backdrop {
-webkit-app-region: no-drag;
position: fixed;
z-index: var(--z-dialog);
display: grid;
@@ -10749,6 +10752,7 @@ details.settings-section > :not(summary) + :not(summary) {
}
.document-parsing-diagnostic-backdrop {
-webkit-app-region: no-drag;
position: fixed;
z-index: var(--z-dialog);
display: grid;
@@ -12794,6 +12798,7 @@ details.settings-section > :not(summary) + :not(summary) {
}
.knowledge-confirm-dialog-backdrop {
-webkit-app-region: no-drag;
position: fixed;
z-index: var(--z-dialog);
display: grid;
@@ -12836,6 +12841,7 @@ details.settings-section > :not(summary) + :not(summary) {
}
.knowledge-library-dialog-backdrop {
-webkit-app-region: no-drag;
position: fixed;
z-index: var(--z-dialog);
display: grid;
@@ -12890,7 +12896,7 @@ details.settings-section > :not(summary) + :not(summary) {
gap: var(--space-4);
}
.knowledge-page
:is(.knowledge-page, .knowledge-library-dialog)
:is(
input:not([type]),
input[type='text'],
@@ -15954,7 +15960,15 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--text-primary);
}
:root[data-theme='dark'] .app-shell strong {
:root[data-theme='dark'] :is(
.app-shell,
.feedback-dialog,
.document-parsing-diagnostic,
.knowledge-library-dialog,
.knowledge-confirm-dialog,
.channel-qr-dialog,
.image-viewer-dialog
) strong {
color: var(--text-primary);
}