Merge branch 'master' into feat/five_min_design
Project CI / Repository checks (pull_request) Failing after 5m26s
Project CI / Frontend tests (pull_request) Failing after 5m49s
Project CI / Backend tests (pull_request) Successful in 4m13s
Project CI / Native shell tests (pull_request) Failing after 3m40s

This commit is contained in:
2026-08-25 10:56:57 +08:00
17 changed files with 724 additions and 108 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.2",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
@@ -1734,12 +1734,12 @@ if (
}
if (
tauriConfig.version !== '0.1.2' ||
packageConfig.version !== '0.1.2' ||
cargoPackageVersion !== '0.1.2'
tauriConfig.version !== '0.1.4' ||
packageConfig.version !== '0.1.4' ||
cargoPackageVersion !== '0.1.4'
) {
throw new Error(
'AI game creator standard release must remain version 0.1.2 while game-chat uses its dedicated version',
'AI game creator standard release must remain version 0.1.4 while game-chat uses its dedicated version',
);
}
+1 -1
View File
@@ -1695,7 +1695,7 @@ dependencies = [
[[package]]
name = "genarrative-ai-game-creator-shell"
version = "0.1.2"
version = "0.1.4"
dependencies = [
"agent-runtime-core",
"axum",
@@ -1,6 +1,6 @@
[package]
name = "genarrative-ai-game-creator-shell"
version = "0.1.2"
version = "0.1.4"
edition = "2021"
publish = false
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Genarrative AI Game Creator",
"version": "0.1.2",
"version": "0.1.4",
"identifier": "world.genarrative.ai-game-creator",
"build": {
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
+5 -1
View File
@@ -1243,6 +1243,9 @@ export function App({
const initialProjectOpenedRef = useRef(false);
const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null);
const queueRunLocalShortcutRef = useRef<() => void>(() => undefined);
const executeRunLocalRef = useRef<(announceToChat: boolean) => void>(
() => undefined,
);
function requestRuntimeConfigOpen() {
if (projectSupervisorOnly && !supervisorChatOnly && !gameChatOnly) {
@@ -3283,6 +3286,7 @@ export function App({
]);
}
queueRunLocalShortcutRef.current = queueRunLocalShortcut;
executeRunLocalRef.current = executeRunLocal;
useEffect(() => {
const nextProjectPath = localProject?.projectPath;
@@ -3300,7 +3304,7 @@ export function App({
}
handledPlayRequestRef.current = requestKey;
onPlayRequestHandled?.(playRequest.requestId);
queueRunLocalShortcutRef.current();
void executeRunLocalRef.current(true);
}, [
localProject?.projectPath,
onPlayRequestHandled,
@@ -34,7 +34,7 @@ export function WorkspaceLauncherShell({
ProjectSupervisor,
}: WorkspaceLauncherShellProps) {
const accountWallet = useAccountWallet(currentUser.id);
const [status, setStatus] = useState('请选择项目');
const [status, setStatus] = useState('');
const recentProjects = useRecentProjects(setStatus);
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
const [launcherView, setLauncherView] = useState<LauncherView>(initialView);
@@ -273,7 +273,6 @@ export function WorkspaceLauncherShell({
{launcherView === 'home' ? (
<HomeView
hasPromo={launcherNotifications.length > 0}
status={status}
onStatusChange={setStatus}
recentProjectRows={recentProjectRows}
onCreateDraftAutomatically={createHomeDraftAutomatically}
@@ -1,4 +1,4 @@
import { Send } from 'lucide-react';
import { ArrowUp } from 'lucide-react';
import type {
ComponentProps,
Dispatch,
@@ -251,14 +251,7 @@ export function ProjectSupervisorView({
type="submit"
disabled={runtimePanelProps.controlBusy || needsUserInput}
>
<Send size={16} aria-hidden="true" />
<span>
{needsUserInput
? '等待回答'
: runtimePanelProps.controlBusy
? '思考中'
: '发送'}
</span>
<ArrowUp size={16} aria-hidden="true" />
</button>
</form>
<small className="project-supervisor-workspace-status">
+372 -5
View File
@@ -5739,6 +5739,24 @@ iframe.preview-frame {
white-space: nowrap;
}
.game-resource-outline button .game-resource-outline-label {
position: relative;
overflow: visible;
}
.game-resource-outline-unread {
position: absolute;
top: -2px;
right: -6px;
width: 6px;
height: 6px;
border: 1px solid var(--platform-nav-fill);
border-radius: 50%;
background: #e5484d;
box-shadow: 0 0 0 1px rgb(229 72 77 / 18%);
pointer-events: none;
}
.game-resource-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -6879,6 +6897,7 @@ iframe.preview-frame {
align-content: start;
gap: 8px;
min-width: 0;
min-height: 156px;
padding: 12px;
border: 1px solid #eadbd4;
border-radius: 12px;
@@ -7022,12 +7041,260 @@ iframe.preview-frame {
font-size: 10px;
}
.game-workbench-chat-avatar {
width: 48px;
height: 48px;
.game-workbench-chat .supervisor-chat-only-runtime-controls {
max-width: none;
}
.game-workbench-chat
.supervisor-chat-only-runtime-controls
.pending-command {
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-warm-bg);
}
.game-workbench-chat
.supervisor-chat-only-runtime-controls
.pending-command
> span {
gap: 4px;
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.game-workbench-chat
.supervisor-chat-only-runtime-controls
.pending-command
> span
small {
color: var(--platform-text-soft);
font-weight: 500;
line-height: 1.4;
}
.game-workbench-chat .pending-command-actions button {
min-width: 52px;
height: 34px;
flex: 0 0 auto;
object-fit: contain;
filter: drop-shadow(0 5px 9px rgb(182 98 63 / 16%));
border: 1px solid var(--platform-surface-border);
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font-size: 11px;
font-weight: 800;
}
.game-workbench-chat .pending-command-actions button:last-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.agent-runtime-status .project-runtime-pending-command > span {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.agent-runtime-status .project-runtime-pending-command > span small {
display: block;
margin-top: 4px;
color: var(--platform-text-soft);
font-weight: 500;
line-height: 1.4;
}
.agent-runtime-status .project-runtime-pending-command .pending-command-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.agent-runtime-status
.project-runtime-pending-command
.pending-command-actions
button:last-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.game-workbench-chat
.agent-runtime-status
.project-runtime-pending-command {
grid-template-columns: minmax(0, 1fr) 136px;
}
.game-workbench-chat
.agent-runtime-status
.project-runtime-pending-command
.pending-command-actions {
display: grid;
grid-template-columns: repeat(2, 64px);
gap: 8px;
width: 136px;
flex: 0 0 136px;
}
.game-workbench-chat
.agent-runtime-status
.project-runtime-pending-command
.pending-command-actions
button {
width: 64px;
min-width: 64px;
max-width: 64px;
padding: 0;
white-space: nowrap;
}
/* Runtime 确认卡在部分项目状态下位于状态包装层外,工作台样式直接绑定卡片自身,避免回落旧的蓝色按钮样式。 */
.game-workbench-chat .project-runtime-pending-command {
display: grid;
grid-template-columns: minmax(0, 1fr) 136px;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-warm-bg);
}
.game-workbench-chat .project-supervisor-surface > .project-runtime-pending-command,
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command {
display: grid;
grid-template-columns: minmax(0, 1fr) 136px;
align-items: center;
gap: 12px;
width: 100%;
margin: 0;
padding: 10px 12px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-warm-bg);
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command > span {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command > span small {
display: block;
margin-top: 4px;
color: var(--platform-text-soft);
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command .pending-command-actions {
display: grid;
grid-template-columns: repeat(2, 64px);
gap: 8px;
width: 136px;
flex: 0 0 136px;
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command .pending-command-actions button {
width: 64px;
min-width: 64px;
max-width: 64px;
height: 34px;
padding: 0;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.game-workbench-chat .project-supervisor-conversation .project-runtime-pending-command .pending-command-actions button:last-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
/* 该卡片的两个操作按钮禁止被全局 button 规则压缩,确保“拒绝 / 确认”完整可见。 */
.project-runtime-pending-command .pending-command-actions button {
display: inline-flex !important;
width: 64px !important;
min-width: 64px !important;
max-width: 64px !important;
flex: 0 0 64px !important;
align-items: center;
justify-content: center;
padding: 0 !important;
overflow: visible;
white-space: nowrap;
}
.game-workbench-chat .project-runtime-pending-command > span {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.game-workbench-chat .project-runtime-pending-command > span small {
display: block;
margin-top: 4px;
color: var(--platform-text-soft);
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.game-workbench-chat .project-runtime-pending-command .pending-command-actions {
display: grid;
grid-template-columns: repeat(2, 64px);
gap: 8px;
width: 136px;
flex: 0 0 136px;
}
.game-workbench-chat
.project-runtime-pending-command
.pending-command-actions
button {
width: 64px;
min-width: 64px;
max-width: 64px;
height: 34px;
padding: 0;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.game-workbench-chat
.project-runtime-pending-command
.pending-command-actions
button:last-child {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.game-workbench-chat .project-supervisor-surface {
@@ -7281,6 +7548,100 @@ iframe.preview-frame {
white-space: nowrap;
}
/* 右侧对话采用简洁的 Codex 式消息流与编辑框,减少卡片嵌套和视觉噪音。 */
.game-workbench-chat .project-supervisor-message-list {
position: absolute;
top: 0;
right: 0;
bottom: 104px;
left: 0;
gap: 34px;
height: auto;
padding: 18px 20px 24px;
scroll-padding-bottom: 24px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: var(--platform-input-fill);
}
.game-workbench-chat .project-supervisor-message-list .message {
display: block;
width: 100%;
max-width: 100%;
padding: 4px 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--platform-text-base);
font-size: 14px;
line-height: 1.85;
}
.game-workbench-chat .project-supervisor-message-list .message + .message {
margin-top: 0;
}
.game-workbench-chat .project-supervisor-message-list .message--user {
width: fit-content;
max-width: min(82%, 640px);
margin-left: auto;
padding: 11px 16px;
border: 1px solid var(--platform-button-primary-border);
border-radius: 14px 14px 4px;
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
.game-workbench-chat .project-supervisor-composer {
display: block;
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 5;
padding: 0;
pointer-events: none;
}
.game-workbench-chat .project-supervisor-composer textarea {
display: block;
width: 100%;
min-height: 88px;
max-height: 180px;
padding: 14px 48px 38px 15px;
border: 1px solid var(--platform-surface-border);
border-radius: 14px;
background: var(--platform-input-fill);
color: var(--platform-text-strong);
font-size: 13px;
line-height: 1.6;
resize: vertical;
pointer-events: auto;
}
.game-workbench-chat .project-supervisor-composer button {
position: absolute;
right: 10px;
bottom: 10px;
display: grid;
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
border: 0;
border-radius: 9px;
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
place-items: center;
pointer-events: auto;
}
.game-workbench-chat .project-supervisor-composer button svg {
width: 14px;
height: 14px;
}
.game-agent-dock {
position: relative;
z-index: 30;
@@ -7691,6 +8052,7 @@ iframe.preview-frame {
.game-workbench-view-actions {
justify-content: flex-end;
}
}
@media (max-width: 760px) {
@@ -7721,6 +8083,11 @@ iframe.preview-frame {
flex-direction: column;
}
.game-workbench-view-actions .game-workbench-play-button {
position: static;
transform: none;
}
.game-workbench-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -93,7 +93,6 @@ function projectTypeLabel(projectKind: HomeProjectRow['projectKind']) {
type HomeViewProps = {
hasPromo: boolean;
status: string;
onStatusChange: (status: string) => void;
recentProjectRows: readonly HomeProjectRow[];
onCreateDraftAutomatically: (
@@ -107,7 +106,6 @@ type HomeViewProps = {
export default function HomeView({
hasPromo,
status,
onStatusChange,
recentProjectRows,
onCreateDraftAutomatically,
@@ -250,11 +248,6 @@ export default function HomeView({
</div>
</RichInputArea>
</form>
{status ? (
<span className="w-[min(814px,calc(100vw-122px))] text-left text-[12px] text-(--platform-text-soft) max-[760px]:w-[min(100%,calc(100vw-76px))]">
{status}
</span>
) : null}
</section>
<section
@@ -266,9 +259,6 @@ export default function HomeView({
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
</h2>
<p className="mb-0 mt-1 text-[12px] text-(--platform-text-soft)">
</p>
</div>
<div className="launcher-project-list-actions">
<button
@@ -45,7 +45,6 @@ import type {
GameIterationVersion,
ProjectResourceCanvasLayoutMode,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import PRODUCT_LOGO from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
import { canonicalAssetBaseName } from '../../features/asset-canvas/assetCanvasNaming';
import {
type AssetCanvasCommitNotification,
@@ -841,6 +840,15 @@ export default function ProjectDevelopmentView({
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
const [activeResourceCategory, setActiveResourceCategory] =
useState<ResourceCategory | null>(null);
const [unreadResourceCategoryState, setUnreadResourceCategoryState] =
useState<{
scopeKey: string;
categories: Set<ResourceCategory>;
}>(() => ({ scopeKey: '', categories: new Set() }));
const resourceCategorySnapshotRef = useRef<{
scopeKey: string;
resourceIdsByCategory: Map<ResourceCategory, Set<string>>;
} | null>(null);
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(
null,
);
@@ -880,8 +888,10 @@ export default function ProjectDevelopmentView({
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
string | null
>(null);
const [resourcePreviewVersionByResourceId, setResourcePreviewVersionByResourceId] =
useState<Map<string, string>>(() => new Map());
const [
resourcePreviewVersionByResourceId,
setResourcePreviewVersionByResourceId,
] = useState<Map<string, string>>(() => new Map());
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [approvalNotice, setApprovalNotice] = useState('');
@@ -1388,6 +1398,24 @@ export default function ProjectDevelopmentView({
),
[resources],
);
const resourceIdsByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
category,
new Set(
(projectResourcesByCategory.get(category) ?? []).map(
(resource) => resource.id,
),
),
]),
),
[projectResourcesByCategory],
);
const resourceCategoryScopeKey = useMemo(
() => JSON.stringify([projectPath, manifest.projectId]),
[manifest.projectId, projectPath],
);
const resourcePageCategories = useMemo(
() => (resources.length > 0 ? categoryOrder : []),
[resources.length],
@@ -1401,6 +1429,63 @@ export default function ProjectDevelopmentView({
) ??
resourcePageCategories[0] ??
null;
const viewedResourceCategory =
mode === 'resources' ? activePageCategory : null;
useEffect(() => {
const previousSnapshot = resourceCategorySnapshotRef.current;
if (
!previousSnapshot ||
previousSnapshot.scopeKey !== resourceCategoryScopeKey
) {
resourceCategorySnapshotRef.current = {
scopeKey: resourceCategoryScopeKey,
resourceIdsByCategory,
};
setUnreadResourceCategoryState({
scopeKey: resourceCategoryScopeKey,
categories: new Set(),
});
return;
}
const categoriesWithNewResources = categoryOrder.filter((category) => {
if (category === viewedResourceCategory) {
return false;
}
const previousIds =
previousSnapshot.resourceIdsByCategory.get(category) ?? new Set();
return Array.from(resourceIdsByCategory.get(category) ?? []).some(
(resourceId) => !previousIds.has(resourceId),
);
});
resourceCategorySnapshotRef.current = {
scopeKey: resourceCategoryScopeKey,
resourceIdsByCategory,
};
setUnreadResourceCategoryState((current) => {
const categories =
current.scopeKey === resourceCategoryScopeKey
? new Set(current.categories)
: new Set<ResourceCategory>();
let changed = current.scopeKey !== resourceCategoryScopeKey;
if (viewedResourceCategory && categories.delete(viewedResourceCategory)) {
changed = true;
}
categoriesWithNewResources.forEach((category) => {
if (!categories.has(category)) {
categories.add(category);
changed = true;
}
});
return changed
? { scopeKey: resourceCategoryScopeKey, categories }
: current;
});
}, [resourceCategoryScopeKey, resourceIdsByCategory, viewedResourceCategory]);
const unreadResourceCategories =
unreadResourceCategoryState.scopeKey === resourceCategoryScopeKey
? unreadResourceCategoryState.categories
: new Set<ResourceCategory>();
resourceCanvasPageWheelRef.current.activeCategory = activePageCategory;
const nextPageCategory =
activePageCategory && resourcePageCategories.length > 1
@@ -1515,8 +1600,7 @@ export default function ProjectDevelopmentView({
: { x: 48, y: 48, scale: 1 };
const setResourceCanvasViewport = useCallback(
(candidate: CanvasViewport) => {
const activeCategory =
resourceCanvasPageWheelRef.current.activeCategory;
const activeCategory = resourceCanvasPageWheelRef.current.activeCategory;
if (!activeCategory) {
return;
}
@@ -1800,8 +1884,7 @@ export default function ProjectDevelopmentView({
const advanceResourceCanvasPage = useCallback(
(stepOffset: number) => {
const currentCategory =
resourceCanvasPageWheelRef.current.activeCategory;
const currentCategory = resourceCanvasPageWheelRef.current.activeCategory;
const normalizedStepOffset = Math.trunc(stepOffset);
if (
!currentCategory ||
@@ -1849,8 +1932,7 @@ export default function ProjectDevelopmentView({
return;
}
const now = performance.now();
const waitMs =
pageWheelSwitchIntervalMs - (now - state.lastSwitchAt);
const waitMs = pageWheelSwitchIntervalMs - (now - state.lastSwitchAt);
if (waitMs > 0) {
if (state.pendingTimerId === null) {
state.pendingTimerId = window.setTimeout(() => {
@@ -1925,8 +2007,7 @@ export default function ProjectDevelopmentView({
}
pageWheelState.accumulatedDeltaY += normalizedDeltaY;
if (
Math.abs(pageWheelState.accumulatedDeltaY) >=
pageWheelDeltaThreshold
Math.abs(pageWheelState.accumulatedDeltaY) >= pageWheelDeltaThreshold
) {
const stepCount = Math.max(
1,
@@ -2063,11 +2144,7 @@ export default function ProjectDevelopmentView({
]);
useLayoutEffect(() => {
if (
mode !== 'resources' ||
!activePageCategory ||
!activePageLayoutReady
) {
if (mode !== 'resources' || !activePageCategory || !activePageLayoutReady) {
return undefined;
}
const canvas = resourceCanvasRef.current;
@@ -2752,9 +2829,7 @@ export default function ProjectDevelopmentView({
}
drag.changed = true;
const coordinate = (start: number, delta: number) =>
normalizeResourceCanvasDragCoordinate(
start + delta / drag.startScale,
);
normalizeResourceCanvasDragCoordinate(start + delta / drag.startScale);
const preview = {
resourceId: drag.resourceId,
x: coordinate(drag.startX, deltaX),
@@ -3944,27 +4019,37 @@ export default function ProjectDevelopmentView({
data-unavailable={!runAvailable || uiEditorRoute || undefined}
className={mode === 'run' ? 'is-active' : ''}
disabled={uiEditorRoute !== null}
onClick={showRunView}
onClick={() => {
if (!runAvailable) {
return;
}
showRunView();
onPlay?.();
}}
>
</button>
</div>
<div className="game-workbench-view-actions">
<button
type="button"
className="game-workbench-play-button"
disabled={!runAvailable || !onPlay || uiEditorRoute !== null}
onClick={() => {
showRunView();
onPlay?.();
}}
title={
runAvailable ? '运行当前游戏' : '首个可运行原型完成后才能播放'
}
>
<Play size={15} aria-hidden="true" />
</button>
{mode === 'run' ? (
<button
type="button"
className="game-workbench-play-button"
disabled={!runAvailable || !onPlay || uiEditorRoute !== null}
onClick={() => {
showRunView();
onPlay?.();
}}
title={
runAvailable
? '运行当前游戏'
: '首个可运行原型完成后才能播放'
}
>
<Play size={15} aria-hidden="true" />
</button>
) : null}
{mode === 'resources' &&
!assetCanvasRoute &&
!resourceEditorRoute &&
@@ -4506,10 +4591,15 @@ export default function ProjectDevelopmentView({
>
{resourcePageCategories.map((category) => {
const Icon = categoryIcons[category];
const hasUnreadResources =
unreadResourceCategories.has(category);
return (
<button
key={category}
type="button"
aria-label={`${categoryLabels[category]}${
hasUnreadResources ? ',有新资源' : ''
}`}
aria-current={
category === activePageCategory
? 'page'
@@ -4525,7 +4615,15 @@ export default function ProjectDevelopmentView({
}
>
<Icon size={14} aria-hidden="true" />
<strong>{categoryLabels[category]}</strong>
<strong className="game-resource-outline-label">
{categoryLabels[category]}
{hasUnreadResources ? (
<span
className="game-resource-outline-unread"
aria-hidden="true"
/>
) : null}
</strong>
</button>
);
})}
@@ -4787,29 +4885,13 @@ export default function ProjectDevelopmentView({
<dd>{selectedResource.mediaType}</dd>
</div>
</dl>
) : (
<p></p>
)}
) : null}
</section>
<section aria-label="数值微调面板">
<header>
<SlidersHorizontal size={16} aria-hidden="true" />
</header>
<label>
<input type="number" disabled placeholder="未载入" />
</label>
<label>
<input type="number" disabled placeholder="未载入" />
</label>
<input
type="text"
disabled
aria-label="新增微调数值项"
placeholder="用自然语言新增微调项(尚未接入)"
/>
</section>
</div>
</section>
@@ -4819,11 +4901,6 @@ export default function ProjectDevelopmentView({
{!uiEditorRoute ? (
<aside className="game-workbench-chat" aria-label="陶泥儿 Agent 对话">
<header>
<img
className="game-workbench-chat-avatar"
src={PRODUCT_LOGO}
alt="陶泥儿"
/>
<div className="game-workbench-chat-title">
<strong></strong>
<small>
@@ -1335,7 +1335,7 @@ export function registerHomeProjectCreationTests() {
expect(screen.getAllByText('今天有什么设计需要帮你整理')).toHaveLength(1);
// 做方案走立项策划链路,建项按钮在这一档改叫「进入立项策划」。
fireEvent.click(screen.getByRole('button', { name: '进入立项策划' }));
expect(screen.getByText('请输入方案需求或上传资料')).not.toBeNull();
expect(screen.queryByText('请输入方案需求或上传资料')).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'create_automatic_local_game_project',
);
@@ -1510,12 +1510,13 @@ export function registerHomeProjectCreationTests() {
});
expect((createButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByLabelText('陶泥儿首页对话')).toBeNull();
expect(screen.getByLabelText('最近项目').textContent).toContain(
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'选择一个项目继续创作',
);
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'正在创建工作区',
);
expect(screen.queryByText('正在创建工作区')).toBeNull();
await act(async () => {
rejectAutomaticProject?.(new Error('自动创建测试结束'));
@@ -1524,7 +1525,7 @@ export function registerHomeProjectCreationTests() {
await waitFor(() => {
expect((createButton as HTMLButtonElement).disabled).toBe(false);
});
expect(screen.getAllByText('自动创建测试结束')).not.toHaveLength(0);
expect(screen.queryByText('自动创建测试结束')).toBeNull();
});
it.each([
@@ -1387,6 +1387,174 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.queryByRole('button', { name: /放大.*内容/ })).toBeNull();
});
it('marks newly added resources on inactive section tabs until the user opens them', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-unread',
'资源未读标识测试',
);
manifest.assets = [
{
id: 'unread-art-initial',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/initial.png',
source: { kind: 'generated' },
},
];
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-resource-unread',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
const outline = await screen.findByLabelText('资源栏目大纲');
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
expect(
within(outline)
.getByRole('button', { name: '美术资源' })
.getAttribute('aria-current'),
).toBe('page');
const manifestWithCurrentArt = {
...manifest,
assets: [
...manifest.assets,
{
id: 'unread-art-current',
kind: 'character' as const,
mediaType: 'image/png',
localPath: 'assets/current.png',
source: { kind: 'generated' as const },
},
],
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: manifestWithCurrentArt,
}),
);
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
const manifestWithAudio = {
...manifestWithCurrentArt,
assets: [
...manifestWithCurrentArt.assets,
{
id: 'unread-audio-new',
kind: 'background-music' as const,
mediaType: 'audio/mpeg',
localPath: 'assets/new.mp3',
source: { kind: 'generated' as const },
},
],
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: manifestWithAudio,
}),
);
const unreadAudioTab = await within(outline).findByRole('button', {
name: '音乐音效,有新资源',
});
expect(
unreadAudioTab.querySelector('.game-resource-outline-unread'),
).not.toBeNull();
expect(
within(outline).queryByRole('button', { name: '美术资源,有新资源' }),
).toBeNull();
fireEvent.click(unreadAudioTab);
await waitFor(() => {
expect(
within(outline)
.getByRole('button', { name: '音乐音效' })
.getAttribute('aria-current'),
).toBe('page');
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
});
const nextProjectManifest = createGameCreationAppManifest(
'workbench-resource-unread-next',
'下一个资源项目',
);
nextProjectManifest.assets = [
{
id: 'next-project-code',
kind: 'game-code',
mediaType: 'text/javascript',
localPath: 'game/game.js',
source: { kind: 'generated' },
},
];
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
projectName: nextProjectManifest.name,
projectPath: '/tmp/workbench-resource-unread-next',
manifest: nextProjectManifest,
}),
);
await waitFor(() => {
expect(
within(screen.getByLabelText('资源栏目大纲')).queryByRole('button', {
name: /有新资源/,
}),
).toBeNull();
});
});
it('keeps the empty resource overview identical in both modes', async () => {
const manifest = createGameCreationAppManifest(
'workbench-empty-section-overview',
@@ -4814,7 +4982,7 @@ export function registerUserSurfaceBoundaryTests() {
}
export function registerProjectSupervisorSurfaceTests() {
it('routes a top workbench play request into the existing game.run_local confirmation', async () => {
it('runs a top workbench play request without a second confirmation', async () => {
const projectPath = '/tmp/top-play-request';
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
@@ -4832,6 +5000,19 @@ export function registerProjectSupervisorSurfaceTests() {
recentRunStopReason: null,
};
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43127/',
port: 43127,
root: projectPath,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'top-play-request',
'顶部播放请求',
);
}
return supervisorHarness.invoke(command, args);
},
);
@@ -4851,14 +5032,17 @@ export function registerProjectSupervisorSurfaceTests() {
);
expect(
await screen.findByText('game.run_local', {}, { timeout: 3_000 }),
await screen.findByText(
/运行通过,已载入客户端运行视图/,
{},
{ timeout: 3_000 },
),
).not.toBeNull();
expect(handled).toHaveBeenCalledWith(7);
expect(screen.getByText(`启动 ${projectPath}/game/`)).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'start_local_game_preview',
expect.anything(),
);
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
projectPath,
});
expect(screen.queryByText('game.run_local')).toBeNull();
});
it('starts a direct product preview without the legacy Canvas-only static smoke', async () => {
@@ -4899,9 +5083,6 @@ export function registerProjectSupervisorSurfaceTests() {
}),
);
expect(await screen.findByText('game.run_local')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(
'运行通过,已载入客户端运行视图:http://127.0.0.1:43126/',