完善陶泥儿桌面窗口外壳与素材导入
新增自绘标题栏、窗口控制和项目标题同步 将泥点账户入口接入标题栏并调整面板描边 收紧本地图片导入类型约束并同步契约文档 补充窗口外壳与泥点面板测试
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 Runtime;direct-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
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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 URL,release / 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();
|
||||
|
||||
Reference in New Issue
Block a user