diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx
index 94d3b7456..8915d5663 100644
--- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx
+++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx
@@ -1,4 +1,4 @@
-import { X } from 'lucide-react';
+import { ChevronLeft, ChevronRight, ListChecks, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
@@ -10,35 +10,104 @@ import {
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
+/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
+export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
+
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
tasks: readonly ResourceCanvasAssetGenerationTask[];
- onClose: () => void;
+ /** 侧栏是否展开;折叠时只留贴边把手。 */
+ open: boolean;
+ onToggleOpen: () => void;
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
+function taskRow(
+ task: ResourceCanvasAssetGenerationTask,
+ nowMillis: number,
+ onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
+) {
+ const focusable = task.status === 'completed' && Boolean(task.assetId);
+ return (
+
+
+ {task.assetName}
+ {task.actionLabel}
+
+
+
+ {RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
+
+ {task.phaseDetail}
+
+ {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
+ resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
+ )}`}
+
+
+ {task.error ? (
+
+ {task.error}
+
+ ) : null}
+ {focusable ? (
+
+ ) : null}
+
+ );
+}
+
/**
- * 「生成任务」浮层面板:列出每条图片类生成任务的状态、后端阶段文案、已耗时与素材名。
+ * 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
*
- * **它是非模态的**:不铺全屏遮罩、不做焦点陷阱、不进 `isResourceCanvasFloatingPanelOpen` 的
- * 遮挡判据——生成在后台跑,面板开着的时候画布必须照样能看能用。这也是它和两块生成浮层
- * (提交表单,`ThemedModal` 模态)的关键差别。
+ * 形态对齐网页端美术画布的任务侧栏:贴边的独立 `
);
}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
index cae916de9..39c007c1f 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
@@ -6728,6 +6728,13 @@ export default function ProjectDevelopmentView({
activePageCategory,
};
resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice;
+ /** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务。 */
+ const resourceAssetGenerationInFlightCount =
+ resourceAssetGenerationTasks.filter(
+ (task) =>
+ task.projectId === manifest.projectId &&
+ !resourceCanvasAssetGenerationTaskIsTerminal(task),
+ ).length;
const replaceResourceAssetGenerationTask = useCallback(
(next: ResourceCanvasAssetGenerationTask) => {
@@ -7416,14 +7423,17 @@ export default function ProjectDevelopmentView({
) : null}
{/*
- 「生成任务」入口:生成在后台跑,面板关掉之后进度、阶段与失败原因都只在这
- 里可见(非模态浮层,画布照常可用)。
+ 「生成任务」入口:常驻可见,开合画布上的任务侧栏;有在途任务时带上数量,
+ 用户关掉侧栏后一眼就能看出还有几条在跑。
*/}
{pendingResourceEdits.length > 0 ||
@@ -8441,18 +8451,20 @@ export default function ProjectDevelopmentView({
/>
) : null}
{/*
- 「生成任务」面板:非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡
- 判据——生成在后台跑,面板开着时画布必须照样能看能用。
+ 「生成任务」侧栏:常驻、可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen`
+ 的模态遮挡判据——生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图,
+ 任务本身活在账本与本地队列里。
*/}
- {resourceAssetGenerationTasksPanelOpen ? (
- task.projectId === manifest.projectId,
- )}
- onClose={() => setResourceAssetGenerationTasksPanelOpen(false)}
- onFocusTask={focusResourceAssetGenerationTask}
- />
- ) : null}
+ task.projectId === manifest.projectId,
+ )}
+ open={resourceAssetGenerationTasksPanelOpen}
+ onToggleOpen={() =>
+ setResourceAssetGenerationTasksPanelOpen((current) => !current)
+ }
+ onFocusTask={focusResourceAssetGenerationTask}
+ />
{resourcePanelOpen ? (
expect(within(taskPanel).getAllByText('已完成')).toHaveLength(2),
+ () =>
+ expect(
+ within(screen.getByRole('region', { name: '已完成' })).getAllByRole(
+ 'listitem',
+ ),
+ ).toHaveLength(2),
{ timeout: 15_000 },
);
expect(within(taskPanel).getAllByText('生成已完成。')).toHaveLength(2);
@@ -11312,6 +11317,71 @@ export function registerProjectAgentStatusTests() {
).not.toBeNull();
}, 20_000);
+ it('keeps a generation progressing while the sidebar is collapsed', async () => {
+ // 前两轮轮询先保持「在途」,让折叠后的在途计数可观测,之后才收口。
+ let listPolls = 0;
+ const panel = await renderAssetGenerationSubmitView({
+ projectId: 'workbench-sidebar-collapsed',
+ projectPath: '/tmp/workbench-sidebar-collapsed',
+ startLocalAsset: async ({ taskId }) => ({
+ taskId,
+ projectId: 'workbench-sidebar-collapsed',
+ kind: 'ui-prototype',
+ assetName: '待提交设计图',
+ status: 'running',
+ phaseDetail: '正在生成。',
+ createdAtMillis: 1,
+ startedAtMillis: 1,
+ finishedAtMillis: null,
+ assetId: null,
+ error: null,
+ }),
+ listRecords: (started) => {
+ listPolls += 1;
+ return [...started.values()].map((task) =>
+ listPolls >= 3
+ ? {
+ ...task,
+ status: 'completed',
+ phaseDetail: '生成已完成。',
+ assetId: 'sidebar-collapsed-asset',
+ finishedAtMillis: 2,
+ }
+ : task,
+ );
+ },
+ });
+
+ fireEvent.click(
+ within(panel).getByRole('button', { name: '生成 UI 设计图' }),
+ );
+
+ // 提交后侧栏自动展开(对齐网页端:排队提交后主动弹任务栏)。
+ const sidebar = await screen.findByRole('region', { name: '生成任务' });
+ expect(within(sidebar).getByText('待提交设计图')).not.toBeNull();
+
+ // 折叠侧栏:折叠只影响这个视图,任务仍在后台推进。
+ fireEvent.click(
+ within(sidebar).getByRole('button', { name: '收起生成任务' }),
+ );
+ expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
+ const handle = screen.getByRole('button', { name: '展开生成任务' });
+ expect(handle.dataset.resourceGenerationTaskCount).toBe('1');
+ // 收口后折叠把手上的在途计数跟着归零 —— 折叠期间进度照常更新。
+ await waitFor(
+ () =>
+ expect(
+ screen.getByRole('button', { name: '展开生成任务' }).dataset
+ .resourceGenerationTaskCount,
+ ).toBe('0'),
+ { timeout: 15_000 },
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '展开生成任务' }));
+ const reopened = await screen.findByRole('region', { name: '生成任务' });
+ expect(within(reopened).getByText('生成已完成。')).not.toBeNull();
+ }, 20_000);
+
it('routes the audio column entries to the existing audio generation chain', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-audio',
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
index 2e7506a2b..8ca99210c 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
-import { cleanup, render, screen } from '@testing-library/react';
+import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
@@ -7,7 +7,10 @@ import {
createResourceCanvasAssetGenerationTask,
type ResourceCanvasAssetGenerationTask,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
-import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
+import {
+ RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
+ ResourceCanvasAssetGenerationTasksPanelView,
+} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
afterEach(() => {
@@ -48,106 +51,171 @@ function task(
};
}
-describe('「生成任务」面板', () => {
- test('是非模态面板:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => {
- const { container } = render(
- undefined}
- onFocusTask={() => undefined}
- />,
- );
+function renderSidebar(
+ tasks: readonly ResourceCanvasAssetGenerationTask[],
+ options: { open?: boolean } = {},
+) {
+ const onToggleOpen = vi.fn();
+ const onFocusTask = vi.fn();
+ const view = render(
+ ,
+ );
+ return { onToggleOpen, onFocusTask, ...view };
+}
+
+describe('「生成任务」侧栏', () => {
+ test('展开时是非模态侧栏:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => {
+ const { container } = renderSidebar([task({ taskId: 't1' })]);
expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull();
expect(container.querySelector('[aria-modal="true"]')).toBeNull();
expect(container.querySelector('.fixed.inset-0')).toBeNull();
});
- test('状态、阶段文案、已耗时与素材名逐条呈现,阶段文案来自后端记录', async () => {
- render(
- undefined}
- onFocusTask={() => undefined}
- />,
- );
+ test('两个分栏各带条数,状态 / 阶段 / 已耗时 / 素材名逐条呈现', () => {
+ renderSidebar([
+ task({ taskId: 't1', assetName: '主界面设计图' }),
+ task({
+ taskId: 't2',
+ assetName: '背包界面设计图',
+ dispatched: true,
+ status: 'running',
+ // 前端的任何常量都不会产出「正在处理。」,出现它只可能来自后端记录。
+ phaseDetail: '正在处理。',
+ startedAtMillis: 2_000,
+ }),
+ task({
+ taskId: 't3',
+ assetName: '结算界面设计图',
+ dispatched: true,
+ status: 'completed',
+ phaseDetail: '生成已完成。',
+ assetId: 'asset-3',
+ finishedAtMillis: 13_000,
+ }),
+ task({
+ taskId: 't4',
+ assetName: '设置界面设计图',
+ dispatched: true,
+ status: 'failed',
+ phaseDetail: '生成失败:远端拒绝',
+ error: '远端拒绝',
+ }),
+ ]);
- expect(screen.getByText('主界面设计图')).not.toBeNull();
- expect(screen.getByText('排队中')).not.toBeNull();
- expect(screen.getByText('生成中')).not.toBeNull();
- expect(screen.getByText('已完成')).not.toBeNull();
- expect(screen.getByText('失败')).not.toBeNull();
- expect(screen.getByText('正在处理。')).not.toBeNull();
- expect(screen.getByText('生成已完成。')).not.toBeNull();
- // 每条任务的阶段只渲染后端给的那一句:面板里没有第二个来源,也没有硬编码的假阶段。
- const runningRow = screen.getByText('背包界面设计图').closest('li');
- expect(runningRow?.textContent).toContain('正在处理。');
- expect(runningRow?.textContent).not.toContain('生成中…');
- expect(screen.getByRole('alert').textContent).toBe('远端拒绝');
- expect(screen.getByText('生成任务').textContent).toBe('生成任务');
+ const activeSection = screen.getByRole('region', { name: '排队与生成中' });
+ expect(within(activeSection).getByText('2')).not.toBeNull();
+ expect(within(activeSection).getByText('主界面设计图')).not.toBeNull();
+ expect(within(activeSection).getByText('排队中')).not.toBeNull();
+ expect(within(activeSection).getByText('正在处理。')).not.toBeNull();
+ expect(within(activeSection).getByText('生成中')).not.toBeNull();
- const user = userEvent.setup();
- // 已耗时的秒表由前端计时,但只在还有未终态任务时走;这里断言它至少渲染了。
+ const doneSection = screen.getByRole('region', { name: '已完成' });
+ expect(within(doneSection).getByText('2')).not.toBeNull();
+ expect(within(doneSection).getByText('生成已完成。')).not.toBeNull();
+ expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝');
expect(
screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length,
).toBeGreaterThan(0);
- void user;
+ });
+
+ test('展开状态下收起与关闭都走同一个折叠开关', async () => {
+ const user = userEvent.setup();
+ const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]);
+ await user.click(screen.getByRole('button', { name: '收起生成任务' }));
+ expect(onToggleOpen).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
+ expect(onToggleOpen).toHaveBeenCalledTimes(2);
+ });
+
+ test('侧栏高度有界、列表自己滚动,「已完成」条数封顶', () => {
+ const doneTasks = Array.from(
+ { length: RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT + 5 },
+ (_, index) =>
+ task({
+ taskId: `done-${index}`,
+ assetName: `历史设计图 ${index}`,
+ dispatched: true,
+ status: 'completed',
+ phaseDetail: '生成已完成。',
+ assetId: `asset-${index}`,
+ finishedAtMillis: 2_000,
+ }),
+ );
+ const { container } = renderSidebar(doneTasks);
+
+ const aside = container.querySelector('aside') as HTMLElement;
+ // 有界高度:上下都给死了,不靠内容撑高。
+ expect(aside.className).toContain('top-16');
+ expect(aside.className).toContain('bottom-24');
+ const scroller = container.querySelector(
+ '[data-resource-generation-task-scroll]',
+ ) as HTMLElement;
+ expect(scroller.className).toContain('overflow-y-auto');
+ expect(scroller.className).toContain('min-h-0');
+
+ expect(
+ within(screen.getByRole('region', { name: '已完成' })).getAllByRole(
+ 'listitem',
+ ),
+ ).toHaveLength(RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT);
+ expect(
+ screen.getByText(
+ `仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 5 条较早记录`,
+ ),
+ ).not.toBeNull();
+ });
+
+ test('折叠时只留贴边把手,把手上的在途计数跟着任务走(0 / 2)', async () => {
+ const user = userEvent.setup();
+ const collapsed = renderSidebar([], { open: false });
+ const handle = screen.getByRole('button', { name: '展开生成任务' });
+ expect(handle.getAttribute('aria-expanded')).toBe('false');
+ expect(handle.dataset.resourceGenerationTaskCount).toBe('0');
+ expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
+ await user.click(handle);
+ expect(collapsed.onToggleOpen).toHaveBeenCalledTimes(1);
+ cleanup();
+
+ const running = task({
+ taskId: 't-running',
+ dispatched: true,
+ status: 'running',
+ phaseDetail: '正在生成。',
+ });
+ const queued = task({ taskId: 't-queued' });
+ const { container } = renderSidebar([running, queued], { open: false });
+ const withTwo = container.querySelector(
+ 'button[data-resource-generation-task-count]',
+ ) as HTMLElement;
+ expect(withTwo.dataset.resourceGenerationTaskCount).toBe('2');
+ expect(screen.getByLabelText('在途生成任务 2').textContent).toBe('2');
});
test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => {
- const onFocusTask = vi.fn();
const user = userEvent.setup();
- render(
- undefined}
- onFocusTask={onFocusTask}
- />,
- );
+ const { onFocusTask } = renderSidebar([
+ task({
+ taskId: 't1',
+ assetName: '主界面设计图',
+ dispatched: true,
+ status: 'completed',
+ phaseDetail: '生成已完成。',
+ assetId: 'asset-1',
+ finishedAtMillis: 5_000,
+ }),
+ task({
+ taskId: 't2',
+ assetName: '背包界面设计图',
+ dispatched: true,
+ status: 'running',
+ phaseDetail: '正在生成。',
+ }),
+ ]);
expect(
screen.queryByRole('button', { name: '定位素材 背包界面设计图' }),
@@ -162,18 +230,8 @@ describe('「生成任务」面板', () => {
});
});
- test('没有任务时给出空状态,关闭按钮始终可用', async () => {
- const onClose = vi.fn();
- const user = userEvent.setup();
- render(
- undefined}
- />,
- );
+ test('没有任务时给出空状态', () => {
+ renderSidebar([]);
expect(screen.getByRole('status').textContent).toBe('还没有生成任务');
- await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
- expect(onClose).toHaveBeenCalledTimes(1);
});
});