{resourceLayoutSaving ? '保存中' : resourceLayoutNotice}
+ {/*
+ 「生成任务」入口排在「依赖 / 类型」排列方式之前:一个是开合任务侧栏的动作,
+ 一个是画布排列方式——动作在前、排列方式收在行尾。运行页签下由下面同一枚
+ 元素补渲染(见 `generationTasksEntry`)。
+ */}
+ {generationTasksEntry}
>
) : null}
- {/*
- 「生成任务」入口:**两个页签下都常驻**(不受 `mode === 'resources'` 限制)。
- 侧栏本体是无条件渲染的非模态浮层,运行态一样可见;入口若只留在资源页签,
- 用户切到运行后关掉侧栏就再也打不开了。有在途任务时带上数量,一眼看出还有几条在跑。
- */}
-
+ {/* 运行页签(或 UI 编辑器)下资源类按钮整组收起,但「生成任务」入口必须留着。 */}
+ {mode === 'resources' && !uiEditorRoute
+ ? null
+ : generationTasksEntry}
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx
new file mode 100644
index 000000000..2d7a47d2f
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx
@@ -0,0 +1,140 @@
+/** @vitest-environment jsdom */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import ProjectDevelopmentView from '../src/view/project-development';
+import {
+ createGameCreationAppManifest,
+ fireEvent,
+ React,
+ render,
+ screen,
+ waitFor,
+} from './appSurface/harness';
+
+/**
+ * 「生成任务」侧栏的两条交互口径:
+ *
+ * 1. **失去焦点即收起**:点画布、点别的工具栏按钮、点侧栏外部都算失去焦点;点侧栏内部
+ * (含任务卡与「定位到素材」)与点那枚开合按钮不算——后者自己负责 toggle,否则会先被
+ * 收起再被 toggle 打开,表现为按钮失灵。
+ * 2. **入口顺序**:「生成任务」排在「依赖 / 类型」排列方式之前(动作在前、排列方式收行尾)。
+ */
+
+function resourceGraphFor(resources: unknown) {
+ const resourceIds = Array.isArray(resources)
+ ? resources.map(
+ (resource) => (resource as { resourceId?: string }).resourceId ?? '',
+ )
+ : [];
+ return {
+ resourceIds,
+ referenceEdges: [],
+ taskFlows: [],
+ categories: [],
+ diagnostics: [],
+ };
+}
+
+function installInvoke() {
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'read_local_project_resource_graph') {
+ return resourceGraphFor(args?.resources);
+ }
+ if (command === 'read_local_project_resource_canvas_layout') {
+ return {
+ schemaVersion: 'game-creator-resource-layout.v1',
+ projectId: args?.expectedProjectId,
+ mode: args?.mode,
+ revision: 0,
+ positions: [],
+ updatedAt: 0,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ },
+ );
+ window.__TAURI__ = { core: { invoke } };
+}
+
+async function renderWorkbench(projectId: string) {
+ installInvoke();
+ const manifest = createGameCreationAppManifest(
+ projectId,
+ `${projectId} 项目`,
+ );
+ render(
+ React.createElement(ProjectDevelopmentView, {
+ projectName: manifest.name,
+ projectPath: `/tmp/${projectId}`,
+ manifest,
+ attachments: [],
+ recentRunStatus: null,
+ recentRunStopReason: null,
+ supervisor: React.createElement('div', null, '项目总控'),
+ onHomeOpen: vi.fn(),
+ onProjectsOpen: vi.fn(),
+ }),
+ );
+ const entry = await screen.findByRole('button', { name: '生成任务' });
+ fireEvent.click(entry);
+ return screen.findByRole('region', { name: '生成任务' });
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ vi.restoreAllMocks();
+});
+
+describe('「生成任务」侧栏的开合与入口位置', () => {
+ it('点侧栏外部(画布 / 其他区域)自动收起', async () => {
+ const sidebar = await renderWorkbench('workbench-tasks-dismiss-outside');
+ expect(sidebar).not.toBeNull();
+
+ fireEvent.pointerDown(document.body);
+
+ await waitFor(() =>
+ expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
+ );
+ });
+
+ it('点侧栏内部与开合按钮都不收起:内部保持打开,按钮仍能 toggle 收起来', async () => {
+ const sidebar = await renderWorkbench('workbench-tasks-dismiss-inside');
+ // 侧栏内部点击(任务列表区域)不该被当成点外部。
+ fireEvent.pointerDown(sidebar);
+ expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull();
+
+ // 开合按钮自己负责 toggle:点一次必须真的收起(不能先被「点外部」收起再被 toggle 打开)。
+ const entry = screen.getByRole('button', { name: '生成任务' });
+ fireEvent.pointerDown(entry);
+ fireEvent.click(entry);
+ await waitFor(() =>
+ expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
+ );
+
+ // 再点一次要能回来。
+ fireEvent.click(entry);
+ expect(
+ await screen.findByRole('region', { name: '生成任务' }),
+ ).not.toBeNull();
+ });
+
+ it('「生成任务」入口排在「依赖 / 类型」排列方式之前', async () => {
+ await renderWorkbench('workbench-tasks-entry-order');
+ const actionsRow = document.querySelector('.game-workbench-view-actions');
+ expect(actionsRow).not.toBeNull();
+ const buttons = Array.from(actionsRow!.querySelectorAll('button'));
+ const tasksIndex = buttons.findIndex(
+ (button) => button.getAttribute('aria-label') === '生成任务',
+ );
+ const sortIndex = buttons.findIndex(
+ (button) => button.getAttribute('aria-label') === '按依赖',
+ );
+ expect(tasksIndex).toBeGreaterThanOrEqual(0);
+ expect(sortIndex).toBeGreaterThanOrEqual(0);
+ // 依赖 / 类型收在行尾,生成任务在它前面。
+ expect(tasksIndex).toBeLessThan(sortIndex);
+ expect(sortIndex).toBe(buttons.length - 2);
+ });
+});