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); }); });