完善陶泥儿桌面窗口外壳与素材导入

新增自绘标题栏、窗口控制和项目标题同步

将泥点账户入口接入标题栏并调整面板描边

收紧本地图片导入类型约束并同步契约文档

补充窗口外壳与泥点面板测试
This commit is contained in:
2026-08-26 21:34:04 +08:00
parent 16a5d28ed3
commit e5424cbf7b
15 changed files with 728 additions and 36 deletions
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "window-chrome",
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
"permissions": [
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize"
]
}
@@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result
## Workflow
1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media.
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`, then use `agc_import_account_assets` with its safe project-relative `localPaths` and re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (PNG/JPEG/WEBP) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
@@ -8,7 +8,7 @@ The client projects three distinct facts:
Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance.
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered media path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers a project-local image. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered media path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true only for PNG/JPEG/WEBP files accepted by the current local-image registration contract; GIF/SVG and non-image files remain discoverable but must not be passed to the image importer. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local image. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`.
@@ -165,6 +165,7 @@ pub(crate) fn open_game_creator_workspace_window(
}
tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path))
.title("陶泥儿")
.decorations(false)
.inner_size(1180.0, 820.0)
.min_inner_size(760.0, 560.0)
.build()
@@ -183,6 +184,7 @@ pub(crate) fn open_game_creator_launcher_window(
} else {
tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url())
.title("陶泥儿")
.decorations(false)
.inner_size(820.0, 640.0)
.min_inner_size(720.0, 520.0)
.build()
@@ -232,6 +234,7 @@ pub(crate) fn open_project_supervisor_chat_window(
supervisor_chat_window_url(project_path),
)
.title("项目总控 Agent 对话")
.decorations(false)
.inner_size(820.0, 720.0)
.min_inner_size(560.0, 480.0)
.build()
@@ -18,6 +18,7 @@
"url": "index.html",
"width": 1280,
"height": 800,
"decorations": false,
"minWidth": 1280,
"minHeight": 720
}
+9
View File
@@ -93,6 +93,7 @@ import type {
TauriInvoke,
UploadLocalAssetResult,
} from './app/types';
import { useWindowChrome } from './components/windowChromeContext';
import {
agentConversationId,
agentRuntimeCancelStatus,
@@ -437,6 +438,7 @@ export function App({
onAgentRuntimeSummariesChange,
onAgentResultsChange,
}: AppProps = {}) {
const { setTitle: setWindowTitle } = useWindowChrome();
// 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于
// Supervisor Runtimedirect-codex 是单回合「生成→试玩→修」循环,没有对应机制。
// 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。
@@ -10602,6 +10604,13 @@ export function App({
const currentProjectTitle = localProject
? manifest.name.trim() || projectNameFromPath(localProject.projectPath)
: seedManifest.name;
useEffect(() => {
if (!supervisorChatOnly) {
return;
}
setWindowTitle(localProject ? currentProjectTitle : undefined);
return () => setWindowTitle(undefined);
}, [currentProjectTitle, localProject, setWindowTitle, supervisorChatOnly]);
const agentRunHistoryOmittedCount =
Math.max(0, agentRunHistoryFiles.length - agentRunHistoryVisibleCount) +
agentRunHistoryOverflowCount;
@@ -0,0 +1,198 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Copy, Minus, Square, X } from 'lucide-react';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import {
WINDOW_CHROME_DEFAULT_TITLE,
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 setTitle = useCallback((nextTitle: string | null | undefined) => {
const normalizedTitle = nextTitle?.trim();
setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE);
}, []);
const contextValue: WindowChromeContextValue = {
isWindowChrome: true,
title,
setTitle,
walletSlot,
};
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();
void nativeWindow
.onResized(syncMaximizedState)
.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">
<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>
<span className="window-chrome__title-wrap">
<span
className="window-chrome__workspace-dot"
aria-hidden="true"
/>
<span
className="window-chrome__title"
title={title}
aria-label={`当前工作区:${title}`}
>
{title}
</span>
</span>
</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>
);
}
@@ -0,0 +1,21 @@
import { createContext, useContext } from 'react';
export const WINDOW_CHROME_DEFAULT_TITLE = '创作工作台';
export type WindowChromeContextValue = {
isWindowChrome: boolean;
title: string;
setTitle: (title: string | null | undefined) => void;
walletSlot: HTMLElement | null;
};
export const WindowChromeContext = createContext<WindowChromeContextValue>({
isWindowChrome: false,
title: WINDOW_CHROME_DEFAULT_TITLE,
setTitle: () => undefined,
walletSlot: null,
});
export function useWindowChrome() {
return useContext(WindowChromeContext);
}
@@ -1,9 +1,14 @@
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { launcherNotifications } from '../../app/constants';
import { closeDialogOnEscape } from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalGameProjectRevisionStatus } from '../../app/types';
import {
useWindowChrome,
WINDOW_CHROME_DEFAULT_TITLE,
} from '../../components/windowChromeContext';
import HomeView from '../../view/home';
import { type LauncherView, Sidebar } from '../../view/layout';
import ProjectDevelopmentView from '../../view/project-development';
@@ -33,6 +38,11 @@ export function WorkspaceLauncherShell({
initialView = 'home',
ProjectSupervisor,
}: WorkspaceLauncherShellProps) {
const {
isWindowChrome,
setTitle: setWindowTitle,
walletSlot,
} = useWindowChrome();
const accountWallet = useAccountWallet(currentUser.id);
const [status, setStatus] = useState('');
const recentProjects = useRecentProjects(setStatus);
@@ -74,6 +84,15 @@ export function WorkspaceLauncherShell({
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
activeProjectContextRef.current = currentProjectContext;
useEffect(() => {
const nextTitle =
launcherView === 'project-development'
? currentProjectContext?.projectName.trim()
: '';
setWindowTitle(nextTitle || WINDOW_CHROME_DEFAULT_TITLE);
return () => setWindowTitle(WINDOW_CHROME_DEFAULT_TITLE);
}, [currentProjectContext?.projectName, launcherView, setWindowTitle]);
useEffect(() => {
const current = activeProjectContextRef.current;
manifestMergeRef.current =
@@ -268,8 +287,6 @@ export function WorkspaceLauncherShell({
</header>
) : null}
<AccountWalletBar controller={accountWallet} />
{launcherView === 'home' ? (
<HomeView
hasPromo={launcherNotifications.length > 0}
@@ -307,7 +324,6 @@ export function WorkspaceLauncherShell({
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
agentResults={activeProjectAgentResults}
planningStartMode={currentProjectContext.startMode === 'planning'}
walletEntry={<AccountWalletBar controller={accountWallet} />}
onPlay={() =>
requestCurrentProjectPlay(currentProjectContext.projectPath)
}
@@ -365,6 +381,14 @@ export function WorkspaceLauncherShell({
/>
) : null}
<AccountWalletDialogs controller={accountWallet} />
{walletSlot ? (
createPortal(
<AccountWalletBar controller={accountWallet} />,
walletSlot,
)
) : isWindowChrome ? null : (
<AccountWalletBar controller={accountWallet} />
)}
<DeveloperAgentDialogs controller={developerAgent} />
{launcherNotice ? (
<div
+20 -17
View File
@@ -5,6 +5,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { App, AuthenticatedClient, WorkspaceLauncher } from './App';
import { WindowChrome } from './components/WindowChrome';
const initialSearchParams = new URLSearchParams(window.location.search);
const supervisorChatMode =
@@ -22,22 +23,24 @@ const initialLauncherView = resolveInitialLauncherView();
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<AuthenticatedClient>
{({ user, logout }) =>
supervisorChatMode ? (
<App
initialProjectPath={supervisorChatProjectPath}
projectSupervisorOnly
supervisorChatOnly
/>
) : (
<WorkspaceLauncher
currentUser={user}
initialView={initialLauncherView}
onLogout={logout}
/>
)
}
</AuthenticatedClient>
<WindowChrome>
<AuthenticatedClient>
{({ user, logout }) =>
supervisorChatMode ? (
<App
initialProjectPath={supervisorChatProjectPath}
projectSupervisorOnly
supervisorChatOnly
/>
) : (
<WorkspaceLauncher
currentUser={user}
initialView={initialLauncherView}
onLogout={logout}
/>
)
}
</AuthenticatedClient>
</WindowChrome>
</React.StrictMode>,
);
+340
View File
@@ -16,6 +16,346 @@ body {
margin: 0;
}
#root {
height: 100dvh;
min-height: 0;
}
/* 桌面壳统一承载标题栏,让 Tauri 与浏览器预览共用同一表面。 */
.window-chrome {
--window-chrome-height: 50px;
--window-chrome-title-side-space: 300px;
display: grid;
grid-template-rows: var(--window-chrome-height) minmax(0, 1fr);
width: 100%;
height: 100dvh;
min-height: 0;
overflow: hidden;
background: var(--platform-body-fill, #fffdf9);
color: var(--platform-text-strong, #3d1f10);
}
.window-chrome__bar {
position: relative;
z-index: 30;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
min-width: 0;
height: var(--window-chrome-height);
padding: 0;
overflow: visible;
border-bottom: 1px solid var(--platform-surface-border, #ead8cb);
background: radial-gradient(
circle at 16% -80%,
rgb(255 255 255 / 88%),
transparent 38%
),
radial-gradient(circle at 72% 0%, rgb(237 190 151 / 20%), transparent 28%),
var(
--platform-desktop-topbar-fill,
linear-gradient(180deg, #fffefa, #fbf1e9)
);
box-shadow: 0 5px 18px rgb(112 57 30 / 7%);
user-select: none;
-webkit-app-region: drag;
}
.window-chrome__bar::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgb(199 101 61 / 22%),
transparent
);
content: '';
pointer-events: none;
}
.window-chrome__leading,
.window-chrome__trailing,
.window-chrome__controls,
.window-chrome__brand,
.window-chrome__wallet-slot {
-webkit-app-region: no-drag;
}
.window-chrome__leading,
.window-chrome__trailing {
display: flex;
align-items: center;
min-width: 0;
}
.window-chrome__leading {
grid-column: 1;
position: relative;
z-index: 1;
justify-self: start;
gap: 12px;
padding-left: 12px;
}
.window-chrome__brand {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
min-width: 132px;
color: var(--platform-text-strong, #3d1f10);
}
.window-chrome__brand-mark {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: 1px solid rgb(199 101 61 / 20%);
border-radius: 11px;
background: linear-gradient(145deg, #fffaf2 5%, #f4d8c3 100%);
box-shadow:
0 5px 12px rgb(182 98 63 / 14%),
inset 0 1px 0 rgb(255 255 255 / 90%);
}
.window-chrome__brand-mark img {
width: 27px;
height: 27px;
object-fit: contain;
filter: drop-shadow(0 3px 4px rgb(139 69 39 / 14%));
}
.window-chrome__brand-copy {
display: grid;
gap: 1px;
line-height: 1;
}
.window-chrome__brand-copy strong {
color: var(--platform-brand-logo-title, #4a220f);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.02em;
}
.window-chrome__brand-copy span {
color: var(--platform-brand-logo-subtitle, #c7653d);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.window-chrome__drag-region {
position: absolute;
z-index: 0;
inset: 0;
display: grid;
place-items: center;
width: auto;
min-width: 0;
color: var(--platform-text-muted, #a38f80);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
cursor: default;
padding-inline: var(--window-chrome-title-side-space);
-webkit-app-region: drag;
}
.window-chrome__title-wrap {
position: relative;
display: block;
min-width: 0;
max-width: min(42vw, 460px, 100%);
}
.window-chrome__title {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.window-chrome__workspace-dot {
position: absolute;
top: 50%;
right: calc(100% + 9px);
width: 6px;
height: 6px;
margin: 0;
border-radius: 50%;
background: var(--platform-accent, #c7653d);
box-shadow: 0 0 0 4px rgb(199 101 61 / 10%);
transform: translateY(-50%);
}
.window-chrome__trailing {
grid-column: 3;
position: relative;
z-index: 1;
justify-self: end;
flex: 0 0 auto;
gap: 12px;
padding-left: 8px;
}
.window-chrome__wallet-slot {
display: flex;
align-items: center;
min-width: 0;
}
.window-chrome__wallet-slot > .launcher-account-bar {
position: static;
top: auto;
right: auto;
}
.window-chrome__controls {
display: flex;
align-self: stretch;
}
.window-chrome__control {
display: grid;
width: 44px;
height: var(--window-chrome-height);
place-items: center;
border: 0;
border-radius: 0;
background: transparent;
color: var(--platform-text-base, #6f5848);
cursor: pointer;
transition:
background 150ms ease,
color 150ms ease;
}
.window-chrome__control:hover,
.window-chrome__control:focus-visible {
background: rgb(204 117 76 / 12%);
color: var(--platform-text-strong, #3d1f10);
outline: none;
}
.window-chrome__control--close:hover,
.window-chrome__control--close:focus-visible {
background: #c7653d;
color: #fffaf5;
}
.window-chrome__content {
position: relative;
z-index: 0;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.window-chrome__content > .client-auth-shell,
.window-chrome__content > .launcher-shell,
.window-chrome__content > .app-shell {
height: 100%;
min-height: 0;
}
.window-chrome__content > .launcher-shell > aside {
height: 100%;
min-height: 0;
}
.window-chrome__content > .launcher-shell .launcher-main,
.window-chrome__content > .app-shell.app-shell--dev {
min-height: 0;
}
.window-chrome__content > .app-shell.app-shell--user .chat-pane {
min-height: calc(100% - 48px);
}
.window-chrome__content .launcher-projects-page,
.window-chrome__content .launcher-main:has(.launcher-projects-page) {
height: 100%;
}
.window-chrome__content > .launcher-shell .launcher-main {
height: 100%;
min-height: 0;
}
.window-chrome__content
> .launcher-shell
.launcher-main
> .platform-theme.min-h-screen {
height: 100%;
min-height: 0;
overflow: auto;
}
.window-chrome__content .launcher-main:has(.game-project-workbench) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding-bottom: 0;
}
.window-chrome__content .game-project-workbench {
flex: 1 1 auto;
height: auto;
min-height: 0;
}
.window-chrome__content .game-workbench-layout {
height: 100%;
min-height: 0;
}
@media (max-width: 760px) {
.window-chrome {
--window-chrome-height: 46px;
}
.window-chrome__bar {
padding: 0;
}
.window-chrome__brand {
min-width: 38px;
}
.window-chrome__brand-copy,
.window-chrome__title {
display: none;
}
.window-chrome__drag-region {
display: none;
padding-inline: 0;
}
.window-chrome__leading {
gap: 5px;
padding-left: 8px;
}
.window-chrome__trailing {
padding-left: 2px;
}
.window-chrome__control {
width: 40px;
}
}
button,
input,
select,
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { WindowChrome } from '../src/components/WindowChrome';
import { useWindowChrome } from '../src/components/windowChromeContext';
function TitleSetter({ value }: { value: string }) {
const { setTitle } = useWindowChrome();
return (
<button type="button" onClick={() => setTitle(value)}>
</button>
);
}
describe('WindowChrome', () => {
it('renders the陶泥儿 brand, default title, and controls', async () => {
const user = userEvent.setup();
render(
<WindowChrome>
<div></div>
</WindowChrome>,
);
expect(screen.getByRole('banner', { name: '窗口标题栏' })).toBeTruthy();
expect(screen.getByLabelText('陶泥儿 GameAgent')).toBeTruthy();
expect(screen.queryByLabelText('本地工作区')).toBeNull();
expect(screen.getByText('创作工作台')).toBeTruthy();
expect(screen.getByText('工作区内容')).toBeTruthy();
expect(screen.queryByRole('menubar')).toBeNull();
await user.click(screen.getByRole('button', { name: '最小化' }));
await user.click(screen.getByRole('button', { name: '最大化' }));
await user.click(screen.getByRole('button', { name: '关闭' }));
});
it('updates the centered title through the shell context', async () => {
const user = userEvent.setup();
render(
<WindowChrome>
<TitleSetter value="星尘花园" />
</WindowChrome>,
);
await user.click(screen.getByRole('button', { name: '更新标题' }));
expect(screen.getByText('星尘花园')).toBeTruthy();
expect(screen.getByLabelText('当前工作区:星尘花园')).toBeTruthy();
});
it('restores the default title when a page clears its title', async () => {
const user = userEvent.setup();
function ClearTitle() {
const { setTitle } = useWindowChrome();
return (
<button type="button" onClick={() => setTitle(' ')}>
</button>
);
}
render(
<WindowChrome>
<ClearTitle />
</WindowChrome>,
);
await user.click(screen.getByRole('button', { name: '清空标题' }));
expect(screen.getByText('创作工作台')).toBeTruthy();
});
it('closes no menus because the title bar has no application menu bar', () => {
render(
<WindowChrome>
<button type="button"></button>
</WindowChrome>,
);
fireEvent.pointerDown(screen.getByRole('button', { name: '页面按钮' }));
expect(screen.queryByRole('menu')).toBeNull();
});
});
@@ -142,6 +142,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
## 技术选择
- 桌面壳:新建 `apps/ai-game-creator-shell`,与现有 `apps/desktop-shell` 分离,避免把游戏创作本地能力塞进主站宿主壳;启动时先检查平台登录态,未登录只展示登录页,登录后进入单窗口客户端首页;正式用户窗口常驻左侧栏和顶部栏,并在首页、项目组、指南 / 反馈和项目开发页之间切换。发布和 debug 启动都只登记并打开 `client` 用户窗口;`index.html?agent-chat` 仅保留为显式前端调试路由,不是 Tauri 自动启动入口。
- 窗口外壳:`client`、动态 `main` / `launcher` / `supervisor-chat` 窗口统一关闭原生 decorations,由前端 `WindowChrome` 绘制陶泥儿品牌 Logo、当前页面 / 项目标题和最小化 / 最大化 / 关闭控制。标题栏不重复展示项目列表或本地工作区入口:首页及非项目页面的居中标题固定为“创作工作台”,打开项目后切换为当前项目名;泥点账户入口通过标题栏右侧插槽渲染,标题文本使用独立的窗口几何居中层,不参与左右入口宽度分配,项目名过长时仅在可用宽度内省略。标题栏只复用 `packages/shared/src/theme.css` 的暖陶土变量与现有产品 IP,不引入另一套主题;浏览器预览或非 Tauri 宿主中窗口控制安全降级,不能阻断页面渲染。
- 平台后端:继续使用 `server-rs + Axum + SpacetimeDB`;本地开发启动独立客户端时,`agc` / Tauri dev 会先启动或复用配套 SpacetimeDB 与 `api-server`,再启动固定端口 Vite,并通过 `/api` 代理访问实际后端端口。
- 本地能力:使用 Tauri Rust command;正式用户 App 在项目运行工作台内承载 `127.0.0.1` 本地 HTTP preview,不再调用系统外部浏览器。运行容器只接受当前授权项目由 `PreviewRegistry` 返回的 loopback URLrelease / dev CSP 都只为 `http://127.0.0.1:*` 开放 `frame-src`,并使用受限 iframe sandbox 隔离游戏脚本;远程 URL、`file://` 和任意手填地址均不得进入该容器。
- Agent Runtime:扩展 `server-rs/crates/platform-agent`,不引入 LangChain、AutoGen、Microsoft Agent Framework 或 OpenAI Agents SDK sidecar 作为核心。
@@ -1,12 +1,6 @@
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
@@ -48,7 +42,7 @@ test('shows only permanent and daily free points in the shared wallet panel', as
await user.hover(balanceButton);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
expect(details.className).toContain('rounded-[1.12rem]');
expect(details.className).toContain('rounded-[1.25rem]');
expect(within(details).getByText('不限时泥点')).toBeTruthy();
expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy();
expect(within(details).getByText('100')).toBeTruthy();
@@ -34,7 +34,7 @@ function MudPointBalanceRow({
detail?: string | null;
}) {
return (
<div className="flex min-h-[3.25rem] items-center justify-between gap-4 border-t border-[var(--platform-subpanel-border)] px-4 py-2.5">
<div className="flex min-h-[3.25rem] items-center justify-between gap-4 border-t border-[rgba(194,145,111,0.28)] px-4 py-2.5">
<div className="min-w-0">
<div className="text-[13px] font-bold text-[var(--platform-text-strong)]">
{label}
@@ -174,7 +174,7 @@ export function PlatformMudPointWalletEntry({
onBlurCapture={closeAfterFocusLeaves}
>
<div
className={`flex items-stretch overflow-hidden rounded-full border border-[rgba(214,184,159,0.78)] bg-[rgba(255,250,244,0.9)] text-[#6f3d24] shadow-[0_0.18rem_0.55rem_rgba(112,62,32,0.08)] ${
className={`flex items-stretch overflow-hidden rounded-full border border-[rgba(194,145,111,0.46)] bg-[rgba(255,250,244,0.94)] text-[#6f3d24] shadow-[0_0.18rem_0.55rem_rgba(112,62,32,0.07)] ${
isCompact ? 'h-8 text-[11px]' : 'h-9 text-xs'
}`}
>
@@ -211,7 +211,7 @@ export function PlatformMudPointWalletEntry({
</span>
</button>
<span
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.62)]"
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.36)]"
aria-hidden="true"
/>
<button
@@ -231,7 +231,7 @@ export function PlatformMudPointWalletEntry({
role="dialog"
aria-label="泥点账户详情"
onMouseEnter={isCompact ? undefined : cancelPendingClose}
className="absolute right-0 top-[calc(100%+0.5rem)] z-[95] w-[min(19rem,calc(100vw-1rem))] overflow-hidden rounded-[1.12rem] border border-[var(--platform-subpanel-border)] bg-[#fffaf4] text-left shadow-[0_1rem_2.8rem_rgba(76,44,27,0.2)]"
className="absolute right-0 top-[calc(100%+0.5rem)] z-[95] w-[min(19rem,calc(100vw-1rem))] overflow-hidden rounded-[1.25rem] border border-[rgba(194,145,111,0.46)] bg-[linear-gradient(180deg,#fffdfa,#fff7ef)] text-left shadow-[0_1.15rem_2.8rem_rgba(76,44,27,0.14),0_0.18rem_0.55rem_rgba(112,62,32,0.07)]"
>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="text-[15px] font-black text-[var(--platform-text-strong)]">
@@ -264,14 +264,14 @@ export function PlatformMudPointWalletEntry({
/>
</>
) : (
<div className="border-t border-[var(--platform-subpanel-border)] px-4 py-6 text-center text-xs font-semibold text-[var(--platform-text-soft)]">
<div className="border-t border-[rgba(194,145,111,0.28)] px-4 py-6 text-center text-xs font-semibold text-[var(--platform-text-soft)]">
{isLoading ? '余额读取中' : error || '余额明细暂不可用'}
</div>
)}
<button
type="button"
className="flex w-full items-center justify-center gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-3 text-[13px] font-black text-[var(--platform-text-strong)] outline-none transition-colors hover:bg-white/45 focus-visible:bg-white/55"
className="flex w-full items-center justify-center gap-2 border-t border-[rgba(194,145,111,0.28)] px-4 py-3 text-[13px] font-black text-[var(--platform-text-strong)] outline-none transition-colors hover:bg-white/55 focus-visible:bg-white/65"
onClick={() => {
closeDetails();
onOpenLedger();