修复 AGC 弹窗打开时自绘标题栏窗口按钮失效
- ThemedModal 的焦点陷阱只放行落在 [data-window-chrome-bar] 内的点击,标题栏拖拽与最小化/最大化/关闭恢复可用,工作区内容点击仍被拦住 - WindowChrome 标题栏加 data-window-chrome-bar 标记,作为这条约定的唯一契约点 - styles.css 明确「全屏弹层一律从标题栏下方开始」,.app-update-overlay 从 inset:0 改为标题栏下方,.game-publish-progress-overlay 显式声明 top - 新增 tests/windowChromeOverlayContract.test.ts 覆盖 7 个全屏弹层;themedModal / WindowChrome 用例补「标题栏点击放行 + 工作区点击仍被拦」回归;gamePublishFeedback 用例按新口径断言 - pitfalls 记录该静默失效的机制与现行口径
This commit is contained in:
@@ -149,7 +149,11 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
<WindowChromeContext.Provider value={contextValue}>
|
||||
<div className="window-chrome">
|
||||
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
|
||||
<header className="window-chrome__bar" aria-label="窗口标题栏">
|
||||
<header
|
||||
className="window-chrome__bar"
|
||||
data-window-chrome-bar
|
||||
aria-label="窗口标题栏"
|
||||
>
|
||||
<div className="window-chrome__leading">
|
||||
<div
|
||||
className="window-chrome__brand"
|
||||
|
||||
@@ -4,6 +4,23 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
type ThemedModalTheme = 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* 自绘标题栏的标记:它是窗口边框,不属于模态内容。
|
||||
*
|
||||
* focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接
|
||||
* `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏
|
||||
* 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱
|
||||
* 挡在模态之外,点空白处不会误触底层界面。
|
||||
*/
|
||||
const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]';
|
||||
|
||||
function isWindowChromeBarTarget(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export type ThemedModalProps = {
|
||||
open: boolean;
|
||||
ariaLabel: string;
|
||||
@@ -55,6 +72,7 @@ export function ThemedModal({
|
||||
escapeDeactivates: false,
|
||||
fallbackFocus: () => panelRef.current!,
|
||||
returnFocusOnDeactivate: true,
|
||||
allowOutsideClick: (event) => isWindowChromeBarTarget(event.target),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -117,7 +117,11 @@ body {
|
||||
.app-update-overlay {
|
||||
position: fixed;
|
||||
z-index: 260;
|
||||
inset: 0;
|
||||
/* 中文注释:全屏弹层一律从自绘标题栏下方开始,标题栏的最小化 / 最大化 / 关闭必须始终可用。 */
|
||||
top: var(--window-chrome-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
padding: 24px;
|
||||
background: rgb(35 20 12 / 48%);
|
||||
@@ -209,7 +213,13 @@ body {
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 网页内自绘标题栏占用的顶部高度;portal 到 body 的固定弹层也要从它下方开始。 */
|
||||
/*
|
||||
* 网页内自绘标题栏占用的顶部高度。
|
||||
*
|
||||
* 约定:portal 到 body 的全屏固定弹层一律从标题栏下方开始(`top: var(--window-chrome-height)`)。
|
||||
* 标题栏是窗口边框,不是弹层内容 —— 弹出任何面板时「最小化 / 最大化 / 关闭」和拖拽都必须
|
||||
* 保持可用;模态内部的焦点陷阱也必须放行落在标题栏上的点击(见 `ThemedModal`)。
|
||||
*/
|
||||
--window-chrome-height: 50px;
|
||||
}
|
||||
|
||||
@@ -9744,7 +9754,11 @@ iframe.preview-frame {
|
||||
.game-publish-progress-overlay {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
inset: 0;
|
||||
/* 中文注释:发布进行中仍然要能最小化 / 关闭窗口,遮罩只压住工作区。 */
|
||||
top: var(--window-chrome-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgb(35 24 19 / 62%);
|
||||
backdrop-filter: blur(3px);
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -2,12 +2,25 @@
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
|
||||
import { ThemedModal } from '../src/components/modal/ThemedModal';
|
||||
import { WindowChrome } from '../src/components/WindowChrome';
|
||||
import { useWindowChrome } from '../src/components/windowChromeContext';
|
||||
|
||||
const nativeWindow = vi.hoisted(() => ({
|
||||
minimize: vi.fn(),
|
||||
toggleMaximize: vi.fn(),
|
||||
isMaximized: vi.fn(),
|
||||
close: vi.fn(),
|
||||
label: 'client',
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => nativeWindow,
|
||||
}));
|
||||
|
||||
function TitleSetter({ value }: { value: string }) {
|
||||
const { setTitle } = useWindowChrome();
|
||||
return (
|
||||
@@ -36,6 +49,14 @@ function ActiveRunsSetter({
|
||||
}
|
||||
|
||||
describe('WindowChrome', () => {
|
||||
beforeEach(() => {
|
||||
nativeWindow.minimize.mockReset();
|
||||
nativeWindow.toggleMaximize.mockReset();
|
||||
nativeWindow.isMaximized.mockReset();
|
||||
nativeWindow.close.mockReset();
|
||||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
it('renders the陶泥儿 brand, default title, and controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -141,4 +162,35 @@ describe('WindowChrome', () => {
|
||||
);
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 回归:发布面板等 ThemedModal 弹窗打开时,标题栏在模态之外,焦点陷阱曾把
|
||||
* 标题栏上的点击一起拦下 —— 三个窗口按钮看着正常但点不动。
|
||||
*/
|
||||
it('keeps the window controls working while a modal covers the workspace', async () => {
|
||||
const user = userEvent.setup();
|
||||
nativeWindow.minimize.mockResolvedValue(undefined);
|
||||
nativeWindow.toggleMaximize.mockResolvedValue(undefined);
|
||||
nativeWindow.close.mockResolvedValue(undefined);
|
||||
nativeWindow.isMaximized.mockResolvedValue(false);
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
|
||||
|
||||
render(
|
||||
<WindowChrome>
|
||||
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
|
||||
<button type="button">确认</button>
|
||||
</ThemedModal>
|
||||
</WindowChrome>,
|
||||
);
|
||||
await screen.findByRole('dialog', { name: '测试弹窗' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '最小化' }));
|
||||
expect(nativeWindow.minimize).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '最大化' }));
|
||||
expect(nativeWindow.toggleMaximize).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
expect(nativeWindow.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,7 +266,11 @@ describe('客户端发布入口的可见反馈', () => {
|
||||
1440,
|
||||
);
|
||||
expect(declaration(overlay, 'position')).toBe('fixed');
|
||||
expect(declaration(overlay, 'inset')).toBe('0');
|
||||
// 遮罩从自绘标题栏下方开始:发布进行中仍然要能最小化 / 关闭窗口。
|
||||
expect(declaration(overlay, 'top')).toBe('var(--window-chrome-height)');
|
||||
expect(declaration(overlay, 'right')).toBe('0');
|
||||
expect(declaration(overlay, 'bottom')).toBe('0');
|
||||
expect(declaration(overlay, 'left')).toBe('0');
|
||||
expect(declaration(overlay, 'z-index')).toBe('500');
|
||||
expect(declaration(overlay, 'pointer-events')).toBe('auto');
|
||||
expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)');
|
||||
|
||||
@@ -35,6 +35,34 @@ function ModalHarness({ noFocusableContent = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。
|
||||
* 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。
|
||||
*/
|
||||
function WindowChromeHarness({
|
||||
onMinimize,
|
||||
onWorkspaceClick,
|
||||
}: {
|
||||
onMinimize: () => void;
|
||||
onWorkspaceClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="window-chrome__bar" data-window-chrome-bar>
|
||||
<button type="button" onClick={onMinimize}>
|
||||
最小化
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={onWorkspaceClick}>
|
||||
工作区按钮
|
||||
</button>
|
||||
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
|
||||
<button type="button">确认</button>
|
||||
</ThemedModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemedModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getClientRects').mockImplementation(
|
||||
@@ -105,4 +133,23 @@ describe('ThemedModal', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
it('lets window title bar clicks through while workspace clicks stay trapped', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMinimize = vi.fn();
|
||||
const onWorkspaceClick = vi.fn();
|
||||
render(
|
||||
<WindowChromeHarness
|
||||
onMinimize={onMinimize}
|
||||
onWorkspaceClick={onWorkspaceClick}
|
||||
/>,
|
||||
);
|
||||
await screen.findByRole('dialog', { name: '测试弹窗' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '最小化' }));
|
||||
expect(onMinimize).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '工作区按钮' }));
|
||||
expect(onWorkspaceClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { repoPath } from './repoPath';
|
||||
import { parseStyleSheet } from './styleCascade';
|
||||
|
||||
const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
|
||||
|
||||
/**
|
||||
* 全屏弹层清单:每一层都必须从自绘标题栏下方开始。
|
||||
*
|
||||
* 标题栏是窗口边框,不是弹层内容 —— 只要有一个全屏遮罩盖住它,弹窗打开时
|
||||
* 「最小化 / 最大化 / 关闭」就会被挡住。焦点陷阱那一半的问题见
|
||||
* `themedModal.test.tsx` 与 `WindowChrome.test.tsx`;新增全屏弹层时把类名加进这份清单。
|
||||
*/
|
||||
const WINDOW_CHROME_SAFE_OVERLAYS = [
|
||||
// ThemedModal 与共享弹层的通用遮罩:top 由这条规则统一抬到标题栏下方。
|
||||
'.fixed.inset-0',
|
||||
'.app-update-overlay',
|
||||
'.game-publish-progress-overlay',
|
||||
'.launcher-dialog-backdrop',
|
||||
'.settings-overlay',
|
||||
'.game-approval-backdrop',
|
||||
'.project-chat-settings-backdrop',
|
||||
] as const;
|
||||
|
||||
function declarationsForSelector(css: string, selector: string) {
|
||||
const merged = new Map<string, string>();
|
||||
for (const rule of parseStyleSheet(css)) {
|
||||
if (!rule.selectors.includes(selector)) {
|
||||
continue;
|
||||
}
|
||||
for (const [property, value] of rule.declarations) {
|
||||
merged.set(property, value);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
describe('窗口标题栏与全屏弹层的层叠约定', () => {
|
||||
const css = readFileSync(STYLES_PATH, 'utf8');
|
||||
|
||||
it.each(WINDOW_CHROME_SAFE_OVERLAYS)('%s 从标题栏下方开始', (selector) => {
|
||||
const declarations = declarationsForSelector(css, selector);
|
||||
expect(
|
||||
declarations.get('top'),
|
||||
`${selector} 必须声明 top: var(--window-chrome-height)`,
|
||||
).toBe('var(--window-chrome-height)');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 2026-09-23 弹窗打开时自绘标题栏的最小化 / 最大化 / 关闭静默失效
|
||||
|
||||
- **现象**:AGC 打开「发布到游戏广场」面板(以及其它任何弹窗)后,右上角三个窗口按钮点了没有任何反应,拖拽标题栏也不能移动窗口;关掉弹窗立刻恢复。标题栏看着完全正常,遮罩也明显只压住了下面的工作区,所以很容易误判成「按钮自己坏了」或 Tauri 窗口 API 挂了。
|
||||
- **原因**:标题栏在模态之外,但它是窗口边框。`ThemedModal` 用的 `focus-trap-react` 在 **document 捕获阶段**监听 `mousedown`/`touchstart`/`click`:模态外的点击一律 `preventDefault()`,`click` 还会 `stopImmediatePropagation()`。React 的监听挂在 document 内的根容器上,捕获阶段就被掐掉的 `click` 永远到不了 React,于是既不报错也不执行 —— 与「焦点陷阱吞掉模态外点击」是同一类问题(见 2026-09-20 发布面板焦点陷阱那条)。另有一条独立的同类缺陷:`.app-update-overlay` 用 `inset: 0`,把标题栏真的盖住了,更新弹窗期间按钮被遮罩挡住。
|
||||
- **处理(现行口径)**:① 全屏弹层一律从标题栏下方开始(`top: var(--window-chrome-height)`),不得用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容点击继续被拦;③ 新增全屏弹层时把类名补进 `apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts` 的清单。
|
||||
- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/themedModal.test.tsx apps/ai-game-creator-shell/tests/WindowChrome.test.tsx apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts`(标题栏点击放行、工作区点击仍被拦、7 个全屏弹层都在标题栏下方);两个新增用例去掉修复后确实失败,确认能守住这条约定。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/styles.css`。
|
||||
|
||||
## Direct 宿主继续请求不能重发原始用户条目
|
||||
|
||||
原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。
|
||||
|
||||
Reference in New Issue
Block a user