From c38d07044a57ffa71c22c75cb933a78382799ffa Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:04:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E6=89=93=E5=BC=80=E6=97=B6=E8=87=AA=E7=BB=98=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A0=8F=E7=AA=97=E5=8F=A3=E6=8C=89=E9=92=AE=E5=A4=B1=E6=95=88?= =?UTF-8?q?=20-=20ThemedModal=20=E7=9A=84=E7=84=A6=E7=82=B9=E9=99=B7?= =?UTF-8?q?=E9=98=B1=E5=8F=AA=E6=94=BE=E8=A1=8C=E8=90=BD=E5=9C=A8=20[data-?= =?UTF-8?q?window-chrome-bar]=20=E5=86=85=E7=9A=84=E7=82=B9=E5=87=BB?= =?UTF-8?q?=EF=BC=8C=E6=A0=87=E9=A2=98=E6=A0=8F=E6=8B=96=E6=8B=BD=E4=B8=8E?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E5=8C=96/=E6=9C=80=E5=A4=A7=E5=8C=96/?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=81=A2=E5=A4=8D=E5=8F=AF=E7=94=A8=EF=BC=8C?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=86=85=E5=AE=B9=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E4=BB=8D=E8=A2=AB=E6=8B=A6=E4=BD=8F=20-=20WindowChrome=20?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=8F=E5=8A=A0=20data-window-chrome-bar?= =?UTF-8?q?=20=E6=A0=87=E8=AE=B0=EF=BC=8C=E4=BD=9C=E4=B8=BA=E8=BF=99?= =?UTF-8?q?=E6=9D=A1=E7=BA=A6=E5=AE=9A=E7=9A=84=E5=94=AF=E4=B8=80=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E7=82=B9=20-=20styles.css=20=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E3=80=8C=E5=85=A8=E5=B1=8F=E5=BC=B9=E5=B1=82=E4=B8=80=E5=BE=8B?= =?UTF-8?q?=E4=BB=8E=E6=A0=87=E9=A2=98=E6=A0=8F=E4=B8=8B=E6=96=B9=E5=BC=80?= =?UTF-8?q?=E5=A7=8B=E3=80=8D=EF=BC=8C.app-update-overlay=20=E4=BB=8E=20in?= =?UTF-8?q?set:0=20=E6=94=B9=E4=B8=BA=E6=A0=87=E9=A2=98=E6=A0=8F=E4=B8=8B?= =?UTF-8?q?=E6=96=B9=EF=BC=8C.game-publish-progress-overlay=20=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=A3=B0=E6=98=8E=20top=20-=20=E6=96=B0=E5=A2=9E=20te?= =?UTF-8?q?sts/windowChromeOverlayContract.test.ts=20=E8=A6=86=E7=9B=96=20?= =?UTF-8?q?7=20=E4=B8=AA=E5=85=A8=E5=B1=8F=E5=BC=B9=E5=B1=82=EF=BC=9Btheme?= =?UTF-8?q?dModal=20/=20WindowChrome=20=E7=94=A8=E4=BE=8B=E8=A1=A5?= =?UTF-8?q?=E3=80=8C=E6=A0=87=E9=A2=98=E6=A0=8F=E7=82=B9=E5=87=BB=E6=94=BE?= =?UTF-8?q?=E8=A1=8C=20+=20=E5=B7=A5=E4=BD=9C=E5=8C=BA=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E4=BB=8D=E8=A2=AB=E6=8B=A6=E3=80=8D=E5=9B=9E=E5=BD=92=EF=BC=9B?= =?UTF-8?q?gamePublishFeedback=20=E7=94=A8=E4=BE=8B=E6=8C=89=E6=96=B0?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E6=96=AD=E8=A8=80=20-=20pitfalls=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=AF=A5=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88=E7=9A=84?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=E4=B8=8E=E7=8E=B0=E8=A1=8C=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/WindowChrome.tsx | 6 ++- .../src/components/modal/ThemedModal.tsx | 18 +++++++ apps/ai-game-creator-shell/src/styles.css | 20 +++++-- .../tests/WindowChrome.test.tsx | 54 ++++++++++++++++++- .../tests/gamePublishFeedback.test.tsx | 6 ++- .../tests/themedModal.test.tsx | 47 ++++++++++++++++ .../tests/windowChromeOverlayContract.test.ts | 53 ++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 8 +++ 8 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index e12c46cd8..19d87059d 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -149,7 +149,11 @@ export function WindowChrome({ children }: WindowChromeProps) {
{appUpdateCheckEnabled ? : null} -
+
panelRef.current!, returnFocusOnDeactivate: true, + allowOutsideClick: (event) => isWindowChromeBarTarget(event.target), }} >
({ + 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).__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).__TAURI_INTERNALS__ = {}; + + render( + + undefined} ariaLabel="测试弹窗"> + + + , + ); + 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); + }); }); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 91b84b4a7..786c790a7 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -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%)'); diff --git a/apps/ai-game-creator-shell/tests/themedModal.test.tsx b/apps/ai-game-creator-shell/tests/themedModal.test.tsx index 5242722e0..3d0be715e 100644 --- a/apps/ai-game-creator-shell/tests/themedModal.test.tsx +++ b/apps/ai-game-creator-shell/tests/themedModal.test.tsx @@ -35,6 +35,34 @@ function ModalHarness({ noFocusableContent = false }) { ); } +/** + * 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。 + * 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。 + */ +function WindowChromeHarness({ + onMinimize, + onWorkspaceClick, +}: { + onMinimize: () => void; + onWorkspaceClick: () => void; +}) { + return ( + <> +
+ +
+ + undefined} ariaLabel="测试弹窗"> + + + + ); +} + 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( + , + ); + 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(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts b/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts new file mode 100644 index 000000000..b9bbb16d9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts @@ -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(); + 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)'); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 56fa9611a..e2daabf13 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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-宿主继续请求输入修复)。