Files
Genarrative/apps/ai-game-creator-shell/src/components/WindowChrome.tsx
T
kdletters 13d272c2d4
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m52s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 6m29s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m45s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m29s
Project CI / Frontend tests (pull_request) Failing after 9m36s
Project CI / Native shell tests (pull_request) Successful in 13m30s
修复 AGC 壳首页无限 setState 循环
- WorkspaceLauncher 发布活动项目面板的 effect 不再依赖每次渲染都换身份的 openActiveProject:回调改走 ref,依赖只留数据
- useDirectActiveTurns 轮询只在快照内容变化时更新状态,空态保持引用稳定,避免每 5 秒换数组身份带动下游 effect
- WindowChrome 的 context value 用 useMemo 收口,避免所有 useWindowChrome 消费方被无谓重渲染
- 新增回归测试 directActiveTurns.test.tsx:轮询返回值不变时快照引用必须不变
- pitfalls 记录现象/根因/定位手段(console.error 里抓调用栈)与验证数据:修复后 35 秒 0 条深度错误、renderer 工作集 4.2GB -> 254MB
2026-09-17 16:53:34 +08:00

239 lines
7.3 KiB
TypeScript

import { getCurrentWindow } from '@tauri-apps/api/window';
import { Copy, Minus, Square, X } from 'lucide-react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
import { AppUpdateNotice } from './AppUpdateNotice';
import {
WINDOW_CHROME_DEFAULT_TITLE,
type WindowChromeActiveProjectRuns,
WindowChromeContext,
type WindowChromeContextValue,
} from './windowChromeContext';
type WindowChromeProps = {
children: ReactNode;
};
function isTauriRuntime() {
return (
typeof window !== 'undefined' &&
Boolean(
(window as Window & { __TAURI_INTERNALS__?: unknown })
.__TAURI_INTERNALS__,
)
);
}
function getNativeWindow() {
if (!isTauriRuntime()) {
return null;
}
try {
return getCurrentWindow();
} catch {
return null;
}
}
export function WindowChrome({ children }: WindowChromeProps) {
const [title, setTitleState] = useState(WINDOW_CHROME_DEFAULT_TITLE);
const [walletSlot, setWalletSlot] = useState<HTMLDivElement | null>(null);
const [activeProjectRuns, setActiveProjectRuns] =
useState<WindowChromeActiveProjectRuns | null>(null);
const setTitle = useCallback((nextTitle: string | null | undefined) => {
const normalizedTitle = nextTitle?.trim();
setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE);
}, []);
/**
* context value 必须 memo:内联对象会让所有 `useWindowChrome()` 消费方在标题栏
* 每次渲染时都重新拿到新对象,进而连带重跑它们依赖 context 的 effect。
*/
const contextValue = useMemo<WindowChromeContextValue>(
() => ({
isWindowChrome: true,
title,
setTitle,
walletSlot,
activeProjectRuns,
setActiveProjectRuns,
}),
[title, setTitle, walletSlot, activeProjectRuns],
);
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
const nativeWindow = getNativeWindow();
if (!nativeWindow) {
return;
}
let disposed = false;
let unlistenResize: (() => void) | undefined;
const syncMaximizedState = () => {
void nativeWindow
.isMaximized()
.then((maximized) => {
if (!disposed) {
setIsMaximized(maximized);
}
})
.catch(() => undefined);
};
syncMaximizedState();
// 直接用窗口订阅入口而不是 `nativeWindow.onResized`:后者在 tauri 2.11 上会
// 走库内那条「读监听注册表条目再注销」的竞态路径(挂载即注销时抛
// `listeners[eventId].handlerId`),窗口标题栏又是每次启动必挂载的组件。
void subscribeTauriEvent('tauri://resize', syncMaximizedState, {
target: { kind: 'Window', label: nativeWindow.label },
})
.then((unlisten) => {
if (disposed) {
unlisten();
} else {
unlistenResize = unlisten;
}
})
.catch(() => undefined);
return () => {
disposed = true;
unlistenResize?.();
};
}, []);
async function minimizeWindow() {
const nativeWindow = getNativeWindow();
if (!nativeWindow) {
return;
}
await nativeWindow.minimize().catch(() => undefined);
}
async function toggleMaximizeWindow() {
const nativeWindow = getNativeWindow();
if (!nativeWindow) {
return;
}
await nativeWindow
.toggleMaximize()
.then(() => nativeWindow.isMaximized())
.then(setIsMaximized)
.catch(() => undefined);
}
async function closeWindow() {
const nativeWindow = getNativeWindow();
if (!nativeWindow) {
return;
}
await nativeWindow.close().catch(() => undefined);
}
return (
<WindowChromeContext.Provider value={contextValue}>
<div className="window-chrome">
<AppUpdateNotice />
<header className="window-chrome__bar" aria-label="窗口标题栏">
<div className="window-chrome__leading">
<div className="window-chrome__brand" aria-label="陶泥儿 GameAgent">
<span className="window-chrome__brand-mark">
<img src={brandIcon} alt="" />
</span>
<span className="window-chrome__brand-copy">
<strong>陶泥儿</strong>
<span>GameAgent</span>
</span>
</div>
</div>
<div className="window-chrome__drag-region" data-tauri-drag-region>
<div className="window-chrome__title-wrap">
{activeProjectRuns &&
(activeProjectRuns.activeTurns.length > 0 ||
activeProjectRuns.readFailed) ? (
<ActiveProjectRunsPanel
activeTurns={activeProjectRuns.activeTurns}
currentProjectPath={activeProjectRuns.currentProjectPath}
readFailed={activeProjectRuns.readFailed}
onOpenProject={activeProjectRuns.onOpenProject}
placement="titlebar"
/>
) : (
<>
<span
className="window-chrome__workspace-dot"
aria-hidden="true"
/>
<span
className="window-chrome__title"
title={title}
aria-label={`当前工作区:${title}`}
>
{title}
</span>
</>
)}
</div>
</div>
<div className="window-chrome__trailing">
<div
ref={setWalletSlot}
className="window-chrome__wallet-slot"
aria-label="泥点"
/>
<div className="window-chrome__controls" aria-label="窗口控制">
<button
type="button"
className="window-chrome__control"
aria-label="最小化"
title="最小化"
onClick={() => void minimizeWindow()}
>
<Minus aria-hidden="true" size={15} strokeWidth={1.8} />
</button>
<button
type="button"
className="window-chrome__control"
aria-label={isMaximized ? '还原窗口' : '最大化'}
title={isMaximized ? '还原窗口' : '最大化'}
onClick={() => void toggleMaximizeWindow()}
>
{isMaximized ? (
<Copy aria-hidden="true" size={13} strokeWidth={1.8} />
) : (
<Square aria-hidden="true" size={13} strokeWidth={1.8} />
)}
</button>
<button
type="button"
className="window-chrome__control window-chrome__control--close"
aria-label="关闭"
title="关闭"
onClick={() => void closeWindow()}
>
<X aria-hidden="true" size={15} strokeWidth={1.8} />
</button>
</div>
</div>
</header>
<div className="window-chrome__content">{children}</div>
</div>
</WindowChromeContext.Provider>
);
}