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 594bc588f..e44ef6805 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 @@ -855,6 +855,8 @@ export default function ProjectDevelopmentView({ const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState< string | null >(null); + const [resourcePreviewVersionByResourceId, setResourcePreviewVersionByResourceId] = + useState>(() => new Map()); const [approvalMode, setApprovalMode] = useState('strict'); const [approvalDialogOpen, setApprovalDialogOpen] = useState(false); const [approvalNotice, setApprovalNotice] = useState(''); @@ -1228,6 +1230,7 @@ export default function ProjectDevelopmentView({ resources, canvasRef: resourceCanvasRef, eagerPreviewLimit: 12, + previewVersionByResourceId: resourcePreviewVersionByResourceId, }); const resourceCardSizeByResourceId = useMemo( () => @@ -2147,6 +2150,7 @@ export default function ProjectDevelopmentView({ setPendingResourceEditsError(''); setPendingResourceEditActionErrors(new Map()); setResourceEditServiceIdentityConfirmations(new Map()); + setResourcePreviewVersionByResourceId(new Map()); resourceRecoveryPanelEpochRef.current += 1; setResourceRecoveryPanelOpen(false); pendingResourceEditActionIdsRef.current = new Set(); @@ -3627,6 +3631,15 @@ export default function ProjectDevelopmentView({ ) { return; } + const committedResourceId = `asset:${notification.assetId}`; + setResourcePreviewVersionByResourceId((current) => { + if (current.get(committedResourceId) === notification.commitId) { + return current; + } + const next = new Map(current); + next.set(committedResourceId, notification.commitId); + return next; + }); onManifestChange?.(projectPath, notification.manifest, { projectId: notification.projectId, revision: notification.projectRevision, @@ -3659,7 +3672,7 @@ export default function ProjectDevelopmentView({ } pendingResourceFocusRef.current = { ...focusIntent, - resourceId: `asset:${notification.assetId}`, + resourceId: committedResourceId, }; setAssetCanvasRoute(null); setMode('resources'); @@ -3832,6 +3845,13 @@ export default function ProjectDevelopmentView({ setMode('run'); } + const showRunUnavailableHint = + !runAvailable && + !focusedResource && + !assetCanvasRoute && + !resourceEditorRoute && + !uiEditorRoute; + return (
- {!runAvailable && !assetCanvasRoute && !resourceEditorRoute ? ( + {showRunUnavailableHint ? (

; eagerPreviewLimit?: number; + previewVersionByResourceId?: ReadonlyMap; }) { - const scopeKey = JSON.stringify([ - input.projectPath, - input.projectId, - input.mode, - ]); + const scopeKey = JSON.stringify([input.projectPath, input.projectId]); const identityByResourceId = useMemo( () => new Map( @@ -213,12 +210,18 @@ export function useProjectResourceCardPreviews(input: { projectResourceCardPreviewIdentity({ projectPath: input.projectPath, projectId: input.projectId, - mode: input.mode, + previewVersion: + input.previewVersionByResourceId?.get(resource.id) ?? '', resource, }), ]), ), - [input.mode, input.projectId, input.projectPath, input.resources], + [ + input.previewVersionByResourceId, + input.projectId, + input.projectPath, + input.resources, + ], ); const [previews, setPreviews] = useState< Map diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 35f052324..f20a6e386 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1963,7 +1963,12 @@ export function registerProjectWorkbenchFoundationTests() { 'resources.focused.document', ); expect(screen.getByLabelText('搜索项目资源')).not.toBeNull(); - expect(screen.getByRole('button', { name: '新增资源' })).not.toBeNull(); + expect(screen.getByRole('button', { name: '生成视频' })).not.toBeNull(); + expect(screen.getByRole('button', { name: '生成音效' })).not.toBeNull(); + expect( + screen.getByRole('button', { name: '生成背景音乐' }), + ).not.toBeNull(); + expect(screen.getByRole('button', { name: '新增 UI 设计' })).not.toBeNull(); expect(screen.getByRole('button', { name: '按依赖' })).not.toBeNull(); expect( screen @@ -4012,6 +4017,35 @@ export function registerProjectWorkbenchFoundationTests() { expect(projectDevelopmentSource).toMatch( /

\{walletEntry\}<\/div>/, ); + expect(projectDevelopmentSource).toMatch( + /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!assetCanvasRoute\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s, + ); + expect(projectDevelopmentSource).toMatch( + /aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s, + ); + expect(projectDevelopmentSource).toMatch( + /\{showRunUnavailableHint\s*\?\s*\(\s* { + const projectDevelopmentSource = readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/view/project-development/index.tsx', + ), + 'utf8', + ); + + expect(projectDevelopmentSource).toMatch( + /previewVersionByResourceId:\s*resourcePreviewVersionByResourceId/, + ); + expect(projectDevelopmentSource).toMatch( + /const committedResourceId = `asset:\$\{notification\.assetId\}`;[\s\S]*?next\.set\(committedResourceId, notification\.commitId\)/, + ); + expect(projectDevelopmentSource).toMatch( + /setResourcePreviewVersionByResourceId\(new Map\(\)\)/, + ); }); it('keeps resource sort tab keyboard focus inside the clipped segmented control', () => { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 05cba939d..41bdc89e9 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -583,13 +583,13 @@ describe('useProjectResourceCardPreviews', () => { await waitFor(() => expect(previewReadCalls(invoke)).toHaveLength(7)); }); - it('rotates the native scope for a mode-only change and cancels the final scope on unmount', () => { + it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => { const art = resource('mode-switch-art'); - const invoke = vi.fn((command: string) => { + const invoke = vi.fn(async (command: string) => { if (command === 'cancel_local_project_resource_preview_scope') { - return Promise.resolve(); + return undefined; } - return new Promise(() => undefined); + return preview(art.path); }); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; @@ -601,35 +601,35 @@ describe('useProjectResourceCardPreviews', () => { mode, resources: [art], canvasRef, + eagerPreviewLimit: 1, }), { initialProps: 'dependency' as const }, ); - - act(() => - result.current.requestPreview( - art, - result.current.identityByResourceId.get(art.id)!, - 'visible', - ), + const firstIdentity = result.current.identityByResourceId.get(art.id)!; + await waitFor(() => + expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'), ); const firstScopeId = previewReadCalls(invoke)[0]?.[1]?.scopeId; expect(firstScopeId).toEqual(expect.any(String)); + expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ + pixelWidth: 640, + pixelHeight: 360, + }); rerender('type'); - expect(invoke).toHaveBeenCalledWith( - 'cancel_local_project_resource_preview_scope', - { scopeId: firstScopeId }, - ); - act(() => - result.current.requestPreview( - art, - result.current.identityByResourceId.get(art.id)!, - 'visible', + expect(result.current.identityByResourceId.get(art.id)).toBe(firstIdentity); + expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'); + expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ + pixelWidth: 640, + pixelHeight: 360, + }); + expect(previewReadCalls(invoke)).toHaveLength(1); + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'cancel_local_project_resource_preview_scope', ), - ); - const secondScopeId = previewReadCalls(invoke)[1]?.[1]?.scopeId; - expect(secondScopeId).toEqual(expect.any(String)); - expect(secondScopeId).not.toBe(firstScopeId); + ).toHaveLength(0); unmount(); const cancelledScopeIds = invoke.mock.calls @@ -638,7 +638,57 @@ describe('useProjectResourceCardPreviews', () => { command === 'cancel_local_project_resource_preview_scope', ) .map((call) => call[1]?.scopeId); - expect(cancelledScopeIds).toEqual([firstScopeId, secondScopeId]); + expect(cancelledScopeIds).toEqual([firstScopeId]); + }); + + it('invalidates an in-place asset preview when its committed version changes', async () => { + const art = resource('refined-art'); + const invoke = vi.fn( + async (_command: string, args?: Record) => + preview(String(args?.relativePath ?? art.path)), + ); + window.__TAURI__ = { core: { invoke } }; + const canvasRef = { current: document.createElement('div') }; + const { result, rerender } = renderHook( + (previewVersion: string) => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-refine-version', + projectId: 'preview-refine-version', + mode: 'dependency', + resources: [art], + canvasRef, + eagerPreviewLimit: 1, + previewVersionByResourceId: new Map([[art.id, previewVersion]]), + }), + { initialProps: 'commit-1' }, + ); + const firstIdentity = result.current.identityByResourceId.get(art.id)!; + + await waitFor(() => + expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'), + ); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + + rerender('commit-2'); + const secondIdentity = result.current.identityByResourceId.get(art.id)!; + expect(secondIdentity).not.toBe(firstIdentity); + await waitFor(() => + expect(URL.revokeObjectURL).toHaveBeenCalledWith( + 'blob:resource-preview-0', + ), + ); + await waitFor(() => + expect(result.current.previews.get(secondIdentity)?.status).toBe( + 'loaded', + ), + ); + expect(previewReadCalls(invoke)).toHaveLength(2); + expect(URL.createObjectURL).toHaveBeenCalledTimes(2); + expect(result.current.previews.has(firstIdentity)).toBe(false); + expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ + pixelWidth: 640, + pixelHeight: 360, + }); }); it('rejects an A-B-A late result even when scope and identity match again', async () => {